From 204155b4e2c159c4a9f7ef70ed07f70b8564b49c Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Mon, 13 Jan 2020 10:26:57 +0100 Subject: [PATCH 01/62] [Graph] Fix various a11y issues (#54097) --- .../dashboard_listing.test.js.snap | 42 +++++++++++++------ .../np_ready/listing/dashboard_listing.js | 9 ++-- .../listing/visualize_listing_table.js | 9 ++-- .../table_list_view/table_list_view.tsx | 13 +++++- .../public/angular/templates/_sidebar.scss | 1 + .../graph/public/angular/templates/index.html | 14 ++++++- .../plugins/graph/public/components/app.tsx | 2 + .../graph/public/components/graph_title.tsx | 26 ++++++++++++ .../guidance_panel/_guidance_panel.scss | 6 --- .../guidance_panel/guidance_panel.tsx | 20 ++++++--- .../graph/public/components/listing.tsx | 11 ++--- 11 files changed, 113 insertions(+), 40 deletions(-) create mode 100644 x-pack/legacy/plugins/graph/public/components/graph_title.tsx diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap index b2f004568841a3..2a9a793ba43c4a 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/__snapshots__/dashboard_listing.test.js.snap @@ -9,6 +9,7 @@ exports[`after fetch hideWriteControls 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1} noItemsFragment={ @@ -16,13 +17,15 @@ exports[`after fetch hideWriteControls 1`] = ` +

-

+ } /> @@ -63,6 +66,7 @@ exports[`after fetch initialFilter 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="my dashboard" listingLimit={1000} noItemsFragment={ @@ -114,13 +118,15 @@ exports[`after fetch initialFilter 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> @@ -161,6 +167,7 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1} noItemsFragment={ @@ -212,13 +219,15 @@ exports[`after fetch renders call to action when no dashboards exist 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> @@ -259,6 +268,7 @@ exports[`after fetch renders table rows 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1000} noItemsFragment={ @@ -310,13 +320,15 @@ exports[`after fetch renders table rows 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> @@ -357,6 +369,7 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1} noItemsFragment={ @@ -408,13 +421,15 @@ exports[`after fetch renders warning when listingLimit is exceeded 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> @@ -455,6 +470,7 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` entityName="dashboard" entityNamePlural="dashboards" findItems={[Function]} + headingId="dashboardListingHeading" initialFilter="" listingLimit={1000} noItemsFragment={ @@ -506,13 +522,15 @@ exports[`renders empty page in before initial fetch to avoid flickering 1`] = ` } iconType="dashboardApp" title={ -

+

-

+ } /> diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js index 827fe6eabe7849..30bf940069fb7b 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/listing/dashboard_listing.js @@ -42,6 +42,7 @@ export class DashboardListing extends React.Component { return ( +

-

+ } /> @@ -90,12 +91,12 @@ export class DashboardListing extends React.Component { +

-

+ } body={ diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js index 840e647edcc86d..b770625cd3d700 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/listing/visualize_listing_table.js @@ -36,6 +36,7 @@ class VisualizeListingTable extends Component { const { visualizeCapabilities, uiSettings, toastNotifications } = getServices(); return ( +

-

+ } /> @@ -130,12 +131,12 @@ class VisualizeListingTable extends Component { +

-

+ } body={ diff --git a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx index 2e7b22a14fb0eb..4c2dac4f39134c 100644 --- a/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/kibana_react/public/table_list_view/table_list_view.tsx @@ -67,6 +67,11 @@ export interface TableListViewProps { tableListTitle: string; toastNotifications: ToastsStart; uiSettings: IUiSettingsClient; + /** + * Id of the heading element describing the table. This id will be used as `aria-labelledby` of the wrapper element. + * If the table is not empty, this component renders its own h1 element using the same id. + */ + headingId?: string; } export interface TableListViewState { @@ -463,7 +468,7 @@ class TableListView extends React.Component -

{this.props.tableListTitle}

+

{this.props.tableListTitle}

@@ -498,7 +503,11 @@ class TableListView extends React.Component - {this.renderPageContent()} + + {this.renderPageContent()} + ); } diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss b/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss index 6fa51c1ba1ec8c..e54158e2ad8ce3 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss +++ b/x-pack/legacy/plugins/graph/public/angular/templates/_sidebar.scss @@ -13,6 +13,7 @@ .help-block { font-size: $euiFontSizeXS; + color: $euiTextColor; } } diff --git a/x-pack/legacy/plugins/graph/public/angular/templates/index.html b/x-pack/legacy/plugins/graph/public/angular/templates/index.html index 20b1059ae45eca..4493d794cb8d12 100644 --- a/x-pack/legacy/plugins/graph/public/angular/templates/index.html +++ b/x-pack/legacy/plugins/graph/public/angular/templates/index.html @@ -1,4 +1,4 @@ -
+
@@ -81,6 +81,7 @@ @@ -386,4 +396,4 @@
- + diff --git a/x-pack/legacy/plugins/graph/public/components/app.tsx b/x-pack/legacy/plugins/graph/public/components/app.tsx index 5ff7fc2e5da93d..957a8f66907a13 100644 --- a/x-pack/legacy/plugins/graph/public/components/app.tsx +++ b/x-pack/legacy/plugins/graph/public/components/app.tsx @@ -16,6 +16,7 @@ import { FieldManager } from './field_manager'; import { SearchBarProps, SearchBar } from './search_bar'; import { GraphStore } from '../state_management'; import { GuidancePanel } from './guidance_panel'; +import { GraphTitle } from './graph_title'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; @@ -52,6 +53,7 @@ export function GraphApp(props: GraphAppProps) { > <> + {props.isInitialized && }
diff --git a/x-pack/legacy/plugins/graph/public/components/graph_title.tsx b/x-pack/legacy/plugins/graph/public/components/graph_title.tsx new file mode 100644 index 00000000000000..8151900da0c076 --- /dev/null +++ b/x-pack/legacy/plugins/graph/public/components/graph_title.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { connect } from 'react-redux'; +import { EuiScreenReaderOnly } from '@elastic/eui'; +import React from 'react'; + +import { GraphState, metaDataSelector } from '../state_management'; + +interface GraphTitleProps { + title: string; +} + +/** + * Component showing the title of the current workspace as a heading visible for screen readers + */ +export const GraphTitle = connect((state: GraphState) => ({ + title: metaDataSelector(state).title, +}))(({ title }: GraphTitleProps) => ( + +

{title}

+
+)); diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss index f1c332eba1aa8a..e1423b794dcd33 100644 --- a/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/_guidance_panel.scss @@ -15,16 +15,10 @@ position: relative; padding-left: $euiSizeXL; margin-bottom: $euiSizeL; - - button { - // make buttons wrap lines like regular text - display: contents; - } } .gphGuidancePanel__item--disabled { color: $euiColorDarkShade; - pointer-events: none; button { color: $euiColorDarkShade !important; diff --git a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx index 5fae9720db39a5..f34b82d6bb1a3c 100644 --- a/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx +++ b/x-pack/legacy/plugins/graph/public/components/guidance_panel/guidance_panel.tsx @@ -13,6 +13,7 @@ import { EuiText, EuiLink, EuiCallOut, + EuiScreenReaderOnly, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import classNames from 'classnames'; @@ -53,6 +54,7 @@ function ListItem({ 'gphGuidancePanel__item--disabled': state === 'disabled', })} aria-disabled={state === 'disabled'} + aria-current={state === 'active' ? 'step' : undefined} > {state !== 'disabled' && ( -

+

{i18n.translate('xpack.graph.guidancePanel.title', { defaultMessage: 'Three steps to your graph', })} @@ -104,7 +106,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { -
    +
      {i18n.translate( @@ -116,7 +118,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { - + {i18n.translate('xpack.graph.guidancePanel.fieldsItem.fieldsButtonLabel', { defaultMessage: 'Add fields.', })} @@ -128,7 +130,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { defaultMessage="Enter a query in the search bar to start exploring. Don't know where to start? {topTerms}." values={{ topTerms: ( - + {i18n.translate('xpack.graph.guidancePanel.nodesItem.topTermsButtonLabel', { defaultMessage: 'Graph the top terms', })} @@ -137,7 +139,7 @@ function GuidancePanelComponent(props: GuidancePanelProps) { }} /> -
+
@@ -157,7 +159,15 @@ function GuidancePanelComponent(props: GuidancePanelProps) { title={i18n.translate('xpack.graph.noDataSourceNotificationMessageTitle', { defaultMessage: 'No data source', })} + heading="h1" > + +

+ {i18n.translate('xpack.graph.noDataSourceNotificationMessageTitle', { + defaultMessage: 'No data source', + })} +

+

+

-

+ } />
@@ -88,12 +89,12 @@ function getNoItemsMessage( +

-

+ } body={ From a04048b1bbb6a90fd899c3c2c14deb5d26293b87 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Mon, 13 Jan 2020 11:35:57 +0100 Subject: [PATCH 02/62] fix(package): upgrade transitive dependency elliptic to v6.5.2 (#54476) --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 96bb533120aa74..3dd7dbe37b2e94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10915,9 +10915,9 @@ element-resize-detector@^1.1.15: batch-processor "^1.0.0" elliptic@^6.0.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.4.0.tgz#cac9af8762c85836187003c8dfe193e5e2eae5df" - integrity sha1-ysmvh2LIWDYYcAPI3+GT5eLq5d8= + version "6.5.2" + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.2.tgz#05c5678d7173c049d8ca433552224a495d0e3762" + integrity sha512-f4x70okzZbIQl/NSRLkI/+tteV/9WqL98zx+SQ69KbXxmVrmjwsNUPn/gYJJ0sHvEak24cZgHIPegRePAtA/xw== dependencies: bn.js "^4.4.0" brorand "^1.0.1" From fb9907e4f06dff0b263219ed0aa38b02fbb6272a Mon Sep 17 00:00:00 2001 From: Chris Mark Date: Mon, 13 Jan 2020 12:40:46 +0200 Subject: [PATCH 03/62] [Home][Tutorial] Add data UI for IBM MQ Filebeat module (#54238) --- .../ibmmq_logs/screenshot.png | Bin 0 -> 256401 bytes .../home/tutorial_resources/logos/ibmmq.svg | 1 + .../server/tutorials/ibmmq_logs/index.js | 71 ++++++++++++++++++ .../kibana/server/tutorials/register.js | 2 + 4 files changed, 74 insertions(+) create mode 100644 src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png create mode 100644 src/legacy/core_plugins/kibana/public/home/tutorial_resources/logos/ibmmq.svg create mode 100644 src/legacy/core_plugins/kibana/server/tutorials/ibmmq_logs/index.js diff --git a/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png b/src/legacy/core_plugins/kibana/public/home/tutorial_resources/ibmmq_logs/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..100a8b6ae367c4959e097b895ccc310f08bebf9d GIT binary patch literal 256401 zcmeFZWmH|;mM)6B1PSgA!QEYgyL)hV_ux)~I|K{v?!nz5xVyW1Wv&Z3?t_Y^3NabL-=2C6lBn1|HGL-M0|-l2*}%^{+ZgJll)Q0dNE95 z$Fyh({|mx;F|n)v1E0Sr6No|6_b2B#qr*!6FOH)!i)GCXX>H>>gmXmy&4nK|dVjFz zz2zds*lwL00}D7s%pABS-)I;{bgkR7N7sWQKv=dlzQ?=d8L2p}vPpcuMHq!XJ_{j% zv?a9+CGk9e9=L<{`?4P?vogl7dtvtvcH;`A`0{vt3SKNz5>6@KWY5o2W)2{)eqdm6 zFy@%|mqrX_I&Ll%4;3Se<7>q1`6Jq@v!z%b0$=)zOhPN z%HnTzc6{b<^SqZUG1ZTAf1pI2c>h_aJmYAptmD#617$QDKrAN1m!?mBAiQtkO1LrY z*VT^*^@i?4cgZ|Lm=J$kM3uiUja8OsC?X!FL9+Wt-^-17O$yanXi`a3p_eDtsPIHw z^D)N1%_k$PqxO*PsO|dc9+qIe(_kq|lE24(jKWWbz$$C%>IcNwuJbDVa101)e-B7B z{-GV$30|32c&&Tddk{PehjVm4<(xOxM!;$^GF`0)jk%(F-gtcz5`iL!#v*?el0nQA zJ@eGIMg6)`OUR@mi6ZIR;9th($4#{sLQo)azb4O1!aJSgIsbL=l4HA!Y)bq@<0~u< zcV=qQ8P6NFV-Ut~(nsm8;Q#@I_GuhvNQwWll^djwr%&Auv0iD(u)lRWFG4;i}-)sAUzobt!|KB0XzC~n*=G0ehqaVweW9uWQ|1U+(Ki#M}83K+KhZF~LB zAhQen=KAp&X^b`LE9T|zSU7Zl86LonT_-{8M_IXD$c@BZd1d!IW#7m!Qt9opf4Dza zOO;nH6Z<8aH$Z&F*U>AU(>-Ph1(P%%qA3?-AVj<#w_?GS3-XMlKY9)Utgg1C;8T1M%Nl)E@I8S z>EW-aHZRm6!PWOOmdPV7Ht)B0{HI_Ca14Hh7{%V2ZQ;Kzlf{37Ug11iS4aIg7eSf} zpd7;k_Uqx?9@C%yqUpCprg!{gV5M&Dpy%~R?wSC(bBGJbh?nG&%sw_)JU4iUvL2IUo*+qp7E0t?PL+CFh!Cy}rOIg|#0oSGY-_ zDwO76B@Kv$hMQs7he#t)VG6jR5kB_m2?+)fE|mqoRJyn

qH| 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�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

Ju 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®Y%s*w#>!4to=o5f30;qD2Ex!f_?joBEl!^0s0i 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 \ No newline at end of file diff --git a/src/legacy/core_plugins/kibana/server/tutorials/ibmmq_logs/index.js b/src/legacy/core_plugins/kibana/server/tutorials/ibmmq_logs/index.js new file mode 100644 index 00000000000000..4ffda2d7a523cf --- /dev/null +++ b/src/legacy/core_plugins/kibana/server/tutorials/ibmmq_logs/index.js @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { TUTORIAL_CATEGORY } from '../../../common/tutorials/tutorial_category'; +import { + onPremInstructions, + cloudInstructions, + onPremCloudInstructions, +} from '../../../common/tutorials/filebeat_instructions'; + +export function ibmmqLogsSpecProvider(server, context) { + const moduleName = 'ibmmq'; + const platforms = ['OSX', 'DEB', 'RPM', 'WINDOWS']; + return { + id: 'ibmmqLogs', + name: i18n.translate('kbn.server.tutorials.ibmmqLogs.nameTitle', { + defaultMessage: 'IBM MQ logs', + }), + category: TUTORIAL_CATEGORY.LOGGING, + shortDescription: i18n.translate('kbn.server.tutorials.ibmmqLogs.shortDescription', { + defaultMessage: 'Collect IBM MQ logs with Filebeat.', + }), + longDescription: i18n.translate('kbn.server.tutorials.ibmmqLogs.longDescription', { + defaultMessage: 'Collect IBM MQ logs with Filebeat. \ +[Learn more]({learnMoreLink}).', + values: { + learnMoreLink: '{config.docs.beats.filebeat}/filebeat-module-ibmmq.html', + }, + }), + euiIconType: '/plugins/kibana/home/tutorial_resources/logos/ibmmq.svg', + artifacts: { + dashboards: [ + { + id: 'ba1d8830-7c7b-11e9-9645-e37efaf5baff', + linkLabel: i18n.translate( + 'kbn.server.tutorials.ibmmqLogs.artifacts.dashboards.linkLabel', + { + defaultMessage: 'IBM MQ Events', + } + ), + isOverview: true, + }, + ], + exportedFields: { + documentationUrl: '{config.docs.beats.filebeat}/exported-fields-ibmmq.html', + }, + }, + completionTimeMinutes: 10, + previewImagePath: '/plugins/kibana/home/tutorial_resources/ibmmq_logs/screenshot.png', + onPrem: onPremInstructions(moduleName, platforms, context), + elasticCloud: cloudInstructions(moduleName, platforms), + onPremElasticCloud: onPremCloudInstructions(moduleName, platforms), + }; +} diff --git a/src/legacy/core_plugins/kibana/server/tutorials/register.js b/src/legacy/core_plugins/kibana/server/tutorials/register.js index 53ec16c1ca593b..69a6ac76e4a8f5 100644 --- a/src/legacy/core_plugins/kibana/server/tutorials/register.js +++ b/src/legacy/core_plugins/kibana/server/tutorials/register.js @@ -83,6 +83,7 @@ import { awsLogsSpecProvider } from './aws_logs'; import { activemqLogsSpecProvider } from './activemq_logs'; import { activemqMetricsSpecProvider } from './activemq_metrics'; import { azureMetricsSpecProvider } from './azure_metrics'; +import { ibmmqLogsSpecProvider } from './ibmmq_logs'; import { stanMetricsSpecProvider } from './stan_metrics'; import { envoyproxyMetricsSpecProvider } from './envoyproxy_metrics'; @@ -156,6 +157,7 @@ export function registerTutorials(server) { server.newPlatform.setup.plugins.home.tutorials.registerTutorial(activemqLogsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(activemqMetricsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(azureMetricsSpecProvider); + server.newPlatform.setup.plugins.home.tutorials.registerTutorial(ibmmqLogsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(stanMetricsSpecProvider); server.newPlatform.setup.plugins.home.tutorials.registerTutorial(envoyproxyMetricsSpecProvider); } From 37e3f63d0d6227c253cdc5cefef12e0bc8e71af4 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 13 Jan 2020 11:43:05 +0100 Subject: [PATCH 04/62] Handle another double quote special case (#54474) Co-authored-by: Elastic Machine --- .../__tests__/utils_string_expanding.txt | 30 +++++++++++++++++++ .../public/np_ready/lib/utils/utils.ts | 14 +++++++++ 2 files changed, 44 insertions(+) diff --git a/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt b/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt index 88467ab3672cd7..7de874c244e74f 100644 --- a/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt +++ b/src/legacy/core_plugins/console/public/np_ready/lib/utils/__tests__/utils_string_expanding.txt @@ -52,3 +52,33 @@ Correctly handle new lines in triple quotes SELECT * FROM "TABLE" """ } +========== +Single quotes escaped special case, start and end +------------------------------------- +{ + "query": "\"test\"" +} +------------------------------------- +{ + "query": "\"test\"" +} +========== +Single quotes escaped special case, start +------------------------------------- +{ + "query": "\"test" +} +------------------------------------- +{ + "query": "\"test" +} +========== +Single quotes escaped special case, end +------------------------------------- +{ + "query": "test\"" +} +------------------------------------- +{ + "query": "test\"" +} diff --git a/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts b/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts index a7f59acf1d77b6..0b10938abe7042 100644 --- a/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts +++ b/src/legacy/core_plugins/console/public/np_ready/lib/utils/utils.ts @@ -84,6 +84,20 @@ export function expandLiteralStrings(data: string) { // Expand to triple quotes if there are _any_ slashes if (string.match(/\\./)) { const firstDoubleQuoteIdx = string.indexOf('"'); + const lastDoubleQuoteIdx = string.lastIndexOf('"'); + + // Handle a special case where we may have a value like "\"test\"". We don't + // want to expand this to """"test"""" - so we terminate before processing the string + // further if we detect this either at the start or end of the double quote section. + + if (string[firstDoubleQuoteIdx + 1] === '\\' && string[firstDoubleQuoteIdx + 2] === '"') { + return string; + } + + if (string[lastDoubleQuoteIdx - 1] === '"' && string[lastDoubleQuoteIdx - 2] === '\\') { + return string; + } + const colonAndAnySpacing = string.slice(0, firstDoubleQuoteIdx); const rawStringifiedValue = string.slice(firstDoubleQuoteIdx, string.length); // Remove one level of JSON stringification From ccd36b3d57e1c8510c82e9eb8c3ebdb356397980 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 13 Jan 2020 11:43:59 +0100 Subject: [PATCH 05/62] Fix floating tools rendering logic (#54505) Co-authored-by: Elastic Machine --- .../editor/legacy/console_editor/editor.tsx | 2 +- .../subscribe_console_resize_checker.ts | 11 ++++++++-- .../legacy_core_editor/legacy_core_editor.ts | 20 +++++++++---------- .../models/legacy_core_editor/smart_resize.ts | 2 +- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx index 40b9cc4640eef1..761a252b56a877 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx +++ b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/console_editor/editor.tsx @@ -164,7 +164,7 @@ function EditorUI() { mappings.retrieveAutoCompleteInfo(); - const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor.getCoreEditor()); + const unsubscribeResizer = subscribeResizeChecker(editorRef.current!, editor); setupAutosave(); return () => { diff --git a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts index 4ecd5d415833cc..1adc56d47927be 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/containers/editor/legacy/subscribe_console_resize_checker.ts @@ -22,8 +22,15 @@ export function subscribeResizeChecker(el: HTMLElement, ...editors: any[]) { const checker = new ResizeChecker(el); checker.on('resize', () => editors.forEach(e => { - e.resize(); - if (e.updateActionsBar) e.updateActionsBar(); + if (e.getCoreEditor) { + e.getCoreEditor().resize(); + } else { + e.resize(); + } + + if (e.updateActionsBar) { + e.updateActionsBar(); + } }) ); return () => checker.destroy(); diff --git a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts index 608c73335b3e53..6262c304e307bf 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts @@ -297,30 +297,30 @@ export class LegacyCoreEditor implements CoreEditor { // pageY is relative to page, so subtract the offset // from pageY to get the new top value const offsetFromPage = $(this.editor.container).offset()!.top; - const startRow = range.start.lineNumber - 1; + const startLine = range.start.lineNumber; const startColumn = range.start.column; - const firstLine = this.getLineValue(startRow); + const firstLine = this.getLineValue(startLine); const maxLineLength = this.getWrapLimit() - 5; const isWrapping = firstLine.length > maxLineLength; - const getScreenCoords = (row: number) => - this.editor.renderer.textToScreenCoordinates(row, startColumn).pageY - offsetFromPage; - const topOfReq = getScreenCoords(startRow); + const getScreenCoords = (line: number) => + this.editor.renderer.textToScreenCoordinates(line - 1, startColumn).pageY - offsetFromPage; + const topOfReq = getScreenCoords(startLine); if (topOfReq >= 0) { let offset = 0; if (isWrapping) { // Try get the line height of the text area in pixels. const textArea = $(this.editor.container.querySelector('textArea')!); - const hasRoomOnNextLine = this.getLineValue(startRow + 1).length < maxLineLength; + const hasRoomOnNextLine = this.getLineValue(startLine).length < maxLineLength; if (textArea && hasRoomOnNextLine) { // Line height + the number of wraps we have on a line. - offset += this.getLineValue(startRow).length * textArea.height()!; + offset += this.getLineValue(startLine).length * textArea.height()!; } else { - if (startRow > 0) { - this.setActionsBar(getScreenCoords(startRow - 1)); + if (startLine > 1) { + this.setActionsBar(getScreenCoords(startLine - 1)); return; } - this.setActionsBar(getScreenCoords(startRow + 1)); + this.setActionsBar(getScreenCoords(startLine + 1)); return; } } diff --git a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts index b88e0e44591d83..7c4d871c4d73e2 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/smart_resize.ts @@ -24,7 +24,7 @@ export default function(editor: any) { const resize = editor.resize; const throttledResize = throttle(() => { - resize.call(editor); + resize.call(editor, false); // Keep current top line in view when resizing to avoid losing user context const userRow = get(throttledResize, 'topRow', 0); From a899df3f3df92f6ebe0c59521d1ecd4766068210 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Mon, 13 Jan 2020 13:57:26 +0300 Subject: [PATCH 06/62] [State Management] State containers improvements (#54436) Some maintenance and minor fixes to state containers based on experience while working with them in #53582 Patch unit tests to use current "terminology" (e.g. "transition" vs "mutation") Fix docs where "store" was used instead of "state container" Allow to create state container without transition. Fix freeze function to deeply freeze objects. Restrict State to BaseState with extends object. in set() function, make sure the flow goes through dispatch to make sure middleware see this update Improve type inference for useTransition() Improve type inference for createStateContainer(). Other issues noticed, but didn't fix in reasonable time: Can't use addMiddleware without explicit type casting #54438 Transitions and Selectors allow any state, not bind to container's state #54439 --- .../state_containers_examples/public/todo.tsx | 17 +- package.json | 2 + renovate.json5 | 8 + src/plugins/kibana_utils/demos/demos.test.ts | 2 +- .../demos/state_containers/counter.ts | 22 ++- .../demos/state_containers/todomvc.ts | 57 +++++-- .../kibana_utils/demos/state_sync/url.ts | 4 +- .../docs/state_containers/README.md | 17 +- .../docs/state_containers/creation.md | 2 +- .../docs/state_containers/no_react.md | 6 +- .../docs/state_containers/react.md | 2 +- .../create_state_container.test.ts | 157 +++++++++--------- .../create_state_container.ts | 44 +++-- ...ate_state_container_react_helpers.test.tsx | 112 ++++++------- .../create_state_container_react_helpers.ts | 2 +- .../public/state_containers/types.ts | 32 ++-- .../public/state_sync/state_sync.test.ts | 25 +-- .../public/state_sync/state_sync.ts | 6 +- .../kibana_utils/public/state_sync/types.ts | 7 +- yarn.lock | 10 ++ 20 files changed, 304 insertions(+), 230 deletions(-) diff --git a/examples/state_containers_examples/public/todo.tsx b/examples/state_containers_examples/public/todo.tsx index 84defb4a91e3f1..84f64f99d01798 100644 --- a/examples/state_containers_examples/public/todo.tsx +++ b/examples/state_containers_examples/public/todo.tsx @@ -41,6 +41,7 @@ import { PureTransition, syncStates, getStateFromKbnUrl, + BaseState, } from '../../../src/plugins/kibana_utils/public'; import { useUrlTracker } from '../../../src/plugins/kibana_react/public'; import { @@ -79,7 +80,7 @@ const TodoApp: React.FC = ({ filter }) => { const { setText } = GlobalStateHelpers.useTransitions(); const { text } = GlobalStateHelpers.useState(); const { edit: editTodo, delete: deleteTodo, add: addTodo } = useTransitions(); - const todos = useState(); + const todos = useState().todos; const filteredTodos = todos.filter(todo => { if (!filter) return true; if (filter === 'completed') return todo.completed; @@ -306,7 +307,7 @@ export const TodoAppPage: React.FC<{ ); }; -function withDefaultState( +function withDefaultState( stateContainer: BaseStateContainer, // eslint-disable-next-line no-shadow defaultState: State @@ -314,14 +315,10 @@ function withDefaultState( return { ...stateContainer, set: (state: State | null) => { - if (Array.isArray(defaultState)) { - stateContainer.set(state || defaultState); - } else { - stateContainer.set({ - ...defaultState, - ...state, - }); - } + stateContainer.set({ + ...defaultState, + ...state, + }); }, }; } diff --git a/package.json b/package.json index 0ed74dd65d1ab9..6b9640d214a5ef 100644 --- a/package.json +++ b/package.json @@ -166,6 +166,7 @@ "custom-event-polyfill": "^0.3.0", "d3": "3.5.17", "d3-cloud": "1.2.5", + "deep-freeze-strict": "^1.1.1", "deepmerge": "^4.2.2", "del": "^5.1.0", "elastic-apm-node": "^3.2.0", @@ -314,6 +315,7 @@ "@types/classnames": "^2.2.9", "@types/d3": "^3.5.43", "@types/dedent": "^0.7.0", + "@types/deep-freeze-strict": "^1.1.0", "@types/delete-empty": "^2.0.0", "@types/elasticsearch": "^5.0.33", "@types/enzyme": "^3.9.0", diff --git a/renovate.json5 b/renovate.json5 index 560403046b0a51..7f67fae8941104 100644 --- a/renovate.json5 +++ b/renovate.json5 @@ -210,6 +210,14 @@ '@types/dedent', ], }, + { + groupSlug: 'deep-freeze-strict', + groupName: 'deep-freeze-strict related packages', + packageNames: [ + 'deep-freeze-strict', + '@types/deep-freeze-strict', + ], + }, { groupSlug: 'delete-empty', groupName: 'delete-empty related packages', diff --git a/src/plugins/kibana_utils/demos/demos.test.ts b/src/plugins/kibana_utils/demos/demos.test.ts index 5c50e152ad46ca..b905aeff41f1ff 100644 --- a/src/plugins/kibana_utils/demos/demos.test.ts +++ b/src/plugins/kibana_utils/demos/demos.test.ts @@ -38,7 +38,7 @@ describe('demos', () => { describe('state sync', () => { test('url sync demo works', async () => { expect(await urlSyncResult).toMatchInlineSnapshot( - `"http://localhost/#?_s=!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test))"` + `"http://localhost/#?_s=(todos:!((completed:!f,id:0,text:'Learning%20state%20containers'),(completed:!f,id:2,text:test)))"` ); }); }); diff --git a/src/plugins/kibana_utils/demos/state_containers/counter.ts b/src/plugins/kibana_utils/demos/state_containers/counter.ts index 643763cc4cee93..4ddf532c1506db 100644 --- a/src/plugins/kibana_utils/demos/state_containers/counter.ts +++ b/src/plugins/kibana_utils/demos/state_containers/counter.ts @@ -19,14 +19,24 @@ import { createStateContainer } from '../../public/state_containers'; -const container = createStateContainer(0, { - increment: (cnt: number) => (by: number) => cnt + by, - double: (cnt: number) => () => cnt * 2, -}); +interface State { + count: number; +} + +const container = createStateContainer( + { count: 0 }, + { + increment: (state: State) => (by: number) => ({ count: state.count + by }), + double: (state: State) => () => ({ count: state.count * 2 }), + }, + { + count: (state: State) => () => state.count, + } +); container.transitions.increment(5); container.transitions.double(); -console.log(container.get()); // eslint-disable-line +console.log(container.selectors.count()); // eslint-disable-line -export const result = container.get(); +export const result = container.selectors.count(); diff --git a/src/plugins/kibana_utils/demos/state_containers/todomvc.ts b/src/plugins/kibana_utils/demos/state_containers/todomvc.ts index 6d0c960e2a5b26..e807783a56f319 100644 --- a/src/plugins/kibana_utils/demos/state_containers/todomvc.ts +++ b/src/plugins/kibana_utils/demos/state_containers/todomvc.ts @@ -25,15 +25,19 @@ export interface TodoItem { id: number; } -export type TodoState = TodoItem[]; +export interface TodoState { + todos: TodoItem[]; +} -export const defaultState: TodoState = [ - { - id: 0, - text: 'Learning state containers', - completed: false, - }, -]; +export const defaultState: TodoState = { + todos: [ + { + id: 0, + text: 'Learning state containers', + completed: false, + }, + ], +}; export interface TodoActions { add: PureTransition; @@ -44,17 +48,34 @@ export interface TodoActions { clearCompleted: PureTransition; } +export interface TodosSelectors { + todos: (state: TodoState) => () => TodoItem[]; + todo: (state: TodoState) => (id: number) => TodoItem | null; +} + export const pureTransitions: TodoActions = { - add: state => todo => [...state, todo], - edit: state => todo => state.map(item => (item.id === todo.id ? { ...item, ...todo } : item)), - delete: state => id => state.filter(item => item.id !== id), - complete: state => id => - state.map(item => (item.id === id ? { ...item, completed: true } : item)), - completeAll: state => () => state.map(item => ({ ...item, completed: true })), - clearCompleted: state => () => state.filter(({ completed }) => !completed), + add: state => todo => ({ todos: [...state.todos, todo] }), + edit: state => todo => ({ + todos: state.todos.map(item => (item.id === todo.id ? { ...item, ...todo } : item)), + }), + delete: state => id => ({ todos: state.todos.filter(item => item.id !== id) }), + complete: state => id => ({ + todos: state.todos.map(item => (item.id === id ? { ...item, completed: true } : item)), + }), + completeAll: state => () => ({ todos: state.todos.map(item => ({ ...item, completed: true })) }), + clearCompleted: state => () => ({ todos: state.todos.filter(({ completed }) => !completed) }), +}; + +export const pureSelectors: TodosSelectors = { + todos: state => () => state.todos, + todo: state => id => state.todos.find(todo => todo.id === id) ?? null, }; -const container = createStateContainer(defaultState, pureTransitions); +const container = createStateContainer( + defaultState, + pureTransitions, + pureSelectors +); container.transitions.add({ id: 1, @@ -64,6 +85,6 @@ container.transitions.add({ container.transitions.complete(0); container.transitions.complete(1); -console.log(container.get()); // eslint-disable-line +console.log(container.selectors.todos()); // eslint-disable-line -export const result = container.get(); +export const result = container.selectors.todos(); diff --git a/src/plugins/kibana_utils/demos/state_sync/url.ts b/src/plugins/kibana_utils/demos/state_sync/url.ts index 657b64f55a7766..2c426cae6733a4 100644 --- a/src/plugins/kibana_utils/demos/state_sync/url.ts +++ b/src/plugins/kibana_utils/demos/state_sync/url.ts @@ -18,7 +18,7 @@ */ import { defaultState, pureTransitions, TodoActions, TodoState } from '../state_containers/todomvc'; -import { BaseStateContainer, createStateContainer } from '../../public/state_containers'; +import { BaseState, BaseStateContainer, createStateContainer } from '../../public/state_containers'; import { createKbnUrlStateStorage, syncState, @@ -55,7 +55,7 @@ export const result = Promise.resolve() return window.location.href; }); -function withDefaultState( +function withDefaultState( // eslint-disable-next-line no-shadow stateContainer: BaseStateContainer, // eslint-disable-next-line no-shadow diff --git a/src/plugins/kibana_utils/docs/state_containers/README.md b/src/plugins/kibana_utils/docs/state_containers/README.md index 3b7a8b8bd4621d..583f8f65ce6b69 100644 --- a/src/plugins/kibana_utils/docs/state_containers/README.md +++ b/src/plugins/kibana_utils/docs/state_containers/README.md @@ -18,14 +18,21 @@ your services or apps. ```ts import { createStateContainer } from 'src/plugins/kibana_utils'; -const container = createStateContainer(0, { - increment: (cnt: number) => (by: number) => cnt + by, - double: (cnt: number) => () => cnt * 2, -}); +const container = createStateContainer( + { count: 0 }, + { + increment: (state: {count: number}) => (by: number) => ({ count: state.count + by }), + double: (state: {count: number}) => () => ({ count: state.count * 2 }), + }, + { + count: (state: {count: number}) => () => state.count, + } +); container.transitions.increment(5); container.transitions.double(); -console.log(container.get()); // 10 + +console.log(container.selectors.count()); // 10 ``` diff --git a/src/plugins/kibana_utils/docs/state_containers/creation.md b/src/plugins/kibana_utils/docs/state_containers/creation.md index 66d28bbd8603f0..f8ded75ed3f45c 100644 --- a/src/plugins/kibana_utils/docs/state_containers/creation.md +++ b/src/plugins/kibana_utils/docs/state_containers/creation.md @@ -32,7 +32,7 @@ Create your a state container. ```ts import { createStateContainer } from 'src/plugins/kibana_utils'; -const container = createStateContainer(defaultState, {}); +const container = createStateContainer(defaultState); console.log(container.get()); ``` diff --git a/src/plugins/kibana_utils/docs/state_containers/no_react.md b/src/plugins/kibana_utils/docs/state_containers/no_react.md index 7a15483d83b44f..a72995f4f1eaed 100644 --- a/src/plugins/kibana_utils/docs/state_containers/no_react.md +++ b/src/plugins/kibana_utils/docs/state_containers/no_react.md @@ -1,13 +1,13 @@ # Consuming state in non-React setting -To read the current `state` of the store use `.get()` method. +To read the current `state` of the store use `.get()` method or `getState()` alias method. ```ts -store.get(); +stateContainer.get(); ``` To listen for latest state changes use `.state$` observable. ```ts -store.state$.subscribe(state => { ... }); +stateContainer.state$.subscribe(state => { ... }); ``` diff --git a/src/plugins/kibana_utils/docs/state_containers/react.md b/src/plugins/kibana_utils/docs/state_containers/react.md index 363fd9253d44fd..1bab1af1d5f688 100644 --- a/src/plugins/kibana_utils/docs/state_containers/react.md +++ b/src/plugins/kibana_utils/docs/state_containers/react.md @@ -9,7 +9,7 @@ ```ts import { createStateContainer, createStateContainerReactHelpers } from 'src/plugins/kibana_utils'; -const container = createStateContainer({}, {}); +const container = createStateContainer({}); export const { Provider, Consumer, diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts index 95f4c35f2ce01b..d4877acaa5ca00 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.test.ts @@ -19,18 +19,9 @@ import { createStateContainer } from './create_state_container'; -const create = (state: S, transitions: T = {} as T) => { - const pureTransitions = { - set: () => (newState: S) => newState, - ...transitions, - }; - const store = createStateContainer(state, pureTransitions); - return { store, mutators: store.transitions }; -}; - -test('can create store', () => { - const { store } = create({}); - expect(store).toMatchObject({ +test('can create state container', () => { + const stateContainer = createStateContainer({}); + expect(stateContainer).toMatchObject({ getState: expect.any(Function), state$: expect.any(Object), transitions: expect.any(Object), @@ -45,9 +36,9 @@ test('can set default state', () => { const defaultState = { foo: 'bar', }; - const { store } = create(defaultState); - expect(store.get()).toEqual(defaultState); - expect(store.getState()).toEqual(defaultState); + const stateContainer = createStateContainer(defaultState); + expect(stateContainer.get()).toEqual(defaultState); + expect(stateContainer.getState()).toEqual(defaultState); }); test('can set state', () => { @@ -57,12 +48,12 @@ test('can set state', () => { const newState = { foo: 'baz', }; - const { store, mutators } = create(defaultState); + const stateContainer = createStateContainer(defaultState); - mutators.set(newState); + stateContainer.set(newState); - expect(store.get()).toEqual(newState); - expect(store.getState()).toEqual(newState); + expect(stateContainer.get()).toEqual(newState); + expect(stateContainer.getState()).toEqual(newState); }); test('does not shallow merge states', () => { @@ -72,22 +63,22 @@ test('does not shallow merge states', () => { const newState = { foo2: 'baz', }; - const { store, mutators } = create(defaultState); + const stateContainer = createStateContainer(defaultState); - mutators.set(newState as any); + stateContainer.set(newState as any); - expect(store.get()).toEqual(newState); - expect(store.getState()).toEqual(newState); + expect(stateContainer.get()).toEqual(newState); + expect(stateContainer.getState()).toEqual(newState); }); test('can subscribe and unsubscribe to state changes', () => { - const { store, mutators } = create({}); + const stateContainer = createStateContainer({}); const spy = jest.fn(); - const subscription = store.state$.subscribe(spy); - mutators.set({ a: 1 }); - mutators.set({ a: 2 }); + const subscription = stateContainer.state$.subscribe(spy); + stateContainer.set({ a: 1 }); + stateContainer.set({ a: 2 }); subscription.unsubscribe(); - mutators.set({ a: 3 }); + stateContainer.set({ a: 3 }); expect(spy).toHaveBeenCalledTimes(2); expect(spy.mock.calls[0][0]).toEqual({ a: 1 }); @@ -95,16 +86,16 @@ test('can subscribe and unsubscribe to state changes', () => { }); test('multiple subscribers can subscribe', () => { - const { store, mutators } = create({}); + const stateContainer = createStateContainer({}); const spy1 = jest.fn(); const spy2 = jest.fn(); - const subscription1 = store.state$.subscribe(spy1); - const subscription2 = store.state$.subscribe(spy2); - mutators.set({ a: 1 }); + const subscription1 = stateContainer.state$.subscribe(spy1); + const subscription2 = stateContainer.state$.subscribe(spy2); + stateContainer.set({ a: 1 }); subscription1.unsubscribe(); - mutators.set({ a: 2 }); + stateContainer.set({ a: 2 }); subscription2.unsubscribe(); - mutators.set({ a: 3 }); + stateContainer.set({ a: 3 }); expect(spy1).toHaveBeenCalledTimes(1); expect(spy2).toHaveBeenCalledTimes(2); @@ -120,19 +111,19 @@ test('can create state container without transitions', () => { expect(stateContainer.get()).toEqual(state); }); -test('creates impure mutators from pure mutators', () => { - const { mutators } = create( +test('creates transitions', () => { + const stateContainer = createStateContainer( {}, { setFoo: () => (bar: any) => ({ foo: bar }), } ); - expect(typeof mutators.setFoo).toBe('function'); + expect(typeof stateContainer.transitions.setFoo).toBe('function'); }); -test('mutators can update state', () => { - const { store, mutators } = create( +test('transitions can update state', () => { + const stateContainer = createStateContainer( { value: 0, foo: 'bar', @@ -143,30 +134,30 @@ test('mutators can update state', () => { } ); - expect(store.get()).toEqual({ + expect(stateContainer.get()).toEqual({ value: 0, foo: 'bar', }); - mutators.add(11); - mutators.setFoo('baz'); + stateContainer.transitions.add(11); + stateContainer.transitions.setFoo('baz'); - expect(store.get()).toEqual({ + expect(stateContainer.get()).toEqual({ value: 11, foo: 'baz', }); - mutators.add(-20); - mutators.setFoo('bazooka'); + stateContainer.transitions.add(-20); + stateContainer.transitions.setFoo('bazooka'); - expect(store.get()).toEqual({ + expect(stateContainer.get()).toEqual({ value: -9, foo: 'bazooka', }); }); -test('mutators methods are not bound', () => { - const { store, mutators } = create( +test('transitions methods are not bound', () => { + const stateContainer = createStateContainer( { value: -3 }, { add: (state: { value: number }) => (increment: number) => ({ @@ -176,13 +167,13 @@ test('mutators methods are not bound', () => { } ); - expect(store.get()).toEqual({ value: -3 }); - mutators.add(4); - expect(store.get()).toEqual({ value: 1 }); + expect(stateContainer.get()).toEqual({ value: -3 }); + stateContainer.transitions.add(4); + expect(stateContainer.get()).toEqual({ value: 1 }); }); -test('created mutators are saved in store object', () => { - const { store, mutators } = create( +test('created transitions are saved in stateContainer object', () => { + const stateContainer = createStateContainer( { value: -3 }, { add: (state: { value: number }) => (increment: number) => ({ @@ -192,55 +183,57 @@ test('created mutators are saved in store object', () => { } ); - expect(typeof store.transitions.add).toBe('function'); - mutators.add(5); - expect(store.get()).toEqual({ value: 2 }); + expect(typeof stateContainer.transitions.add).toBe('function'); + stateContainer.transitions.add(5); + expect(stateContainer.get()).toEqual({ value: 2 }); }); -test('throws when state is modified inline - 1', () => { - const container = createStateContainer({ a: 'b' }, {}); +test('throws when state is modified inline', () => { + const container = createStateContainer({ a: 'b', array: [{ a: 'b' }] }); - let error: TypeError | null = null; - try { + expect(() => { (container.get().a as any) = 'c'; - } catch (err) { - error = err; - } + }).toThrowErrorMatchingInlineSnapshot( + `"Cannot assign to read only property 'a' of object '#'"` + ); - expect(error).toBeInstanceOf(TypeError); -}); + expect(() => { + (container.getState().a as any) = 'c'; + }).toThrowErrorMatchingInlineSnapshot( + `"Cannot assign to read only property 'a' of object '#'"` + ); -test('throws when state is modified inline - 2', () => { - const container = createStateContainer({ a: 'b' }, {}); + expect(() => { + (container.getState().array as any).push('c'); + }).toThrowErrorMatchingInlineSnapshot(`"Cannot add property 1, object is not extensible"`); - let error: TypeError | null = null; - try { - (container.getState().a as any) = 'c'; - } catch (err) { - error = err; - } + expect(() => { + (container.getState().array[0] as any).c = 'b'; + }).toThrowErrorMatchingInlineSnapshot(`"Cannot add property c, object is not extensible"`); - expect(error).toBeInstanceOf(TypeError); + expect(() => { + container.set(null as any); + expect(container.getState()).toBeNull(); + }).not.toThrow(); }); -test('throws when state is modified inline in subscription', done => { +test('throws when state is modified inline in subscription', () => { const container = createStateContainer({ a: 'b' }, { set: () => (newState: any) => newState }); container.subscribe(value => { - let error: TypeError | null = null; - try { + expect(() => { (value.a as any) = 'd'; - } catch (err) { - error = err; - } - expect(error).toBeInstanceOf(TypeError); - done(); + }).toThrowErrorMatchingInlineSnapshot( + `"Cannot assign to read only property 'a' of object '#'"` + ); }); + container.transitions.set({ a: 'c' }); }); describe('selectors', () => { test('can specify no selectors, or can skip them', () => { + createStateContainer({}); createStateContainer({}, {}); createStateContainer({}, {}, {}); }); diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts index b949a9daed0ae6..d420aec30f068c 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container.ts @@ -20,34 +20,52 @@ import { BehaviorSubject } from 'rxjs'; import { skip } from 'rxjs/operators'; import { RecursiveReadonly } from '@kbn/utility-types'; +import deepFreeze from 'deep-freeze-strict'; import { PureTransitionsToTransitions, PureTransition, ReduxLikeStateContainer, PureSelectorsToSelectors, + BaseState, } from './types'; const $$observable = (typeof Symbol === 'function' && (Symbol as any).observable) || '@@observable'; +const $$setActionType = '@@SET'; const freeze: (value: T) => RecursiveReadonly = process.env.NODE_ENV !== 'production' ? (value: T): RecursiveReadonly => { - if (!value) return value as RecursiveReadonly; - if (value instanceof Array) return value as RecursiveReadonly; - if (typeof value === 'object') return Object.freeze({ ...value }) as RecursiveReadonly; - else return value as RecursiveReadonly; + const isFreezable = value !== null && typeof value === 'object'; + if (isFreezable) return deepFreeze(value) as RecursiveReadonly; + return value as RecursiveReadonly; } : (value: T) => value as RecursiveReadonly; -export const createStateContainer = < - State, - PureTransitions extends object = {}, - PureSelectors extends object = {} +export function createStateContainer( + defaultState: State +): ReduxLikeStateContainer; +export function createStateContainer( + defaultState: State, + pureTransitions: PureTransitions +): ReduxLikeStateContainer; +export function createStateContainer< + State extends BaseState, + PureTransitions extends object, + PureSelectors extends object +>( + defaultState: State, + pureTransitions: PureTransitions, + pureSelectors: PureSelectors +): ReduxLikeStateContainer; +export function createStateContainer< + State extends BaseState, + PureTransitions extends object, + PureSelectors extends object >( defaultState: State, pureTransitions: PureTransitions = {} as PureTransitions, pureSelectors: PureSelectors = {} as PureSelectors -): ReduxLikeStateContainer => { +): ReduxLikeStateContainer { const data$ = new BehaviorSubject>(freeze(defaultState)); const state$ = data$.pipe(skip(1)); const get = () => data$.getValue(); @@ -56,9 +74,13 @@ export const createStateContainer = < state$, getState: () => data$.getValue(), set: (state: State) => { - data$.next(freeze(state)); + container.dispatch({ type: $$setActionType, args: [state] }); }, reducer: (state, action) => { + if (action.type === $$setActionType) { + return freeze(action.args[0] as State); + } + const pureTransition = (pureTransitions as Record>)[ action.type ]; @@ -86,4 +108,4 @@ export const createStateContainer = < [$$observable]: state$, }; return container; -}; +} diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx index c1a35441b637b4..0f25f65c30ade0 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.test.tsx @@ -23,15 +23,6 @@ import { act, Simulate } from 'react-dom/test-utils'; import { createStateContainer } from './create_state_container'; import { createStateContainerReactHelpers } from './create_state_container_react_helpers'; -const create = (state: S, transitions: T = {} as T) => { - const pureTransitions = { - set: () => (newState: S) => newState, - ...transitions, - }; - const store = createStateContainer(state, pureTransitions); - return { store, mutators: store.transitions }; -}; - let container: HTMLDivElement | null; beforeEach(() => { @@ -56,12 +47,12 @@ test('can create React context', () => { }); test(' passes state to ', () => { - const { store } = create({ hello: 'world' }); - const { Provider, Consumer } = createStateContainerReactHelpers(); + const stateContainer = createStateContainer({ hello: 'world' }); + const { Provider, Consumer } = createStateContainerReactHelpers(); ReactDOM.render( - - {(s: typeof store) => s.get().hello} + + {(s: typeof stateContainer) => s.get().hello} , container ); @@ -79,8 +70,8 @@ interface Props1 { } test(' passes state to connect()()', () => { - const { store } = create({ hello: 'Bob' }); - const { Provider, connect } = createStateContainerReactHelpers(); + const stateContainer = createStateContainer({ hello: 'Bob' }); + const { Provider, connect } = createStateContainerReactHelpers(); const Demo: React.FC = ({ message, stop }) => ( <> @@ -92,7 +83,7 @@ test(' passes state to connect()()', () => { const DemoConnected = connect(mergeProps)(Demo); ReactDOM.render( - + , container @@ -101,14 +92,14 @@ test(' passes state to connect()()', () => { expect(container!.innerHTML).toBe('Bob?'); }); -test('context receives Redux store', () => { - const { store } = create({ foo: 'bar' }); - const { Provider, context } = createStateContainerReactHelpers(); +test('context receives stateContainer', () => { + const stateContainer = createStateContainer({ foo: 'bar' }); + const { Provider, context } = createStateContainerReactHelpers(); ReactDOM.render( /* eslint-disable no-shadow */ - - {store => store.get().foo} + + {stateContainer => stateContainer.get().foo} , /* eslint-enable no-shadow */ container @@ -117,21 +108,21 @@ test('context receives Redux store', () => { expect(container!.innerHTML).toBe('bar'); }); -xtest('can use multiple stores in one React app', () => {}); +test.todo('can use multiple stores in one React app'); describe('hooks', () => { describe('useStore', () => { - test('can select store using useStore hook', () => { - const { store } = create({ foo: 'bar' }); - const { Provider, useContainer } = createStateContainerReactHelpers(); + test('can select store using useContainer hook', () => { + const stateContainer = createStateContainer({ foo: 'bar' }); + const { Provider, useContainer } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { // eslint-disable-next-line no-shadow - const store = useContainer(); - return <>{store.get().foo}; + const stateContainer = useContainer(); + return <>{stateContainer.get().foo}; }; ReactDOM.render( - + , container @@ -143,15 +134,15 @@ describe('hooks', () => { describe('useState', () => { test('can select state using useState hook', () => { - const { store } = create({ foo: 'qux' }); - const { Provider, useState } = createStateContainerReactHelpers(); + const stateContainer = createStateContainer({ foo: 'qux' }); + const { Provider, useState } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { const { foo } = useState(); return <>{foo}; }; ReactDOM.render( - + , container @@ -161,23 +152,20 @@ describe('hooks', () => { }); test('re-renders when state changes', () => { - const { - store, - mutators: { setFoo }, - } = create( + const stateContainer = createStateContainer( { foo: 'bar' }, { setFoo: (state: { foo: string }) => (foo: string) => ({ ...state, foo }), } ); - const { Provider, useState } = createStateContainerReactHelpers(); + const { Provider, useState } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { const { foo } = useState(); return <>{foo}; }; ReactDOM.render( - + , container @@ -185,7 +173,7 @@ describe('hooks', () => { expect(container!.innerHTML).toBe('bar'); act(() => { - setFoo('baz'); + stateContainer.transitions.setFoo('baz'); }); expect(container!.innerHTML).toBe('baz'); }); @@ -193,7 +181,7 @@ describe('hooks', () => { describe('useTransitions', () => { test('useTransitions hook returns mutations that can update state', () => { - const { store } = create( + const stateContainer = createStateContainer( { cnt: 0, }, @@ -206,7 +194,7 @@ describe('hooks', () => { ); const { Provider, useState, useTransitions } = createStateContainerReactHelpers< - typeof store + typeof stateContainer >(); const Demo: React.FC<{}> = () => { const { cnt } = useState(); @@ -220,7 +208,7 @@ describe('hooks', () => { }; ReactDOM.render( - + , container @@ -240,7 +228,7 @@ describe('hooks', () => { describe('useSelector', () => { test('can select deeply nested value', () => { - const { store } = create({ + const stateContainer = createStateContainer({ foo: { bar: { baz: 'qux', @@ -248,14 +236,14 @@ describe('hooks', () => { }, }); const selector = (state: { foo: { bar: { baz: string } } }) => state.foo.bar.baz; - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); const Demo: React.FC<{}> = () => { const value = useSelector(selector); return <>{value}; }; ReactDOM.render( - + , container @@ -265,7 +253,7 @@ describe('hooks', () => { }); test('re-renders when state changes', () => { - const { store, mutators } = create({ + const stateContainer = createStateContainer({ foo: { bar: { baz: 'qux', @@ -280,7 +268,7 @@ describe('hooks', () => { }; ReactDOM.render( - + , container @@ -288,7 +276,7 @@ describe('hooks', () => { expect(container!.innerHTML).toBe('qux'); act(() => { - mutators.set({ + stateContainer.set({ foo: { bar: { baz: 'quux', @@ -300,9 +288,9 @@ describe('hooks', () => { }); test("re-renders only when selector's result changes", async () => { - const { store, mutators } = create({ a: 'b', foo: 'bar' }); + const stateContainer = createStateContainer({ a: 'b', foo: 'bar' }); const selector = (state: { foo: string }) => state.foo; - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); let cnt = 0; const Demo: React.FC<{}> = () => { @@ -311,7 +299,7 @@ describe('hooks', () => { return <>{value}; }; ReactDOM.render( - + , container @@ -321,14 +309,14 @@ describe('hooks', () => { expect(cnt).toBe(1); act(() => { - mutators.set({ a: 'c', foo: 'bar' }); + stateContainer.set({ a: 'c', foo: 'bar' }); }); await new Promise(r => setTimeout(r, 1)); expect(cnt).toBe(1); act(() => { - mutators.set({ a: 'd', foo: 'bar 2' }); + stateContainer.set({ a: 'd', foo: 'bar 2' }); }); await new Promise(r => setTimeout(r, 1)); @@ -336,9 +324,9 @@ describe('hooks', () => { }); test('does not re-render on same shape object', async () => { - const { store, mutators } = create({ foo: { bar: 'baz' } }); + const stateContainer = createStateContainer({ foo: { bar: 'baz' } }); const selector = (state: { foo: any }) => state.foo; - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); let cnt = 0; const Demo: React.FC<{}> = () => { @@ -347,7 +335,7 @@ describe('hooks', () => { return <>{JSON.stringify(value)}; }; ReactDOM.render( - + , container @@ -357,14 +345,14 @@ describe('hooks', () => { expect(cnt).toBe(1); act(() => { - mutators.set({ foo: { bar: 'baz' } }); + stateContainer.set({ foo: { bar: 'baz' } }); }); await new Promise(r => setTimeout(r, 1)); expect(cnt).toBe(1); act(() => { - mutators.set({ foo: { bar: 'qux' } }); + stateContainer.set({ foo: { bar: 'qux' } }); }); await new Promise(r => setTimeout(r, 1)); @@ -372,7 +360,7 @@ describe('hooks', () => { }); test('can set custom comparator function to prevent re-renders on deep equality', async () => { - const { store, mutators } = create( + const stateContainer = createStateContainer( { foo: { bar: 'baz' } }, { set: () => (newState: { foo: { bar: string } }) => newState, @@ -380,7 +368,7 @@ describe('hooks', () => { ); const selector = (state: { foo: any }) => state.foo; const comparator = (prev: any, curr: any) => JSON.stringify(prev) === JSON.stringify(curr); - const { Provider, useSelector } = createStateContainerReactHelpers(); + const { Provider, useSelector } = createStateContainerReactHelpers(); let cnt = 0; const Demo: React.FC<{}> = () => { @@ -389,7 +377,7 @@ describe('hooks', () => { return <>{JSON.stringify(value)}; }; ReactDOM.render( - + , container @@ -399,13 +387,13 @@ describe('hooks', () => { expect(cnt).toBe(1); act(() => { - mutators.set({ foo: { bar: 'baz' } }); + stateContainer.set({ foo: { bar: 'baz' } }); }); await new Promise(r => setTimeout(r, 1)); expect(cnt).toBe(1); }); - xtest('unsubscribes when React un-mounts', () => {}); + test.todo('unsubscribes when React un-mounts'); }); }); diff --git a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts index 45b34b13251f44..36903f2d7c90f7 100644 --- a/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts +++ b/src/plugins/kibana_utils/public/state_containers/create_state_container_react_helpers.ts @@ -35,7 +35,7 @@ export const createStateContainerReactHelpers = useContainer().transitions; + const useTransitions: () => Container['transitions'] = () => useContainer().transitions; const useSelector = ( selector: (state: UnboxState) => Result, diff --git a/src/plugins/kibana_utils/public/state_containers/types.ts b/src/plugins/kibana_utils/public/state_containers/types.ts index e120f60e72b8f3..5f27a3d2c1dcad 100644 --- a/src/plugins/kibana_utils/public/state_containers/types.ts +++ b/src/plugins/kibana_utils/public/state_containers/types.ts @@ -20,12 +20,13 @@ import { Observable } from 'rxjs'; import { Ensure, RecursiveReadonly } from '@kbn/utility-types'; +export type BaseState = object; export interface TransitionDescription { type: Type; args: Args; } -export type Transition = (...args: Args) => State; -export type PureTransition = ( +export type Transition = (...args: Args) => State; +export type PureTransition = ( state: RecursiveReadonly ) => Transition; export type EnsurePureTransition = Ensure>; @@ -34,15 +35,15 @@ export type PureTransitionsToTransitions = { [K in keyof T]: PureTransitionToTransition>; }; -export interface BaseStateContainer { +export interface BaseStateContainer { get: () => RecursiveReadonly; set: (state: State) => void; state$: Observable>; } export interface StateContainer< - State, - PureTransitions extends object = {}, + State extends BaseState, + PureTransitions extends object, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; @@ -50,7 +51,7 @@ export interface StateContainer< } export interface ReduxLikeStateContainer< - State, + State extends BaseState, PureTransitions extends object = {}, PureSelectors extends object = {} > extends StateContainer { @@ -63,14 +64,16 @@ export interface ReduxLikeStateContainer< } export type Dispatch = (action: T) => void; - -export type Middleware = ( +export type Middleware = ( store: Pick, 'getState' | 'dispatch'> ) => ( next: (action: TransitionDescription) => TransitionDescription | any ) => Dispatch; -export type Reducer = (state: State, action: TransitionDescription) => State; +export type Reducer = ( + state: State, + action: TransitionDescription +) => State; export type UnboxState< Container extends StateContainer @@ -80,7 +83,7 @@ export type UnboxTransitions< > = Container extends StateContainer ? T : never; export type Selector = (...args: Args) => Result; -export type PureSelector = ( +export type PureSelector = ( state: State ) => Selector; export type EnsurePureSelector = Ensure>; @@ -93,7 +96,12 @@ export type PureSelectorsToSelectors = { export type Comparator = (previous: Result, current: Result) => boolean; -export type MapStateToProps = (state: State) => StateProps; -export type Connect = ( +export type MapStateToProps = ( + state: State +) => StateProps; +export type Connect = < + Props extends object, + StatePropKeys extends keyof Props +>( mapStateToProp: MapStateToProps> ) => (component: React.ComponentType) => React.FC>; diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts index cc513bc674d0fc..08ad1551420d2e 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { BaseStateContainer, createStateContainer } from '../state_containers'; +import { BaseState, BaseStateContainer, createStateContainer } from '../state_containers'; import { defaultState, pureTransitions, @@ -89,7 +89,7 @@ describe('state_sync', () => { // initial sync of storage to state is not happening expect(container.getState()).toEqual(defaultState); - const storageState2 = [{ id: 1, text: 'todo', completed: true }]; + const storageState2 = { todos: [{ id: 1, text: 'todo', completed: true }] }; (testStateStorage.get as jest.Mock).mockImplementation(() => storageState2); storageChange$.next(storageState2); @@ -124,7 +124,7 @@ describe('state_sync', () => { start(); const originalState = container.getState(); - const storageState = [...originalState]; + const storageState = { ...originalState }; (testStateStorage.get as jest.Mock).mockImplementation(() => storageState); storageChange$.next(storageState); @@ -134,7 +134,7 @@ describe('state_sync', () => { }); it('storage change to null should notify state', () => { - container.set([{ completed: false, id: 1, text: 'changed' }]); + container.set({ todos: [{ completed: false, id: 1, text: 'changed' }] }); const { stop, start } = syncStates([ { stateContainer: withDefaultState(container, defaultState), @@ -189,8 +189,8 @@ describe('state_sync', () => { ]); start(); - const newStateFromUrl = [{ completed: false, id: 1, text: 'changed' }]; - history.replace('/#?_s=!((completed:!f,id:1,text:changed))'); + const newStateFromUrl = { todos: [{ completed: false, id: 1, text: 'changed' }] }; + history.replace('/#?_s=(todos:!((completed:!f,id:1,text:changed)))'); expect(container.getState()).toEqual(newStateFromUrl); expect(JSON.parse(sessionStorage.getItem(key)!)).toEqual(newStateFromUrl); @@ -220,7 +220,7 @@ describe('state_sync', () => { expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"` ); stop(); @@ -248,14 +248,14 @@ describe('state_sync', () => { expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"` ); await tick(); expect(history.length).toBe(startHistoryLength + 1); expect(getCurrentUrl()).toMatchInlineSnapshot( - `"/#?_s=!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3'))"` + `"/#?_s=(todos:!((completed:!t,id:0,text:'Learning%20state%20containers'),(completed:!t,id:2,text:'2'),(completed:!t,id:3,text:'3')))"` ); stop(); @@ -294,7 +294,7 @@ describe('state_sync', () => { }); }); -function withDefaultState( +function withDefaultState( stateContainer: BaseStateContainer, // eslint-disable-next-line no-shadow defaultState: State @@ -302,7 +302,10 @@ function withDefaultState( return { ...stateContainer, set: (state: State | null) => { - stateContainer.set(state || defaultState); + stateContainer.set({ + ...defaultState, + ...state, + }); }, }; } diff --git a/src/plugins/kibana_utils/public/state_sync/state_sync.ts b/src/plugins/kibana_utils/public/state_sync/state_sync.ts index f0ef1423dec71b..9c1116e5da5318 100644 --- a/src/plugins/kibana_utils/public/state_sync/state_sync.ts +++ b/src/plugins/kibana_utils/public/state_sync/state_sync.ts @@ -23,6 +23,7 @@ import defaultComparator from 'fast-deep-equal'; import { IStateSyncConfig } from './types'; import { IStateStorage } from './state_sync_state_storage'; import { distinctUntilChangedWithInitialValue } from '../../common'; +import { BaseState } from '../state_containers'; /** * Utility for syncing application state wrapped in state container @@ -86,7 +87,10 @@ export interface ISyncStateRef({ +export function syncState< + State extends BaseState, + StateStorage extends IStateStorage = IStateStorage +>({ storageKey, stateStorage, stateContainer, diff --git a/src/plugins/kibana_utils/public/state_sync/types.ts b/src/plugins/kibana_utils/public/state_sync/types.ts index 0f7395ad0f0e56..3009c1d161a532 100644 --- a/src/plugins/kibana_utils/public/state_sync/types.ts +++ b/src/plugins/kibana_utils/public/state_sync/types.ts @@ -17,10 +17,11 @@ * under the License. */ -import { BaseStateContainer } from '../state_containers/types'; +import { BaseState, BaseStateContainer } from '../state_containers/types'; import { IStateStorage } from './state_sync_state_storage'; -export interface INullableBaseStateContainer extends BaseStateContainer { +export interface INullableBaseStateContainer + extends BaseStateContainer { // State container for stateSync() have to accept "null" // for example, set() implementation could handle null and fallback to some default state // this is required to handle edge case, when state in storage becomes empty and syncing is in progress. @@ -29,7 +30,7 @@ export interface INullableBaseStateContainer extends BaseStateContainer { /** diff --git a/yarn.lock b/yarn.lock index 3dd7dbe37b2e94..ff098b7b9c891f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3439,6 +3439,11 @@ resolved "https://registry.yarnpkg.com/@types/dedent/-/dedent-0.7.0.tgz#155f339ca404e6dd90b9ce46a3f78fd69ca9b050" integrity sha512-EGlKlgMhnLt/cM4DbUSafFdrkeJoC9Mvnj0PUCU7tFmTjMjNRT957kXCx0wYm3JuEq4o4ZsS5vG+NlkM2DMd2A== +"@types/deep-freeze-strict@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@types/deep-freeze-strict/-/deep-freeze-strict-1.1.0.tgz#447a6a2576191344aa42310131dd3df5c41492c4" + integrity sha1-RHpqJXYZE0SqQjEBMd099cQUksQ= + "@types/delete-empty@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/delete-empty/-/delete-empty-2.0.0.tgz#1647ae9e68f708a6ba778531af667ec55bc61964" @@ -10034,6 +10039,11 @@ deep-extend@^0.6.0: resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== +deep-freeze-strict@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz#77d0583ca24a69be4bbd9ac2fae415d55523e5b0" + integrity sha1-d9BYPKJKab5LvZrC+uQV1VUj5bA= + deep-is@^0.1.3, deep-is@~0.1.3: version "0.1.3" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" From e7472e2f0073d8ad0bca291244b9093bb65915cd Mon Sep 17 00:00:00 2001 From: Jaime Soriano Pastor Date: Mon, 13 Jan 2020 13:25:48 +0100 Subject: [PATCH 07/62] Fix icon path in tutorial introduction (#49684) Some icons are included as SVG files with relative paths to their location. Add the base path so these files are correctly displayed when Kibana is not running from the root path. --- .../public/home/np_ready/components/tutorial/tutorial.js | 7 ++++++- .../public/home/np_ready/components/tutorial_directory.js | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js index 314ddf2196f06c..c7aa5b0f5b2f97 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial/tutorial.js @@ -363,6 +363,11 @@ class TutorialUi extends React.Component { ); } + let icon = this.state.tutorial.euiIconType; + if (icon && icon.includes('/')) { + icon = this.props.addBasePath(icon); + } + const instructions = this.getInstructions(); content = (
@@ -371,7 +376,7 @@ class TutorialUi extends React.Component { description={this.props.replaceTemplateStrings(this.state.tutorial.longDescription)} previewUrl={previewUrl} exportedFieldsUrl={exportedFieldsUrl} - iconType={this.state.tutorial.euiIconType} + iconType={icon} isBeta={this.state.tutorial.isBeta} /> diff --git a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js index 06da6f35ee42e5..697c1b0468cd14 100644 --- a/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js +++ b/src/legacy/core_plugins/kibana/public/home/np_ready/components/tutorial_directory.js @@ -129,7 +129,7 @@ class TutorialDirectoryUi extends React.Component { let tutorialCards = tutorialConfigs.map(tutorialConfig => { // add base path to SVG based icons let icon = tutorialConfig.euiIconType; - if (icon != null && icon.includes('/')) { + if (icon && icon.includes('/')) { icon = this.props.addBasePath(icon); } From 14df4c096c7b41be3aec803b5d80d639d88acba9 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 13 Jan 2020 07:28:39 -0500 Subject: [PATCH 08/62] [Maps] refactor isPointsOnly, isLinesOnly, and isPolygonsOnly to make synchronous (#54067) * [Maps] refactor isPointsOnly, isLinesOnly, and isPolygonsOnly to make synchronous * fix jest test * review feedback Co-authored-by: Elastic Machine --- .../__snapshots__/vector_icon.test.js.snap | 8 +- .../vector/components/legend/vector_icon.js | 91 ++++------- .../components/legend/vector_icon.test.js | 126 ++++----------- .../components/legend/vector_style_legend.js | 67 ++------ .../vector/components/vector_style_editor.js | 22 +-- .../dynamic_color_property.test.js.snap | 92 +---------- .../components/categorical_legend.js | 36 ++--- .../properties/dynamic_color_property.js | 24 +-- .../properties/dynamic_color_property.test.js | 32 +--- .../properties/dynamic_style_property.js | 10 +- .../public/layers/styles/vector/style_util.js | 4 - .../layers/styles/vector/vector_style.js | 151 +++++++++--------- .../layers/styles/vector/vector_style.test.js | 109 ++----------- 13 files changed, 199 insertions(+), 573 deletions(-) diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap index 57368b52a2bced..5837a80ec30833 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/__snapshots__/vector_icon.test.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Renders CircleIcon with correct styles when isPointOnly 1`] = ` +exports[`Renders CircleIcon 1`] = ` `; -exports[`Renders LineIcon with correct styles when isLineOnly 1`] = ` +exports[`Renders LineIcon 1`] = ` `; -exports[`Renders PolygonIcon with correct styles when not line only or not point only 1`] = ` +exports[`Renders PolygonIcon 1`] = ` `; -exports[`Renders SymbolIcon with correct styles when isPointOnly and symbolId provided 1`] = ` +exports[`Renders SymbolIcon 1`] = ` ; - } +export function VectorIcon({ fillColor, isPointsOnly, isLinesOnly, strokeColor, symbolId }) { + if (isLinesOnly) { const style = { - stroke: this.props.getColorForProperty(VECTOR_STYLES.LINE_COLOR, false), - strokeWidth: '1px', - fill: this.props.getColorForProperty(VECTOR_STYLES.FILL_COLOR, false), + stroke: strokeColor, + strokeWidth: '4px', }; + return ; + } - if (!this.state.isPointsOnly) { - return ; - } + const style = { + stroke: strokeColor, + strokeWidth: '1px', + fill: fillColor, + }; - if (!this.props.symbolId) { - return ; - } + if (!isPointsOnly) { + return ; + } - return ( - - ); + if (!symbolId) { + return ; } + + return ( + + ); } VectorIcon.propTypes = { - getColorForProperty: PropTypes.func.isRequired, + fillColor: PropTypes.string, + isPointsOnly: PropTypes.bool.isRequired, + isLinesOnly: PropTypes.bool.isRequired, + strokeColor: PropTypes.string.isRequired, symbolId: PropTypes.string, - loadIsPointsOnly: PropTypes.func.isRequired, - loadIsLinesOnly: PropTypes.func.isRequired, }; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js index ee0058a6ef1aa2..9d1a4d75beba28 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_icon.test.js @@ -8,113 +8,51 @@ import React from 'react'; import { shallow } from 'enzyme'; import { VectorIcon } from './vector_icon'; -import { VectorStyle } from '../../vector_style'; -import { extractColorFromStyleProperty } from './extract_color_from_style_property'; -import { VECTOR_STYLES } from '../../vector_style_defaults'; -let isPointsOnly = false; -let isLinesOnly = false; -const styles = { - fillColor: { - type: VectorStyle.STYLE_TYPE.STATIC, - options: { - color: '#ff0000', - }, - }, - lineColor: { - type: VectorStyle.STYLE_TYPE.DYNAMIC, - options: { - color: 'Blues', - field: { - name: 'prop1', - }, - }, - }, -}; - -const defaultProps = { - getColorForProperty: (styleProperty, isLinesOnly) => { - if (isLinesOnly) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'grey'); - } - - if (styleProperty === VECTOR_STYLES.LINE_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'none'); - } else if (styleProperty === VECTOR_STYLES.FILL_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.FILL_COLOR], 'grey'); - } else { - //unexpected - console.error('Cannot return color for properties other then line or fill color'); - } - }, - - loadIsPointsOnly: () => { - return isPointsOnly; - }, - loadIsLinesOnly: () => { - return isLinesOnly; - }, -}; - -function configureIsLinesOnly() { - isLinesOnly = true; - isPointsOnly = false; -} - -function configureIsPointsOnly() { - isLinesOnly = false; - isPointsOnly = true; -} - -function configureNotLineOrPointOnly() { - isLinesOnly = false; - isPointsOnly = false; -} - -test('Renders PolygonIcon with correct styles when not line only or not point only', async () => { - configureNotLineOrPointOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders PolygonIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); -test('Renders LineIcon with correct styles when isLineOnly', async () => { - configureIsLinesOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders LineIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); -test('Renders CircleIcon with correct styles when isPointOnly', async () => { - configureIsPointsOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders CircleIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); -test('Renders SymbolIcon with correct styles when isPointOnly and symbolId provided', async () => { - configureIsPointsOnly(); - const component = shallow(); - - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); +test('Renders SymbolIcon', () => { + const component = shallow( + + ); expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js index df302c42d48ed8..a7e98c83468ae4 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/legend/vector_style_legend.js @@ -4,57 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; -import React, { Component, Fragment } from 'react'; - -export class VectorStyleLegend extends Component { - state = { - styles: [], - }; - - componentDidMount() { - this._isMounted = true; - this._prevStyleDescriptors = undefined; - this._loadRows(); - } - - componentDidUpdate() { - this._loadRows(); - } - - componentWillUnmount() { - this._isMounted = false; - } - - _loadRows = _.debounce(async () => { - const styles = await this.props.getLegendDetailStyleProperties(); - const styleDescriptorPromises = styles.map(async style => { - return { - type: style.getStyleName(), - options: style.getOptions(), - fieldMeta: style.getFieldMeta(), - label: await style.getField().getLabel(), - }; - }); - - const styleDescriptors = await Promise.all(styleDescriptorPromises); - if (this._isMounted && !_.isEqual(styleDescriptors, this._prevStyleDescriptors)) { - this._prevStyleDescriptors = styleDescriptors; - this.setState({ styles: styles }); - } - }, 100); - - render() { - return this.state.styles.map(style => { - return ( - - {style.renderLegendDetailRow({ - loadIsLinesOnly: this.props.loadIsLinesOnly, - loadIsPointsOnly: this.props.loadIsPointsOnly, - symbolId: this.props.symbolId, - })} - - ); - }); - } +import React, { Fragment } from 'react'; + +export function VectorStyleLegend({ isLinesOnly, isPointsOnly, styles, symbolId }) { + return styles.map(style => { + return ( + + {style.renderLegendDetailRow({ + isLinesOnly, + isPointsOnly, + symbolId, + })} + + ); + }); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js index 8e80e036dbb8bc..dffe513644db84 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js @@ -86,25 +86,14 @@ export class VectorStyleEditor extends Component { async _loadSupportedFeatures() { const supportedFeatures = await this.props.layer.getSource().getSupportedShapeTypes(); - const isPointsOnly = await this.props.loadIsPointsOnly(); - const isLinesOnly = await this.props.loadIsLinesOnly(); - if (!this._isMounted) { return; } - if ( - _.isEqual(supportedFeatures, this.state.supportedFeatures) && - isPointsOnly === this.state.isPointsOnly && - isLinesOnly === this.state.isLinesOnly - ) { - return; - } - let selectedFeature = VECTOR_SHAPE_TYPES.POLYGON; - if (isPointsOnly) { + if (this.props.isPointsOnly) { selectedFeature = VECTOR_SHAPE_TYPES.POINT; - } else if (isLinesOnly) { + } else if (this.props.isLinesOnly) { selectedFeature = VECTOR_SHAPE_TYPES.LINE; } @@ -112,12 +101,7 @@ export class VectorStyleEditor extends Component { !_.isEqual(supportedFeatures, this.state.supportedFeatures) || selectedFeature !== this.state.selectedFeature ) { - this.setState({ - supportedFeatures, - selectedFeature, - isPointsOnly, - isLinesOnly, - }); + this.setState({ supportedFeatures, selectedFeature }); } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap index 26e36cb97a7911..8da8cfaa71e2c6 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/__snapshots__/dynamic_color_property.test.js.snap @@ -1,98 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Should render categorical legend 1`] = ` -
- - - - - - - 0_format - - - - - - - - - - - - 10_format - - - - - - - - - - - - - - - foobar_label - - - - - - -
-`; +exports[`Should render categorical legend 1`] = `""`; exports[`Should render ranged legend 1`] = ` { - return isLinesOnly; - }; - - const loadIsPointsOnly = () => { - return isPointsOnly; - }; - - const getColorForProperty = (styleProperty, isLinesOnly) => { - if (isLinesOnly) { - return color; - } - - return this.getStyleName() === styleProperty ? color : 'none'; - }; - + const fillColor = this.getStyleName() === VECTOR_STYLES.FILL_COLOR ? color : 'none'; return ( ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js index dbf704c9cbe4ce..0affeefde13135 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -24,7 +24,7 @@ const mockField = { }, }; -test('Should render ranged legend', async () => { +test('Should render ranged legend', () => { const colorStyle = new DynamicColorProperty( { color: 'Blues', @@ -40,25 +40,15 @@ test('Should render ranged legend', async () => { ); const legendRow = colorStyle.renderLegendDetailRow({ - loadIsPointsOnly: () => { - return true; - }, - loadIsLinesOnly: () => { - return false; - }, + isPointsOnly: true, + isLinesOnly: false, }); - const component = shallow(legendRow); - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); }); -test('Should render categorical legend', async () => { +test('Should render categorical legend', () => { const colorStyle = new DynamicColorProperty( { useCustomColorRamp: true, @@ -84,20 +74,10 @@ test('Should render categorical legend', async () => { ); const legendRow = colorStyle.renderLegendDetailRow({ - loadIsPointsOnly: () => { - return true; - }, - loadIsLinesOnly: () => { - return false; - }, + isPointsOnly: true, + isLinesOnly: false, }); - const component = shallow(legendRow); - // Ensure all promises resolve - await new Promise(resolve => process.nextTick(resolve)); - // Ensure the state changes are reflected - component.update(); - expect(component).toMatchSnapshot(); }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index bac3c96581967c..cb5858fa47b3ef 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -165,12 +165,12 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return null; } - _renderCategoricalLegend({ loadIsPointsOnly, loadIsLinesOnly, symbolId }) { + _renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }) { return ( ); @@ -180,11 +180,11 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return ; } - renderLegendDetailRow({ loadIsPointsOnly, loadIsLinesOnly, symbolId }) { + renderLegendDetailRow({ isPointsOnly, isLinesOnly, symbolId }) { if (this.isRanged()) { return this._renderRangeLegend(); } else if (this.hasBreaks()) { - return this._renderCategoricalLegend({ loadIsPointsOnly, loadIsLinesOnly, symbolId }); + return this._renderCategoricalLegend({ isPointsOnly, isLinesOnly, symbolId }); } else { return null; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js index b8fc428a62a529..7bd60ea6502bc3 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js @@ -17,10 +17,6 @@ export function isOnlySingleFeatureType(featureType, supportedFeatures, hasFeatu return supportedFeatures[0] === featureType; } - if (!hasFeatureType) { - return false; - } - const featureTypes = Object.keys(hasFeatureType); return featureTypes.reduce((isOnlyTargetFeatureType, featureTypeKey) => { const hasFeature = hasFeatureType[featureTypeKey]; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js index ea80b188e16463..d1efcbb72d1a7e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js @@ -143,8 +143,8 @@ export class VectorStyle extends AbstractStyle { styleProperties={styleProperties} symbolDescriptor={this._descriptor.properties[VECTOR_STYLES.SYMBOL]} layer={layer} - loadIsPointsOnly={this._getIsPointsOnly} - loadIsLinesOnly={this._getIsLinesOnly} + isPointsOnly={this._getIsPointsOnly()} + isLinesOnly={this._getIsLinesOnly()} onIsTimeAwareChange={onIsTimeAwareChange} isTimeAware={this.isTimeAware()} showIsTimeAware={propertiesWithFieldMeta.length > 0} @@ -218,43 +218,57 @@ export class VectorStyle extends AbstractStyle { async pluckStyleMetaFromSourceDataRequest(sourceDataRequest) { const features = _.get(sourceDataRequest.getData(), 'features', []); - if (features.length === 0) { - return {}; - } - - const dynamicProperties = this.getDynamicPropertiesArray(); const supportedFeatures = await this._source.getSupportedShapeTypes(); - const isSingleFeatureType = supportedFeatures.length === 1; - if (dynamicProperties.length === 0 && isSingleFeatureType) { - // no meta data to pull from source data request. - return {}; - } - - let hasPoints = false; - let hasLines = false; - let hasPolygons = false; - for (let i = 0; i < features.length; i++) { - const feature = features[i]; - if (!hasPoints && POINTS.includes(feature.geometry.type)) { - hasPoints = true; - } - if (!hasLines && LINES.includes(feature.geometry.type)) { - hasLines = true; - } - if (!hasPolygons && POLYGONS.includes(feature.geometry.type)) { - hasPolygons = true; + const hasFeatureType = { + [VECTOR_SHAPE_TYPES.POINT]: false, + [VECTOR_SHAPE_TYPES.LINE]: false, + [VECTOR_SHAPE_TYPES.POLYGON]: false, + }; + if (supportedFeatures.length > 1) { + for (let i = 0; i < features.length; i++) { + const feature = features[i]; + if (!hasFeatureType[VECTOR_SHAPE_TYPES.POINT] && POINTS.includes(feature.geometry.type)) { + hasFeatureType[VECTOR_SHAPE_TYPES.POINT] = true; + } + if (!hasFeatureType[VECTOR_SHAPE_TYPES.LINE] && LINES.includes(feature.geometry.type)) { + hasFeatureType[VECTOR_SHAPE_TYPES.LINE] = true; + } + if ( + !hasFeatureType[VECTOR_SHAPE_TYPES.POLYGON] && + POLYGONS.includes(feature.geometry.type) + ) { + hasFeatureType[VECTOR_SHAPE_TYPES.POLYGON] = true; + } } } const featuresMeta = { - hasFeatureType: { - [VECTOR_SHAPE_TYPES.POINT]: hasPoints, - [VECTOR_SHAPE_TYPES.LINE]: hasLines, - [VECTOR_SHAPE_TYPES.POLYGON]: hasPolygons, + geometryTypes: { + isPointsOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.POINT, + supportedFeatures, + hasFeatureType + ), + isLinesOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.LINE, + supportedFeatures, + hasFeatureType + ), + isPolygonsOnly: isOnlySingleFeatureType( + VECTOR_SHAPE_TYPES.POLYGON, + supportedFeatures, + hasFeatureType + ), }, }; + const dynamicProperties = this.getDynamicPropertiesArray(); + if (dynamicProperties.length === 0 || features.length === 0) { + // no additional meta data to pull from source data request. + return featuresMeta; + } + dynamicProperties.forEach(dynamicProperty => { const styleMeta = dynamicProperty.pluckStyleMetaFromFeatures(features); if (styleMeta) { @@ -291,24 +305,16 @@ export class VectorStyle extends AbstractStyle { ); } - _isOnlySingleFeatureType = async featureType => { - return isOnlySingleFeatureType( - featureType, - await this._source.getSupportedShapeTypes(), - this._getStyleMeta().hasFeatureType - ); - }; - - _getIsPointsOnly = async () => { - return this._isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POINT); + _getIsPointsOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isPointsOnly', false); }; - _getIsLinesOnly = async () => { - return this._isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.LINE); + _getIsLinesOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isLinesOnly', false); }; - _getIsPolygonsOnly = async () => { - return this._isOnlySingleFeatureType(VECTOR_SHAPE_TYPES.POLYGON); + _getIsPolygonsOnly = () => { + return _.get(this._getStyleMeta(), 'geometryTypes.isPolygonsOnly', false); }; _getDynamicPropertyByFieldName(fieldName) { @@ -393,50 +399,44 @@ export class VectorStyle extends AbstractStyle { : this._descriptor.properties.symbol.options.symbolId; } - _getColorForProperty = (styleProperty, isLinesOnly) => { - const styles = this.getRawProperties(); - if (isLinesOnly) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'grey'); - } - - if (styleProperty === VECTOR_STYLES.LINE_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.LINE_COLOR], 'none'); - } else if (styleProperty === VECTOR_STYLES.FILL_COLOR) { - return extractColorFromStyleProperty(styles[VECTOR_STYLES.FILL_COLOR], 'grey'); - } else { - //unexpected - console.error('Cannot return color for properties other then line or fill color'); - } - }; - getIcon = () => { - const symbolId = this._getSymbolId(); + const isLinesOnly = this._getIsLinesOnly(); + const strokeColor = isLinesOnly + ? extractColorFromStyleProperty(this._descriptor.properties[VECTOR_STYLES.LINE_COLOR], 'grey') + : extractColorFromStyleProperty( + this._descriptor.properties[VECTOR_STYLES.LINE_COLOR], + 'none' + ); + const fillColor = isLinesOnly + ? null + : extractColorFromStyleProperty( + this._descriptor.properties[VECTOR_STYLES.FILL_COLOR], + 'grey' + ); return ( ); }; - _getLegendDetailStyleProperties = async () => { - const isLinesOnly = await this._getIsLinesOnly(); - const isPolygonsOnly = await this._getIsPolygonsOnly(); - + _getLegendDetailStyleProperties = () => { return this.getDynamicPropertiesArray().filter(styleProperty => { const styleName = styleProperty.getStyleName(); if ([VECTOR_STYLES.ICON_ORIENTATION, VECTOR_STYLES.LABEL_TEXT].includes(styleName)) { return false; } - if (isLinesOnly) { + if (this._getIsLinesOnly()) { return LINE_STYLES.includes(styleName); } - if (isPolygonsOnly) { + if (this._getIsPolygonsOnly()) { return POLYGON_STYLES.includes(styleName); } @@ -445,16 +445,15 @@ export class VectorStyle extends AbstractStyle { }; async hasLegendDetails() { - const styles = await this._getLegendDetailStyleProperties(); - return styles.length > 0; + return this._getLegendDetailStyleProperties().length > 0; } renderLegendDetails() { return ( ); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js index aa0badd5583d5b..3d2911720c3120 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js @@ -159,11 +159,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { const vectorStyle = new VectorStyle({}, new MockSource()); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); - expect(featuresMeta.hasFeatureType).toEqual({ - LINE: false, - POINT: true, - POLYGON: false, - }); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(true); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(false); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); }); it('Should identify when feature collection only contains lines', async () => { @@ -189,11 +187,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { const vectorStyle = new VectorStyle({}, new MockSource()); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); - expect(featuresMeta.hasFeatureType).toEqual({ - LINE: true, - POINT: false, - POLYGON: false, - }); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(false); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(true); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); }); }); @@ -241,11 +237,9 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { ); const featuresMeta = await vectorStyle.pluckStyleMetaFromSourceDataRequest(sourceDataRequest); - expect(featuresMeta.hasFeatureType).toEqual({ - LINE: false, - POINT: true, - POLYGON: false, - }); + expect(featuresMeta.geometryTypes.isPointsOnly).toBe(true); + expect(featuresMeta.geometryTypes.isLinesOnly).toBe(false); + expect(featuresMeta.geometryTypes.isPolygonsOnly).toBe(false); }); it('Should extract scaled field range', async () => { @@ -275,88 +269,3 @@ describe('pluckStyleMetaFromSourceDataRequest', () => { }); }); }); - -describe('checkIfOnlyFeatureType', () => { - describe('source supports single feature type', () => { - it('isPointsOnly should be true when source feature type only supports points', async () => { - const vectorStyle = new VectorStyle( - {}, - new MockSource({ - supportedShapeTypes: [VECTOR_SHAPE_TYPES.POINT], - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(true); - }); - - it('isLineOnly should be false when source feature type only supports points', async () => { - const vectorStyle = new VectorStyle( - {}, - new MockSource({ - supportedShapeTypes: [VECTOR_SHAPE_TYPES.POINT], - }) - ); - const isLineOnly = await vectorStyle._getIsLinesOnly(); - expect(isLineOnly).toBe(false); - }); - }); - - describe('source supports multiple feature types', () => { - it('isPointsOnly should be true when data contains just points', async () => { - const vectorStyle = new VectorStyle( - { - __styleMeta: { - hasFeatureType: { - POINT: true, - LINE: false, - POLYGON: false, - }, - }, - }, - new MockSource({ - supportedShapeTypes: Object.values(VECTOR_SHAPE_TYPES), - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(true); - }); - - it('isPointsOnly should be false when data contains just lines', async () => { - const vectorStyle = new VectorStyle( - { - __styleMeta: { - hasFeatureType: { - POINT: false, - LINE: true, - POLYGON: false, - }, - }, - }, - new MockSource({ - supportedShapeTypes: Object.values(VECTOR_SHAPE_TYPES), - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(false); - }); - - it('isPointsOnly should be false when data contains points, lines, and polygons', async () => { - const vectorStyle = new VectorStyle( - { - __styleMeta: { - hasFeatureType: { - POINT: true, - LINE: true, - POLYGON: true, - }, - }, - }, - new MockSource({ - supportedShapeTypes: Object.values(VECTOR_SHAPE_TYPES), - }) - ); - const isPointsOnly = await vectorStyle._getIsPointsOnly(); - expect(isPointsOnly).toBe(false); - }); - }); -}); From 05c48cf153760c20e21b7d9a114085304240d711 Mon Sep 17 00:00:00 2001 From: cachedout Date: Mon, 13 Jan 2020 13:42:33 +0000 Subject: [PATCH 09/62] Display APM server memory in bytes (#54275) * Display APM server memory in bytes * Add tests for helpers --- .../__snapshots__/helpers.test.js.snap | 48 +++++++++++++++++++ .../overview/__tests__/helpers.test.js | 42 ++++++++++++++++ .../components/cluster/overview/apm_panel.js | 8 +--- .../cluster/overview/elasticsearch_panel.js | 3 +- .../components/cluster/overview/helpers.js | 13 +++-- 5 files changed, 101 insertions(+), 13 deletions(-) create mode 100644 x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/__snapshots__/helpers.test.js.snap create mode 100644 x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/helpers.test.js diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/__snapshots__/helpers.test.js.snap b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/__snapshots__/helpers.test.js.snap new file mode 100644 index 00000000000000..ea9d312413168b --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/__snapshots__/helpers.test.js.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Bytes Usage should format correctly with only usedBytes 1`] = ` + +
+ 50.0 B +
+
+`; + +exports[`Bytes Usage should format correctly with used and max bytes 1`] = ` + +
+ 50.0 B / 100.0 B +
+
+`; + +exports[`BytesPercentageUsage should format correctly with used bytes and max bytes 1`] = ` + +
+ 50.00% +
+
+
+ 50.0 B / 100.0 B +
+
+
+`; + +exports[`BytesPercentageUsage should return zero bytes if both parameters are not present 1`] = ` +
+ 0 +
+`; diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/helpers.test.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/helpers.test.js new file mode 100644 index 00000000000000..fea8f0001540af --- /dev/null +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/__tests__/helpers.test.js @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { renderWithIntl } from '../../../../../../../../test_utils/enzyme_helpers'; +import { BytesUsage, BytesPercentageUsage } from '../helpers'; + +describe('Bytes Usage', () => { + it('should format correctly with used and max bytes', () => { + const props = { + usedBytes: 50, + maxBytes: 100, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); + + it('should format correctly with only usedBytes', () => { + const props = { + usedBytes: 50, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); +}); + +describe('BytesPercentageUsage', () => { + it('should format correctly with used bytes and max bytes', () => { + const props = { + usedBytes: 50, + maxBytes: 100, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); + it('should return zero bytes if both parameters are not present', () => { + const props = { + usedBytes: 50, + }; + expect(renderWithIntl()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js index 3ba04359c2672b..84dc13e9da1de3 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/apm_panel.js @@ -8,11 +8,7 @@ import React from 'react'; import moment from 'moment'; import { get } from 'lodash'; import { formatMetric } from 'plugins/monitoring/lib/format_number'; -import { - ClusterItemContainer, - BytesPercentageUsage, - DisabledIfNoDataAndInSetupModeLink, -} from './helpers'; +import { ClusterItemContainer, BytesUsage, DisabledIfNoDataAndInSetupModeLink } from './helpers'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { @@ -153,7 +149,7 @@ export function ApmPanel(props) { /> - + diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js index fc23110f940e88..7b08c89f538814 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/elasticsearch_panel.js @@ -10,7 +10,6 @@ import { formatNumber } from 'plugins/monitoring/lib/format_number'; import { ClusterItemContainer, HealthStatusIndicator, - BytesUsage, BytesPercentageUsage, DisabledIfNoDataAndInSetupModeLink, } from './helpers'; @@ -291,7 +290,7 @@ export function ElasticsearchPanel(props) { /> - diff --git a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js index ae7cc1b4e965c1..0d9290225cd5f0 100644 --- a/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js +++ b/x-pack/legacy/plugins/monitoring/public/components/cluster/overview/helpers.js @@ -6,7 +6,7 @@ import React from 'react'; import { get } from 'lodash'; -import { formatBytesUsage, formatPercentageUsage } from 'plugins/monitoring/lib/format_number'; +import { formatBytesUsage, formatPercentageUsage, formatNumber } from '../../../lib/format_number'; import { EuiSpacer, EuiFlexItem, @@ -88,10 +88,13 @@ export function BytesUsage({ usedBytes, maxBytes }) { if (usedBytes && maxBytes) { return ( - {formatPercentageUsage(usedBytes, maxBytes)} - - {formatBytesUsage(usedBytes, maxBytes)} - + {formatBytesUsage(usedBytes, maxBytes)} + + ); + } else if (usedBytes) { + return ( + + {formatNumber(usedBytes, 'byte')} ); } From 2d62ff2cbfb0bed6f5658e8dd39ef1207273ca46 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Mon, 13 Jan 2020 15:04:06 +0100 Subject: [PATCH 10/62] [NP] Remove observables from es internal contract (#54556) * request context always uses the latest es client * update integration tests Co-authored-by: Elastic Machine --- .../elasticsearch_service.mock.ts | 2 - .../elasticsearch_service.test.ts | 40 +------------------ .../elasticsearch/elasticsearch_service.ts | 2 - src/core/server/elasticsearch/types.ts | 3 -- .../core_service.test.mocks.ts | 5 ++- .../integration_tests/core_services.test.ts | 22 ++++++---- src/core/server/server.ts | 8 +--- 7 files changed, 22 insertions(+), 60 deletions(-) diff --git a/src/core/server/elasticsearch/elasticsearch_service.mock.ts b/src/core/server/elasticsearch/elasticsearch_service.mock.ts index 1b52f22c4da09a..a4e51ca55b3e71 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.mock.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.mock.ts @@ -74,8 +74,6 @@ const createInternalSetupContractMock = () => { legacy: { config$: new BehaviorSubject({} as ElasticsearchConfig), }, - adminClient$: new BehaviorSubject(createClusterClientMock()), - dataClient$: new BehaviorSubject(createClusterClientMock()), }; setupContract.adminClient.asScoped.mockReturnValue(createScopedClusterClientMock()); setupContract.dataClient.asScoped.mockReturnValue(createScopedClusterClientMock()); diff --git a/src/core/server/elasticsearch/elasticsearch_service.test.ts b/src/core/server/elasticsearch/elasticsearch_service.test.ts index 9f694ac1c46da2..5a7d223fec7ad9 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.test.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.test.ts @@ -21,7 +21,7 @@ import { first } from 'rxjs/operators'; import { MockClusterClient } from './elasticsearch_service.test.mocks'; -import { BehaviorSubject, combineLatest } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; import { Env } from '../config'; import { getEnvOptions } from '../config/__mocks__/env'; import { CoreContext } from '../core_context'; @@ -91,44 +91,6 @@ describe('#setup', () => { expect(mockDataClusterClientInstance.callAsInternalUser).toHaveBeenCalledTimes(1); }); - it('returns data and admin client observables as a part of the contract', async () => { - const mockAdminClusterClientInstance = { close: jest.fn() }; - const mockDataClusterClientInstance = { close: jest.fn() }; - MockClusterClient.mockImplementationOnce( - () => mockAdminClusterClientInstance - ).mockImplementationOnce(() => mockDataClusterClientInstance); - - const setupContract = await elasticsearchService.setup(deps); - - const [esConfig, adminClient, dataClient] = await combineLatest( - setupContract.legacy.config$, - setupContract.adminClient$, - setupContract.dataClient$ - ) - .pipe(first()) - .toPromise(); - - expect(adminClient).toBe(mockAdminClusterClientInstance); - expect(dataClient).toBe(mockDataClusterClientInstance); - - expect(MockClusterClient).toHaveBeenCalledTimes(2); - expect(MockClusterClient).toHaveBeenNthCalledWith( - 1, - esConfig, - expect.objectContaining({ context: ['elasticsearch', 'admin'] }), - undefined - ); - expect(MockClusterClient).toHaveBeenNthCalledWith( - 2, - esConfig, - expect.objectContaining({ context: ['elasticsearch', 'data'] }), - expect.any(Function) - ); - - expect(mockAdminClusterClientInstance.close).not.toHaveBeenCalled(); - expect(mockDataClusterClientInstance.close).not.toHaveBeenCalled(); - }); - describe('#createClient', () => { it('allows to specify config properties', async () => { const setupContract = await elasticsearchService.setup(deps); diff --git a/src/core/server/elasticsearch/elasticsearch_service.ts b/src/core/server/elasticsearch/elasticsearch_service.ts index db3fda3a504ab5..aba246ce66fb50 100644 --- a/src/core/server/elasticsearch/elasticsearch_service.ts +++ b/src/core/server/elasticsearch/elasticsearch_service.ts @@ -152,8 +152,6 @@ export class ElasticsearchService implements CoreService clients.config)) }, - adminClient$, - dataClient$, adminClient, dataClient, diff --git a/src/core/server/elasticsearch/types.ts b/src/core/server/elasticsearch/types.ts index 22340bf3f2fc62..899b273c5c60ad 100644 --- a/src/core/server/elasticsearch/types.ts +++ b/src/core/server/elasticsearch/types.ts @@ -77,7 +77,4 @@ export interface InternalElasticsearchServiceSetup extends ElasticsearchServiceS readonly legacy: { readonly config$: Observable; }; - - readonly adminClient$: Observable; - readonly dataClient$: Observable; } diff --git a/src/core/server/http/integration_tests/core_service.test.mocks.ts b/src/core/server/http/integration_tests/core_service.test.mocks.ts index 3982df567ed7ce..6fa3357168027d 100644 --- a/src/core/server/http/integration_tests/core_service.test.mocks.ts +++ b/src/core/server/http/integration_tests/core_service.test.mocks.ts @@ -16,8 +16,11 @@ * specific language governing permissions and limitations * under the License. */ +import { elasticsearchServiceMock } from '../../elasticsearch/elasticsearch_service.mock'; export const clusterClientMock = jest.fn(); jest.doMock('../../elasticsearch/scoped_cluster_client', () => ({ - ScopedClusterClient: clusterClientMock, + ScopedClusterClient: clusterClientMock.mockImplementation(function() { + return elasticsearchServiceMock.createScopedClusterClient(); + }), })); diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index f3867faa2ae75f..65c4f1432721de 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -133,7 +133,7 @@ describe('http service', () => { const { http } = await root.setup(); const { registerAuth } = http; - await registerAuth((req, res, toolkit) => { + registerAuth((req, res, toolkit) => { return toolkit.authenticated({ responseHeaders: authResponseHeader }); }); @@ -157,7 +157,7 @@ describe('http service', () => { const { http } = await root.setup(); const { registerAuth } = http; - await registerAuth((req, res, toolkit) => { + registerAuth((req, res, toolkit) => { return toolkit.authenticated({ responseHeaders: authResponseHeader }); }); @@ -222,12 +222,15 @@ describe('http service', () => { const { http } = await root.setup(); const { registerAuth, createRouter } = http; - await registerAuth((req, res, toolkit) => - toolkit.authenticated({ requestHeaders: authHeaders }) - ); + registerAuth((req, res, toolkit) => toolkit.authenticated({ requestHeaders: authHeaders })); const router = createRouter('/new-platform'); - router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + router.get({ path: '/', validate: false }, async (context, req, res) => { + // it forces client initialization since the core creates them lazily. + await context.core.elasticsearch.adminClient.callAsCurrentUser('ping'); + await context.core.elasticsearch.dataClient.callAsCurrentUser('ping'); + return res.ok(); + }); await root.start(); @@ -247,7 +250,12 @@ describe('http service', () => { const { createRouter } = http; const router = createRouter('/new-platform'); - router.get({ path: '/', validate: false }, (context, req, res) => res.ok()); + router.get({ path: '/', validate: false }, async (context, req, res) => { + // it forces client initialization since the core creates them lazily. + await context.core.elasticsearch.adminClient.callAsCurrentUser('ping'); + await context.core.elasticsearch.dataClient.callAsCurrentUser('ping'); + return res.ok(); + }); await root.start(); diff --git a/src/core/server/server.ts b/src/core/server/server.ts index 611842e8a7de0d..7c3f9f249db137 100644 --- a/src/core/server/server.ts +++ b/src/core/server/server.ts @@ -17,7 +17,6 @@ * under the License. */ -import { take } from 'rxjs/operators'; import { Type } from '@kbn/config-schema'; import { @@ -216,9 +215,6 @@ export class Server { coreId, 'core', async (context, req, res): Promise => { - // it consumes elasticsearch observables to provide the same client throughout the context lifetime. - const adminClient = await coreSetup.elasticsearch.adminClient$.pipe(take(1)).toPromise(); - const dataClient = await coreSetup.elasticsearch.dataClient$.pipe(take(1)).toPromise(); const savedObjectsClient = coreSetup.savedObjects.getScopedClient(req); const uiSettingsClient = coreSetup.uiSettings.asScopedToClient(savedObjectsClient); @@ -230,8 +226,8 @@ export class Server { client: savedObjectsClient, }, elasticsearch: { - adminClient: adminClient.asScoped(req), - dataClient: dataClient.asScoped(req), + adminClient: coreSetup.elasticsearch.adminClient.asScoped(req), + dataClient: coreSetup.elasticsearch.dataClient.asScoped(req), }, uiSettings: { client: uiSettingsClient, From ebd2c2190bdcfbbf1eb530f310b5458877d2805a Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Mon, 13 Jan 2020 16:26:33 +0200 Subject: [PATCH 11/62] Management advanced settings telemetry (#54369) * management telemetry * Use getUserProvided --- .../telemetry/common/constants.ts | 6 ++ .../telemetry/server/collectors/index.ts | 1 + .../server/collectors/management/index.ts | 20 ++++++ .../telemetry_management_collector.ts | 63 +++++++++++++++++++ .../core_plugins/telemetry/server/plugin.ts | 2 + 5 files changed, 92 insertions(+) create mode 100644 src/legacy/core_plugins/telemetry/server/collectors/management/index.ts create mode 100644 src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts diff --git a/src/legacy/core_plugins/telemetry/common/constants.ts b/src/legacy/core_plugins/telemetry/common/constants.ts index 7e366676a8565b..cb4ff79969a322 100644 --- a/src/legacy/core_plugins/telemetry/common/constants.ts +++ b/src/legacy/core_plugins/telemetry/common/constants.ts @@ -75,3 +75,9 @@ export const UI_METRIC_USAGE_TYPE = 'ui_metric'; * Link to Advanced Settings. */ export const PATH_TO_ADVANCED_SETTINGS = 'kibana#/management/kibana/settings'; + +/** + * The type name used within the Monitoring index to publish management stats. + * @type {string} + */ +export const KIBANA_MANAGEMENT_STATS_TYPE = 'management'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/index.ts b/src/legacy/core_plugins/telemetry/server/collectors/index.ts index 2f2a53278117be..04ee4773cd60d5 100644 --- a/src/legacy/core_plugins/telemetry/server/collectors/index.ts +++ b/src/legacy/core_plugins/telemetry/server/collectors/index.ts @@ -22,3 +22,4 @@ export { registerTelemetryUsageCollector } from './usage'; export { registerUiMetricUsageCollector } from './ui_metric'; export { registerLocalizationUsageCollector } from './localization'; export { registerTelemetryPluginUsageCollector } from './telemetry_plugin'; +export { registerManagementUsageCollector } from './management'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/management/index.ts b/src/legacy/core_plugins/telemetry/server/collectors/management/index.ts new file mode 100644 index 00000000000000..979bbed3765e2c --- /dev/null +++ b/src/legacy/core_plugins/telemetry/server/collectors/management/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { registerManagementUsageCollector } from './telemetry_management_collector'; diff --git a/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts b/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts new file mode 100644 index 00000000000000..f45cf7fc6bb33f --- /dev/null +++ b/src/legacy/core_plugins/telemetry/server/collectors/management/telemetry_management_collector.ts @@ -0,0 +1,63 @@ +/* + * 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 { Server } from 'hapi'; +import { size } from 'lodash'; +import { KIBANA_MANAGEMENT_STATS_TYPE } from '../../../common/constants'; +import { UsageCollectionSetup } from '../../../../../../plugins/usage_collection/server'; +import { SavedObjectsClient } from '../../../../../../core/server'; + +export type UsageStats = Record; + +export async function getTranslationCount(loader: any, locale: string): Promise { + const translations = await loader.getTranslationsByLocale(locale); + return size(translations.messages); +} + +export function createCollectorFetch(server: Server) { + return async function fetchUsageStats(): Promise { + const internalRepo = server.newPlatform.setup.core.savedObjects.createInternalRepository(); + const uiSettingsClient = server.newPlatform.start.core.uiSettings.asScopedToClient( + new SavedObjectsClient(internalRepo) + ); + + const user = await uiSettingsClient.getUserProvided(); + const modifiedEntries = Object.keys(user) + .filter((key: string) => key !== 'buildNum') + .reduce((obj: any, key: string) => { + obj[key] = user[key].userValue; + return obj; + }, {}); + + return modifiedEntries; + }; +} + +export function registerManagementUsageCollector( + usageCollection: UsageCollectionSetup, + server: any +) { + const collector = usageCollection.makeUsageCollector({ + type: KIBANA_MANAGEMENT_STATS_TYPE, + isReady: () => true, + fetch: createCollectorFetch(server), + }); + + usageCollection.registerCollector(collector); +} diff --git a/src/legacy/core_plugins/telemetry/server/plugin.ts b/src/legacy/core_plugins/telemetry/server/plugin.ts index 06a974f473498e..b5b53b1daba553 100644 --- a/src/legacy/core_plugins/telemetry/server/plugin.ts +++ b/src/legacy/core_plugins/telemetry/server/plugin.ts @@ -27,6 +27,7 @@ import { registerTelemetryUsageCollector, registerLocalizationUsageCollector, registerTelemetryPluginUsageCollector, + registerManagementUsageCollector, } from './collectors'; export interface PluginsSetup { @@ -50,5 +51,6 @@ export class TelemetryPlugin { registerLocalizationUsageCollector(usageCollection, server); registerTelemetryUsageCollector(usageCollection, server); registerUiMetricUsageCollector(usageCollection, server); + registerManagementUsageCollector(usageCollection, server); } } From 641c67091f8bc30e557fdd5a7dcf7b117dcc677e Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Mon, 13 Jan 2020 08:09:55 -0700 Subject: [PATCH 12/62] [SEIM][Detection Engine] Time gap detection and logging ## Summary This adds utilities and logging of time gap detection. Gaps happen whenever rules begin to fall behind their interval. This isn't a perfect works for all inputs and if it detects unexpected input that is not of an interval format (but could be valid date time math) it will just return null and ignore it. This also fixes a bug with interval where we were using the object instead of the primitive since alerting team changed their structure. For testing, fire up any rule and shutdown Kibana for more than 6 minutes and then when restarting you should see the warning message. ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. ~~- [ ] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility)~~ ~~- [ ] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md)~~ ~~- [ ] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~~ - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios ~~- [ ] This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~~ ### For maintainers ~~- [ ] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~~ - [x] This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) --- .../signals/signal_rule_alert_type.ts | 22 +- .../detection_engine/signals/utils.test.ts | 258 ++++++++++++++++++ .../lib/detection_engine/signals/utils.ts | 66 +++++ 3 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index ab2c1733b04ca2..774afb6d7deb07 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -6,6 +6,7 @@ import { schema } from '@kbn/config-schema'; import { Logger } from 'src/core/server'; +import moment from 'moment'; import { SIGNALS_ID, DEFAULT_MAX_SIGNALS, @@ -17,6 +18,7 @@ import { getInputIndex } from './get_input_output_index'; import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { getFilter } from './get_filter'; import { SignalRuleAlertTypeDefinition } from './types'; +import { getGapBetweenRuns } from './utils'; export const signalRulesAlertType = ({ logger, @@ -57,7 +59,8 @@ export const signalRulesAlertType = ({ version: schema.number({ defaultValue: 1 }), }), }, - async executor({ alertId, services, params }) { + // fun fact: previousStartedAt is not actually a Date but a String of a date + async executor({ previousStartedAt, alertId, services, params }) { const { from, ruleId, @@ -70,7 +73,6 @@ export const signalRulesAlertType = ({ to, type, } = params; - // TODO: Remove this hard extraction of name once this is fixed: https://github.com/elastic/kibana/issues/50522 const savedObject = await services.savedObjectsClient.get('alert', alertId); const name: string = savedObject.attributes.name; @@ -78,9 +80,19 @@ export const signalRulesAlertType = ({ const createdBy: string = savedObject.attributes.createdBy; const updatedBy: string = savedObject.attributes.updatedBy; - const interval: string = savedObject.attributes.interval; + const interval: string = savedObject.attributes.schedule.interval; const enabled: boolean = savedObject.attributes.enabled; - + const gap = getGapBetweenRuns({ + previousStartedAt: previousStartedAt != null ? moment(previousStartedAt) : null, // TODO: Remove this once previousStartedAt is no longer a string + interval, + from, + to, + }); + if (gap != null && gap.asMilliseconds() > 0) { + logger.warn( + `Signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" has a time gap of ${gap.humanize()} (${gap.asMilliseconds()}ms), and could be missing signals within that time. Consider increasing your look behind time or adding more Kibana instances.` + ); + } // set searchAfter page size to be the lesser of default page size or maxSignals. const searchAfterSize = DEFAULT_SEARCH_AFTER_PAGE_SIZE <= params.maxSignals @@ -155,7 +167,7 @@ export const signalRulesAlertType = ({ // TODO: Error handling and writing of errors into a signal that has error // handling/conditions logger.error( - `Error from signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` + `Error from signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" message: ${err.message}` ); } }, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts new file mode 100644 index 00000000000000..d6a3da5a393f8c --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.test.ts @@ -0,0 +1,258 @@ +/* + * 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 moment from 'moment'; + +import { generateId, parseInterval, getDriftTolerance, getGapBetweenRuns } from './utils'; + +describe('utils', () => { + let nowDate = moment('2020-01-01T00:00:00.000Z'); + + beforeEach(() => { + nowDate = moment('2020-01-01T00:00:00.000Z'); + }); + + describe('generateId', () => { + test('it generates expected output', () => { + const id = generateId('index-123', 'doc-123', 'version-123', 'rule-123'); + expect(id).toEqual('10622e7d06c9e38a532e71fc90e3426c1100001fb617aec8cb974075da52db06'); + }); + + test('expected output is a hex', () => { + const id = generateId('index-123', 'doc-123', 'version-123', 'rule-123'); + expect(id).toMatch(/[a-f0-9]+/); + }); + }); + + describe('getIntervalMilliseconds', () => { + test('it returns a duration when given one that is valid', () => { + const duration = parseInterval('5m'); + expect(duration).not.toBeNull(); + expect(duration?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); + }); + + test('it returns null given an invalid duration', () => { + const duration = parseInterval('junk'); + expect(duration).toBeNull(); + }); + }); + + describe('getDriftToleranceMilliseconds', () => { + test('it returns a drift tolerance in milliseconds of 1 minute when from overlaps to by 1 minute and the interval is 5 minutes', () => { + const drift = getDriftTolerance({ + from: 'now-6m', + to: 'now', + interval: moment.duration(5, 'minutes'), + }); + expect(drift).not.toBeNull(); + expect(drift?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); + }); + + test('it returns a drift tolerance of 0 when from equals the interval', () => { + const drift = getDriftTolerance({ + from: 'now-5m', + to: 'now', + interval: moment.duration(5, 'minutes'), + }); + expect(drift?.asMilliseconds()).toEqual(0); + }); + + test('it returns a drift tolerance of 5 minutes when from is 10 minutes but the interval is 5 minutes', () => { + const drift = getDriftTolerance({ + from: 'now-10m', + to: 'now', + interval: moment.duration(5, 'minutes'), + }); + expect(drift).not.toBeNull(); + expect(drift?.asMilliseconds()).toEqual(moment.duration(5, 'minutes').asMilliseconds()); + }); + + test('it returns a drift tolerance of 10 minutes when from is 10 minutes ago and the interval is 0', () => { + const drift = getDriftTolerance({ + from: 'now-10m', + to: 'now', + interval: moment.duration(0, 'milliseconds'), + }); + expect(drift).not.toBeNull(); + expect(drift?.asMilliseconds()).toEqual(moment.duration(10, 'minutes').asMilliseconds()); + }); + + test('returns null if the "to" is not "now" since we have limited support for date math', () => { + const drift = getDriftTolerance({ + from: 'now-6m', + to: 'invalid', // if not set to "now" this function returns null + interval: moment.duration(1000, 'milliseconds'), + }); + expect(drift).toBeNull(); + }); + + test('returns null if the "from" does not start with "now-" since we have limited support for date math', () => { + const drift = getDriftTolerance({ + from: 'valid', // if not set to "now-x" where x is an interval such as 6m + to: 'now', + interval: moment.duration(1000, 'milliseconds'), + }); + expect(drift).toBeNull(); + }); + + test('returns null if the "from" starts with "now-" but has a string instead of an integer', () => { + const drift = getDriftTolerance({ + from: 'now-dfdf', // if not set to "now-x" where x is an interval such as 6m + to: 'now', + interval: moment.duration(1000, 'milliseconds'), + }); + expect(drift).toBeNull(); + }); + }); + + describe('getGapBetweenRuns', () => { + test('it returns a gap of 0 when from and interval match each other and the previous started was from the previous interval time', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(5, 'minutes'), + interval: '5m', + from: 'now-5m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(0); + }); + + test('it returns a negative gap of 1 minute when from overlaps to by 1 minute and the previousStartedAt was 5 minutes ago', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(5, 'minutes'), + interval: '5m', + from: 'now-6m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(-1, 'minute').asMilliseconds()); + }); + + test('it returns a negative gap of 5 minutes when from overlaps to by 1 minute and the previousStartedAt was 5 minutes ago', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(5, 'minutes'), + interval: '5m', + from: 'now-10m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(-5, 'minute').asMilliseconds()); + }); + + test('it returns a negative gap of 1 minute when from overlaps to by 1 minute and the previousStartedAt was 10 minutes ago and so was the interval', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(10, 'minutes'), + interval: '10m', + from: 'now-11m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(-1, 'minute').asMilliseconds()); + }); + + test('it returns a gap of only -30 seconds when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is 30 seconds more', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate + .clone() + .subtract(5, 'minutes') + .subtract(30, 'seconds'), + interval: '5m', + from: 'now-6m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(-30, 'seconds').asMilliseconds()); + }); + + test('it returns an exact 0 gap when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is one minute late', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(6, 'minutes'), + interval: '5m', + from: 'now-6m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(0, 'minute').asMilliseconds()); + }); + + test('it returns a gap of 30 seconds when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is one minute and 30 seconds late', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate + .clone() + .subtract(6, 'minutes') + .subtract(30, 'seconds'), + interval: '5m', + from: 'now-6m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(30, 'seconds').asMilliseconds()); + }); + + test('it returns a gap of 1 minute when the from overlaps with now by 1 minute, the interval is 5 minutes but the previous started is two minutes late', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone().subtract(7, 'minutes'), + interval: '5m', + from: 'now-6m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap?.asMilliseconds()).not.toBeNull(); + expect(gap?.asMilliseconds()).toEqual(moment.duration(1, 'minute').asMilliseconds()); + }); + + test('it returns null if given a previousStartedAt of null', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: null, + interval: '5m', + from: 'now-5m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).toBeNull(); + }); + + test('it returns null if the interval is an invalid string such as "invalid"', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone(), + interval: 'invalid', // if not set to "x" where x is an interval such as 6m + from: 'now-5m', + to: 'now', + now: nowDate.clone(), + }); + expect(gap).toBeNull(); + }); + + test('it returns null if from is an invalid string such as "invalid"', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone(), + interval: '5m', + from: 'invalid', // if not set to "now-x" where x is an interval such as 6m + to: 'now', + now: nowDate.clone(), + }); + expect(gap).toBeNull(); + }); + + test('it returns null if to is an invalid string such as "invalid"', () => { + const gap = getGapBetweenRuns({ + previousStartedAt: nowDate.clone(), + interval: '5m', + from: 'now-5m', + to: 'invalid', // if not set to "now" this function returns null + now: nowDate.clone(), + }); + expect(gap).toBeNull(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts index f25ce1d9054666..5a4c67ebaaa362 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/utils.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { createHash } from 'crypto'; +import moment from 'moment'; + +import { parseDuration } from '../../../../../alerting/server/lib'; export const generateId = ( docIndex: string, @@ -14,3 +17,66 @@ export const generateId = ( createHash('sha256') .update(docIndex.concat(docId, version, ruleId)) .digest('hex'); + +export const parseInterval = (intervalString: string): moment.Duration | null => { + try { + return moment.duration(parseDuration(intervalString)); + } catch (err) { + return null; + } +}; + +export const getDriftTolerance = ({ + from, + to, + interval, +}: { + from: string; + to: string; + interval: moment.Duration; +}): moment.Duration | null => { + if (to.trim() !== 'now') { + // we only support 'now' for drift detection + return null; + } + if (!from.trim().startsWith('now-')) { + // we only support from tha starts with now for drift detection + return null; + } + const split = from.split('-'); + const duration = parseInterval(split[1]); + if (duration !== null) { + return duration.subtract(interval); + } else { + return null; + } +}; + +export const getGapBetweenRuns = ({ + previousStartedAt, + interval, + from, + to, + now = moment(), +}: { + previousStartedAt: moment.Moment | undefined | null; + interval: string; + from: string; + to: string; + now?: moment.Moment; +}): moment.Duration | null => { + if (previousStartedAt == null) { + return null; + } + const intervalDuration = parseInterval(interval); + if (intervalDuration == null) { + return null; + } + const driftTolerance = getDriftTolerance({ from, to, interval: intervalDuration }); + if (driftTolerance == null) { + return null; + } + const diff = moment.duration(now.diff(previousStartedAt)); + const drift = diff.subtract(intervalDuration); + return drift.subtract(driftTolerance); +}; From ea4a1ac12c03f3f312a0c734acb9e73da4d4266e Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Mon, 13 Jan 2020 07:21:24 -0800 Subject: [PATCH 13/62] Fixing the spaces header aria-controls a11y issue (#54512) * Fixing the spaces header aria-controls a11y issue * Updating snapshots Co-authored-by: Elastic Machine --- .../__snapshots__/nav_control_popover.test.tsx.snap | 3 ++- .../__snapshots__/spaces_description.test.tsx.snap | 1 + .../nav_control/components/spaces_description.test.tsx | 1 + .../public/nav_control/components/spaces_description.tsx | 2 ++ .../spaces/public/nav_control/components/spaces_menu.tsx | 2 ++ .../spaces/public/nav_control/nav_control_popover.tsx | 6 +++++- 6 files changed, 13 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap b/x-pack/legacy/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap index 5cad4e794cfda6..45daa03e94c2e3 100644 --- a/x-pack/legacy/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap +++ b/x-pack/legacy/plugins/spaces/public/nav_control/__snapshots__/nav_control_popover.test.tsx.snap @@ -5,7 +5,7 @@ exports[`NavControlPopover renders without crashing 1`] = ` anchorPosition="downRight" button={ diff --git a/x-pack/legacy/plugins/spaces/public/nav_control/components/__snapshots__/spaces_description.test.tsx.snap b/x-pack/legacy/plugins/spaces/public/nav_control/components/__snapshots__/spaces_description.test.tsx.snap index 079dab701cc1dd..8e78f64ac59cb0 100644 --- a/x-pack/legacy/plugins/spaces/public/nav_control/components/__snapshots__/spaces_description.test.tsx.snap +++ b/x-pack/legacy/plugins/spaces/public/nav_control/components/__snapshots__/spaces_description.test.tsx.snap @@ -4,6 +4,7 @@ exports[`SpacesDescription renders without crashing 1`] = ` diff --git a/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_description.test.tsx b/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_description.test.tsx index aacf3845e0e0f3..157dcab3e0be16 100644 --- a/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_description.test.tsx +++ b/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_description.test.tsx @@ -13,6 +13,7 @@ describe('SpacesDescription', () => { expect( shallow( void; capabilities: Capabilities; } export const SpacesDescription: FC = (props: Props) => { const panelProps = { + id: props.id, className: 'spcDescription', title: 'Spaces', }; diff --git a/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_menu.tsx b/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_menu.tsx index 96ce18896b426b..4d89f57d4ccf18 100644 --- a/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_menu.tsx +++ b/x-pack/legacy/plugins/spaces/public/nav_control/components/spaces_menu.tsx @@ -20,6 +20,7 @@ import { ManageSpacesButton } from './manage_spaces_button'; import { SpaceAvatar } from '../../space_avatar'; interface Props { + id: string; spaces: Space[]; isLoading: boolean; onSelectSpace: (space: Space) => void; @@ -48,6 +49,7 @@ class SpacesMenuUI extends Component { : this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem); const panelProps = { + id: this.props.id, className: 'spcMenu', title: intl.formatMessage({ id: 'xpack.spaces.navControl.spacesMenu.changeCurrentSpaceTitle', diff --git a/x-pack/legacy/plugins/spaces/public/nav_control/nav_control_popover.tsx b/x-pack/legacy/plugins/spaces/public/nav_control/nav_control_popover.tsx index f291027e15232c..59c8052a644da2 100644 --- a/x-pack/legacy/plugins/spaces/public/nav_control/nav_control_popover.tsx +++ b/x-pack/legacy/plugins/spaces/public/nav_control/nav_control_popover.tsx @@ -32,6 +32,8 @@ interface State { spaces: Space[]; } +const popoutContentId = 'headerSpacesMenuContent'; + export class NavControlPopover extends Component { private activeSpace$?: Subscription; @@ -71,6 +73,7 @@ export class NavControlPopover extends Component { if (!this.state.loading && this.state.spaces.length < 2) { element = ( @@ -78,6 +81,7 @@ export class NavControlPopover extends Component { } else { element = ( { private getButton = (linkIcon: JSX.Element, linkTitle: string) => { return ( Date: Mon, 13 Jan 2020 11:14:37 -0500 Subject: [PATCH 14/62] Discover a11y tests (#54209) Comprehensive discover a11y tests --- .../saved_objects/saved_object_finder.tsx | 3 + test/accessibility/apps/discover.ts | 72 ++++++++++++++++++- test/accessibility/services/a11y/a11y.ts | 6 -- test/functional/page_objects/discover_page.js | 16 +++++ 4 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/plugins/kibana_react/public/saved_objects/saved_object_finder.tsx b/src/plugins/kibana_react/public/saved_objects/saved_object_finder.tsx index bd2beaf77a3059..1522c6b42824c5 100644 --- a/src/plugins/kibana_react/public/saved_objects/saved_object_finder.tsx +++ b/src/plugins/kibana_react/public/saved_objects/saved_object_finder.tsx @@ -346,6 +346,9 @@ class SavedObjectFinderUi extends React.Component< placeholder={i18n.translate('kibana-react.savedObjects.finder.searchPlaceholder', { defaultMessage: 'Search…', })} + aria-label={i18n.translate('kibana-react.savedObjects.finder.searchPlaceholder', { + defaultMessage: 'Search…', + })} fullWidth value={this.state.query} onChange={e => { diff --git a/test/accessibility/apps/discover.ts b/test/accessibility/apps/discover.ts index 38ee5b7db39c47..e25d2955159710 100644 --- a/test/accessibility/apps/discover.ts +++ b/test/accessibility/apps/discover.ts @@ -20,10 +20,12 @@ import { FtrProviderContext } from '../ftr_provider_context'; export default function({ getService, getPageObjects }: FtrProviderContext) { - const PageObjects = getPageObjects(['common', 'timePicker']); + const PageObjects = getPageObjects(['common', 'discover', 'header', 'share', 'timePicker']); const a11y = getService('a11y'); const esArchiver = getService('esArchiver'); const kibanaServer = getService('kibanaServer'); + const inspector = getService('inspector'); + const filterBar = getService('filterBar'); describe('Discover', () => { before(async () => { @@ -39,5 +41,73 @@ export default function({ getService, getPageObjects }: FtrProviderContext) { it('main view', async () => { await a11y.testAppSnapshot(); }); + + it('Click save button', async () => { + await PageObjects.discover.clickSaveSearchButton(); + await a11y.testAppSnapshot(); + }); + + it('Save search panel', async () => { + await PageObjects.discover.inputSavedSearchTitle('a11ySearch'); + await a11y.testAppSnapshot(); + }); + + it('Confirm saved search', async () => { + await PageObjects.discover.clickConfirmSavedSearch(); + await a11y.testAppSnapshot(); + }); + + // skipping the test for new because we can't fix it right now + it.skip('Click on new to clear the search', async () => { + await PageObjects.discover.clickNewSearchButton(); + await a11y.testAppSnapshot(); + }); + + it('Open load saved search panel', async () => { + await PageObjects.discover.openLoadSavedSearchPanel(); + await a11y.testAppSnapshot(); + await PageObjects.discover.closeLoadSavedSearchPanel(); + }); + + it('Open inspector panel', async () => { + await inspector.open(); + await a11y.testAppSnapshot(); + await inspector.close(); + }); + + it('Open add filter', async () => { + await PageObjects.discover.openAddFilterPanel(); + await a11y.testAppSnapshot(); + }); + + it('Select values for a filter', async () => { + await filterBar.addFilter('extension.raw', 'is one of', 'jpg'); + await a11y.testAppSnapshot(); + }); + + it('Load a new search from the panel', async () => { + await PageObjects.discover.clickSaveSearchButton(); + await PageObjects.discover.inputSavedSearchTitle('filterSearch'); + await PageObjects.discover.clickConfirmSavedSearch(); + await PageObjects.discover.openLoadSavedSearchPanel(); + await PageObjects.discover.loadSavedSearch('filterSearch'); + await a11y.testAppSnapshot(); + }); + + // unable to validate on EUI pop-over + it('click share button', async () => { + await PageObjects.share.clickShareTopNavButton(); + await a11y.testAppSnapshot(); + }); + + it('Open sidebar filter', async () => { + await PageObjects.discover.openSidebarFieldFilter(); + await a11y.testAppSnapshot(); + }); + + it('Close sidebar filter', async () => { + await PageObjects.discover.closeSidebarFieldFilter(); + await a11y.testAppSnapshot(); + }); }); } diff --git a/test/accessibility/services/a11y/a11y.ts b/test/accessibility/services/a11y/a11y.ts index 7adfe7ebfcc7d7..72440b648e5388 100644 --- a/test/accessibility/services/a11y/a11y.ts +++ b/test/accessibility/services/a11y/a11y.ts @@ -45,7 +45,6 @@ export const normalizeResult = (report: any) => { export function A11yProvider({ getService }: FtrProviderContext) { const browser = getService('browser'); const Wd = getService('__webdriver__'); - const log = getService('log'); /** * Accessibility testing service using the Axe (https://www.deque.com/axe/) @@ -78,11 +77,6 @@ export function A11yProvider({ getService }: FtrProviderContext) { private testAxeReport(report: AxeReport) { const errorMsgs = []; - for (const result of report.incomplete) { - // these items require human review and can't be definitively validated - log.warning(printResult(chalk.yellow('UNABLE TO VALIDATE'), result)); - } - for (const result of report.violations) { errorMsgs.push(printResult(chalk.red('VIOLATION'), result)); } diff --git a/test/functional/page_objects/discover_page.js b/test/functional/page_objects/discover_page.js index 3ba0f217813f2c..85d8cff675f2d0 100644 --- a/test/functional/page_objects/discover_page.js +++ b/test/functional/page_objects/discover_page.js @@ -63,6 +63,18 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { }); } + async inputSavedSearchTitle(searchName) { + await testSubjects.setValue('savedObjectTitle', searchName); + } + + async clickConfirmSavedSearch() { + await testSubjects.click('confirmSaveSavedObjectButton'); + } + + async openAddFilterPanel() { + await testSubjects.click('addFilter'); + } + async waitUntilSearchingHasFinished() { const spinner = await testSubjects.find('loadingSpinner'); await find.waitForElementHidden(spinner, defaultFindTimeout * 10); @@ -117,6 +129,10 @@ export function DiscoverPageProvider({ getService, getPageObjects }) { await testSubjects.click('discoverOpenButton'); } + async closeLoadSavedSearchPanel() { + await testSubjects.click('euiFlyoutCloseButton'); + } + async getChartCanvas() { return await find.byCssSelector('.echChart canvas:last-of-type'); } From 3ce2025c75c2989af6fae36fc610d264bdee03b9 Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Mon, 13 Jan 2020 11:53:47 -0500 Subject: [PATCH 15/62] [CANVAS] Relax workpad schema to allow existing templates to work (#54019) Co-authored-by: Elastic Machine --- .../canvas/i18n/templates/template_strings.ts | 2 +- .../public/components/workpad_templates/index.js | 3 +++ .../canvas/server/routes/workpad/workpad_schema.ts | 12 +++++++----- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/x-pack/legacy/plugins/canvas/i18n/templates/template_strings.ts b/x-pack/legacy/plugins/canvas/i18n/templates/template_strings.ts index 261f67067cfaf0..5ab6a908641dea 100644 --- a/x-pack/legacy/plugins/canvas/i18n/templates/template_strings.ts +++ b/x-pack/legacy/plugins/canvas/i18n/templates/template_strings.ts @@ -42,7 +42,7 @@ export const getTemplateStrings = (): TemplateStringDict => ({ defaultMessage: 'Pitch', }), help: i18n.translate('xpack.canvas.templates.pitchHelp', { - defaultMessage: 'Branded presentation with large photos"', + defaultMessage: 'Branded presentation with large photos', }), }, Status: { diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_templates/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_templates/index.js index cf07d1ed229f0e..139d0f283bf1a1 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_templates/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_templates/index.js @@ -24,7 +24,10 @@ export const WorkpadTemplates = compose( cloneWorkpad: props => workpad => { workpad.id = getId('workpad'); workpad.name = `My Canvas Workpad - ${workpad.name}`; + // Remove unneeded fields workpad.tags = undefined; + workpad.displayName = undefined; + workpad.help = undefined; return workpadService .create(workpad) .then(() => props.router.navigateTo('loadWorkpad', { id: workpad.id, page: 1 })) diff --git a/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts b/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts index 0bcb161575901b..0c31f517a74b3c 100644 --- a/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts +++ b/x-pack/plugins/canvas/server/routes/workpad/workpad_schema.ts @@ -24,11 +24,13 @@ export const WorkpadElementSchema = schema.object({ export const WorkpadPageSchema = schema.object({ elements: schema.arrayOf(WorkpadElementSchema), - groups: schema.arrayOf( - schema.object({ - id: schema.string(), - position: PositionSchema, - }) + groups: schema.maybe( + schema.arrayOf( + schema.object({ + id: schema.string(), + position: PositionSchema, + }) + ) ), id: schema.string(), style: schema.recordOf(schema.string(), schema.string()), From 8e7ea11657204b6eb5968e99c97640f68108fa20 Mon Sep 17 00:00:00 2001 From: Brandon Kobel Date: Mon, 13 Jan 2020 08:56:01 -0800 Subject: [PATCH 16/62] Adding tests to ensure src/core/utils/merge doesn't pollute prototypes (#54511) Co-authored-by: Elastic Machine --- src/core/utils/merge.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/core/utils/merge.test.ts b/src/core/utils/merge.test.ts index aa98f510674116..c857e980dec21a 100644 --- a/src/core/utils/merge.test.ts +++ b/src/core/utils/merge.test.ts @@ -61,4 +61,15 @@ describe('merge', () => { expect(merge({ a: 0 }, {}, {})).toEqual({ a: 0 }); expect(merge({ a: 0 }, { a: 1 }, {})).toEqual({ a: 1 }); }); + + test(`doesn't pollute prototypes`, () => { + merge({}, JSON.parse('{ "__proto__": { "foo": "bar" } }')); + merge({}, JSON.parse('{ "constructor": { "prototype": { "foo": "bar" } } }')); + merge( + {}, + JSON.parse('{ "__proto__": { "foo": "bar" } }'), + JSON.parse('{ "constructor": { "prototype": { "foo": "bar" } } }') + ); + expect(({} as any).foo).toBe(undefined); + }); }); From 6826ece3b0e6279bb822f9be1715893af78c7242 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Mon, 13 Jan 2020 18:14:36 +0100 Subject: [PATCH 17/62] [ML] Fix appState/globalState (#52987) Replaces appState/globalState with a custom hook useUrlState(). --- x-pack/legacy/plugins/ml/common/types/jobs.ts | 16 + .../__snapshots__/index.test.tsx.snap | 108 +- .../annotation_flyout/index.test.tsx | 31 +- .../annotations/annotation_flyout/index.tsx | 117 +- .../annotations_table/annotations_table.js | 10 +- .../anomalies_table/anomalies_table.js | 6 +- .../anomalies_table_columns.js | 4 +- .../components/anomalies_table/links_menu.js | 4 +- .../checkbox_showcharts.js | 52 - .../checkbox_showcharts.tsx | 52 + .../controls/checkbox_showcharts/index.d.ts | 9 - .../{index.js => index.ts} | 2 +- .../application/components/controls/index.js | 9 - .../{select_interval/index.d.ts => index.ts} | 9 +- .../select_interval/{index.js => index.ts} | 2 +- .../select_interval/select_interval.test.js | 30 - .../select_interval/select_interval.test.tsx | 55 + ...select_interval.js => select_interval.tsx} | 60 +- .../controls/select_severity/index.d.ts | 13 - .../select_severity/{index.js => index.ts} | 2 +- .../select_severity/select_severity.js | 139 --- ...erity.test.js => select_severity.test.tsx} | 49 +- .../select_severity/select_severity.tsx | 141 +++ .../job_selector/{index.js => index.ts} | 0 .../job_select_service_utils.d.ts | 22 - .../job_selector/job_select_service_utils.js | 261 ----- .../job_selector/job_select_service_utils.ts | 156 +++ .../{job_selector.js => job_selector.tsx} | 190 ++-- .../job_selector/use_job_selection.ts | 88 ++ .../components/navigation_menu/tabs.tsx | 13 +- .../navigation_menu/top_nav/top_nav.tsx | 14 +- .../explorer/__tests__/explorer_directive.js | 64 -- .../application/explorer/actions/index.ts | 2 +- .../explorer/actions/job_selection.ts | 14 +- .../explorer/actions/load_explorer_data.ts | 139 ++- .../public/application/explorer/explorer.d.ts | 12 +- .../public/application/explorer/explorer.js | 1010 ++++++++--------- ...orer_charts_container_service.test.js.snap | 11 +- .../explorer_chart_distribution.js | 6 +- .../explorer_chart_single_metric.js | 6 +- .../explorer_charts_container.js | 19 +- .../explorer_charts_container.test.js | 37 +- .../explorer_charts_container_service.d.ts | 9 +- .../explorer_charts_container_service.js | 1000 ++++++++-------- .../explorer_charts_container_service.test.js | 130 +-- .../explorer/explorer_constants.ts | 13 +- .../explorer/explorer_dashboard_service.ts | 151 ++- .../application/explorer/explorer_utils.d.ts | 27 +- .../application/explorer/explorer_utils.js | 50 +- .../explorer/hooks/use_selected_cells.ts | 67 ++ .../explorer/reducers/app_state_reducer.ts | 89 -- .../explorer_reducer/check_selected_cells.ts | 23 +- .../clear_influencer_filter_settings.ts | 11 - .../reducers/explorer_reducer/initialize.ts | 35 - .../explorer_reducer/job_selection_change.ts | 23 +- .../reducers/explorer_reducer/reducer.ts | 82 +- .../set_influencer_filter_settings.ts | 17 +- .../reducers/explorer_reducer/state.ts | 15 +- .../application/explorer/reducers/index.ts | 1 - .../select_limit/{index.js => index.ts} | 2 +- .../explorer/select_limit/select_limit.js | 80 -- ...ct_limit.test.js => select_limit.test.tsx} | 18 +- .../explorer/select_limit/select_limit.tsx | 40 + .../select_limit/select_limit_service.js | 19 - .../application/routing/routes/explorer.tsx | 227 ++-- .../routing/routes/timeseriesexplorer.tsx | 308 +++-- .../public/application/routing/use_refresh.ts | 30 + .../services/annotations_service.test.tsx | 4 +- .../services/annotations_service.tsx | 18 +- .../services/forecast_service.d.ts | 7 + .../services/ml_api_service/index.d.ts | 6 +- .../services/timefilter_refresh_service.tsx | 5 +- .../__tests__/timeseriesexplorer_directive.js | 40 - .../entity_control/entity_control.tsx | 34 +- .../forecasting_modal/forecasting_modal.js | 12 +- .../timeseries_chart/timeseries_chart.js | 35 +- .../timeseries_chart/timeseries_chart.test.js | 1 - .../timeseriesexplorer.d.ts | 8 +- .../timeseriesexplorer/timeseriesexplorer.js | 909 ++++++++------- .../timeseriesexplorer_constants.ts | 4 - .../timeseriesexplorer_utils.d.ts | 2 +- .../timeseriesexplorer_utils.js | 3 +- .../observable_utils.test.tsx.snap | 13 - .../util/__tests__/app_state_utils.js | 71 -- .../application/util/app_state_utils.d.ts | 16 - .../application/util/app_state_utils.js | 70 -- .../util/observable_utils.test.tsx | 43 - .../application/util/observable_utils.tsx | 67 -- .../ml/public/application/util/url_state.ts | 91 ++ x-pack/package.json | 2 + 90 files changed, 3160 insertions(+), 3752 deletions(-) delete mode 100644 x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.js create mode 100644 x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx delete mode 100644 x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.d.ts rename x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/{index.js => index.ts} (76%) delete mode 100644 x-pack/legacy/plugins/ml/public/application/components/controls/index.js rename x-pack/legacy/plugins/ml/public/application/components/controls/{select_interval/index.d.ts => index.ts} (57%) rename x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/{index.js => index.ts} (77%) delete mode 100644 x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.js create mode 100644 x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.tsx rename x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/{select_interval.js => select_interval.tsx} (56%) delete mode 100644 x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.d.ts rename x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/{index.js => index.ts} (73%) delete mode 100644 x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.js rename x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/{select_severity.test.js => select_severity.test.tsx} (55%) create mode 100644 x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx rename x-pack/legacy/plugins/ml/public/application/components/job_selector/{index.js => index.ts} (100%) delete mode 100644 x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.d.ts delete mode 100644 x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.js create mode 100644 x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts rename x-pack/legacy/plugins/ml/public/application/components/job_selector/{job_selector.js => job_selector.tsx} (74%) create mode 100644 x-pack/legacy/plugins/ml/public/application/components/job_selector/use_job_selection.ts delete mode 100644 x-pack/legacy/plugins/ml/public/application/explorer/__tests__/explorer_directive.js create mode 100644 x-pack/legacy/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts delete mode 100644 x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts delete mode 100644 x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts rename x-pack/legacy/plugins/ml/public/application/explorer/select_limit/{index.js => index.ts} (79%) delete mode 100644 x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.js rename x-pack/legacy/plugins/ml/public/application/explorer/select_limit/{select_limit.test.js => select_limit.test.tsx} (59%) create mode 100644 x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.tsx delete mode 100644 x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit_service.js create mode 100644 x-pack/legacy/plugins/ml/public/application/routing/use_refresh.ts delete mode 100644 x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js delete mode 100644 x-pack/legacy/plugins/ml/public/application/util/__snapshots__/observable_utils.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/ml/public/application/util/__tests__/app_state_utils.js delete mode 100644 x-pack/legacy/plugins/ml/public/application/util/app_state_utils.d.ts delete mode 100644 x-pack/legacy/plugins/ml/public/application/util/app_state_utils.js delete mode 100644 x-pack/legacy/plugins/ml/public/application/util/observable_utils.test.tsx delete mode 100644 x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx create mode 100644 x-pack/legacy/plugins/ml/public/application/util/url_state.ts diff --git a/x-pack/legacy/plugins/ml/common/types/jobs.ts b/x-pack/legacy/plugins/ml/common/types/jobs.ts index 07c2be3e7f0b44..47f34f6568eed9 100644 --- a/x-pack/legacy/plugins/ml/common/types/jobs.ts +++ b/x-pack/legacy/plugins/ml/common/types/jobs.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Moment } from 'moment'; + // TS TODO: This is not yet a fully fledged representation of the job data structure, // but it fulfills some basic TypeScript related needs. export interface MlJob { @@ -63,6 +65,20 @@ export interface MlSummaryJob { export type MlSummaryJobs = MlSummaryJob[]; +export interface MlJobWithTimeRange extends MlJob { + groups: string[]; + timeRange: { + from: number; + to: number; + fromPx: number; + toPx: number; + fromMoment: Moment; + toMoment: Moment; + widthPx: number; + label: string; + }; +} + export function isMlJob(arg: any): arg is MlJob { return typeof arg.job_id === 'string'; } diff --git a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/__snapshots__/index.test.tsx.snap index 29831190824ad1..dba73c246c3d01 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/__snapshots__/index.test.tsx.snap @@ -1,109 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AnnotationFlyout Initialization. 1`] = ` - -`; +exports[`AnnotationFlyout Initialization. 1`] = `""`; diff --git a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.test.tsx b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.test.tsx index 7fa47f3518b81b..d71a23f4782826 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.test.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.test.tsx @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { injectObservablesAsProps } from '../../../util/observable_utils'; +import useObservable from 'react-use/lib/useObservable'; + import mockAnnotations from '../annotations_table/__mocks__/mock_annotations.json'; -import React, { ComponentType } from 'react'; +import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { Annotation } from '../../../../../common/types/annotations'; @@ -25,11 +26,14 @@ describe('AnnotationFlyout', () => { const annotation = mockAnnotations[1] as Annotation; annotation$.next(annotation); - // injectObservablesAsProps wraps the observable in a new component - const ObservableComponent = injectObservablesAsProps( - { annotation: annotation$ }, - (AnnotationFlyout as any) as ComponentType - ); + // useObservable wraps the observable in a new component + const ObservableComponent = (props: any) => { + const annotationProp = useObservable(annotation$); + if (annotationProp === undefined) { + return null; + } + return ; + }; const wrapper = mountWithIntl(); const updateBtn = wrapper.find('EuiButton').first(); @@ -40,11 +44,14 @@ describe('AnnotationFlyout', () => { const annotation = mockAnnotations[2] as Annotation; annotation$.next(annotation); - // injectObservablesAsProps wraps the observable in a new component - const ObservableComponent = injectObservablesAsProps( - { annotation: annotation$ }, - (AnnotationFlyout as any) as ComponentType - ); + // useObservable wraps the observable in a new component + const ObservableComponent = (props: any) => { + const annotationProp = useObservable(annotation$); + if (annotationProp === undefined) { + return null; + } + return ; + }; const wrapper = mountWithIntl(); const updateBtn = wrapper.find('EuiButton').first(); diff --git a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx index 84c16360795ea5..66685188227101 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotation_flyout/index.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Component, ComponentType, Fragment, ReactNode } from 'react'; +import React, { Component, Fragment, FC, ReactNode } from 'react'; +import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; import { @@ -23,16 +24,16 @@ import { } from '@elastic/eui'; import { CommonProps } from '@elastic/eui'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; -import { InjectedIntlProps } from 'react-intl'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + import { toastNotifications } from 'ui/notify'; import { ANNOTATION_MAX_LENGTH_CHARS } from '../../../../../common/constants/annotations'; import { annotation$, - annotationsRefresh$, + annotationsRefreshed, AnnotationState, } from '../../../services/annotations_service'; -import { injectObservablesAsProps } from '../../../util/observable_utils'; import { AnnotationDescriptionList } from '../annotation_description_list'; import { DeleteAnnotationModal } from '../delete_annotation_modal'; @@ -46,7 +47,7 @@ interface State { isDeleteModalVisible: boolean; } -class AnnotationFlyoutIntl extends Component { +class AnnotationFlyoutIntl extends Component { public state: State = { isDeleteModalVisible: false, }; @@ -73,7 +74,7 @@ class AnnotationFlyoutIntl extends Component { - const { annotation, intl } = this.props; + const { annotation } = this.props; if (annotation === null) { return; @@ -82,31 +83,30 @@ class AnnotationFlyoutIntl extends Component { @@ -116,7 +116,7 @@ class AnnotationFlyoutIntl extends Component { // Validates the entered text, returning an array of error messages // for display in the form. An empty array is returned if the text is valid. - const { annotation, intl } = this.props; + const { annotation } = this.props; const errors: string[] = []; if (annotation === null) { return errors; @@ -124,8 +124,7 @@ class AnnotationFlyoutIntl extends Component ANNOTATION_MAX_LENGTH_CHARS) { const charsOver = textLength - ANNOTATION_MAX_LENGTH_CHARS; errors.push( - intl.formatMessage( - { - id: 'xpack.ml.timeSeriesExplorer.annotationFlyout.maxLengthError', - defaultMessage: - '{charsOver, number} {charsOver, plural, one {character} other {characters}} above maximum length of {maxChars}', - }, - { + i18n.translate('xpack.ml.timeSeriesExplorer.annotationFlyout.maxLengthError', { + defaultMessage: + '{charsOver, number} {charsOver, plural, one {character} other {characters}} above maximum length of {maxChars}', + values: { maxChars: ANNOTATION_MAX_LENGTH_CHARS, charsOver, - } - ) + }, + }) ); } @@ -153,7 +149,7 @@ class AnnotationFlyoutIntl extends Component { - const { annotation, intl } = this.props; + const { annotation } = this.props; if (annotation === null) { return; @@ -164,27 +160,25 @@ class AnnotationFlyoutIntl extends Component { - annotationsRefresh$.next(true); + annotationsRefreshed(); if (typeof annotation._id === 'undefined') { toastNotifications.addSuccess( - intl.formatMessage( + i18n.translate( + 'xpack.ml.timeSeriesExplorer.timeSeriesChart.addedAnnotationNotificationMessage', { - id: - 'xpack.ml.timeSeriesExplorer.timeSeriesChart.addedAnnotationNotificationMessage', defaultMessage: 'Added an annotation for job with ID {jobId}.', - }, - { jobId: annotation.job_id } + values: { jobId: annotation.job_id }, + } ) ); } else { toastNotifications.addSuccess( - intl.formatMessage( + i18n.translate( + 'xpack.ml.timeSeriesExplorer.timeSeriesChart.updatedAnnotationNotificationMessage', { - id: - 'xpack.ml.timeSeriesExplorer.timeSeriesChart.updatedAnnotationNotificationMessage', defaultMessage: 'Updated annotation for job with ID {jobId}.', - }, - { jobId: annotation.job_id } + values: { jobId: annotation.job_id }, + } ) ); } @@ -192,26 +186,24 @@ class AnnotationFlyoutIntl extends Component { if (typeof annotation._id === 'undefined') { toastNotifications.addDanger( - intl.formatMessage( + i18n.translate( + 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithCreatingAnnotationNotificationErrorMessage', { - id: - 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithCreatingAnnotationNotificationErrorMessage', defaultMessage: 'An error occurred creating the annotation for job with ID {jobId}: {error}', - }, - { jobId: annotation.job_id, error: JSON.stringify(resp) } + values: { jobId: annotation.job_id, error: JSON.stringify(resp) }, + } ) ); } else { toastNotifications.addDanger( - intl.formatMessage( + i18n.translate( + 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithUpdatingAnnotationNotificationErrorMessage', { - id: - 'xpack.ml.timeSeriesExplorer.timeSeriesChart.errorWithUpdatingAnnotationNotificationErrorMessage', defaultMessage: 'An error occurred updating the annotation for job with ID {jobId}: {error}', - }, - { jobId: annotation.job_id, error: JSON.stringify(resp) } + values: { jobId: annotation.job_id, error: JSON.stringify(resp) }, + } ) ); } @@ -219,7 +211,7 @@ class AnnotationFlyoutIntl extends Component ANNOTATION_MAX_LENGTH_CHARS * lengthRatioToShowWarning ) { - helpText = intl.formatMessage( + helpText = i18n.translate( + 'xpack.ml.timeSeriesExplorer.annotationFlyout.approachingMaxLengthWarning', { - id: 'xpack.ml.timeSeriesExplorer.annotationFlyout.approachingMaxLengthWarning', defaultMessage: '{charsRemaining, number} {charsRemaining, plural, one {character} other {characters}} remaining', - }, - { charsRemaining: ANNOTATION_MAX_LENGTH_CHARS - annotation.annotation.length } + values: { charsRemaining: ANNOTATION_MAX_LENGTH_CHARS - annotation.annotation.length }, + } ); } @@ -344,7 +336,12 @@ class AnnotationFlyoutIntl extends Component = props => { + const annotationProp = useObservable(annotation$); + + if (annotationProp === undefined) { + return null; + } + + return ; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index f270d14b53e561..6c4e8925f369f9 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/legacy/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -42,7 +42,11 @@ import { isTimeSeriesViewJob, } from '../../../../../common/util/job_utils'; -import { annotation$, annotationsRefresh$ } from '../../../services/annotations_service'; +import { + annotation$, + annotationsRefresh$, + annotationsRefreshed, +} from '../../../services/annotations_service'; import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; @@ -136,7 +140,7 @@ const AnnotationsTable = injectI18n( this.annotationsRefreshSubscription = annotationsRefresh$.subscribe(() => this.getAnnotations() ); - annotationsRefresh$.next(true); + annotationsRefreshed(); } } @@ -150,7 +154,7 @@ const AnnotationsTable = injectI18n( this.state.isLoading === false && this.state.jobId !== this.props.jobs[0].job_id ) { - annotationsRefresh$.next(true); + annotationsRefreshed(); this.previousJobId = this.props.jobs[0].job_id; } } diff --git a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table.js b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table.js index d1beb360793f25..bc3ce88921110a 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table.js +++ b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table.js @@ -146,7 +146,7 @@ class AnomaliesTable extends Component { }; render() { - const { timefilter, tableData, filter, influencerFilter } = this.props; + const { bounds, tableData, filter, influencerFilter } = this.props; if ( tableData === undefined || @@ -175,7 +175,7 @@ class AnomaliesTable extends Component { tableData.examplesByJobId, this.isShowingAggregatedData(), tableData.interval, - timefilter, + bounds, tableData.showViewSeriesLink, this.state.showRuleEditorFlyout, this.state.itemIdToExpandedRowMap, @@ -224,7 +224,7 @@ class AnomaliesTable extends Component { } } AnomaliesTable.propTypes = { - timefilter: PropTypes.object.isRequired, + bounds: PropTypes.object.isRequired, tableData: PropTypes.object, filter: PropTypes.func, influencerFilter: PropTypes.func, diff --git a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js index 75941edddeb56c..36faac45164f47 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js +++ b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/anomalies_table_columns.js @@ -55,7 +55,7 @@ export function getColumns( examplesByJobId, isAggregatedData, interval, - timefilter, + bounds, showViewSeriesLink, showRuleEditorFlyout, itemIdToExpandedRowMap, @@ -262,10 +262,10 @@ export function getColumns( return ( ); diff --git a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/links_menu.js b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/links_menu.js index b4821ddb564c91..8cbee27bdd9a81 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/links_menu.js +++ b/x-pack/legacy/plugins/ml/public/application/components/anomalies_table/links_menu.js @@ -37,10 +37,10 @@ export const LinksMenu = injectI18n( class LinksMenu extends Component { static propTypes = { anomaly: PropTypes.object.isRequired, + bounds: PropTypes.object.isRequired, showViewSeriesLink: PropTypes.bool, isAggregatedData: PropTypes.bool, interval: PropTypes.string, - timefilter: PropTypes.object.isRequired, showRuleEditorFlyout: PropTypes.func, }; @@ -146,7 +146,7 @@ export const LinksMenu = injectI18n( viewSeries = () => { const record = this.props.anomaly.source; - const bounds = this.props.timefilter.getActiveBounds(); + const bounds = this.props.bounds; const from = bounds.min.toISOString(); // e.g. 2016-02-08T16:00:00.000Z const to = bounds.max.toISOString(); diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.js b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.js deleted file mode 100644 index 89a5fafc491b56..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.js +++ /dev/null @@ -1,52 +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. - */ - -/* - * React component for a checkbox element to toggle charts display. - */ -import React, { Component } from 'react'; -import { BehaviorSubject } from 'rxjs'; - -import { EuiCheckbox } from '@elastic/eui'; - -import makeId from '@elastic/eui/lib/components/form/form_row/make_id'; -import { FormattedMessage } from '@kbn/i18n/react'; - -import { injectObservablesAsProps } from '../../../util/observable_utils'; - -export const showCharts$ = new BehaviorSubject(true); - -class CheckboxShowChartsUnwrapped extends Component { - onChange = e => { - const showCharts = e.target.checked; - showCharts$.next(showCharts); - }; - - render() { - return ( - - } - checked={this.props.showCharts} - onChange={this.onChange} - /> - ); - } -} - -const CheckboxShowCharts = injectObservablesAsProps( - { - showCharts: showCharts$, - }, - CheckboxShowChartsUnwrapped -); - -export { CheckboxShowCharts }; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx new file mode 100644 index 00000000000000..70538d4dc3a917 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/checkbox_showcharts.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for a checkbox element to toggle charts display. + */ +import React, { FC } from 'react'; + +import { EuiCheckbox } from '@elastic/eui'; +// @ts-ignore +import makeId from '@elastic/eui/lib/components/form/form_row/make_id'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { useUrlState } from '../../../util/url_state'; + +const SHOW_CHARTS_DEFAULT = true; +const SHOW_CHARTS_APP_STATE_NAME = 'mlShowCharts'; + +export const useShowCharts = () => { + const [appState, setAppState] = useUrlState('_a'); + + return [ + appState?.mlShowCharts !== undefined ? appState?.mlShowCharts : SHOW_CHARTS_DEFAULT, + (d: boolean) => setAppState(SHOW_CHARTS_APP_STATE_NAME, d), + ]; +}; + +export const CheckboxShowCharts: FC = () => { + const [showCharts, setShowCarts] = useShowCharts(); + + const onChange = (e: React.ChangeEvent) => { + setShowCarts(e.target.checked); + }; + + return ( + + } + checked={showCharts} + onChange={onChange} + /> + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.d.ts b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.d.ts deleted file mode 100644 index 4d6952d3b3fc3b..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.d.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. - */ - -import { BehaviorSubject } from 'rxjs'; - -export const showCharts$: BehaviorSubject; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.js b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.ts similarity index 76% rename from x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.js rename to x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.ts index b7957b807591c9..d868b9570f3378 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.js +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/checkbox_showcharts/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { CheckboxShowCharts, showCharts$ } from './checkbox_showcharts'; +export { useShowCharts, CheckboxShowCharts } from './checkbox_showcharts'; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/index.js b/x-pack/legacy/plugins/ml/public/application/components/controls/index.js deleted file mode 100644 index 26cb89d672632d..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/index.js +++ /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. - */ - -export { CheckboxShowCharts, showCharts$ } from './checkbox_showcharts'; -export { interval$, SelectInterval } from './select_interval'; -export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity'; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.d.ts b/x-pack/legacy/plugins/ml/public/application/components/controls/index.ts similarity index 57% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.d.ts rename to x-pack/legacy/plugins/ml/public/application/components/controls/index.ts index 4a8273972389a5..f3e1ef83588679 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/index.ts @@ -4,9 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BehaviorSubject } from 'rxjs'; - -export const interval$: BehaviorSubject<{ - value: string; - text: string; -}>; +export { CheckboxShowCharts } from './checkbox_showcharts'; +export { SelectInterval } from './select_interval'; +export { SelectSeverity, SEVERITY_OPTIONS } from './select_severity'; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.ts similarity index 77% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.js rename to x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.ts index aec48f4c626ca0..32a0b530778180 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.js +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { interval$, SelectInterval } from './select_interval'; +export { useTableInterval, SelectInterval } from './select_interval'; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.js deleted file mode 100644 index c99d25a68f7225..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.js +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { SelectInterval } from './select_interval'; - -describe('SelectInterval', () => { - test('creates correct initial selected value', () => { - const wrapper = shallowWithIntl(); - const defaultSelectedValue = wrapper.props().interval.val; - - expect(defaultSelectedValue).toBe('auto'); - }); - - test('currently selected value is updated correctly on click', () => { - const wrapper = shallowWithIntl(); - const select = wrapper.first().shallow(); - - const defaultSelectedValue = wrapper.props().interval.val; - expect(defaultSelectedValue).toBe('auto'); - - select.simulate('change', { target: { value: 'day' } }); - const updatedSelectedValue = wrapper.props().interval.val; - expect(updatedSelectedValue).toBe('day'); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.tsx b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.tsx new file mode 100644 index 00000000000000..e1861b887b2a9d --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.test.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter } from 'react-router-dom'; +import { mount } from 'enzyme'; + +import { EuiSelect } from '@elastic/eui'; + +import { SelectInterval } from './select_interval'; + +describe('SelectInterval', () => { + test('creates correct initial selected value', () => { + const wrapper = mount( + + + + ); + const select = wrapper.find(EuiSelect); + + const defaultSelectedValue = select.props().value; + expect(defaultSelectedValue).toBe('auto'); + }); + + test('currently selected value is updated correctly on click', done => { + const wrapper = mount( + + + + ); + const select = wrapper.find(EuiSelect).first(); + const defaultSelectedValue = select.props().value; + expect(defaultSelectedValue).toBe('auto'); + + const onChange = select.props().onChange; + + act(() => { + if (onChange !== undefined) { + onChange({ target: { value: 'day' } } as React.ChangeEvent); + } + }); + + setImmediate(() => { + wrapper.update(); + const updatedSelect = wrapper.find(EuiSelect).first(); + const updatedSelectedValue = updatedSelect.props().value; + expect(updatedSelectedValue).toBe('day'); + done(); + }); + }); +}); diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx similarity index 56% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.js rename to x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx index fce538c0c8c7eb..cea3ef2a497b05 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.js +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_interval/select_interval.tsx @@ -8,15 +8,18 @@ * React component for rendering a select element with various aggregation interval levels. */ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { BehaviorSubject } from 'rxjs'; +import React, { FC } from 'react'; import { EuiSelect } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { injectObservablesAsProps } from '../../../util/observable_utils'; +import { useUrlState } from '../../../util/url_state'; + +interface TableInterval { + display: string; + val: string; +} const OPTIONS = [ { @@ -41,13 +44,13 @@ const OPTIONS = [ }, ]; -function optionValueToInterval(value) { +function optionValueToInterval(value: string) { // Builds the corresponding interval object with the required display and val properties // from the specified value. const option = OPTIONS.find(opt => opt.value === value); // Default to auto if supplied value doesn't map to one of the options. - let interval = OPTIONS[0]; + let interval: TableInterval = { display: OPTIONS[0].text, val: OPTIONS[0].value }; if (option !== undefined) { interval = { display: option.text, val: option.value }; } @@ -55,30 +58,31 @@ function optionValueToInterval(value) { return interval; } -export const interval$ = new BehaviorSubject(optionValueToInterval(OPTIONS[0].value)); +const TABLE_INTERVAL_DEFAULT = optionValueToInterval('auto'); +const TABLE_INTERVAL_APP_STATE_NAME = 'mlSelectInterval'; -class SelectIntervalUnwrapped extends Component { - static propTypes = { - interval: PropTypes.object.isRequired, - }; +export const useTableInterval = () => { + const [appState, setAppState] = useUrlState('_a'); - onChange = e => { - const interval = optionValueToInterval(e.target.value); - interval$.next(interval); - }; + return [ + (appState && appState[TABLE_INTERVAL_APP_STATE_NAME]) || TABLE_INTERVAL_DEFAULT, + (d: TableInterval) => setAppState(TABLE_INTERVAL_APP_STATE_NAME, d), + ]; +}; - render() { - return ( - - ); - } -} +export const SelectInterval: FC = () => { + const [interval, setInterval] = useTableInterval(); -const SelectInterval = injectObservablesAsProps({ interval: interval$ }, SelectIntervalUnwrapped); + const onChange = (e: React.ChangeEvent) => { + setInterval(optionValueToInterval(e.target.value)); + }; -export { SelectInterval }; + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.d.ts b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.d.ts deleted file mode 100644 index 006d23da56f822..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.d.ts +++ /dev/null @@ -1,13 +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 { BehaviorSubject } from 'rxjs'; - -export const severity$: BehaviorSubject<{ - val: number; - display: string; - color: string; -}>; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.ts similarity index 73% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.js rename to x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.ts index f26c16c6ff77de..1f524dc1c2ffd0 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.js +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { SelectSeverity, severity$, SEVERITY_OPTIONS } from './select_severity'; +export { useTableSeverity, SelectSeverity, SEVERITY_OPTIONS } from './select_severity'; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.js deleted file mode 100644 index 53d65d6622b947..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.js +++ /dev/null @@ -1,139 +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. - */ - -/* - * React component for rendering a select element with threshold levels. - */ -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { BehaviorSubject } from 'rxjs'; - -import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; - -import { getSeverityColor } from '../../../../../common/util/anomaly_utils'; -import { injectObservablesAsProps } from '../../../util/observable_utils'; - -const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', { - defaultMessage: 'warning', -}); -const minorLabel = i18n.translate('xpack.ml.controls.selectSeverity.minorLabel', { - defaultMessage: 'minor', -}); -const majorLabel = i18n.translate('xpack.ml.controls.selectSeverity.majorLabel', { - defaultMessage: 'major', -}); -const criticalLabel = i18n.translate('xpack.ml.controls.selectSeverity.criticalLabel', { - defaultMessage: 'critical', -}); - -const optionsMap = { - [warningLabel]: 0, - [minorLabel]: 25, - [majorLabel]: 50, - [criticalLabel]: 75, -}; - -export const SEVERITY_OPTIONS = [ - { - val: 0, - display: warningLabel, - color: getSeverityColor(0), - }, - { - val: 25, - display: minorLabel, - color: getSeverityColor(25), - }, - { - val: 50, - display: majorLabel, - color: getSeverityColor(50), - }, - { - val: 75, - display: criticalLabel, - color: getSeverityColor(75), - }, -]; - -function optionValueToThreshold(value) { - // Get corresponding threshold object with required display and val properties from the specified value. - let threshold = SEVERITY_OPTIONS.find(opt => opt.val === value); - - // Default to warning if supplied value doesn't map to one of the options. - if (threshold === undefined) { - threshold = SEVERITY_OPTIONS[0]; - } - - return threshold; -} - -export const severity$ = new BehaviorSubject(SEVERITY_OPTIONS[0]); - -class SelectSeverityUnwrapped extends Component { - onChange = valueDisplay => { - const threshold = optionValueToThreshold(optionsMap[valueDisplay]); - severity$.next(threshold); - }; - - getOptions = () => - SEVERITY_OPTIONS.map(({ color, display, val }) => ({ - value: display, - inputDisplay: ( - - - {display} - - - ), - dropdownDisplay: ( - - - {display} - - - -

- -

-
-
- ), - })); - - render() { - const { severity } = this.props; - const options = this.getOptions(); - - return ( - - ); - } -} - -SelectSeverityUnwrapped.propTypes = { - classNames: PropTypes.string, -}; - -SelectSeverityUnwrapped.defaultProps = { - classNames: '', -}; - -const SelectSeverity = injectObservablesAsProps({ severity: severity$ }, SelectSeverityUnwrapped); - -export { SelectSeverity }; diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.js b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.tsx similarity index 55% rename from x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.js rename to x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.tsx index ec2fe7d1cdeac0..e30c48c10a1943 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.js +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.test.tsx @@ -5,16 +5,25 @@ */ import React from 'react'; -import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { act } from 'react-dom/test-utils'; +import { MemoryRouter } from 'react-router-dom'; +import { mount } from 'enzyme'; + +import { EuiSuperSelect } from '@elastic/eui'; + import { SelectSeverity } from './select_severity'; describe('SelectSeverity', () => { test('creates correct severity options and initial selected value', () => { - const wrapper = shallowWithIntl(); - const select = wrapper.first().shallow(); + const wrapper = mount( + + + + ); + const select = wrapper.find(EuiSuperSelect); - const options = select.instance().getOptions(); - const defaultSelectedValue = wrapper.props().severity.display; + const options = select.props().options; + const defaultSelectedValue = select.props().valueOfSelected; expect(defaultSelectedValue).toBe('warning'); expect(options.length).toEqual(4); @@ -53,15 +62,31 @@ describe('SelectSeverity', () => { ); }); - test('state for currently selected value is updated correctly on click', () => { - const wrapper = shallowWithIntl(); - const select = wrapper.first().shallow(); + test('state for currently selected value is updated correctly on click', done => { + const wrapper = mount( + + + + ); - const defaultSelectedValue = wrapper.props().severity.display; + const select = wrapper.find(EuiSuperSelect).first(); + const defaultSelectedValue = select.props().valueOfSelected; expect(defaultSelectedValue).toBe('warning'); - select.simulate('change', 'critical'); - const updatedSelectedValue = wrapper.props().severity.display; - expect(updatedSelectedValue).toBe('critical'); + const onChange = select.props().onChange; + + act(() => { + if (onChange !== undefined) { + onChange('critical'); + } + }); + + setImmediate(() => { + wrapper.update(); + const updatedSelect = wrapper.find(EuiSuperSelect).first(); + const updatedSelectedValue = updatedSelect.props().valueOfSelected; + expect(updatedSelectedValue).toBe('critical'); + done(); + }); }); }); diff --git a/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx new file mode 100644 index 00000000000000..a03594a5f213e1 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/controls/select_severity/select_severity.tsx @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for rendering a select element with threshold levels. + */ +import React, { Fragment, FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; + +import { getSeverityColor } from '../../../../../common/util/anomaly_utils'; +import { useUrlState } from '../../../util/url_state'; + +const warningLabel = i18n.translate('xpack.ml.controls.selectSeverity.warningLabel', { + defaultMessage: 'warning', +}); +const minorLabel = i18n.translate('xpack.ml.controls.selectSeverity.minorLabel', { + defaultMessage: 'minor', +}); +const majorLabel = i18n.translate('xpack.ml.controls.selectSeverity.majorLabel', { + defaultMessage: 'major', +}); +const criticalLabel = i18n.translate('xpack.ml.controls.selectSeverity.criticalLabel', { + defaultMessage: 'critical', +}); + +const optionsMap = { + [warningLabel]: 0, + [minorLabel]: 25, + [majorLabel]: 50, + [criticalLabel]: 75, +}; + +interface TableSeverity { + val: number; + display: string; + color: string; +} + +export const SEVERITY_OPTIONS: TableSeverity[] = [ + { + val: 0, + display: warningLabel, + color: getSeverityColor(0), + }, + { + val: 25, + display: minorLabel, + color: getSeverityColor(25), + }, + { + val: 50, + display: majorLabel, + color: getSeverityColor(50), + }, + { + val: 75, + display: criticalLabel, + color: getSeverityColor(75), + }, +]; + +function optionValueToThreshold(value: number) { + // Get corresponding threshold object with required display and val properties from the specified value. + let threshold = SEVERITY_OPTIONS.find(opt => opt.val === value); + + // Default to warning if supplied value doesn't map to one of the options. + if (threshold === undefined) { + threshold = SEVERITY_OPTIONS[0]; + } + + return threshold; +} + +const TABLE_SEVERITY_DEFAULT = SEVERITY_OPTIONS[0]; +const TABLE_SEVERITY_APP_STATE_NAME = 'mlSelectSeverity'; + +export const useTableSeverity = () => { + const [appState, setAppState] = useUrlState('_a'); + + return [ + (appState && appState[TABLE_SEVERITY_APP_STATE_NAME]) || TABLE_SEVERITY_DEFAULT, + (d: TableSeverity) => setAppState(TABLE_SEVERITY_APP_STATE_NAME, d), + ]; +}; + +const getSeverityOptions = () => + SEVERITY_OPTIONS.map(({ color, display, val }) => ({ + value: display, + inputDisplay: ( + + + {display} + + + ), + dropdownDisplay: ( + + + {display} + + + +

+ +

+
+
+ ), + })); + +interface Props { + classNames?: string; +} + +export const SelectSeverity: FC = ({ classNames } = { classNames: '' }) => { + const [severity, setSeverity] = useTableSeverity(); + + const onChange = (valueDisplay: string) => { + setSeverity(optionValueToThreshold(optionsMap[valueDisplay])); + }; + + return ( + + ); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/index.js b/x-pack/legacy/plugins/ml/public/application/components/job_selector/index.ts similarity index 100% rename from x-pack/legacy/plugins/ml/public/application/components/job_selector/index.js rename to x-pack/legacy/plugins/ml/public/application/components/job_selector/index.ts diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.d.ts b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.d.ts deleted file mode 100644 index fe5966524c7e5f..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BehaviorSubject } from 'rxjs'; - -import { State } from 'ui/state_management/state'; - -export declare type JobSelectService$ = BehaviorSubject<{ - selection: string[]; - groups: string[]; - resetSelection: boolean; -}>; - -declare interface JobSelectService { - jobSelectService$: JobSelectService$; - unsubscribeFromGlobalState(): void; -} - -export const jobSelectServiceFactory: (globalState: State) => JobSelectService; diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.js b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.js deleted file mode 100644 index 7f5c1465686486..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.js +++ /dev/null @@ -1,261 +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 { difference, isEqual } from 'lodash'; -import { BehaviorSubject } from 'rxjs'; -import { toastNotifications } from 'ui/notify'; -import { i18n } from '@kbn/i18n'; -import moment from 'moment'; -import d3 from 'd3'; - -import { mlJobService } from '../../services/job_service'; - -function warnAboutInvalidJobIds(invalidIds) { - if (invalidIds.length > 0) { - toastNotifications.addWarning( - i18n.translate('xpack.ml.jobSelect.requestedJobsDoesNotExistWarningMessage', { - defaultMessage: `Requested -{invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`, - values: { - invalidIdsLength: invalidIds.length, - invalidIds, - }, - }) - ); - } -} - -// check that the ids read from the url exist by comparing them to the -// jobs loaded via mlJobsService. -function getInvalidJobIds(ids) { - return ids.filter(id => { - const jobExists = mlJobService.jobs.some(job => job.job_id === id); - return jobExists === false && id !== '*'; - }); -} - -export const jobSelectServiceFactory = globalState => { - const { jobIds, selectedGroups } = getSelectedJobIds(globalState); - const jobSelectService$ = new BehaviorSubject({ - selection: jobIds, - groups: selectedGroups, - resetSelection: false, - }); - - // Subscribe to changes to globalState and trigger - // a jobSelectService update if the job selection changed. - const listener = () => { - const { jobIds: newJobIds, selectedGroups: newSelectedGroups } = getSelectedJobIds(globalState); - const oldSelectedJobIds = jobSelectService$.getValue().selection; - - if (newJobIds && !isEqual(oldSelectedJobIds, newJobIds)) { - jobSelectService$.next({ selection: newJobIds, groups: newSelectedGroups }); - } - }; - - globalState.on('save_with_changes', listener); - - const unsubscribeFromGlobalState = () => { - globalState.off('save_with_changes', listener); - }; - - return { jobSelectService$, unsubscribeFromGlobalState }; -}; - -function loadJobIdsFromGlobalState(globalState) { - // jobIds, groups - // fetch to get the latest state - globalState.fetch(); - - const jobIds = []; - let groups = []; - - if (globalState.ml && globalState.ml.jobIds) { - let tempJobIds = []; - groups = globalState.ml.groups || []; - - if (typeof globalState.ml.jobIds === 'string') { - tempJobIds.push(globalState.ml.jobIds); - } else { - tempJobIds = globalState.ml.jobIds; - } - tempJobIds = tempJobIds.map(id => String(id)); - - const invalidIds = getInvalidJobIds(tempJobIds); - warnAboutInvalidJobIds(invalidIds); - - let validIds = difference(tempJobIds, invalidIds); - // if there are no valid ids, warn and then select the first job - if (validIds.length === 0) { - toastNotifications.addWarning( - i18n.translate('xpack.ml.jobSelect.noJobsSelectedWarningMessage', { - defaultMessage: 'No jobs selected, auto selecting first job', - }) - ); - - if (mlJobService.jobs.length) { - validIds = [mlJobService.jobs[0].job_id]; - } - } - jobIds.push(...validIds); - } else { - // no jobs selected, use the first in the list - if (mlJobService.jobs.length) { - jobIds.push(mlJobService.jobs[0].job_id); - } - } - return { jobIds, selectedGroups: groups }; -} - -// TODO: -// Merge `setGlobalStateSkipRefresh()` and `setGlobalState()` into -// a single function similar to how we do `appStateHandler()`. -// When changing jobs in job selector it would trigger multiple events -// which in return would be consumed by Single Metric Viewer and could cause -// race conditions when updating the whole page. Because we don't control -// the internals of the involved timefilter event triggering, we use -// a global `skipRefresh` to control when Single Metric Viewer should -// skip updates triggered by timefilter. -export function setGlobalStateSkipRefresh(globalState, skipRefresh) { - globalState.fetch(); - if (globalState.ml === undefined) { - globalState.ml = {}; - } - globalState.ml.skipRefresh = skipRefresh; - globalState.save(); -} - -export function setGlobalState(globalState, { selectedIds, selectedGroups, skipRefresh }) { - globalState.fetch(); - if (globalState.ml === undefined) { - globalState.ml = {}; - } - globalState.ml.jobIds = selectedIds; - globalState.ml.groups = selectedGroups || []; - globalState.ml.skipRefresh = !!skipRefresh; - globalState.save(); -} - -// called externally to retrieve the selected jobs ids -export function getSelectedJobIds(globalState) { - return loadJobIdsFromGlobalState(globalState); -} - -export function getGroupsFromJobs(jobs) { - const groups = {}; - const groupsMap = {}; - - jobs.forEach(job => { - // Organize job by group - if (job.groups !== undefined) { - job.groups.forEach(g => { - if (groups[g] === undefined) { - groups[g] = { - id: g, - jobIds: [job.job_id], - timeRange: { - to: job.timeRange.to, - toMoment: null, - from: job.timeRange.from, - fromMoment: null, - fromPx: job.timeRange.fromPx, - toPx: job.timeRange.toPx, - widthPx: null, - }, - }; - - groupsMap[g] = [job.job_id]; - } else { - groups[g].jobIds.push(job.job_id); - groupsMap[g].push(job.job_id); - // keep track of earliest 'from' / latest 'to' for group range - if (groups[g].timeRange.to === null || job.timeRange.to > groups[g].timeRange.to) { - groups[g].timeRange.to = job.timeRange.to; - groups[g].timeRange.toMoment = job.timeRange.toMoment; - } - if (groups[g].timeRange.from === null || job.timeRange.from < groups[g].timeRange.from) { - groups[g].timeRange.from = job.timeRange.from; - groups[g].timeRange.fromMoment = job.timeRange.fromMoment; - } - if (groups[g].timeRange.toPx === null || job.timeRange.toPx > groups[g].timeRange.toPx) { - groups[g].timeRange.toPx = job.timeRange.toPx; - } - if ( - groups[g].timeRange.fromPx === null || - job.timeRange.fromPx < groups[g].timeRange.fromPx - ) { - groups[g].timeRange.fromPx = job.timeRange.fromPx; - } - } - }); - } - }); - - Object.keys(groups).forEach(groupId => { - const group = groups[groupId]; - group.timeRange.widthPx = group.timeRange.toPx - group.timeRange.fromPx; - group.timeRange.toMoment = moment(group.timeRange.to); - group.timeRange.fromMoment = moment(group.timeRange.from); - // create label - const fromString = group.timeRange.fromMoment.format('MMM Do YYYY, HH:mm'); - const toString = group.timeRange.toMoment.format('MMM Do YYYY, HH:mm'); - group.timeRange.label = i18n.translate('xpack.ml.jobSelectList.groupTimeRangeLabel', { - defaultMessage: '{fromString} to {toString}', - values: { - fromString, - toString, - }, - }); - }); - - return { groups: Object.keys(groups).map(g => groups[g]), groupsMap }; -} - -export function normalizeTimes(jobs, dateFormatTz, ganttBarWidth) { - const jobsWithTimeRange = jobs.filter(job => { - return job.timeRange.to !== undefined && job.timeRange.from !== undefined; - }); - - const min = Math.min(...jobsWithTimeRange.map(job => +job.timeRange.from)); - const max = Math.max(...jobsWithTimeRange.map(job => +job.timeRange.to)); - const ganttScale = d3.scale - .linear() - .domain([min, max]) - .range([1, ganttBarWidth]); - - jobs.forEach(job => { - if (job.timeRange.to !== undefined && job.timeRange.from !== undefined) { - job.timeRange.fromPx = ganttScale(job.timeRange.from); - job.timeRange.toPx = ganttScale(job.timeRange.to); - job.timeRange.widthPx = job.timeRange.toPx - job.timeRange.fromPx; - // Ensure at least 1 px in width so it's always visible - if (job.timeRange.widthPx < 1) { - job.timeRange.widthPx = 1; - } - - job.timeRange.toMoment = moment(job.timeRange.to).tz(dateFormatTz); - job.timeRange.fromMoment = moment(job.timeRange.from).tz(dateFormatTz); - - const fromString = job.timeRange.fromMoment.format('MMM Do YYYY, HH:mm'); - const toString = job.timeRange.toMoment.format('MMM Do YYYY, HH:mm'); - job.timeRange.label = i18n.translate('xpack.ml.jobSelector.jobTimeRangeLabel', { - defaultMessage: '{fromString} to {toString}', - values: { - fromString, - toString, - }, - }); - } else { - job.timeRange.widthPx = 0; - job.timeRange.fromPx = 0; - job.timeRange.toPx = 0; - job.timeRange.label = i18n.translate('xpack.ml.jobSelector.noResultsForJobLabel', { - defaultMessage: 'No results', - }); - } - }); - return jobs; -} diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts new file mode 100644 index 00000000000000..1484f0a391b671 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_select_service_utils.ts @@ -0,0 +1,156 @@ +/* + * 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 moment from 'moment'; +import d3 from 'd3'; + +import { Dictionary } from '../../../../common/types/common'; +import { MlJobWithTimeRange } from '../../../../common/types/jobs'; + +export function getGroupsFromJobs(jobs: MlJobWithTimeRange[]) { + const groups: Dictionary = {}; + const groupsMap: Dictionary = {}; + + jobs.forEach(job => { + // Organize job by group + if (job.groups !== undefined) { + job.groups.forEach(g => { + if (groups[g] === undefined) { + groups[g] = { + id: g, + jobIds: [job.job_id], + timeRange: { + to: job.timeRange.to, + toMoment: null, + from: job.timeRange.from, + fromMoment: null, + fromPx: job.timeRange.fromPx, + toPx: job.timeRange.toPx, + widthPx: null, + }, + }; + + groupsMap[g] = [job.job_id]; + } else { + groups[g].jobIds.push(job.job_id); + groupsMap[g].push(job.job_id); + // keep track of earliest 'from' / latest 'to' for group range + if (groups[g].timeRange.to === null || job.timeRange.to > groups[g].timeRange.to) { + groups[g].timeRange.to = job.timeRange.to; + groups[g].timeRange.toMoment = job.timeRange.toMoment; + } + if (groups[g].timeRange.from === null || job.timeRange.from < groups[g].timeRange.from) { + groups[g].timeRange.from = job.timeRange.from; + groups[g].timeRange.fromMoment = job.timeRange.fromMoment; + } + if (groups[g].timeRange.toPx === null || job.timeRange.toPx > groups[g].timeRange.toPx) { + groups[g].timeRange.toPx = job.timeRange.toPx; + } + if ( + groups[g].timeRange.fromPx === null || + job.timeRange.fromPx < groups[g].timeRange.fromPx + ) { + groups[g].timeRange.fromPx = job.timeRange.fromPx; + } + } + }); + } + }); + + Object.keys(groups).forEach(groupId => { + const group = groups[groupId]; + group.timeRange.widthPx = group.timeRange.toPx - group.timeRange.fromPx; + group.timeRange.toMoment = moment(group.timeRange.to); + group.timeRange.fromMoment = moment(group.timeRange.from); + // create label + const fromString = group.timeRange.fromMoment.format('MMM Do YYYY, HH:mm'); + const toString = group.timeRange.toMoment.format('MMM Do YYYY, HH:mm'); + group.timeRange.label = i18n.translate('xpack.ml.jobSelectList.groupTimeRangeLabel', { + defaultMessage: '{fromString} to {toString}', + values: { + fromString, + toString, + }, + }); + }); + + return { groups: Object.keys(groups).map(g => groups[g]), groupsMap }; +} + +export function getTimeRangeFromSelection(jobs: MlJobWithTimeRange[], selection: string[]) { + if (jobs.length > 0) { + const times: number[] = []; + jobs.forEach(job => { + if (selection.includes(job.job_id)) { + if (job.timeRange.from !== undefined) { + times.push(job.timeRange.from); + } + if (job.timeRange.to !== undefined) { + times.push(job.timeRange.to); + } + } + }); + if (times.length) { + const extent = d3.extent(times); + const selectedTime = { + from: moment(extent[0]).toISOString(), + to: moment(extent[1]).toISOString(), + }; + return selectedTime; + } + } +} + +export function normalizeTimes( + jobs: MlJobWithTimeRange[], + dateFormatTz: string, + ganttBarWidth: number +) { + const jobsWithTimeRange = jobs.filter(job => { + return job.timeRange.to !== undefined && job.timeRange.from !== undefined; + }); + + const min = Math.min(...jobsWithTimeRange.map(job => +job.timeRange.from)); + const max = Math.max(...jobsWithTimeRange.map(job => +job.timeRange.to)); + const ganttScale = d3.scale + .linear() + .domain([min, max]) + .range([1, ganttBarWidth]); + + jobs.forEach(job => { + if (job.timeRange.to !== undefined && job.timeRange.from !== undefined) { + job.timeRange.fromPx = ganttScale(job.timeRange.from); + job.timeRange.toPx = ganttScale(job.timeRange.to); + job.timeRange.widthPx = job.timeRange.toPx - job.timeRange.fromPx; + // Ensure at least 1 px in width so it's always visible + if (job.timeRange.widthPx < 1) { + job.timeRange.widthPx = 1; + } + + job.timeRange.toMoment = moment(job.timeRange.to).tz(dateFormatTz); + job.timeRange.fromMoment = moment(job.timeRange.from).tz(dateFormatTz); + + const fromString = job.timeRange.fromMoment.format('MMM Do YYYY, HH:mm'); + const toString = job.timeRange.toMoment.format('MMM Do YYYY, HH:mm'); + job.timeRange.label = i18n.translate('xpack.ml.jobSelector.jobTimeRangeLabel', { + defaultMessage: '{fromString} to {toString}', + values: { + fromString, + toString, + }, + }); + } else { + job.timeRange.widthPx = 0; + job.timeRange.fromPx = 0; + job.timeRange.toPx = 0; + job.timeRange.label = i18n.translate('xpack.ml.jobSelector.noResultsForJobLabel', { + defaultMessage: 'No results', + }); + } + }); + return jobs; +} diff --git a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.js b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.tsx similarity index 74% rename from x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.js rename to x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.tsx index b86118c451bb7c..f1d9dcb0ec7956 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.js +++ b/x-pack/legacy/plugins/ml/public/application/components/job_selector/job_selector.tsx @@ -4,23 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEqual } from 'lodash'; import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { PropTypes } from 'prop-types'; -import moment from 'moment'; +import PropTypes from 'prop-types'; -import { ml } from '../../services/ml_api_service'; -import { JobSelectorTable } from './job_selector_table'; -import { IdBadges } from './id_badges'; -import { NewSelectionIdBadges } from './new_selection_id_badges'; -import { timefilter } from 'ui/timefilter'; -import { - getGroupsFromJobs, - normalizeTimes, - setGlobalState, - setGlobalStateSkipRefresh, -} from './job_select_service_utils'; -import { toastNotifications } from 'ui/notify'; import { EuiButton, EuiButtonEmpty, @@ -33,15 +19,42 @@ import { EuiSwitch, EuiTitle, } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; -function mergeSelection(jobIds, groupObjs, singleSelection) { +import { toastNotifications } from 'ui/notify'; + +import { Dictionary } from '../../../../common/types/common'; +import { MlJobWithTimeRange } from '../../../../common/types/jobs'; +import { ml } from '../../services/ml_api_service'; +import { useUrlState } from '../../util/url_state'; +// @ts-ignore +import { JobSelectorTable } from './job_selector_table'; +// @ts-ignore +import { IdBadges } from './id_badges'; +// @ts-ignore +import { NewSelectionIdBadges } from './new_selection_id_badges'; +import { + getGroupsFromJobs, + getTimeRangeFromSelection, + normalizeTimes, +} from './job_select_service_utils'; + +interface GroupObj { + groupId: string; + jobIds: string[]; +} +function mergeSelection( + jobIds: string[], + groupObjs: GroupObj[], + singleSelection: boolean +): string[] { if (singleSelection) { return jobIds; } - const selectedIds = []; - const alreadySelected = []; + const selectedIds: string[] = []; + const alreadySelected: string[] = []; groupObjs.forEach(group => { selectedIds.push(group.groupId); @@ -58,8 +71,9 @@ function mergeSelection(jobIds, groupObjs, singleSelection) { return selectedIds; } -function getInitialGroupsMap(selectedGroups) { - const map = {}; +type GroupsMap = Dictionary; +function getInitialGroupsMap(selectedGroups: GroupObj[]): GroupsMap { + const map: GroupsMap = {}; if (selectedGroups.length) { selectedGroups.forEach(group => { @@ -73,17 +87,20 @@ function getInitialGroupsMap(selectedGroups) { const BADGE_LIMIT = 10; const DEFAULT_GANTT_BAR_WIDTH = 299; // pixels -export function JobSelector({ - dateFormatTz, - globalState, - jobSelectService$, - selectedJobIds, - selectedGroups, - singleSelection, - timeseriesOnly, -}) { - const [jobs, setJobs] = useState([]); - const [groups, setGroups] = useState([]); +interface JobSelectorProps { + dateFormatTz: string; + singleSelection: boolean; + timeseriesOnly: boolean; +} + +export function JobSelector({ dateFormatTz, singleSelection, timeseriesOnly }: JobSelectorProps) { + const [globalState, setGlobalState] = useUrlState('_g'); + + const selectedJobIds = globalState?.ml?.jobIds ?? []; + const selectedGroups = globalState?.ml?.groups ?? []; + + const [jobs, setJobs] = useState([]); + const [groups, setGroups] = useState([]); const [maps, setMaps] = useState({ groupsMap: getInitialGroupsMap(selectedGroups), jobsMap: {} }); const [selectedIds, setSelectedIds] = useState( mergeSelection(selectedJobIds, selectedGroups, singleSelection) @@ -96,20 +113,12 @@ export function JobSelector({ const [applyTimeRange, setApplyTimeRange] = useState(true); const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); const [ganttBarWidth, setGanttBarWidth] = useState(DEFAULT_GANTT_BAR_WIDTH); - const flyoutEl = useRef(null); + const flyoutEl = useRef<{ flyout: HTMLElement }>(null); + // Ensure JobSelectionBar gets updated when selection via globalState changes. useEffect(() => { - // listen for update from Single Metric Viewer - const subscription = jobSelectService$.subscribe(({ selection, resetSelection }) => { - if (resetSelection === true) { - setSelectedIds(selection); - } - }); - - return function cleanup() { - subscription.unsubscribe(); - }; - }, []); // eslint-disable-line + setSelectedIds(mergeSelection(selectedJobIds, selectedGroups, singleSelection)); + }, [JSON.stringify([selectedJobIds, selectedGroups])]); // Ensure current selected ids always show up in flyout useEffect(() => { @@ -121,7 +130,9 @@ export function JobSelector({ const handleResize = useCallback(() => { if (jobs.length > 0 && flyoutEl && flyoutEl.current && flyoutEl.current.flyout) { // get all cols in flyout table - const tableHeaderCols = flyoutEl.current.flyout.querySelectorAll('table thead th'); + const tableHeaderCols: NodeListOf = flyoutEl.current.flyout.querySelectorAll( + 'table thead th' + ); // get the width of the last col const derivedWidth = tableHeaderCols[tableHeaderCols.length - 1].offsetWidth - 16; const normalizedJobs = normalizeTimes(jobs, dateFormatTz, derivedWidth); @@ -145,21 +156,12 @@ export function JobSelector({ handleResize(); }, [handleResize, jobs]); - // On opening and closing the flyout, optionally update a global `skipRefresh` flag. - // This allows us to circumvent race conditions which could happen by triggering both - // timefilter and job selector related events in Single Metric Viewer. - function closeFlyout(setSkipRefresh = true) { + function closeFlyout() { setIsFlyoutVisible(false); - if (setSkipRefresh) { - setGlobalStateSkipRefresh(globalState, false); - } } - function showFlyout(setSkipRefresh = true) { + function showFlyout() { setIsFlyoutVisible(true); - if (setSkipRefresh) { - setGlobalStateSkipRefresh(globalState, true); - } } function handleJobSelectionClick() { @@ -174,8 +176,8 @@ export function JobSelector({ setGroups(groupsWithTimerange); setMaps({ groupsMap, jobsMap: resp.jobsMap }); }) - .catch(err => { - console.log('Error fetching jobs', err); + .catch((err: any) => { + console.error('Error fetching jobs with time range', err); // eslint-disable-line toastNotifications.addDanger({ title: i18n.translate('xpack.ml.jobSelector.jobFetchErrorMessage', { defaultMessage: 'An error occurred fetching jobs. Refresh and try again.', @@ -184,14 +186,14 @@ export function JobSelector({ }); } - function handleNewSelection({ selectionFromTable }) { + function handleNewSelection({ selectionFromTable }: { selectionFromTable: any }) { setNewSelection(selectionFromTable); } function applySelection() { // allNewSelection will be a list of all job ids (including those from groups) selected from the table - const allNewSelection = []; - const groupSelection = []; + const allNewSelection: string[] = []; + const groupSelection: Array<{ groupId: string; jobIds: string[] }> = []; newSelection.forEach(id => { if (maps.groupsMap[id] !== undefined) { @@ -206,68 +208,29 @@ export function JobSelector({ // create a Set to remove duplicate values const allNewSelectionUnique = Array.from(new Set(allNewSelection)); - const isPrevousSelection = isEqual( - { selectedJobIds, selectedGroups }, - { selectedJobIds: allNewSelectionUnique, selectedGroups: groupSelection } - ); - setSelectedIds(newSelection); setNewSelection([]); - // If the job selection is unchanged, then we close the modal and - // disable skipping the timefilter listener flag in globalState. - // If the job selection changed, this will not - // update skipRefresh yet to avoid firing multiple events via - // applyTimeRangeFromSelection() and setGlobalState(). - closeFlyout(isPrevousSelection); - - // If the job selection changed, then when - // calling `applyTimeRangeFromSelection()` here - // Single Metric Viewer will skip an update - // triggered by timefilter to avoid a race - // condition caused by the job update listener - // that's also going to be triggered. - applyTimeRangeFromSelection(allNewSelectionUnique); - - // Set `skipRefresh` again to `false` here so after - // both the time range and jobs have been updated - // Single Metric Viewer should again update itself. - setGlobalState(globalState, { - selectedIds: allNewSelectionUnique, - selectedGroups: groupSelection, - skipRefresh: false, - }); - } + closeFlyout(); - function applyTimeRangeFromSelection(selection) { - if (applyTimeRange && jobs.length > 0) { - const times = []; - jobs.forEach(job => { - if (selection.includes(job.job_id)) { - if (job.timeRange.from !== undefined) { - times.push(job.timeRange.from); - } - if (job.timeRange.to !== undefined) { - times.push(job.timeRange.to); - } - } - }); - if (times.length) { - const min = Math.min(...times); - const max = Math.max(...times); - timefilter.setTime({ - from: moment(min).toISOString(), - to: moment(max).toISOString(), - }); - } - } + const time = applyTimeRange + ? getTimeRangeFromSelection(jobs, allNewSelectionUnique) + : undefined; + + setGlobalState({ + ml: { + jobIds: allNewSelectionUnique, + groups: groupSelection, + }, + ...(time !== undefined ? { time } : {}), + }); } function toggleTimerangeSwitch() { setApplyTimeRange(!applyTimeRange); } - function removeId(id) { + function removeId(id: string) { setNewSelection(newSelection.filter(item => item !== id)); } @@ -315,6 +278,7 @@ export function JobSelector({ if (isFlyoutVisible) { return ( { + const jobExists = jobs.some(job => job.job_id === id); + return jobExists === false && id !== '*'; + }); +} + +function warnAboutInvalidJobIds(invalidIds: string[]) { + if (invalidIds.length > 0) { + toastNotifications.addWarning( + i18n.translate('xpack.ml.jobSelect.requestedJobsDoesNotExistWarningMessage', { + defaultMessage: `Requested +{invalidIdsLength, plural, one {job {invalidIds} does not exist} other {jobs {invalidIds} do not exist}}`, + values: { + invalidIdsLength: invalidIds.length, + invalidIds: invalidIds.join(), + }, + }) + ); + } +} + +export interface JobSelection { + jobIds: string[]; + selectedGroups: string[]; +} + +export const useJobSelection = (jobs: MlJobWithTimeRange[], dateFormatTz: string) => { + const [globalState, setGlobalState] = useUrlState('_g'); + + const jobSelection: JobSelection = { jobIds: [], selectedGroups: [] }; + + const ids = globalState?.ml?.jobIds || []; + const tmpIds = (typeof ids === 'string' ? [ids] : ids).map((id: string) => String(id)); + const invalidIds = getInvalidJobIds(jobs, tmpIds); + const validIds = difference(tmpIds, invalidIds); + validIds.sort(); + + jobSelection.jobIds = validIds; + jobSelection.selectedGroups = globalState?.ml?.groups ?? []; + + useEffect(() => { + warnAboutInvalidJobIds(invalidIds); + }, [invalidIds]); + + useEffect(() => { + // if there are no valid ids, warn and then select the first job + if (validIds.length === 0 && jobs.length > 0) { + toastNotifications.addWarning( + i18n.translate('xpack.ml.jobSelect.noJobsSelectedWarningMessage', { + defaultMessage: 'No jobs selected, auto selecting first job', + }) + ); + + const mlGlobalState = globalState?.ml || {}; + mlGlobalState.jobIds = [jobs[0].job_id]; + + const time = getTimeRangeFromSelection(jobs, mlGlobalState.jobIds); + + setGlobalState({ + ...{ ml: mlGlobalState }, + ...(time !== undefined ? { time } : {}), + }); + } + }, [jobs, validIds]); + + return jobSelection; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/tabs.tsx b/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/tabs.tsx index 20fa2cca41231a..ac83d598f23826 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/tabs.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/tabs.tsx @@ -5,8 +5,14 @@ */ import React, { FC, useState } from 'react'; +import { encode } from 'rison-node'; + import { EuiTabs, EuiTab, EuiLink } from '@elastic/eui'; + import { i18n } from '@kbn/i18n'; + +import { useUrlState } from '../../util/url_state'; + import { Tab } from './main_tabs'; import { TabId } from './navigation_menu'; @@ -67,6 +73,7 @@ enum TAB_TEST_SUBJECT { type TAB_TEST_SUBJECTS = keyof typeof TAB_TEST_SUBJECT; export const Tabs: FC = ({ tabId, mainTabId, disableLinks }) => { + const [globalState] = useUrlState('_g'); const [selectedTabId, setSelectedTabId] = useState(tabId); function onSelectedTabChanged(id: string) { setSelectedTabId(id); @@ -78,12 +85,16 @@ export const Tabs: FC = ({ tabId, mainTabId, disableLinks }) => { {tabs.map((tab: Tab) => { const id = tab.id; + // globalState (e.g. selected jobs and time range) should be retained when changing pages. + // appState will not be considered. + const fullGlobalStateString = globalState !== undefined ? `?_g=${encode(globalState)}` : ''; + return ( diff --git a/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/top_nav/top_nav.tsx b/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/top_nav/top_nav.tsx index 523970dfe12f8a..ca6146f3e23b53 100644 --- a/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/top_nav/top_nav.tsx +++ b/x-pack/legacy/plugins/ml/public/application/components/navigation_menu/top_nav/top_nav.tsx @@ -6,11 +6,14 @@ import React, { FC, Fragment, useState, useEffect } from 'react'; import { Subscription } from 'rxjs'; -import { EuiSuperDatePicker } from '@elastic/eui'; +import { EuiSuperDatePicker, OnRefreshProps } from '@elastic/eui'; import { TimeHistory } from 'ui/timefilter'; import { TimeRange } from 'src/plugins/data/public'; -import { mlTimefilterRefresh$ } from '../../../services/timefilter_refresh_service'; +import { + mlTimefilterRefresh$, + mlTimefilterTimeChange$, +} from '../../../services/timefilter_refresh_service'; import { useUiContext } from '../../../contexts/ui/use_ui_context'; interface Duration { @@ -29,6 +32,10 @@ function getRecentlyUsedRangesFactory(timeHistory: TimeHistory) { }; } +function updateLastRefresh(timeRange: OnRefreshProps) { + mlTimefilterRefresh$.next({ lastRefresh: Date.now(), timeRange }); +} + export const TopNav: FC = () => { const { chrome, timefilter, timeHistory } = useUiContext(); const getRecentlyUsedRanges = getRecentlyUsedRangesFactory(timeHistory); @@ -74,6 +81,7 @@ export const TopNav: FC = () => { timefilter.setTime(newTime); setTime(newTime); setRecentlyUsedRanges(getRecentlyUsedRanges()); + mlTimefilterTimeChange$.next({ lastRefresh: Date.now(), timeRange: { start, end } }); } function updateInterval({ @@ -104,7 +112,7 @@ export const TopNav: FC = () => { isAutoRefreshOnly={!isTimeRangeSelectorEnabled} refreshInterval={refreshInterval.value} onTimeChange={updateFilter} - onRefresh={() => mlTimefilterRefresh$.next()} + onRefresh={updateLastRefresh} onRefreshChange={updateInterval} recentlyUsedRanges={recentlyUsedRanges} dateFormat={dateFormat} diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/__tests__/explorer_directive.js b/x-pack/legacy/plugins/ml/public/application/explorer/__tests__/explorer_directive.js deleted file mode 100644 index 4626ee48b53f7a..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/explorer/__tests__/explorer_directive.js +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import ngMock from 'ng_mock'; -import sinon from 'sinon'; -import expect from '@kbn/expect'; - -import { - uiChromeMock, - uiTimefilterMock, - uiTimeHistoryMock, -} from '../../contexts/ui/__mocks__/mocks_mocha'; -import * as useUiContextModule from '../../contexts/ui/use_ui_context'; -import * as UiTimefilterModule from 'ui/timefilter'; - -describe('ML - Anomaly Explorer Directive', () => { - let $scope; - let $compile; - let $element; - let stubContext; - let stubTimefilterFetch; - - beforeEach(ngMock.module('kibana')); - beforeEach(() => { - ngMock.inject(function($injector) { - stubContext = sinon.stub(useUiContextModule, 'useUiContext').callsFake(function fakeFn() { - return { - chrome: uiChromeMock, - timefilter: uiTimefilterMock, - timeHistory: uiTimeHistoryMock, - }; - }); - stubTimefilterFetch = sinon - .stub(UiTimefilterModule.timefilter, 'getFetch$') - .callsFake(uiTimefilterMock.getFetch$); - - $compile = $injector.get('$compile'); - const $rootScope = $injector.get('$rootScope'); - $scope = $rootScope.$new(); - }); - }); - - afterEach(() => { - stubContext.restore(); - stubTimefilterFetch.restore(); - $scope.$destroy(); - }); - - it('Initialize Anomaly Explorer Directive', done => { - ngMock.inject(function() { - expect(() => { - $element = $compile('')($scope); - }).to.not.throwError(); - - // directive has scope: false - const scope = $element.isolateScope(); - expect(scope).to.eql(undefined); - done(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/actions/index.ts b/x-pack/legacy/plugins/ml/public/application/explorer/actions/index.ts index 1528a7ce7eee1f..a16081892cdc82 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/actions/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/actions/index.ts @@ -5,4 +5,4 @@ */ export { jobSelectionActionCreator } from './job_selection'; -export { loadExplorerData } from './load_explorer_data'; +export { useExplorerData } from './load_explorer_data'; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/actions/job_selection.ts b/x-pack/legacy/plugins/ml/public/application/explorer/actions/job_selection.ts index 76d66bfbbf12b0..994d67bfdb02c1 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/actions/job_selection.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/actions/job_selection.ts @@ -10,13 +10,10 @@ import { map } from 'rxjs/operators'; import { mlFieldFormatService } from '../../services/field_format_service'; import { mlJobService } from '../../services/job_service'; -import { createJobs, RestoredAppState } from '../explorer_utils'; +import { EXPLORER_ACTION } from '../explorer_constants'; +import { createJobs } from '../explorer_utils'; -export function jobSelectionActionCreator( - actionName: string, - selectedJobIds: string[], - { filterData, selectedCells, viewBySwimlaneFieldName }: RestoredAppState -) { +export function jobSelectionActionCreator(selectedJobIds: string[]) { return from(mlFieldFormatService.populateFormats(selectedJobIds)).pipe( map(resp => { if (resp.err) { @@ -32,13 +29,10 @@ export function jobSelectionActionCreator( const selectedJobs = jobs.filter(job => job.selected); return { - type: actionName, + type: EXPLORER_ACTION.JOB_SELECTION_CHANGE, payload: { loading: false, - selectedCells, selectedJobs, - viewBySwimlaneFieldName, - filterData, }, }; }) diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/legacy/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 6d4edd909fa8fd..ed734051342248 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -6,11 +6,12 @@ import memoizeOne from 'memoize-one'; import { isEqual } from 'lodash'; +import useObservable from 'react-use/lib/useObservable'; -import { forkJoin, of } from 'rxjs'; -import { mergeMap, tap } from 'rxjs/operators'; +import { forkJoin, of, Observable, Subject } from 'rxjs'; +import { mergeMap, switchMap, tap } from 'rxjs/operators'; -import { explorerChartsContainerServiceFactory } from '../explorer_charts/explorer_charts_container_service'; +import { anomalyDataChange } from '../explorer_charts/explorer_charts_container_service'; import { VIEW_BY_JOB_LABEL } from '../explorer_constants'; import { explorerService } from '../explorer_dashboard_service'; import { @@ -25,35 +26,82 @@ import { loadTopInfluencers, loadViewBySwimlane, loadViewByTopFieldValuesForSelectedTime, + AppStateSelectedCells, + ExplorerJob, + TimeRangeBounds, } from '../explorer_utils'; import { ExplorerState } from '../reducers'; +// Memoize the data fetching methods. +// wrapWithLastRefreshArg() wraps any given function and preprends a `lastRefresh` argument +// which will be considered by memoizeOne. This way we can add the `lastRefresh` argument as a +// caching parameter without having to change all the original functions which shouldn't care +// about this parameter. The generic type T retains and returns the type information of +// the original function. const memoizeIsEqual = (newArgs: any[], lastArgs: any[]) => isEqual(newArgs, lastArgs); +const wrapWithLastRefreshArg = any>(func: T) => { + return function(lastRefresh: number, ...args: Parameters): ReturnType { + return func.apply(null, args); + }; +}; +const memoize = any>(func: T) => { + return memoizeOne(wrapWithLastRefreshArg(func), memoizeIsEqual); +}; -// Memoize the data fetching methods -// TODO: We need to track an attribute that allows refetching when the date picker -// triggers a refresh, otherwise we'll get back the stale data. Note this was also -// an issue with the previous version and the custom caching done within the component. -const memoizedLoadAnnotationsTableData = memoizeOne(loadAnnotationsTableData, memoizeIsEqual); -const memoizedLoadDataForCharts = memoizeOne(loadDataForCharts, memoizeIsEqual); -const memoizedLoadFilteredTopInfluencers = memoizeOne(loadFilteredTopInfluencers, memoizeIsEqual); -const memoizedLoadOverallData = memoizeOne(loadOverallData, memoizeIsEqual); -const memoizedLoadTopInfluencers = memoizeOne(loadTopInfluencers, memoizeIsEqual); -const memoizedLoadViewBySwimlane = memoizeOne(loadViewBySwimlane, memoizeIsEqual); -const memoizedLoadAnomaliesTableData = memoizeOne(loadAnomaliesTableData, memoizeIsEqual); +const memoizedAnomalyDataChange = memoize(anomalyDataChange); +const memoizedLoadAnnotationsTableData = memoize( + loadAnnotationsTableData +); +const memoizedLoadDataForCharts = memoize(loadDataForCharts); +const memoizedLoadFilteredTopInfluencers = memoize( + loadFilteredTopInfluencers +); +const memoizedLoadOverallData = memoize(loadOverallData); +const memoizedLoadTopInfluencers = memoize(loadTopInfluencers); +const memoizedLoadViewBySwimlane = memoize(loadViewBySwimlane); +const memoizedLoadAnomaliesTableData = memoize(loadAnomaliesTableData); const dateFormatTz = getDateFormatTz(); +export interface LoadExplorerDataConfig { + bounds: TimeRangeBounds; + influencersFilterQuery: any; + lastRefresh: number; + noInfluencersConfigured: boolean; + selectedCells: AppStateSelectedCells | undefined; + selectedJobs: ExplorerJob[]; + swimlaneBucketInterval: any; + swimlaneLimit: number; + tableInterval: string; + tableSeverity: number; + viewBySwimlaneFieldName: string; +} + +export const isLoadExplorerDataConfig = (arg: any): arg is LoadExplorerDataConfig => { + return ( + arg !== undefined && + arg.bounds !== undefined && + arg.selectedJobs !== undefined && + arg.selectedJobs !== null && + arg.viewBySwimlaneFieldName !== undefined + ); +}; + /** * Fetches the data necessary for the Anomaly Explorer using observables. * - * @param state ExplorerState + * @param config LoadExplorerDataConfig * * @return Partial */ -export function loadExplorerData(state: ExplorerState) { +function loadExplorerData(config: LoadExplorerDataConfig): Observable> { + if (!isLoadExplorerDataConfig(config)) { + return of({}); + } + const { bounds, + lastRefresh, influencersFilterQuery, noInfluencersConfigured, selectedCells, @@ -63,19 +111,12 @@ export function loadExplorerData(state: ExplorerState) { tableInterval, tableSeverity, viewBySwimlaneFieldName, - } = state; - - if (selectedJobs === null || bounds === undefined || viewBySwimlaneFieldName === undefined) { - return of({}); - } - - // TODO This factory should be refactored so we can load the charts using memoization. - const updateCharts = explorerChartsContainerServiceFactory(explorerService.setCharts); + } = config; const selectionInfluencers = getSelectionInfluencers(selectedCells, viewBySwimlaneFieldName); const jobIds = - selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL + selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL ? selectedCells.lanes : selectedJobs.map(d => d.id); @@ -89,12 +130,14 @@ export function loadExplorerData(state: ExplorerState) { // annotationsData, anomalyChartRecords, influencers, overallState, tableData, topFieldValues return forkJoin({ annotationsData: memoizedLoadAnnotationsTableData( + lastRefresh, selectedCells, selectedJobs, swimlaneBucketInterval.asSeconds(), bounds ), anomalyChartRecords: memoizedLoadDataForCharts( + lastRefresh, jobIds, timerange.earliestMs, timerange.latestMs, @@ -105,6 +148,7 @@ export function loadExplorerData(state: ExplorerState) { influencers: selectionInfluencers.length === 0 ? memoizedLoadTopInfluencers( + lastRefresh, jobIds, timerange.earliestMs, timerange.latestMs, @@ -113,8 +157,14 @@ export function loadExplorerData(state: ExplorerState) { influencersFilterQuery ) : Promise.resolve({}), - overallState: memoizedLoadOverallData(selectedJobs, swimlaneBucketInterval, bounds), + overallState: memoizedLoadOverallData( + lastRefresh, + selectedJobs, + swimlaneBucketInterval, + bounds + ), tableData: memoizedLoadAnomaliesTableData( + lastRefresh, selectedCells, selectedJobs, dateFormatTz, @@ -126,7 +176,7 @@ export function loadExplorerData(state: ExplorerState) { influencersFilterQuery ), topFieldValues: - selectedCells !== null && selectedCells.showTopFieldValues === true + selectedCells !== undefined && selectedCells.showTopFieldValues === true ? loadViewByTopFieldValuesForSelectedTime( timerange.earliestMs, timerange.latestMs, @@ -143,10 +193,22 @@ export function loadExplorerData(state: ExplorerState) { tap(explorerService.setViewBySwimlaneLoading), // Trigger a side-effect to update the charts. tap(({ anomalyChartRecords }) => { - if (selectedCells !== null && Array.isArray(anomalyChartRecords)) { - updateCharts(anomalyChartRecords, timerange.earliestMs, timerange.latestMs); + if (selectedCells !== undefined && Array.isArray(anomalyChartRecords)) { + memoizedAnomalyDataChange( + lastRefresh, + anomalyChartRecords, + timerange.earliestMs, + timerange.latestMs, + tableSeverity + ); } else { - updateCharts([], timerange.earliestMs, timerange.latestMs); + memoizedAnomalyDataChange( + lastRefresh, + [], + timerange.earliestMs, + timerange.latestMs, + tableSeverity + ); } }), // Load view-by swimlane data and filtered top influencers. @@ -161,6 +223,7 @@ export function loadExplorerData(state: ExplorerState) { anomalyChartRecords !== undefined && anomalyChartRecords.length > 0 ? memoizedLoadFilteredTopInfluencers( + lastRefresh, jobIds, timerange.earliestMs, timerange.latestMs, @@ -171,6 +234,7 @@ export function loadExplorerData(state: ExplorerState) { ) : Promise.resolve(influencers), viewBySwimlaneState: memoizedLoadViewBySwimlane( + lastRefresh, topFieldValues, { earliest: overallState.overallSwimlaneData.earliest, @@ -183,7 +247,10 @@ export function loadExplorerData(state: ExplorerState) { noInfluencersConfigured ), }), - ({ annotationsData, overallState, tableData }, { influencers, viewBySwimlaneState }) => { + ( + { annotationsData, overallState, tableData }, + { influencers, viewBySwimlaneState } + ): Partial => { return { annotationsData, influencers, @@ -195,3 +262,13 @@ export function loadExplorerData(state: ExplorerState) { ) ); } + +const loadExplorerData$ = new Subject(); +const explorerData$ = loadExplorerData$.pipe( + switchMap((config: LoadExplorerDataConfig) => loadExplorerData(config)) +); + +export const useExplorerData = (): [Partial | undefined, (d: any) => void] => { + const explorerData = useObservable(explorerData$); + return [explorerData, c => loadExplorerData$.next(c)]; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer.d.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer.d.ts index de58b9228c0769..b8df021990f584 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer.d.ts @@ -6,13 +6,17 @@ import { FC } from 'react'; -import { State } from 'ui/state_management/state'; +import { UrlState } from '../util/url_state'; -import { JobSelectService$ } from '../components/job_selector/job_select_service_utils'; +import { JobSelection } from '../components/job_selector/use_job_selection'; + +import { ExplorerState } from '../explorer/reducers'; +import { AppStateSelectedCells } from '../explorer/explorer_utils'; declare interface ExplorerProps { - globalState: State; - jobSelectService$: JobSelectService$; + explorerState: ExplorerState; + showCharts: boolean; + setSelectedCells: (swimlaneSelectedCells: AppStateSelectedCells) => void; } export const Explorer: FC; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer.js index bcac1b6405ff8b..79071319965781 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer.js @@ -10,9 +10,10 @@ import PropTypes from 'prop-types'; import React, { createRef } from 'react'; -import { FormattedMessage, injectI18n } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import DragSelect from 'dragselect/dist/ds.min.js'; -import { merge, Subject } from 'rxjs'; +import { Subject } from 'rxjs'; import { map, takeUntil } from 'rxjs/operators'; import { @@ -24,7 +25,6 @@ import { EuiSpacer, } from '@elastic/eui'; -import { annotationsRefresh$ } from '../services/annotations_service'; import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; import { AnnotationsTable } from '../components/annotations/annotations_table'; import { @@ -36,7 +36,6 @@ import { ChartTooltip } from '../components/chart_tooltip'; import { ExplorerSwimlane } from './explorer_swimlane'; import { KqlFilterBar } from '../components/kql_filter_bar'; import { TimeBuckets } from '../util/time_buckets'; -import { getSelectedJobIds } from '../components/job_selector/job_select_service_utils'; import { InfluencersList } from '../components/influencers_list'; import { ALLOW_CELL_RANGE_SELECTION, @@ -45,12 +44,11 @@ import { } from './explorer_dashboard_service'; import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; import { NavigationMenu } from '../components/navigation_menu'; -import { CheckboxShowCharts, showCharts$ } from '../components/controls/checkbox_showcharts'; +import { CheckboxShowCharts } from '../components/controls/checkbox_showcharts'; import { JobSelector } from '../components/job_selector'; -import { SelectInterval, interval$ } from '../components/controls/select_interval/select_interval'; +import { SelectInterval } from '../components/controls/select_interval/select_interval'; import { SelectLimit, limit$ } from './select_limit/select_limit'; -import { SelectSeverity, severity$ } from '../components/controls/select_severity/select_severity'; -import { injectObservablesAsProps } from '../util/observable_utils'; +import { SelectSeverity } from '../components/controls/select_severity/select_severity'; import { getKqlQueryValues, removeFilterFromQueryString, @@ -58,9 +56,8 @@ import { escapeParens, escapeDoubleQuotes, } from '../components/kql_filter_bar/utils'; -import { mlJobService } from '../services/job_service'; -import { getDateFormatTz, restoreAppState } from './explorer_utils'; +import { getDateFormatTz } from './explorer_utils'; import { getSwimlaneContainerWidth } from './legacy_utils'; import { @@ -80,8 +77,6 @@ import { ResizeChecker } from '../../../../../../../src/plugins/kibana_utils/pub import { timefilter } from 'ui/timefilter'; import { toastNotifications } from 'ui/notify'; -import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; - function mapSwimlaneOptionsToEuiOptions(options) { return options.map(option => ({ value: option, @@ -97,571 +92,484 @@ const ExplorerPage = ({ children, jobSelectorProps, resizeRef }) => (
); -export const Explorer = injectI18n( - injectObservablesAsProps( - { - annotationsRefresh: annotationsRefresh$, - explorerState: explorerService.state$, - showCharts: showCharts$, - }, - class Explorer extends React.Component { - static propTypes = { - annotationsRefresh: PropTypes.bool, - explorerState: PropTypes.object.isRequired, - explorer: PropTypes.object, - globalState: PropTypes.object.isRequired, - jobSelectService$: PropTypes.object.isRequired, - showCharts: PropTypes.bool.isRequired, - }; - - _unsubscribeAll = new Subject(); - // make sure dragSelect is only available if the mouse pointer is actually over a swimlane - disableDragSelectOnMouseLeave = true; - - dragSelect = new DragSelect({ - selectables: document.getElementsByClassName('sl-cell'), - callback(elements) { - if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { - elements = [elements[0]]; - } - - if (elements.length > 0) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.NEW_SELECTION, - elements, - }); - } - - this.disableDragSelectOnMouseLeave = true; - }, - onDragStart() { - if (ALLOW_CELL_RANGE_SELECTION) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.DRAG_START, - }); - this.disableDragSelectOnMouseLeave = false; - } - }, - onElementSelect() { - if (ALLOW_CELL_RANGE_SELECTION) { - dragSelect$.next({ - action: DRAG_SELECT_ACTION.ELEMENT_SELECT, - }); - } - }, - }); - - // Listens to render updates of the swimlanes to update dragSelect - swimlaneRenderDoneListener = () => { - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); - }; - - resizeRef = createRef(); - resizeChecker = undefined; - resizeHandler = () => { - explorerService.setSwimlaneContainerWidth(getSwimlaneContainerWidth()); - }; - - componentDidMount() { - timefilter.enableTimeRangeSelector(); - timefilter.enableAutoRefreshSelector(); - - explorerService.setBounds(timefilter.getActiveBounds()); - - // Refresh all the data when the time range is altered. - merge(mlTimefilterRefresh$, timefilter.getFetch$()) - .pipe(takeUntil(this._unsubscribeAll)) - .subscribe(() => { - explorerService.setBounds(timefilter.getActiveBounds()); - }); - - limit$ - .pipe( - takeUntil(this._unsubscribeAll), - map(d => d.val) - ) - .subscribe(explorerService.setSwimlaneLimit); - - interval$ - .pipe( - takeUntil(this._unsubscribeAll), - map(d => ({ tableInterval: d.val })) - ) - .subscribe(explorerService.setState); - - severity$ - .pipe( - takeUntil(this._unsubscribeAll), - map(d => ({ tableSeverity: d.val })) - ) - .subscribe(explorerService.setState); - - // Required to redraw the time series chart when the container is resized. - this.resizeChecker = new ResizeChecker(this.resizeRef.current); - this.resizeChecker.on('resize', this.resizeHandler); - - // restore state stored in URL via AppState and subscribe to - // job updates via job selector. - if (mlJobService.jobs.length > 0) { - let initialized = false; - - this.props.jobSelectService$ - .pipe(takeUntil(this._unsubscribeAll)) - .subscribe(({ selection }) => { - if (selection !== undefined) { - if (!initialized) { - explorerService.initialize( - selection, - restoreAppState(this.props.explorerState.appState) - ); - initialized = true; - } else { - explorerService.updateJobSelection( - selection, - restoreAppState(this.props.explorerState.appState) - ); - } - } - }); - } else { - explorerService.clearJobs(); - } +export class Explorer extends React.Component { + static propTypes = { + explorerState: PropTypes.object.isRequired, + setSelectedCells: PropTypes.func.isRequired, + showCharts: PropTypes.bool.isRequired, + }; + + _unsubscribeAll = new Subject(); + // make sure dragSelect is only available if the mouse pointer is actually over a swimlane + disableDragSelectOnMouseLeave = true; + + dragSelect = new DragSelect({ + selectables: document.getElementsByClassName('sl-cell'), + callback(elements) { + if (elements.length > 1 && !ALLOW_CELL_RANGE_SELECTION) { + elements = [elements[0]]; } - componentWillUnmount() { - this._unsubscribeAll.next(); - this._unsubscribeAll.complete(); - this.resizeChecker.destroy(); + if (elements.length > 0) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.NEW_SELECTION, + elements, + }); } - resetCache() { - this.anomaliesTablePreviousArgs = null; + this.disableDragSelectOnMouseLeave = true; + }, + onDragStart() { + if (ALLOW_CELL_RANGE_SELECTION) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.DRAG_START, + }); + this.disableDragSelectOnMouseLeave = false; } - - componentDidUpdate() { - // TODO migrate annotations update - if (this.props.annotationsRefresh === true) { - annotationsRefresh$.next(false); - } + }, + onElementSelect() { + if (ALLOW_CELL_RANGE_SELECTION) { + dragSelect$.next({ + action: DRAG_SELECT_ACTION.ELEMENT_SELECT, + }); } - - viewByChangeHandler = e => explorerService.setViewBySwimlaneFieldName(e.target.value); - - isSwimlaneSelectActive = false; - onSwimlaneEnterHandler = () => this.setSwimlaneSelectActive(true); - onSwimlaneLeaveHandler = () => this.setSwimlaneSelectActive(false); - setSwimlaneSelectActive = active => { - if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) { - this.dragSelect.stop(); - this.isSwimlaneSelectActive = active; - return; - } - if (!this.isSwimlaneSelectActive && active) { - this.dragSelect.start(); - this.dragSelect.clearSelection(); - this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); - this.isSwimlaneSelectActive = active; - } - }; - - // Listener for click events in the swimlane to load corresponding anomaly data. - swimlaneCellClick = selectedCells => { - // If selectedCells is an empty object we clear any existing selection, - // otherwise we save the new selection in AppState and update the Explorer. - if (Object.keys(selectedCells).length === 0) { - explorerService.clearSelection(); - } else { - explorerService.setSelectedCells(selectedCells); - } - }; - // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes - // and will cause a syntax error when called with getKqlQueryValues - applyFilter = (fieldName, fieldValue, action) => { - const { filterActive, indexPattern, queryString } = this.props.explorerState; - - let newQueryString = ''; - const operator = 'and '; - const sanitizedFieldName = escapeParens(fieldName); - const sanitizedFieldValue = escapeDoubleQuotes(fieldValue); - - if (action === FILTER_ACTION.ADD) { - // Don't re-add if already exists in the query - const queryPattern = getQueryPattern(fieldName, fieldValue); - if (queryString.match(queryPattern) !== null) { - return; - } - newQueryString = `${ - queryString ? `${queryString} ${operator}` : '' - }${sanitizedFieldName}:"${sanitizedFieldValue}"`; - } else if (action === FILTER_ACTION.REMOVE) { - if (filterActive === false) { - return; - } else { - newQueryString = removeFilterFromQueryString( - queryString, - sanitizedFieldName, - sanitizedFieldValue - ); - } - } - - try { - const queryValues = getKqlQueryValues(`${newQueryString}`, indexPattern); - this.applyInfluencersFilterQuery(queryValues); - } catch (e) { - console.log('Invalid kuery syntax', e); // eslint-disable-line no-console - - toastNotifications.addDanger( - this.props.intl.formatMessage({ - id: 'xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable', - defaultMessage: - 'Invalid syntax in query bar. The input must be valid Kibana Query Language (KQL)', - }) - ); - } - }; - - applyInfluencersFilterQuery = payload => { - const { filterQuery: influencersFilterQuery } = payload; - - if ( - influencersFilterQuery.match_all && - Object.keys(influencersFilterQuery.match_all).length === 0 - ) { - explorerService.clearInfluencerFilterSettings(); - } else { - explorerService.setInfluencerFilterSettings(payload); - } - }; - - render() { - const { globalState, intl, jobSelectService$, showCharts } = this.props; - - const { - annotationsData, - anomalyChartRecords, - chartsData, - filterActive, - filterPlaceHolder, - indexPattern, - influencers, - loading, - maskAll, - noInfluencersConfigured, - overallSwimlaneData, + }, + }); + + // Listens to render updates of the swimlanes to update dragSelect + swimlaneRenderDoneListener = () => { + this.dragSelect.clearSelection(); + this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); + }; + + resizeRef = createRef(); + resizeChecker = undefined; + resizeHandler = () => { + explorerService.setSwimlaneContainerWidth(getSwimlaneContainerWidth()); + }; + + componentDidMount() { + limit$ + .pipe( + takeUntil(this._unsubscribeAll), + map(d => d.val) + ) + .subscribe(explorerService.setSwimlaneLimit); + + // Required to redraw the time series chart when the container is resized. + this.resizeChecker = new ResizeChecker(this.resizeRef.current); + this.resizeChecker.on('resize', this.resizeHandler); + } + + componentWillUnmount() { + this._unsubscribeAll.next(); + this._unsubscribeAll.complete(); + this.resizeChecker.destroy(); + } + + resetCache() { + this.anomaliesTablePreviousArgs = null; + } + + viewByChangeHandler = e => explorerService.setViewBySwimlaneFieldName(e.target.value); + + isSwimlaneSelectActive = false; + onSwimlaneEnterHandler = () => this.setSwimlaneSelectActive(true); + onSwimlaneLeaveHandler = () => this.setSwimlaneSelectActive(false); + setSwimlaneSelectActive = active => { + if (this.isSwimlaneSelectActive && !active && this.disableDragSelectOnMouseLeave) { + this.dragSelect.stop(); + this.isSwimlaneSelectActive = active; + return; + } + if (!this.isSwimlaneSelectActive && active) { + this.dragSelect.start(); + this.dragSelect.clearSelection(); + this.dragSelect.setSelectables(document.getElementsByClassName('sl-cell')); + this.isSwimlaneSelectActive = active; + } + }; + + // Listener for click events in the swimlane to load corresponding anomaly data. + swimlaneCellClick = selectedCells => { + // If selectedCells is an empty object we clear any existing selection, + // otherwise we save the new selection in AppState and update the Explorer. + if (Object.keys(selectedCells).length === 0) { + this.props.setSelectedCells(); + } else { + this.props.setSelectedCells(selectedCells); + } + }; + // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes + // and will cause a syntax error when called with getKqlQueryValues + applyFilter = (fieldName, fieldValue, action) => { + const { filterActive, indexPattern, queryString } = this.props.explorerState; + + let newQueryString = ''; + const operator = 'and '; + const sanitizedFieldName = escapeParens(fieldName); + const sanitizedFieldValue = escapeDoubleQuotes(fieldValue); + + if (action === FILTER_ACTION.ADD) { + // Don't re-add if already exists in the query + const queryPattern = getQueryPattern(fieldName, fieldValue); + if (queryString.match(queryPattern) !== null) { + return; + } + newQueryString = `${ + queryString ? `${queryString} ${operator}` : '' + }${sanitizedFieldName}:"${sanitizedFieldValue}"`; + } else if (action === FILTER_ACTION.REMOVE) { + if (filterActive === false) { + return; + } else { + newQueryString = removeFilterFromQueryString( queryString, - selectedCells, - selectedJobs, - swimlaneContainerWidth, - tableData, - tableQueryString, - viewByLoadedForTimeFormatted, - viewBySwimlaneData, - viewBySwimlaneDataLoading, - viewBySwimlaneFieldName, - viewBySwimlaneOptions, - } = this.props.explorerState; - - const { jobIds: selectedJobIds, selectedGroups } = getSelectedJobIds(globalState); - const jobSelectorProps = { - dateFormatTz: getDateFormatTz(), - globalState, - jobSelectService$, - selectedJobIds, - selectedGroups, - }; - - const noJobsFound = selectedJobs === null || selectedJobs.length === 0; - const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0; - - if (loading === true) { - return ( - - - - ); - } - - if (noJobsFound) { - return ( - - - - ); - } - - if (noJobsFound && hasResults === false) { - return ( - - - - ); - } - - const mainColumnWidthClassName = - noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; - const mainColumnClasses = `column ${mainColumnWidthClassName}`; - - const showOverallSwimlane = - overallSwimlaneData !== null && - overallSwimlaneData.laneLabels && - overallSwimlaneData.laneLabels.length > 0; - const showViewBySwimlane = - viewBySwimlaneData !== null && - viewBySwimlaneData.laneLabels && - viewBySwimlaneData.laneLabels.length > 0; - - return ( - -
- {/* Make sure ChartTooltip is inside this plain wrapping div so positioning can be infered correctly. */} - - - {noInfluencersConfigured === false && influencers !== undefined && ( -
- -
- )} - - {noInfluencersConfigured && ( -
-
- )} - - {noInfluencersConfigured === false && ( -
- - - - -
- )} + sanitizedFieldName, + sanitizedFieldValue + ); + } + } -
- - - + try { + const queryValues = getKqlQueryValues(`${newQueryString}`, indexPattern); + this.applyInfluencersFilterQuery(queryValues); + } catch (e) { + console.log('Invalid kuery syntax', e); // eslint-disable-line no-console + + toastNotifications.addDanger( + i18n.translate('xpack.ml.explorer.invalidKuerySyntaxErrorMessageFromTable', { + defaultMessage: + 'Invalid syntax in query bar. The input must be valid Kibana Query Language (KQL)', + }) + ); + } + }; + + applyInfluencersFilterQuery = payload => { + const { filterQuery: influencersFilterQuery } = payload; + + if ( + influencersFilterQuery.match_all && + Object.keys(influencersFilterQuery.match_all).length === 0 + ) { + explorerService.clearInfluencerFilterSettings(); + } else { + explorerService.setInfluencerFilterSettings(payload); + } + }; + + render() { + const { showCharts } = this.props; + + const { + annotationsData, + chartsData, + filterActive, + filterPlaceHolder, + indexPattern, + influencers, + loading, + maskAll, + noInfluencersConfigured, + overallSwimlaneData, + queryString, + selectedCells, + selectedJobs, + severity, + swimlaneContainerWidth, + tableData, + tableQueryString, + viewByLoadedForTimeFormatted, + viewBySwimlaneData, + viewBySwimlaneDataLoading, + viewBySwimlaneFieldName, + viewBySwimlaneOptions, + } = this.props.explorerState; + + const jobSelectorProps = { + dateFormatTz: getDateFormatTz(), + }; + + const noJobsFound = selectedJobs === null || selectedJobs.length === 0; + const hasResults = overallSwimlaneData.points && overallSwimlaneData.points.length > 0; + + if (loading === true) { + return ( + + + + ); + } -
- {showOverallSwimlane && ( - - )} -
+ if (noJobsFound) { + return ( + + + + ); + } - {viewBySwimlaneOptions.length > 0 && ( - <> - - - - - - - - - - - - - -
- {viewByLoadedForTimeFormatted && ( - - )} - {viewByLoadedForTimeFormatted === undefined && ( - - )} - {filterActive === true && - viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && ( - - )} -
-
-
-
- - {showViewBySwimlane && ( - <> - -
- -
- - )} - - {viewBySwimlaneDataLoading && } - - {!showViewBySwimlane && - !viewBySwimlaneDataLoading && - viewBySwimlaneFieldName !== null && ( - - )} - - )} + if (noJobsFound && hasResults === false) { + return ( + + + + ); + } - {annotationsData.length > 0 && ( - <> - - - - - - - - )} + const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; + const mainColumnClasses = `column ${mainColumnWidthClassName}`; + + const showOverallSwimlane = + overallSwimlaneData !== null && + overallSwimlaneData.laneLabels && + overallSwimlaneData.laneLabels.length > 0; + const showViewBySwimlane = + viewBySwimlaneData !== null && + viewBySwimlaneData.laneLabels && + viewBySwimlaneData.laneLabels.length > 0; + + const bounds = timefilter.getActiveBounds(); + + return ( + +
+ {/* Make sure ChartTooltip is inside this plain wrapping div so positioning can be infered correctly. */} + + + {noInfluencersConfigured === false && influencers !== undefined && ( +
+ +
+ )} + + {noInfluencersConfigured && ( +
+
+ )} + + {noInfluencersConfigured === false && ( +
+ + + + +
+ )} - - - +
+ + + + +
+ {showOverallSwimlane && ( + + )} +
- - + {viewBySwimlaneOptions.length > 0 && ( + <> + + - + - + - + + + + + +
+ {viewByLoadedForTimeFormatted && ( + + )} + {viewByLoadedForTimeFormatted === undefined && ( + + )} + {filterActive === true && viewBySwimlaneFieldName === VIEW_BY_JOB_LABEL && ( + + )} +
- {anomalyChartRecords.length > 0 && selectedCells !== null && ( - - - - - - )}
- + {showViewBySwimlane && ( + <> + +
+ +
+ + )} + + {viewBySwimlaneDataLoading && } -
- {showCharts && } -
+ {!showViewBySwimlane && + !viewBySwimlaneDataLoading && + viewBySwimlaneFieldName !== null && ( + + )} + + )} - 0 && ( + <> + + + + -
+ + + + )} + + + + + + + + + + + + + + + + + {chartsData.seriesToPlot.length > 0 && selectedCells !== undefined && ( + + + + + + )} + + + + +
+ {showCharts && }
- - ); - } - } - ) -); + + +
+
+ + ); + } +} diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap index df76b049e98376..1c0124b90ae77d 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/__snapshots__/explorer_charts_container_service.test.js.snap @@ -1,15 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 1`] = ` -Object { - "chartsPerRow": 1, - "seriesToPlot": Array [], - "timeFieldName": "timestamp", - "tooManyBuckets": false, -} -`; - -exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 2`] = ` Object { "chartsPerRow": 1, "seriesToPlot": Array [ @@ -69,7 +60,7 @@ Object { } `; -exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 3`] = ` +exports[`explorerChartsContainerService call anomalyChangeListener with actual series config 2`] = ` Object { "chartsPerRow": 1, "seriesToPlot": Array [ diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js index 757fd00192fc8a..ce819a8d6dc8c8 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.js @@ -32,7 +32,6 @@ import { LoadingIndicator } from '../../components/loading_indicator/loading_ind import { TimeBuckets } from '../../util/time_buckets'; import { mlFieldFormatService } from '../../services/field_format_service'; import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; -import { severity$ } from '../../components/controls/select_severity/select_severity'; import { CHART_TYPE } from '../explorer_constants'; @@ -51,6 +50,7 @@ export const ExplorerChartDistribution = injectI18n( class ExplorerChartDistribution extends React.Component { static propTypes = { seriesConfig: PropTypes.object, + severity: PropTypes.number, }; componentDidMount() { @@ -66,6 +66,7 @@ export const ExplorerChartDistribution = injectI18n( const element = this.rootNode; const config = this.props.seriesConfig; + const severity = this.props.severity; if (typeof config === 'undefined' || Array.isArray(config.chartData) === false) { // just return so the empty directive renders without an error later on @@ -400,13 +401,12 @@ export const ExplorerChartDistribution = injectI18n( .on('mouseout', () => mlChartTooltipService.hide()); // Update all dots to new positions. - const threshold = severity$.getValue(); dots .attr('cx', d => lineChartXScale(d.date)) .attr('cy', d => lineChartYScale(d[CHART_Y_ATTRIBUTE])) .attr('class', d => { let markerClass = 'metric-value'; - if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= threshold.val) { + if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity) { markerClass += ' anomaly-marker '; markerClass += getSeverityWithLow(d.anomalyScore).id; } diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js index 5319692b00a385..583375c87007e4 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.js @@ -42,7 +42,6 @@ import { TimeBuckets } from '../../util/time_buckets'; import { mlEscape } from '../../util/string_utils'; import { mlFieldFormatService } from '../../services/field_format_service'; import { mlChartTooltipService } from '../../components/chart_tooltip/chart_tooltip_service'; -import { severity$ } from '../../components/controls/select_severity/select_severity'; import { injectI18n } from '@kbn/i18n/react'; @@ -54,6 +53,7 @@ export const ExplorerChartSingleMetric = injectI18n( static propTypes = { tooManyBuckets: PropTypes.bool, seriesConfig: PropTypes.object, + severity: PropTypes.number, }; componentDidMount() { @@ -69,6 +69,7 @@ export const ExplorerChartSingleMetric = injectI18n( const element = this.rootNode; const config = this.props.seriesConfig; + const severity = this.props.severity; if (typeof config === 'undefined' || Array.isArray(config.chartData) === false) { // just return so the empty directive renders without an error later on @@ -312,13 +313,12 @@ export const ExplorerChartSingleMetric = injectI18n( .on('mouseout', () => mlChartTooltipService.hide()); // Update all dots to new positions. - const threshold = severity$.getValue(); dots .attr('cx', d => lineChartXScale(d.date)) .attr('cy', d => lineChartYScale(d.value)) .attr('class', d => { let markerClass = 'metric-value'; - if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= threshold.val) { + if (_.has(d, 'anomalyScore') && Number(d.anomalyScore) >= severity) { markerClass += ` anomaly-marker ${getSeverityWithLow(d.anomalyScore).id}`; } return markerClass; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js index 140c5a87056e51..99de38c1e0a84a 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.js @@ -52,7 +52,7 @@ function getChartId(series) { } // Wrapper for a single explorer chart -function ExplorerChartContainer({ series, tooManyBuckets, wrapLabel }) { +function ExplorerChartContainer({ series, severity, tooManyBuckets, wrapLabel }) { const { detectorLabel, entityFields } = series; const chartType = getChartType(series); @@ -121,10 +121,20 @@ function ExplorerChartContainer({ series, tooManyBuckets, wrapLabel }) { chartType === CHART_TYPE.POPULATION_DISTRIBUTION ) { return ( - + ); } - return ; + return ( + + ); })()} ); @@ -146,7 +156,7 @@ export class ExplorerChartsContainer extends React.Component { } render() { - const { chartsPerRow, seriesToPlot, tooManyBuckets } = this.props; + const { chartsPerRow, seriesToPlot, severity, tooManyBuckets } = this.props; // doesn't allow a setting of `columns={1}` when chartsPerRow would be 1. // If that's the case we trick it doing that with the following settings: @@ -166,6 +176,7 @@ export class ExplorerChartsContainer extends React.Component { > diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js index f0b94cb724c577..4b2d307e72c665 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container.test.js @@ -4,6 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +import React from 'react'; +import { mount, shallow } from 'enzyme'; + +import { I18nProvider } from '@kbn/i18n/react'; + +import { chartLimits } from '../../util/chart_utils'; + +import { getDefaultChartsData } from './explorer_charts_container_service'; +import { ExplorerChartsContainer } from './explorer_charts_container'; + import './explorer_chart_single_metric.test.mocks'; import { chartData } from './__mocks__/mock_chart_data'; import seriesConfig from './__mocks__/mock_series_config_filebeat.json'; @@ -38,17 +48,12 @@ jest.mock( getBasePath: () => { return ''; }, + getInjected: () => true, }), { virtual: true } ); -import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; -import React from 'react'; - -import { chartLimits } from '../../util/chart_utils'; -import { getDefaultChartsData } from './explorer_charts_container_service'; - -import { ExplorerChartsContainer } from './explorer_charts_container'; +jest.mock('ui/new_platform'); describe('ExplorerChartsContainer', () => { const mockedGetBBox = { x: 0, y: -11.5, width: 12.1875, height: 14.5 }; @@ -58,7 +63,11 @@ describe('ExplorerChartsContainer', () => { afterEach(() => (SVGElement.prototype.getBBox = originalGetBBox)); test('Minimal Initialization', () => { - const wrapper = shallowWithIntl(); + const wrapper = shallow( + + + + ); expect(wrapper.html()).toBe( '
' @@ -78,7 +87,11 @@ describe('ExplorerChartsContainer', () => { chartsPerRow: 1, tooManyBuckets: false, }; - const wrapper = mountWithIntl(); + const wrapper = mount( + + + + ); // We test child components with snapshots separately // so we just do some high level sanity check here. @@ -101,7 +114,11 @@ describe('ExplorerChartsContainer', () => { chartsPerRow: 1, tooManyBuckets: false, }; - const wrapper = mountWithIntl(); + const wrapper = mount( + + + + ); // We test child components with snapshots separately // so we just do some high level sanity check here. diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts index ccd52a26f2abcd..962072b974867d 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.d.ts @@ -13,6 +13,9 @@ export declare interface ExplorerChartsData { export declare const getDefaultChartsData: () => ExplorerChartsData; -export declare const explorerChartsContainerServiceFactory: ( - callback: (data: ExplorerChartsData) => void -) => (anomalyRecords: any[], earliestMs: number, latestMs: number) => void; +export declare const anomalyDataChange: ( + anomalyRecords: any[], + earliestMs: number, + latestMs: number, + severity?: number +) => void; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js index 4aad4fba857460..e0fb97a81f587c 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.js @@ -23,8 +23,8 @@ import { } from '../../../../common/util/job_utils'; import { mlResultsService } from '../../services/results_service'; import { mlJobService } from '../../services/job_service'; -import { severity$ } from '../../components/controls/select_severity/select_severity'; import { getChartContainerWidth } from '../legacy_utils'; +import { explorerService } from '../explorer_dashboard_service'; import { CHART_TYPE } from '../explorer_constants'; @@ -38,593 +38,581 @@ export function getDefaultChartsData() { }; } -export function explorerChartsContainerServiceFactory(callback) { - const CHART_MAX_POINTS = 500; - const ANOMALIES_MAX_RESULTS = 500; - const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. - const ML_TIME_FIELD_NAME = 'timestamp'; - const USE_OVERALL_CHART_LIMITS = false; - const MAX_CHARTS_PER_ROW = 4; - - callback(getDefaultChartsData()); +const CHART_MAX_POINTS = 500; +const ANOMALIES_MAX_RESULTS = 500; +const MAX_SCHEDULED_EVENTS = 10; // Max number of scheduled events displayed per bucket. +const ML_TIME_FIELD_NAME = 'timestamp'; +const USE_OVERALL_CHART_LIMITS = false; +const MAX_CHARTS_PER_ROW = 4; + +// callback(getDefaultChartsData()); + +export const anomalyDataChange = function(anomalyRecords, earliestMs, latestMs, severity = 0) { + const data = getDefaultChartsData(); + + const filteredRecords = anomalyRecords.filter(record => { + return Number(record.record_score) >= severity; + }); + const allSeriesRecords = processRecordsForDisplay(filteredRecords); + // Calculate the number of charts per row, depending on the width available, to a max of 4. + const chartsContainerWidth = getChartContainerWidth(); + let chartsPerRow = Math.min( + Math.max(Math.floor(chartsContainerWidth / 550), 1), + MAX_CHARTS_PER_ROW + ); + if (allSeriesRecords.length === 1) { + chartsPerRow = 1; + } - const anomalyDataChange = function(anomalyRecords, earliestMs, latestMs) { - const data = getDefaultChartsData(); + data.chartsPerRow = chartsPerRow; - const threshold = severity$.getValue(); + // Build the data configs of the anomalies to be displayed. + // TODO - implement paging? + // For now just take first 6 (or 8 if 4 charts per row). + const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6); + const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot); + const seriesConfigs = recordsToPlot.map(buildConfig); - const filteredRecords = anomalyRecords.filter(record => { - return Number(record.record_score) >= threshold.val; - }); - const allSeriesRecords = processRecordsForDisplay(filteredRecords); - // Calculate the number of charts per row, depending on the width available, to a max of 4. - const chartsContainerWidth = getChartContainerWidth(); - let chartsPerRow = Math.min( - Math.max(Math.floor(chartsContainerWidth / 550), 1), - MAX_CHARTS_PER_ROW - ); - if (allSeriesRecords.length === 1) { - chartsPerRow = 1; - } + // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. + data.tooManyBuckets = false; + const chartWidth = Math.floor(chartsContainerWidth / chartsPerRow); + const { chartRange, tooManyBuckets } = calculateChartRange( + seriesConfigs, + earliestMs, + latestMs, + chartWidth, + recordsToPlot, + data.timeFieldName + ); + data.tooManyBuckets = tooManyBuckets; - data.chartsPerRow = chartsPerRow; - - // Build the data configs of the anomalies to be displayed. - // TODO - implement paging? - // For now just take first 6 (or 8 if 4 charts per row). - const maxSeriesToPlot = Math.max(chartsPerRow * 2, 6); - const recordsToPlot = allSeriesRecords.slice(0, maxSeriesToPlot); - const seriesConfigs = recordsToPlot.map(buildConfig); - - // Calculate the time range of the charts, which is a function of the chart width and max job bucket span. - data.tooManyBuckets = false; - const chartWidth = Math.floor(chartsContainerWidth / chartsPerRow); - const { chartRange, tooManyBuckets } = calculateChartRange( - seriesConfigs, - earliestMs, - latestMs, - chartWidth, - recordsToPlot, - data.timeFieldName - ); - data.tooManyBuckets = tooManyBuckets; + // initialize the charts with loading indicators + data.seriesToPlot = seriesConfigs.map(config => ({ + ...config, + loading: true, + chartData: null, + })); - // initialize the charts with loading indicators - data.seriesToPlot = seriesConfigs.map(config => ({ - ...config, - loading: true, - chartData: null, - })); + explorerService.setCharts({ ...data }); - callback(data); + if (seriesConfigs.length === 0) { + return; + } - // Query 1 - load the raw metric data. - function getMetricData(config, range) { - const { jobId, detectorIndex, entityFields, interval } = config; + // Query 1 - load the raw metric data. + function getMetricData(config, range) { + const { jobId, detectorIndex, entityFields, interval } = config; - const job = mlJobService.getJob(jobId); + const job = mlJobService.getJob(jobId); - // If source data can be plotted, use that, otherwise model plot will be available. - const useSourceData = isSourceDataChartableForDetector(job, detectorIndex); - if (useSourceData === true) { - const datafeedQuery = _.get(config, 'datafeedConfig.query', null); - return mlResultsService - .getMetricData( - config.datafeedConfig.indices, - config.entityFields, - datafeedQuery, - config.metricFunction, - config.metricFieldName, - config.timeField, - range.min, - range.max, - config.interval - ) - .toPromise(); - } else { - // Extract the partition, by, over fields on which to filter. - const criteriaFields = []; - const detector = job.analysis_config.detectors[detectorIndex]; - if (_.has(detector, 'partition_field_name')) { - const partitionEntity = _.find(entityFields, { - fieldName: detector.partition_field_name, - }); - if (partitionEntity !== undefined) { - criteriaFields.push( - { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, - { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } - ); - } + // If source data can be plotted, use that, otherwise model plot will be available. + const useSourceData = isSourceDataChartableForDetector(job, detectorIndex); + if (useSourceData === true) { + const datafeedQuery = _.get(config, 'datafeedConfig.query', null); + return mlResultsService + .getMetricData( + config.datafeedConfig.indices, + config.entityFields, + datafeedQuery, + config.metricFunction, + config.metricFieldName, + config.timeField, + range.min, + range.max, + config.interval + ) + .toPromise(); + } else { + // Extract the partition, by, over fields on which to filter. + const criteriaFields = []; + const detector = job.analysis_config.detectors[detectorIndex]; + if (_.has(detector, 'partition_field_name')) { + const partitionEntity = _.find(entityFields, { + fieldName: detector.partition_field_name, + }); + if (partitionEntity !== undefined) { + criteriaFields.push( + { fieldName: 'partition_field_name', fieldValue: partitionEntity.fieldName }, + { fieldName: 'partition_field_value', fieldValue: partitionEntity.fieldValue } + ); } + } - if (_.has(detector, 'over_field_name')) { - const overEntity = _.find(entityFields, { fieldName: detector.over_field_name }); - if (overEntity !== undefined) { - criteriaFields.push( - { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, - { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } - ); - } + if (_.has(detector, 'over_field_name')) { + const overEntity = _.find(entityFields, { fieldName: detector.over_field_name }); + if (overEntity !== undefined) { + criteriaFields.push( + { fieldName: 'over_field_name', fieldValue: overEntity.fieldName }, + { fieldName: 'over_field_value', fieldValue: overEntity.fieldValue } + ); } + } - if (_.has(detector, 'by_field_name')) { - const byEntity = _.find(entityFields, { fieldName: detector.by_field_name }); - if (byEntity !== undefined) { - criteriaFields.push( - { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, - { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } - ); - } + if (_.has(detector, 'by_field_name')) { + const byEntity = _.find(entityFields, { fieldName: detector.by_field_name }); + if (byEntity !== undefined) { + criteriaFields.push( + { fieldName: 'by_field_name', fieldValue: byEntity.fieldName }, + { fieldName: 'by_field_value', fieldValue: byEntity.fieldValue } + ); } - - return new Promise((resolve, reject) => { - const obj = { - success: true, - results: {}, - }; - - return mlResultsService - .getModelPlotOutput( - jobId, - detectorIndex, - criteriaFields, - range.min, - range.max, - interval - ) - .toPromise() - .then(resp => { - // Return data in format required by the explorer charts. - const results = resp.results; - Object.keys(results).forEach(time => { - obj.results[time] = results[time].actual; - }); - resolve(obj); - }) - .catch(resp => { - reject(resp); - }); - }); } - } - // Query 2 - load the anomalies. - // Criteria to return the records for this series are the detector_index plus - // the specific combination of 'entity' fields i.e. the partition / by / over fields. - function getRecordsForCriteria(config, range) { - let criteria = []; - criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex }); - criteria = criteria.concat(config.entityFields); - return mlResultsService - .getRecordsForCriteria( - [config.jobId], - criteria, - 0, - range.min, - range.max, - ANOMALIES_MAX_RESULTS - ) - .toPromise(); - } + return new Promise((resolve, reject) => { + const obj = { + success: true, + results: {}, + }; - // Query 3 - load any scheduled events for the job. - function getScheduledEvents(config, range) { - return mlResultsService - .getScheduledEventsByBucket( - [config.jobId], - range.min, - range.max, - config.interval, - 1, - MAX_SCHEDULED_EVENTS - ) - .toPromise(); + return mlResultsService + .getModelPlotOutput(jobId, detectorIndex, criteriaFields, range.min, range.max, interval) + .toPromise() + .then(resp => { + // Return data in format required by the explorer charts. + const results = resp.results; + Object.keys(results).forEach(time => { + obj.results[time] = results[time].actual; + }); + resolve(obj); + }) + .catch(resp => { + reject(resp); + }); + }); } + } - // Query 4 - load context data distribution - function getEventDistribution(config, range) { - const chartType = getChartType(config); - - let splitField; - let filterField = null; - - // Define splitField and filterField based on chartType - if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) { - splitField = config.entityFields.find(f => f.fieldType === 'by'); - filterField = config.entityFields.find(f => f.fieldType === 'partition'); - } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) { - splitField = config.entityFields.find(f => f.fieldType === 'over'); - filterField = config.entityFields.find(f => f.fieldType === 'partition'); - } + // Query 2 - load the anomalies. + // Criteria to return the records for this series are the detector_index plus + // the specific combination of 'entity' fields i.e. the partition / by / over fields. + function getRecordsForCriteria(config, range) { + let criteria = []; + criteria.push({ fieldName: 'detector_index', fieldValue: config.detectorIndex }); + criteria = criteria.concat(config.entityFields); + return mlResultsService + .getRecordsForCriteria( + [config.jobId], + criteria, + 0, + range.min, + range.max, + ANOMALIES_MAX_RESULTS + ) + .toPromise(); + } - const datafeedQuery = _.get(config, 'datafeedConfig.query', null); - return mlResultsService.getEventDistributionData( - config.datafeedConfig.indices, - splitField, - filterField, - datafeedQuery, - config.metricFunction, - config.metricFieldName, - config.timeField, + // Query 3 - load any scheduled events for the job. + function getScheduledEvents(config, range) { + return mlResultsService + .getScheduledEventsByBucket( + [config.jobId], range.min, range.max, - config.interval - ); + config.interval, + 1, + MAX_SCHEDULED_EVENTS + ) + .toPromise(); + } + + // Query 4 - load context data distribution + function getEventDistribution(config, range) { + const chartType = getChartType(config); + + let splitField; + let filterField = null; + + // Define splitField and filterField based on chartType + if (chartType === CHART_TYPE.EVENT_DISTRIBUTION) { + splitField = config.entityFields.find(f => f.fieldType === 'by'); + filterField = config.entityFields.find(f => f.fieldType === 'partition'); + } else if (chartType === CHART_TYPE.POPULATION_DISTRIBUTION) { + splitField = config.entityFields.find(f => f.fieldType === 'over'); + filterField = config.entityFields.find(f => f.fieldType === 'partition'); } - // first load and wait for required data, - // only after that trigger data processing and page render. - // TODO - if query returns no results e.g. source data has been deleted, - // display a message saying 'No data between earliest/latest'. - const seriesPromises = seriesConfigs.map(seriesConfig => - Promise.all([ - getMetricData(seriesConfig, chartRange), - getRecordsForCriteria(seriesConfig, chartRange), - getScheduledEvents(seriesConfig, chartRange), - getEventDistribution(seriesConfig, chartRange), - ]) + const datafeedQuery = _.get(config, 'datafeedConfig.query', null); + return mlResultsService.getEventDistributionData( + config.datafeedConfig.indices, + splitField, + filterField, + datafeedQuery, + config.metricFunction, + config.metricFieldName, + config.timeField, + range.min, + range.max, + config.interval ); + } - function processChartData(response, seriesIndex) { - const metricData = response[0].results; - const records = response[1].records; - const jobId = seriesConfigs[seriesIndex].jobId; - const scheduledEvents = response[2].events[jobId]; - const eventDistribution = response[3]; - const chartType = getChartType(seriesConfigs[seriesIndex]); - - // Sort records in ascending time order matching up with chart data - records.sort((recordA, recordB) => { - return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME]; - }); + // first load and wait for required data, + // only after that trigger data processing and page render. + // TODO - if query returns no results e.g. source data has been deleted, + // display a message saying 'No data between earliest/latest'. + const seriesPromises = seriesConfigs.map(seriesConfig => + Promise.all([ + getMetricData(seriesConfig, chartRange), + getRecordsForCriteria(seriesConfig, chartRange), + getScheduledEvents(seriesConfig, chartRange), + getEventDistribution(seriesConfig, chartRange), + ]) + ); + + function processChartData(response, seriesIndex) { + const metricData = response[0].results; + const records = response[1].records; + const jobId = seriesConfigs[seriesIndex].jobId; + const scheduledEvents = response[2].events[jobId]; + const eventDistribution = response[3]; + const chartType = getChartType(seriesConfigs[seriesIndex]); + + // Sort records in ascending time order matching up with chart data + records.sort((recordA, recordB) => { + return recordA[ML_TIME_FIELD_NAME] - recordB[ML_TIME_FIELD_NAME]; + }); - // Return dataset in format used by the chart. - // i.e. array of Objects with keys date (timestamp), value, - // plus anomalyScore for points with anomaly markers. - let chartData = []; - if (metricData !== undefined) { - if (eventDistribution.length > 0 && records.length > 0) { - const filterField = records[0].by_field_value || records[0].over_field_value; - chartData = eventDistribution.filter(d => d.entity !== filterField); - _.map(metricData, (value, time) => { - // The filtering for rare/event_distribution charts needs to be handled - // differently because of how the source data is structured. - // For rare chart values we are only interested wether a value is either `0` or not, - // `0` acts like a flag in the chart whether to display the dot/marker. - // All other charts (single metric, population) are metric based and with - // those a value of `null` acts as the flag to hide a data point. - if ( - (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) || - (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null) - ) { - chartData.push({ - date: +time, - value: value, - entity: filterField, - }); - } - }); - } else { - chartData = _.map(metricData, (value, time) => ({ - date: +time, - value: value, - })); - } + // Return dataset in format used by the chart. + // i.e. array of Objects with keys date (timestamp), value, + // plus anomalyScore for points with anomaly markers. + let chartData = []; + if (metricData !== undefined) { + if (eventDistribution.length > 0 && records.length > 0) { + const filterField = records[0].by_field_value || records[0].over_field_value; + chartData = eventDistribution.filter(d => d.entity !== filterField); + _.map(metricData, (value, time) => { + // The filtering for rare/event_distribution charts needs to be handled + // differently because of how the source data is structured. + // For rare chart values we are only interested wether a value is either `0` or not, + // `0` acts like a flag in the chart whether to display the dot/marker. + // All other charts (single metric, population) are metric based and with + // those a value of `null` acts as the flag to hide a data point. + if ( + (chartType === CHART_TYPE.EVENT_DISTRIBUTION && value > 0) || + (chartType !== CHART_TYPE.EVENT_DISTRIBUTION && value !== null) + ) { + chartData.push({ + date: +time, + value: value, + entity: filterField, + }); + } + }); + } else { + chartData = _.map(metricData, (value, time) => ({ + date: +time, + value: value, + })); } + } - // Iterate through the anomaly records, adding anomalyScore properties - // to the chartData entries for anomalous buckets. - const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType); - _.each(records, record => { - // Look for a chart point with the same time as the record. - // If none found, insert a point for anomalies due to a gap in the data. - const recordTime = record[ML_TIME_FIELD_NAME]; - let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime); - if (chartPoint === undefined) { - chartPoint = { date: new Date(recordTime), value: null }; - chartData.push(chartPoint); - } + // Iterate through the anomaly records, adding anomalyScore properties + // to the chartData entries for anomalous buckets. + const chartDataForPointSearch = getChartDataForPointSearch(chartData, records[0], chartType); + _.each(records, record => { + // Look for a chart point with the same time as the record. + // If none found, insert a point for anomalies due to a gap in the data. + const recordTime = record[ML_TIME_FIELD_NAME]; + let chartPoint = findChartPointForTime(chartDataForPointSearch, recordTime); + if (chartPoint === undefined) { + chartPoint = { date: new Date(recordTime), value: null }; + chartData.push(chartPoint); + } - chartPoint.anomalyScore = record.record_score; + chartPoint.anomalyScore = record.record_score; - if (record.actual !== undefined) { - chartPoint.actual = record.actual; - chartPoint.typical = record.typical; - } else { - const causes = _.get(record, 'causes', []); - if (causes.length > 0) { - chartPoint.byFieldName = record.by_field_name; - chartPoint.numberOfCauses = causes.length; - if (causes.length === 1) { - // If only a single cause, copy actual and typical values to the top level. - const cause = _.first(record.causes); - chartPoint.actual = cause.actual; - chartPoint.typical = cause.typical; - } + if (record.actual !== undefined) { + chartPoint.actual = record.actual; + chartPoint.typical = record.typical; + } else { + const causes = _.get(record, 'causes', []); + if (causes.length > 0) { + chartPoint.byFieldName = record.by_field_name; + chartPoint.numberOfCauses = causes.length; + if (causes.length === 1) { + // If only a single cause, copy actual and typical values to the top level. + const cause = _.first(record.causes); + chartPoint.actual = cause.actual; + chartPoint.typical = cause.typical; } } + } + + if (record.multi_bucket_impact !== undefined) { + chartPoint.multiBucketImpact = record.multi_bucket_impact; + } + }); - if (record.multi_bucket_impact !== undefined) { - chartPoint.multiBucketImpact = record.multi_bucket_impact; + // Add a scheduledEvents property to any points in the chart data set + // which correspond to times of scheduled events for the job. + if (scheduledEvents !== undefined) { + _.each(scheduledEvents, (events, time) => { + const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time)); + if (chartPoint !== undefined) { + // Note if the scheduled event coincides with an absence of the underlying metric data, + // we don't worry about plotting the event. + chartPoint.scheduledEvents = events; } }); + } - // Add a scheduledEvents property to any points in the chart data set - // which correspond to times of scheduled events for the job. - if (scheduledEvents !== undefined) { - _.each(scheduledEvents, (events, time) => { - const chartPoint = findChartPointForTime(chartDataForPointSearch, Number(time)); - if (chartPoint !== undefined) { - // Note if the scheduled event coincides with an absence of the underlying metric data, - // we don't worry about plotting the event. - chartPoint.scheduledEvents = events; - } - }); - } + return chartData; + } - return chartData; + function getChartDataForPointSearch(chartData, record, chartType) { + if ( + chartType === CHART_TYPE.EVENT_DISTRIBUTION || + chartType === CHART_TYPE.POPULATION_DISTRIBUTION + ) { + return chartData.filter(d => { + return d.entity === (record && (record.by_field_value || record.over_field_value)); + }); } - function getChartDataForPointSearch(chartData, record, chartType) { - if ( - chartType === CHART_TYPE.EVENT_DISTRIBUTION || - chartType === CHART_TYPE.POPULATION_DISTRIBUTION - ) { - return chartData.filter(d => { - return d.entity === (record && (record.by_field_value || record.over_field_value)); - }); - } + return chartData; + } - return chartData; - } + function findChartPointForTime(chartData, time) { + return chartData.find(point => point.date === time); + } - function findChartPointForTime(chartData, time) { - return chartData.find(point => point.date === time); + Promise.all(seriesPromises) + .then(response => { + // calculate an overall min/max for all series + const processedData = response.map(processChartData); + const allDataPoints = _.reduce( + processedData, + (datapoints, series) => { + _.each(series, d => datapoints.push(d)); + return datapoints; + }, + [] + ); + const overallChartLimits = chartLimits(allDataPoints); + + data.seriesToPlot = response.map((d, i) => ({ + ...seriesConfigs[i], + loading: false, + chartData: processedData[i], + plotEarliest: chartRange.min, + plotLatest: chartRange.max, + selectedEarliest: earliestMs, + selectedLatest: latestMs, + chartLimits: USE_OVERALL_CHART_LIMITS ? overallChartLimits : chartLimits(processedData[i]), + })); + explorerService.setCharts({ ...data }); + }) + .catch(error => { + console.error(error); + }); +}; + +function processRecordsForDisplay(anomalyRecords) { + // Aggregate the anomaly data by detector, and entity (by/over/partition). + if (anomalyRecords.length === 0) { + return []; + } + + // Aggregate by job, detector, and analysis fields (partition, by, over). + const aggregatedData = {}; + _.each(anomalyRecords, record => { + // Check if we can plot a chart for this record, depending on whether the source data + // is chartable, and if model plot is enabled for the job. + const job = mlJobService.getJob(record.job_id); + let isChartable = isSourceDataChartableForDetector(job, record.detector_index); + if (isChartable === false) { + // Check if model plot is enabled for this job. + // Need to check the entity fields for the record in case the model plot config has a terms list. + const entityFields = getEntityFieldList(record); + isChartable = isModelPlotEnabled(job, record.detector_index, entityFields); } - Promise.all(seriesPromises) - .then(response => { - // calculate an overall min/max for all series - const processedData = response.map(processChartData); - const allDataPoints = _.reduce( - processedData, - (datapoints, series) => { - _.each(series, d => datapoints.push(d)); - return datapoints; - }, - [] - ); - const overallChartLimits = chartLimits(allDataPoints); - - data.seriesToPlot = response.map((d, i) => ({ - ...seriesConfigs[i], - loading: false, - chartData: processedData[i], - plotEarliest: chartRange.min, - plotLatest: chartRange.max, - selectedEarliest: earliestMs, - selectedLatest: latestMs, - chartLimits: USE_OVERALL_CHART_LIMITS - ? overallChartLimits - : chartLimits(processedData[i]), - })); - callback(data); - }) - .catch(error => { - console.error(error); - }); - }; + if (isChartable === false) { + return; + } + const jobId = record.job_id; + if (aggregatedData[jobId] === undefined) { + aggregatedData[jobId] = {}; + } + const detectorsForJob = aggregatedData[jobId]; - function processRecordsForDisplay(anomalyRecords) { - // Aggregate the anomaly data by detector, and entity (by/over/partition). - if (anomalyRecords.length === 0) { - return []; + const detectorIndex = record.detector_index; + if (detectorsForJob[detectorIndex] === undefined) { + detectorsForJob[detectorIndex] = {}; } - // Aggregate by job, detector, and analysis fields (partition, by, over). - const aggregatedData = {}; - _.each(anomalyRecords, record => { - // Check if we can plot a chart for this record, depending on whether the source data - // is chartable, and if model plot is enabled for the job. - const job = mlJobService.getJob(record.job_id); - let isChartable = isSourceDataChartableForDetector(job, record.detector_index); - if (isChartable === false) { - // Check if model plot is enabled for this job. - // Need to check the entity fields for the record in case the model plot config has a terms list. - const entityFields = getEntityFieldList(record); - isChartable = isModelPlotEnabled(job, record.detector_index, entityFields); - } + // TODO - work out how best to display results from detectors with just an over field. + const firstFieldName = + record.partition_field_name || record.by_field_name || record.over_field_name; + const firstFieldValue = + record.partition_field_value || record.by_field_value || record.over_field_value; + if (firstFieldName !== undefined) { + const groupsForDetector = detectorsForJob[detectorIndex]; - if (isChartable === false) { - return; + if (groupsForDetector[firstFieldName] === undefined) { + groupsForDetector[firstFieldName] = {}; } - const jobId = record.job_id; - if (aggregatedData[jobId] === undefined) { - aggregatedData[jobId] = {}; + const valuesForGroup = groupsForDetector[firstFieldName]; + if (valuesForGroup[firstFieldValue] === undefined) { + valuesForGroup[firstFieldValue] = {}; } - const detectorsForJob = aggregatedData[jobId]; - const detectorIndex = record.detector_index; - if (detectorsForJob[detectorIndex] === undefined) { - detectorsForJob[detectorIndex] = {}; - } + const dataForGroupValue = valuesForGroup[firstFieldValue]; - // TODO - work out how best to display results from detectors with just an over field. - const firstFieldName = - record.partition_field_name || record.by_field_name || record.over_field_name; - const firstFieldValue = - record.partition_field_value || record.by_field_value || record.over_field_value; - if (firstFieldName !== undefined) { - const groupsForDetector = detectorsForJob[detectorIndex]; - - if (groupsForDetector[firstFieldName] === undefined) { - groupsForDetector[firstFieldName] = {}; - } - const valuesForGroup = groupsForDetector[firstFieldName]; - if (valuesForGroup[firstFieldValue] === undefined) { - valuesForGroup[firstFieldValue] = {}; - } - - const dataForGroupValue = valuesForGroup[firstFieldValue]; - - let isSecondSplit = false; - if (record.partition_field_name !== undefined) { - const splitFieldName = record.over_field_name || record.by_field_name; - if (splitFieldName !== undefined) { - isSecondSplit = true; - } + let isSecondSplit = false; + if (record.partition_field_name !== undefined) { + const splitFieldName = record.over_field_name || record.by_field_name; + if (splitFieldName !== undefined) { + isSecondSplit = true; } + } - if (isSecondSplit === false) { - if (dataForGroupValue.maxScoreRecord === undefined) { + if (isSecondSplit === false) { + if (dataForGroupValue.maxScoreRecord === undefined) { + dataForGroupValue.maxScore = record.record_score; + dataForGroupValue.maxScoreRecord = record; + } else { + if (record.record_score > dataForGroupValue.maxScore) { dataForGroupValue.maxScore = record.record_score; dataForGroupValue.maxScoreRecord = record; - } else { - if (record.record_score > dataForGroupValue.maxScore) { - dataForGroupValue.maxScore = record.record_score; - dataForGroupValue.maxScoreRecord = record; - } } - } else { - // Aggregate another level for the over or by field. - const secondFieldName = record.over_field_name || record.by_field_name; - const secondFieldValue = record.over_field_value || record.by_field_value; + } + } else { + // Aggregate another level for the over or by field. + const secondFieldName = record.over_field_name || record.by_field_name; + const secondFieldValue = record.over_field_value || record.by_field_value; - if (dataForGroupValue[secondFieldName] === undefined) { - dataForGroupValue[secondFieldName] = {}; - } + if (dataForGroupValue[secondFieldName] === undefined) { + dataForGroupValue[secondFieldName] = {}; + } - const splitsForGroup = dataForGroupValue[secondFieldName]; - if (splitsForGroup[secondFieldValue] === undefined) { - splitsForGroup[secondFieldValue] = {}; - } + const splitsForGroup = dataForGroupValue[secondFieldName]; + if (splitsForGroup[secondFieldValue] === undefined) { + splitsForGroup[secondFieldValue] = {}; + } - const dataForSplitValue = splitsForGroup[secondFieldValue]; - if (dataForSplitValue.maxScoreRecord === undefined) { + const dataForSplitValue = splitsForGroup[secondFieldValue]; + if (dataForSplitValue.maxScoreRecord === undefined) { + dataForSplitValue.maxScore = record.record_score; + dataForSplitValue.maxScoreRecord = record; + } else { + if (record.record_score > dataForSplitValue.maxScore) { dataForSplitValue.maxScore = record.record_score; dataForSplitValue.maxScoreRecord = record; - } else { - if (record.record_score > dataForSplitValue.maxScore) { - dataForSplitValue.maxScore = record.record_score; - dataForSplitValue.maxScoreRecord = record; - } } } + } + } else { + // Detector with no partition or by field. + const dataForDetector = detectorsForJob[detectorIndex]; + if (dataForDetector.maxScoreRecord === undefined) { + dataForDetector.maxScore = record.record_score; + dataForDetector.maxScoreRecord = record; } else { - // Detector with no partition or by field. - const dataForDetector = detectorsForJob[detectorIndex]; - if (dataForDetector.maxScoreRecord === undefined) { + if (record.record_score > dataForDetector.maxScore) { dataForDetector.maxScore = record.record_score; dataForDetector.maxScoreRecord = record; - } else { - if (record.record_score > dataForDetector.maxScore) { - dataForDetector.maxScore = record.record_score; - dataForDetector.maxScoreRecord = record; - } } } - }); - - console.log('explorer charts aggregatedData is:', aggregatedData); - let recordsForSeries = []; - // Convert to an array of the records with the highest record_score per unique series. - _.each(aggregatedData, detectorsForJob => { - _.each(detectorsForJob, groupsForDetector => { - if (groupsForDetector.maxScoreRecord !== undefined) { - // Detector with no partition / by field. - recordsForSeries.push(groupsForDetector.maxScoreRecord); - } else { - _.each(groupsForDetector, valuesForGroup => { - _.each(valuesForGroup, dataForGroupValue => { - if (dataForGroupValue.maxScoreRecord !== undefined) { - recordsForSeries.push(dataForGroupValue.maxScoreRecord); - } else { - // Second level of aggregation for partition and by/over. - _.each(dataForGroupValue, splitsForGroup => { - _.each(splitsForGroup, dataForSplitValue => { - recordsForSeries.push(dataForSplitValue.maxScoreRecord); - }); + } + }); + + console.log('explorer charts aggregatedData is:', aggregatedData); + let recordsForSeries = []; + // Convert to an array of the records with the highest record_score per unique series. + _.each(aggregatedData, detectorsForJob => { + _.each(detectorsForJob, groupsForDetector => { + if (groupsForDetector.maxScoreRecord !== undefined) { + // Detector with no partition / by field. + recordsForSeries.push(groupsForDetector.maxScoreRecord); + } else { + _.each(groupsForDetector, valuesForGroup => { + _.each(valuesForGroup, dataForGroupValue => { + if (dataForGroupValue.maxScoreRecord !== undefined) { + recordsForSeries.push(dataForGroupValue.maxScoreRecord); + } else { + // Second level of aggregation for partition and by/over. + _.each(dataForGroupValue, splitsForGroup => { + _.each(splitsForGroup, dataForSplitValue => { + recordsForSeries.push(dataForSplitValue.maxScoreRecord); }); - } - }); + }); + } }); - } - }); + }); + } }); - recordsForSeries = _.sortBy(recordsForSeries, 'record_score').reverse(); + }); + recordsForSeries = _.sortBy(recordsForSeries, 'record_score').reverse(); - return recordsForSeries; - } + return recordsForSeries; +} - function calculateChartRange( - seriesConfigs, - earliestMs, - latestMs, - chartWidth, - recordsToPlot, - timeFieldName - ) { - let tooManyBuckets = false; - // Calculate the time range for the charts. - // Fit in as many points in the available container width plotted at the job bucket span. - const midpointMs = Math.ceil((earliestMs + latestMs) / 2); - const maxBucketSpanMs = - Math.max.apply(null, _.pluck(seriesConfigs, 'bucketSpanSeconds')) * 1000; - - const pointsToPlotFullSelection = Math.ceil((latestMs - earliestMs) / maxBucketSpanMs); - - // Optimally space points 5px apart. - const optimumPointSpacing = 5; - const optimumNumPoints = chartWidth / optimumPointSpacing; - - // Increase actual number of points if we can't plot the selected range - // at optimal point spacing. - const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection); - const halfPoints = Math.ceil(plotPoints / 2); - let chartRange = { - min: midpointMs - halfPoints * maxBucketSpanMs, - max: midpointMs + halfPoints * maxBucketSpanMs, - }; - - if (plotPoints > CHART_MAX_POINTS) { - tooManyBuckets = true; - // For each series being plotted, display the record with the highest score if possible. - const maxTimeSpan = maxBucketSpanMs * CHART_MAX_POINTS; - let minMs = recordsToPlot[0][timeFieldName]; - let maxMs = recordsToPlot[0][timeFieldName]; - - _.each(recordsToPlot, record => { - const diffMs = maxMs - minMs; - if (diffMs < maxTimeSpan) { - const recordTime = record[timeFieldName]; - if (recordTime < minMs) { - if (maxMs - recordTime <= maxTimeSpan) { - minMs = recordTime; - } - } +function calculateChartRange( + seriesConfigs, + earliestMs, + latestMs, + chartWidth, + recordsToPlot, + timeFieldName +) { + let tooManyBuckets = false; + // Calculate the time range for the charts. + // Fit in as many points in the available container width plotted at the job bucket span. + const midpointMs = Math.ceil((earliestMs + latestMs) / 2); + const maxBucketSpanMs = Math.max.apply(null, _.pluck(seriesConfigs, 'bucketSpanSeconds')) * 1000; + + const pointsToPlotFullSelection = Math.ceil((latestMs - earliestMs) / maxBucketSpanMs); + + // Optimally space points 5px apart. + const optimumPointSpacing = 5; + const optimumNumPoints = chartWidth / optimumPointSpacing; + + // Increase actual number of points if we can't plot the selected range + // at optimal point spacing. + const plotPoints = Math.max(optimumNumPoints, pointsToPlotFullSelection); + const halfPoints = Math.ceil(plotPoints / 2); + let chartRange = { + min: midpointMs - halfPoints * maxBucketSpanMs, + max: midpointMs + halfPoints * maxBucketSpanMs, + }; - if (recordTime > maxMs) { - if (recordTime - minMs <= maxTimeSpan) { - maxMs = recordTime; - } + if (plotPoints > CHART_MAX_POINTS) { + tooManyBuckets = true; + // For each series being plotted, display the record with the highest score if possible. + const maxTimeSpan = maxBucketSpanMs * CHART_MAX_POINTS; + let minMs = recordsToPlot[0][timeFieldName]; + let maxMs = recordsToPlot[0][timeFieldName]; + + _.each(recordsToPlot, record => { + const diffMs = maxMs - minMs; + if (diffMs < maxTimeSpan) { + const recordTime = record[timeFieldName]; + if (recordTime < minMs) { + if (maxMs - recordTime <= maxTimeSpan) { + minMs = recordTime; } } - }); - if (maxMs - minMs < maxTimeSpan) { - // Expand out to cover as much as the requested time span as possible. - minMs = Math.max(earliestMs, minMs - maxTimeSpan); - maxMs = Math.min(latestMs, maxMs + maxTimeSpan); + if (recordTime > maxMs) { + if (recordTime - minMs <= maxTimeSpan) { + maxMs = recordTime; + } + } } + }); - chartRange = { min: minMs, max: maxMs }; + if (maxMs - minMs < maxTimeSpan) { + // Expand out to cover as much as the requested time span as possible. + minMs = Math.max(earliestMs, minMs - maxTimeSpan); + maxMs = Math.min(latestMs, maxMs + maxTimeSpan); } - return { - chartRange, - tooManyBuckets, - }; + chartRange = { min: minMs, max: maxMs }; } - return anomalyDataChange; + return { + chartRange, + tooManyBuckets, + }; } diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js index 483a359f98e5b9..fbbf5eb3240952 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_charts/explorer_charts_container_service.test.js @@ -102,119 +102,79 @@ jest.mock('ui/chrome', () => ({ }), })); -import { - explorerChartsContainerServiceFactory, - getDefaultChartsData, -} from './explorer_charts_container_service'; +jest.mock('../explorer_dashboard_service', () => ({ + explorerService: { + setCharts: jest.fn(), + }, +})); -describe('explorerChartsContainerService', () => { - test('Initialize factory', done => { - explorerChartsContainerServiceFactory(callback); +import { anomalyDataChange, getDefaultChartsData } from './explorer_charts_container_service'; +import { explorerService } from '../explorer_dashboard_service'; - function callback(data) { - expect(data).toEqual(getDefaultChartsData()); - done(); - } +describe('explorerChartsContainerService', () => { + afterEach(() => { + explorerService.setCharts.mockClear(); }); test('call anomalyChangeListener with empty series config', done => { - // callback will be called multiple times. - // the callbackData array contains the expected data values for each consecutive call. - const callbackData = []; - callbackData.push(getDefaultChartsData()); - callbackData.push({ - ...getDefaultChartsData(), - chartsPerRow: 2, + anomalyDataChange([], 1486656000000, 1486670399999); + + setImmediate(() => { + expect(explorerService.setCharts.mock.calls.length).toBe(1); + expect(explorerService.setCharts.mock.calls[0][0]).toStrictEqual({ + ...getDefaultChartsData(), + chartsPerRow: 2, + }); + done(); }); - - const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback); - - anomalyDataChangeListener([], 1486656000000, 1486670399999); - - function callback(data) { - if (callbackData.length > 0) { - expect(data).toEqual({ - ...callbackData.shift(), - }); - } - if (callbackData.length === 0) { - done(); - } - } }); test('call anomalyChangeListener with actual series config', done => { - let callbackCount = 0; - const expectedTestCount = 3; - - const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback); + anomalyDataChange(mockAnomalyChartRecords, 1486656000000, 1486670399999); - anomalyDataChangeListener(mockAnomalyChartRecords, 1486656000000, 1486670399999); - - function callback(data) { - callbackCount++; - expect(data).toMatchSnapshot(); - if (callbackCount === expectedTestCount) { - done(); - } - } + setImmediate(() => { + expect(explorerService.setCharts.mock.calls.length).toBe(2); + expect(explorerService.setCharts.mock.calls[0][0]).toMatchSnapshot(); + expect(explorerService.setCharts.mock.calls[1][0]).toMatchSnapshot(); + done(); + }); }); test('filtering should skip values of null', done => { - let callbackCount = 0; - const expectedTestCount = 3; - - const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback); - const mockAnomalyChartRecordsClone = _.cloneDeep(mockAnomalyChartRecords).map(d => { d.job_id = 'mock-job-id-distribution'; return d; }); - anomalyDataChangeListener(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); + anomalyDataChange(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); - function callback(data) { - callbackCount++; + setImmediate(() => { + expect(explorerService.setCharts.mock.calls.length).toBe(2); + expect(explorerService.setCharts.mock.calls[0][0].seriesToPlot.length).toBe(1); + expect(explorerService.setCharts.mock.calls[1][0].seriesToPlot.length).toBe(1); - if (callbackCount === 1) { - expect(data.seriesToPlot).toHaveLength(0); - } - if (callbackCount === 3) { - expect(data.seriesToPlot).toHaveLength(1); - - // the mock source dataset has a length of 115. one data point has a value of `null`, - // and another one `0`. the received dataset should have a length of 114, - // it should remove the datapoint with `null` and keep the one with `0`. - const chartData = data.seriesToPlot[0].chartData; - expect(chartData).toHaveLength(114); - expect(chartData.filter(d => d.value === 0)).toHaveLength(1); - expect(chartData.filter(d => d.value === null)).toHaveLength(0); - } - if (callbackCount === expectedTestCount) { - done(); - } - } + // the mock source dataset has a length of 115. one data point has a value of `null`, + // and another one `0`. the received dataset should have a length of 114, + // it should remove the datapoint with `null` and keep the one with `0`. + const chartData = explorerService.setCharts.mock.calls[1][0].seriesToPlot[0].chartData; + expect(chartData).toHaveLength(114); + expect(chartData.filter(d => d.value === 0)).toHaveLength(1); + expect(chartData.filter(d => d.value === null)).toHaveLength(0); + done(); + }); }); test('field value with trailing dot should not throw an error', done => { - let callbackCount = 0; - const expectedTestCount = 3; - - const anomalyDataChangeListener = explorerChartsContainerServiceFactory(callback); - const mockAnomalyChartRecordsClone = _.cloneDeep(mockAnomalyChartRecords); mockAnomalyChartRecordsClone[1].partition_field_value = 'AAL.'; expect(() => { - anomalyDataChangeListener(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); + anomalyDataChange(mockAnomalyChartRecordsClone, 1486656000000, 1486670399999); }).not.toThrow(); - function callback() { - callbackCount++; - - if (callbackCount === expectedTestCount) { - done(); - } - } + setImmediate(() => { + expect(explorerService.setCharts.mock.calls.length).toBe(2); + done(); + }); }); }); diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts index 66cd98f7ebe29a..b084f503272ccf 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_constants.ts @@ -17,24 +17,15 @@ export const DRAG_SELECT_ACTION = { }; export const EXPLORER_ACTION = { - APP_STATE_SET: 'appStateSet', - APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS: 'appStateClearInfluencerFilterSettings', - APP_STATE_CLEAR_SELECTION: 'appStateClearSelection', - APP_STATE_SAVE_SELECTION: 'appStateSaveSelection', - APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME: 'appStateSaveViewBySwimlaneFieldName', - APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS: 'appStateSaveInfluencerFilterSettings', CLEAR_INFLUENCER_FILTER_SETTINGS: 'clearInfluencerFilterSettings', CLEAR_JOBS: 'clearJobs', - CLEAR_SELECTION: 'clearSelection', - INITIALIZE: 'initialize', JOB_SELECTION_CHANGE: 'jobSelectionChange', - LOAD_JOBS: 'loadJobs', - RESET: 'reset', SET_BOUNDS: 'setBounds', SET_CHARTS: 'setCharts', + SET_EXPLORER_DATA: 'setExplorerData', + SET_FILTER_DATA: 'setFilterData', SET_INFLUENCER_FILTER_SETTINGS: 'setInfluencerFilterSettings', SET_SELECTED_CELLS: 'setSelectedCells', - SET_STATE: 'setState', SET_SWIMLANE_CONTAINER_WIDTH: 'setSwimlaneContainerWidth', SET_SWIMLANE_LIMIT: 'setSwimlaneLimit', SET_VIEW_BY_SWIMLANE_FIELD_NAME: 'setViewBySwimlaneFieldName', diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts index 713857835b3b9a..89e1a908b1ecc6 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_dashboard_service.ts @@ -9,30 +9,25 @@ * components in the Explorer dashboard. */ -import { isEqual, pick } from 'lodash'; +import { isEqual } from 'lodash'; -import { from, isObservable, BehaviorSubject, Observable, Subject } from 'rxjs'; -import { distinctUntilChanged, flatMap, map, pairwise, scan } from 'rxjs/operators'; +import { from, isObservable, Observable, Subject } from 'rxjs'; +import { distinctUntilChanged, flatMap, map, scan } from 'rxjs/operators'; import { DeepPartial } from '../../../common/types/common'; -import { jobSelectionActionCreator, loadExplorerData } from './actions'; +import { jobSelectionActionCreator } from './actions'; import { ExplorerChartsData } from './explorer_charts/explorer_charts_container_service'; import { EXPLORER_ACTION } from './explorer_constants'; -import { RestoredAppState, SelectedCells, TimeRangeBounds } from './explorer_utils'; -import { - explorerReducer, - getExplorerDefaultState, - ExplorerAppState, - ExplorerState, -} from './reducers'; +import { AppStateSelectedCells, TimeRangeBounds } from './explorer_utils'; +import { explorerReducer, getExplorerDefaultState, ExplorerState } from './reducers'; export const ALLOW_CELL_RANGE_SELECTION = true; export const dragSelect$ = new Subject(); type ExplorerAction = Action | Observable; -const explorerAction$ = new BehaviorSubject({ type: EXPLORER_ACTION.RESET }); +export const explorerAction$ = new Subject(); export type ActionPayload = any; @@ -51,94 +46,79 @@ const explorerFilteredAction$ = explorerAction$.pipe( // applies action and returns state const explorerState$: Observable = explorerFilteredAction$.pipe( - scan(explorerReducer, getExplorerDefaultState()), - pairwise(), - map(([prev, curr]) => { - if ( - curr.selectedJobs !== null && - curr.bounds !== undefined && - !isEqual(getCompareState(prev), getCompareState(curr)) - ) { - explorerAction$.next(loadExplorerData(curr).pipe(map(d => setStateActionCreator(d)))); - } - return curr; - }) + scan(explorerReducer, getExplorerDefaultState()) ); +interface ExplorerAppState { + mlExplorerSwimlane: { + selectedType?: string; + selectedLanes?: string[]; + selectedTimes?: number[]; + showTopFieldValues?: boolean; + viewByFieldName?: string; + }; + mlExplorerFilter: { + influencersFilterQuery?: unknown; + filterActive?: boolean; + filteredFields?: string[]; + queryString?: string; + }; +} + const explorerAppState$: Observable = explorerState$.pipe( - map((state: ExplorerState) => state.appState), + map( + (state: ExplorerState): ExplorerAppState => { + const appState: ExplorerAppState = { + mlExplorerFilter: {}, + mlExplorerSwimlane: {}, + }; + + if (state.selectedCells !== undefined) { + const swimlaneSelectedCells = state.selectedCells; + appState.mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; + appState.mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; + appState.mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; + appState.mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; + } + + if (state.viewBySwimlaneFieldName !== undefined) { + appState.mlExplorerSwimlane.viewByFieldName = state.viewBySwimlaneFieldName; + } + + if (state.filterActive) { + appState.mlExplorerFilter.influencersFilterQuery = state.influencersFilterQuery; + appState.mlExplorerFilter.filterActive = state.filterActive; + appState.mlExplorerFilter.filteredFields = state.filteredFields; + appState.mlExplorerFilter.queryString = state.queryString; + } + + return appState; + } + ), distinctUntilChanged(isEqual) ); -function getCompareState(state: ExplorerState) { - return pick(state, [ - 'bounds', - 'filterActive', - 'filteredFields', - 'influencersFilterQuery', - 'isAndOperator', - 'noInfluencersConfigured', - 'selectedCells', - 'selectedJobs', - 'swimlaneContainerWidth', - 'swimlaneLimit', - 'tableInterval', - 'tableSeverity', - 'viewBySwimlaneFieldName', - ]); -} - -export const setStateActionCreator = (payload: DeepPartial) => ({ - type: EXPLORER_ACTION.SET_STATE, +const setExplorerDataActionCreator = (payload: DeepPartial) => ({ + type: EXPLORER_ACTION.SET_EXPLORER_DATA, + payload, +}); +const setFilterDataActionCreator = (payload: DeepPartial) => ({ + type: EXPLORER_ACTION.SET_FILTER_DATA, payload, }); - -interface AppStateSelection { - type: string; - lanes: string[]; - times: number[]; - showTopFieldValues: boolean; - viewByFieldName: string; -} // Export observable state and action dispatchers as service export const explorerService = { appState$: explorerAppState$, state$: explorerState$, - appStateClearSelection: () => { - explorerAction$.next({ type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION }); - }, - appStateSaveSelection: (payload: AppStateSelection) => { - explorerAction$.next({ type: EXPLORER_ACTION.APP_STATE_SAVE_SELECTION, payload }); - }, clearInfluencerFilterSettings: () => { explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_INFLUENCER_FILTER_SETTINGS }); }, clearJobs: () => { explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_JOBS }); }, - clearSelection: () => { - explorerAction$.next({ type: EXPLORER_ACTION.CLEAR_SELECTION }); - }, - updateJobSelection: (selectedJobIds: string[], restoredAppState: RestoredAppState) => { - explorerAction$.next( - jobSelectionActionCreator( - EXPLORER_ACTION.JOB_SELECTION_CHANGE, - selectedJobIds, - restoredAppState - ) - ); - }, - initialize: (selectedJobIds: string[], restoredAppState: RestoredAppState) => { - explorerAction$.next( - jobSelectionActionCreator(EXPLORER_ACTION.INITIALIZE, selectedJobIds, restoredAppState) - ); - }, - reset: () => { - explorerAction$.next({ type: EXPLORER_ACTION.RESET }); - }, - setAppState: (payload: DeepPartial) => { - explorerAction$.next({ type: EXPLORER_ACTION.APP_STATE_SET, payload }); + updateJobSelection: (selectedJobIds: string[]) => { + explorerAction$.next(jobSelectionActionCreator(selectedJobIds)); }, setBounds: (payload: TimeRangeBounds) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_BOUNDS, payload }); @@ -152,14 +132,17 @@ export const explorerService = { payload, }); }, - setSelectedCells: (payload: SelectedCells) => { + setSelectedCells: (payload: AppStateSelectedCells | undefined) => { explorerAction$.next({ type: EXPLORER_ACTION.SET_SELECTED_CELLS, payload, }); }, - setState: (payload: DeepPartial) => { - explorerAction$.next(setStateActionCreator(payload)); + setExplorerData: (payload: DeepPartial) => { + explorerAction$.next(setExplorerDataActionCreator(payload)); + }, + setFilterData: (payload: DeepPartial) => { + explorerAction$.next(setFilterDataActionCreator(payload)); }, setSwimlaneContainerWidth: (payload: number) => { explorerAction$.next({ diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts index d7873e6d52d785..0ab75b1db29722 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.d.ts @@ -11,8 +11,7 @@ import { CombinedJob } from '../jobs/new_job/common/job_creator/configs'; import { TimeBucketsInterval } from '../util/time_buckets'; interface ClearedSelectedAnomaliesState { - anomalyChartRecords: []; - selectedCells: null; + selectedCells: undefined; viewByLoadedForTimeFormatted: null; } @@ -37,7 +36,7 @@ export declare const getDefaultSwimlaneData: () => SwimlaneData; export declare const getInfluencers: (selectedJobs: any[]) => string[]; export declare const getSelectionInfluencers: ( - selectedCells: SelectedCells, + selectedCells: AppStateSelectedCells | undefined, fieldName: string ) => any[]; @@ -47,7 +46,7 @@ interface SelectionTimeRange { } export declare const getSelectionTimeRange: ( - selectedCells: SelectedCells, + selectedCells: AppStateSelectedCells | undefined, interval: number, bounds: TimeRangeBounds ) => SelectionTimeRange; @@ -62,7 +61,7 @@ interface ViewBySwimlaneOptionsArgs { filterActive: boolean; filteredFields: any[]; isAndOperator: boolean; - selectedCells: SelectedCells; + selectedCells: AppStateSelectedCells; selectedJobs: ExplorerJob[]; } @@ -94,7 +93,7 @@ declare interface SwimlaneBounds { } export declare const loadAnnotationsTableData: ( - selectedCells: SelectedCells, + selectedCells: AppStateSelectedCells | undefined, selectedJobs: ExplorerJob[], interval: number, bounds: TimeRangeBounds @@ -109,7 +108,7 @@ export declare interface AnomaliesTableData { } export declare const loadAnomaliesTableData: ( - selectedCells: SelectedCells, + selectedCells: AppStateSelectedCells | undefined, selectedJobs: ExplorerJob[], dateFormatTz: any, interval: number, @@ -125,7 +124,7 @@ export declare const loadDataForCharts: ( earliestMs: number, latestMs: number, influencers: any[], - selectedCells: SelectedCells, + selectedCells: AppStateSelectedCells | undefined, influencersFilterQuery: any ) => Promise; @@ -178,25 +177,17 @@ export declare const loadViewByTopFieldValuesForSelectedTime: ( noInfluencersConfigured: boolean ) => Promise; -declare interface FilterData { +export declare interface FilterData { influencersFilterQuery: any; filterActive: boolean; filteredFields: string[]; queryString: string; } -declare interface SelectedCells { +export declare interface AppStateSelectedCells { type: string; lanes: string[]; times: number[]; showTopFieldValues: boolean; viewByFieldName: string; } - -export declare interface RestoredAppState { - selectedCells?: SelectedCells; - filterData: {} | FilterData; - viewBySwimlaneFieldName: string; -} - -export declare const restoreAppState: (appState: any) => RestoredAppState; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js index b54b691f3aba69..4fb4e7d4df94f2 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/explorer_utils.js @@ -53,8 +53,7 @@ export function createJobs(jobs) { export function getClearedSelectedAnomaliesState() { return { - anomalyChartRecords: [], - selectedCells: null, + selectedCells: undefined, viewByLoadedForTimeFormatted: null, }; } @@ -195,7 +194,7 @@ export function getSelectionTimeRange(selectedCells, interval, bounds) { let earliestMs = bounds.min.valueOf(); let latestMs = bounds.max.valueOf(); - if (selectedCells !== null && selectedCells.times !== undefined) { + if (selectedCells !== undefined && selectedCells.times !== undefined) { // time property of the cell data is an array, with the elements being // the start times of the first and last cell selected. earliestMs = @@ -212,7 +211,7 @@ export function getSelectionTimeRange(selectedCells, interval, bounds) { export function getSelectionInfluencers(selectedCells, fieldName) { if ( - selectedCells !== null && + selectedCells !== undefined && selectedCells.type !== SWIMLANE_TYPE.OVERALL && selectedCells.viewByFieldName !== undefined && selectedCells.viewByFieldName !== VIEW_BY_JOB_LABEL @@ -346,7 +345,7 @@ export function getViewBySwimlaneOptions({ if (selectedJobIds.length > 1) { // If more than one job selected, default to job ID. viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL; - } else if (mlJobService.jobs.length > 0) { + } else if (mlJobService.jobs.length > 0 && selectedJobIds.length > 0) { // For a single job, default to the first partition, over, // by or influencer field of the first selected job. const firstSelectedJob = mlJobService.jobs.find(job => { @@ -525,7 +524,7 @@ export function processViewByResults( export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, bounds) { const jobIds = - selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL + selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL ? selectedCells.lanes : selectedJobs.map(d => d.id); const timeRange = getSelectionTimeRange(selectedCells, interval, bounds); @@ -587,7 +586,7 @@ export async function loadAnomaliesTableData( influencersFilterQuery ) { const jobIds = - selectedCells !== null && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL + selectedCells !== undefined && selectedCells.viewByFieldName === VIEW_BY_JOB_LABEL ? selectedCells.lanes : selectedJobs.map(d => d.id); const influencers = getSelectionInfluencers(selectedCells, fieldName); @@ -677,7 +676,7 @@ export async function loadDataForCharts( // Just skip doing the request when this function // is called without the minimum required data. if ( - selectedCells === null && + selectedCells === undefined && influencers.length === 0 && influencersFilterQuery === undefined ) { @@ -705,7 +704,7 @@ export async function loadDataForCharts( } if ( - (selectedCells !== null && Object.keys(selectedCells).length > 0) || + (selectedCells !== undefined && Object.keys(selectedCells).length > 0) || influencersFilterQuery !== undefined ) { console.log('Explorer anomaly charts data set:', resp.records); @@ -879,36 +878,3 @@ export async function loadTopInfluencers( } }); } - -export function restoreAppState(appState) { - // Select any jobs set in the global state (i.e. passed in the URL). - let selectedCells; - let filterData = {}; - - // keep swimlane selection, restore selectedCells from AppState - if (appState.mlExplorerSwimlane.selectedType !== undefined) { - selectedCells = { - type: appState.mlExplorerSwimlane.selectedType, - lanes: appState.mlExplorerSwimlane.selectedLanes, - times: appState.mlExplorerSwimlane.selectedTimes, - showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues, - viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName, - }; - } - - // keep influencers filter selection, restore from AppState - if (appState.mlExplorerFilter.influencersFilterQuery !== undefined) { - filterData = { - influencersFilterQuery: appState.mlExplorerFilter.influencersFilterQuery, - filterActive: appState.mlExplorerFilter.filterActive, - filteredFields: appState.mlExplorerFilter.filteredFields, - queryString: appState.mlExplorerFilter.queryString, - }; - } - - return { - filterData, - selectedCells, - viewBySwimlaneFieldName: appState.mlExplorerSwimlane.viewByFieldName, - }; -} diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts b/x-pack/legacy/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts new file mode 100644 index 00000000000000..2b3e1c7bd656f9 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/explorer/hooks/use_selected_cells.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useUrlState } from '../../util/url_state'; +import { SWIMLANE_TYPE } from '../../explorer/explorer_constants'; +import { AppStateSelectedCells } from '../../explorer/explorer_utils'; + +export const useSelectedCells = (): [ + AppStateSelectedCells | undefined, + (swimlaneSelectedCells: AppStateSelectedCells) => void +] => { + const [appState, setAppState] = useUrlState('_a'); + + let selectedCells: AppStateSelectedCells | undefined; + + // keep swimlane selection, restore selectedCells from AppState + if ( + appState && + appState.mlExplorerSwimlane && + appState.mlExplorerSwimlane.selectedType !== undefined + ) { + selectedCells = { + type: appState.mlExplorerSwimlane.selectedType, + lanes: appState.mlExplorerSwimlane.selectedLanes, + times: appState.mlExplorerSwimlane.selectedTimes, + showTopFieldValues: appState.mlExplorerSwimlane.showTopFieldValues, + viewByFieldName: appState.mlExplorerSwimlane.viewByFieldName, + }; + } + + const setSelectedCells = (swimlaneSelectedCells: AppStateSelectedCells) => { + const mlExplorerSwimlane = { ...appState.mlExplorerSwimlane }; + if (swimlaneSelectedCells !== undefined) { + swimlaneSelectedCells.showTopFieldValues = false; + + const currentSwimlaneType = selectedCells?.type; + const currentShowTopFieldValues = selectedCells?.showTopFieldValues; + const newSwimlaneType = selectedCells?.type; + + if ( + (currentSwimlaneType === SWIMLANE_TYPE.OVERALL && + newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) || + newSwimlaneType === SWIMLANE_TYPE.OVERALL || + currentShowTopFieldValues === true + ) { + swimlaneSelectedCells.showTopFieldValues = true; + } + + mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; + mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; + mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; + mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; + setAppState('mlExplorerSwimlane', mlExplorerSwimlane); + } else { + delete mlExplorerSwimlane.selectedType; + delete mlExplorerSwimlane.selectedLanes; + delete mlExplorerSwimlane.selectedTimes; + delete mlExplorerSwimlane.showTopFieldValues; + setAppState('mlExplorerSwimlane', mlExplorerSwimlane); + } + }; + + return [selectedCells, setSelectedCells]; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts deleted file mode 100644 index 66e00a41a3f313..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/app_state_reducer.ts +++ /dev/null @@ -1,89 +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 { cloneDeep } from 'lodash'; - -import { EXPLORER_ACTION } from '../explorer_constants'; -import { Action } from '../explorer_dashboard_service'; - -export interface ExplorerAppState { - mlExplorerSwimlane: { - selectedType?: string; - selectedLanes?: string[]; - selectedTimes?: number[]; - showTopFieldValues?: boolean; - viewByFieldName?: string; - }; - mlExplorerFilter: { - influencersFilterQuery?: unknown; - filterActive?: boolean; - filteredFields?: string[]; - queryString?: string; - }; -} - -export function getExplorerDefaultAppState(): ExplorerAppState { - return { - mlExplorerSwimlane: {}, - mlExplorerFilter: {}, - }; -} - -export const appStateReducer = (state: ExplorerAppState, nextAction: Action) => { - const { type, payload } = nextAction; - - const appState = cloneDeep(state); - - if (appState.mlExplorerSwimlane === undefined) { - appState.mlExplorerSwimlane = {}; - } - if (appState.mlExplorerFilter === undefined) { - appState.mlExplorerFilter = {}; - } - - switch (type) { - case EXPLORER_ACTION.APP_STATE_SET: - return { ...appState, ...payload }; - - case EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION: - delete appState.mlExplorerSwimlane.selectedType; - delete appState.mlExplorerSwimlane.selectedLanes; - delete appState.mlExplorerSwimlane.selectedTimes; - delete appState.mlExplorerSwimlane.showTopFieldValues; - break; - - case EXPLORER_ACTION.APP_STATE_SAVE_SELECTION: - const swimlaneSelectedCells = payload; - appState.mlExplorerSwimlane.selectedType = swimlaneSelectedCells.type; - appState.mlExplorerSwimlane.selectedLanes = swimlaneSelectedCells.lanes; - appState.mlExplorerSwimlane.selectedTimes = swimlaneSelectedCells.times; - appState.mlExplorerSwimlane.showTopFieldValues = swimlaneSelectedCells.showTopFieldValues; - appState.mlExplorerSwimlane.viewByFieldName = swimlaneSelectedCells.viewByFieldName; - break; - - case EXPLORER_ACTION.APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME: - appState.mlExplorerSwimlane.viewByFieldName = payload.viewBySwimlaneFieldName; - break; - - case EXPLORER_ACTION.APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS: - appState.mlExplorerFilter.influencersFilterQuery = payload.influencersFilterQuery; - appState.mlExplorerFilter.filterActive = payload.filterActive; - appState.mlExplorerFilter.filteredFields = payload.filteredFields; - appState.mlExplorerFilter.queryString = payload.queryString; - break; - - case EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS: - delete appState.mlExplorerFilter.influencersFilterQuery; - delete appState.mlExplorerFilter.filterActive; - delete appState.mlExplorerFilter.filteredFields; - delete appState.mlExplorerFilter.queryString; - break; - - default: - } - - return appState; -}; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts index 28f04bf65634a6..daeb9ae54013c7 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/check_selected_cells.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EXPLORER_ACTION, SWIMLANE_TYPE } from '../../explorer_constants'; +import { SWIMLANE_TYPE } from '../../explorer_constants'; import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; -import { appStateReducer } from '../app_state_reducer'; - import { ExplorerState } from './state'; interface SwimlanePoint { @@ -21,18 +19,26 @@ interface SwimlanePoint { // If filter is active - selectedCell may not be available due to swimlane view by change to filter fieldName // Ok to keep cellSelection in this case export const checkSelectedCells = (state: ExplorerState) => { - const { filterActive, selectedCells, viewBySwimlaneData, viewBySwimlaneDataLoading } = state; - - if (viewBySwimlaneDataLoading) { + const { + filterActive, + loading, + selectedCells, + viewBySwimlaneData, + viewBySwimlaneDataLoading, + } = state; + + if (loading || viewBySwimlaneDataLoading) { return {}; } let clearSelection = false; if ( + selectedCells !== undefined && selectedCells !== null && selectedCells.type === SWIMLANE_TYPE.VIEW_BY && viewBySwimlaneData !== undefined && - viewBySwimlaneData.points !== undefined + viewBySwimlaneData.points !== undefined && + viewBySwimlaneData.points.length > 0 ) { clearSelection = filterActive === false && @@ -49,9 +55,6 @@ export const checkSelectedCells = (state: ExplorerState) => { if (clearSelection === true) { return { - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), ...getClearedSelectedAnomaliesState(), }; } diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts index 29c077a5cba434..1614da14e355a4 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/clear_influencer_filter_settings.ts @@ -4,24 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EXPLORER_ACTION } from '../../explorer_constants'; import { getClearedSelectedAnomaliesState } from '../../explorer_utils'; -import { appStateReducer } from '../app_state_reducer'; - import { ExplorerState } from './state'; export function clearInfluencerFilterSettings(state: ExplorerState): ExplorerState { - const appStateClearInfluencer = appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS, - }); - const appStateClearSelection = appStateReducer(appStateClearInfluencer, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }); - return { ...state, - appState: appStateClearSelection, filterActive: false, filteredFields: [], influencersFilterQuery: undefined, diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts deleted file mode 100644 index 8536c8f3e542ee..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/initialize.ts +++ /dev/null @@ -1,35 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ActionPayload } from '../../explorer_dashboard_service'; -import { getInfluencers } from '../../explorer_utils'; - -import { getIndexPattern } from './get_index_pattern'; -import { ExplorerState } from './state'; - -export const initialize = (state: ExplorerState, payload: ActionPayload): ExplorerState => { - const { selectedCells, selectedJobs, viewBySwimlaneFieldName, filterData } = payload; - let currentSelectedCells = state.selectedCells; - let currentviewBySwimlaneFieldName = state.viewBySwimlaneFieldName; - - if (viewBySwimlaneFieldName !== undefined) { - currentviewBySwimlaneFieldName = viewBySwimlaneFieldName; - } - - if (selectedCells !== undefined && currentSelectedCells === null) { - currentSelectedCells = selectedCells; - } - - return { - ...state, - indexPattern: getIndexPattern(selectedJobs), - noInfluencersConfigured: getInfluencers(selectedJobs).length === 0, - selectedCells: currentSelectedCells, - selectedJobs, - viewBySwimlaneFieldName: currentviewBySwimlaneFieldName, - ...(filterData.influencersFilterQuery !== undefined ? { ...filterData } : {}), - }; -}; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts index 9fe8ebbb2c481c..a26c0564c6b16d 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/job_selection_change.ts @@ -4,27 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants'; import { ActionPayload } from '../../explorer_dashboard_service'; -import { - getClearedSelectedAnomaliesState, - getDefaultSwimlaneData, - getInfluencers, -} from '../../explorer_utils'; - -import { appStateReducer } from '../app_state_reducer'; +import { getDefaultSwimlaneData, getInfluencers } from '../../explorer_utils'; import { getIndexPattern } from './get_index_pattern'; -import { getExplorerDefaultState, ExplorerState } from './state'; +import { ExplorerState } from './state'; export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload): ExplorerState => { const { selectedJobs } = payload; const stateUpdate: ExplorerState = { ...state, - appState: appStateReducer(getExplorerDefaultState().appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), - ...getClearedSelectedAnomaliesState(), noInfluencersConfigured: getInfluencers(selectedJobs).length === 0, overallSwimlaneData: getDefaultSwimlaneData(), selectedJobs, @@ -32,9 +21,6 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload) // clear filter if selected jobs have no influencers if (stateUpdate.noInfluencersConfigured === true) { - stateUpdate.appState = appStateReducer(stateUpdate.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS, - }); const noFilterState = { filterActive: false, filteredFields: [], @@ -51,11 +37,6 @@ export const jobSelectionChange = (state: ExplorerState, payload: ActionPayload) stateUpdate.indexPattern = getIndexPattern(selectedJobs); } - if (selectedJobs.length > 1) { - stateUpdate.viewBySwimlaneFieldName = VIEW_BY_JOB_LABEL; - return stateUpdate; - } - stateUpdate.loading = true; return stateUpdate; }; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts index 1919ce949683f5..c31b26b7adb7b7 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts @@ -7,7 +7,7 @@ import { formatHumanReadableDateTime } from '../../../util/date_utils'; import { getDefaultChartsData } from '../../explorer_charts/explorer_charts_container_service'; -import { EXPLORER_ACTION, SWIMLANE_TYPE, VIEW_BY_JOB_LABEL } from '../../explorer_constants'; +import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants'; import { Action } from '../../explorer_dashboard_service'; import { getClearedSelectedAnomaliesState, @@ -16,13 +16,11 @@ import { getSwimlaneBucketInterval, getViewBySwimlaneOptions, } from '../../explorer_utils'; -import { appStateReducer } from '../app_state_reducer'; import { checkSelectedCells } from './check_selected_cells'; import { clearInfluencerFilterSettings } from './clear_influencer_filter_settings'; -import { initialize } from './initialize'; import { jobSelectionChange } from './job_selection_change'; -import { getExplorerDefaultState, ExplorerState } from './state'; +import { ExplorerState } from './state'; import { setInfluencerFilterSettings } from './set_influencer_filter_settings'; import { setKqlQueryBarPlaceholder } from './set_kql_query_bar_placeholder'; @@ -40,45 +38,15 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo nextState = { ...state, ...getClearedSelectedAnomaliesState(), - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), loading: false, selectedJobs: [], }; break; - case EXPLORER_ACTION.CLEAR_SELECTION: - nextState = { - ...state, - ...getClearedSelectedAnomaliesState(), - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), - }; - break; - - case EXPLORER_ACTION.INITIALIZE: - nextState = initialize(state, payload); - break; - case EXPLORER_ACTION.JOB_SELECTION_CHANGE: nextState = jobSelectionChange(state, payload); break; - case EXPLORER_ACTION.APP_STATE_SET: - case EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION: - case EXPLORER_ACTION.APP_STATE_SAVE_SELECTION: - case EXPLORER_ACTION.APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME: - case EXPLORER_ACTION.APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS: - case EXPLORER_ACTION.APP_STATE_CLEAR_INFLUENCER_FILTER_SETTINGS: - nextState = { ...state, appState: appStateReducer(state.appState, nextAction) }; - break; - - case EXPLORER_ACTION.RESET: - nextState = getExplorerDefaultState(); - break; - case EXPLORER_ACTION.SET_BOUNDS: nextState = { ...state, bounds: payload }; break; @@ -102,44 +70,15 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo case EXPLORER_ACTION.SET_SELECTED_CELLS: const selectedCells = payload; - selectedCells.showTopFieldValues = false; - - const currentSwimlaneType = state.selectedCells?.type; - const currentShowTopFieldValues = state.selectedCells?.showTopFieldValues; - const newSwimlaneType = selectedCells?.type; - - if ( - (currentSwimlaneType === SWIMLANE_TYPE.OVERALL && - newSwimlaneType === SWIMLANE_TYPE.VIEW_BY) || - newSwimlaneType === SWIMLANE_TYPE.OVERALL || - currentShowTopFieldValues === true - ) { - selectedCells.showTopFieldValues = true; - } - nextState = { ...state, - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_SAVE_SELECTION, - payload, - }), selectedCells, }; break; - case EXPLORER_ACTION.SET_STATE: - if (payload.viewBySwimlaneFieldName) { - nextState = { - ...state, - ...payload, - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_SAVE_VIEW_BY_SWIMLANE_FIELD_NAME, - payload: { viewBySwimlaneFieldName: payload.viewBySwimlaneFieldName }, - }), - }; - } else { - nextState = { ...state, ...payload }; - } + case EXPLORER_ACTION.SET_EXPLORER_DATA: + case EXPLORER_ACTION.SET_FILTER_DATA: + nextState = { ...state, ...payload }; break; case EXPLORER_ACTION.SET_SWIMLANE_CONTAINER_WIDTH: @@ -157,10 +96,6 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo case EXPLORER_ACTION.SET_SWIMLANE_LIMIT: nextState = { ...state, - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), - ...getClearedSelectedAnomaliesState(), swimlaneLimit: payload, }; break; @@ -180,9 +115,6 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo nextState = { ...state, ...getClearedSelectedAnomaliesState(), - appState: appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_CLEAR_SELECTION, - }), maskAll, viewBySwimlaneFieldName, }; @@ -216,7 +148,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo ); // Does a sanity check on the selected `viewBySwimlaneFieldName` - // and return the available `viewBySwimlaneOptions`. + // and returns the available `viewBySwimlaneOptions`. const { viewBySwimlaneFieldName, viewBySwimlaneOptions } = getViewBySwimlaneOptions({ currentViewBySwimlaneFieldName: nextState.viewBySwimlaneFieldName, filterActive: nextState.filterActive, @@ -238,7 +170,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo ...nextState, swimlaneBucketInterval, viewByLoadedForTimeFormatted: - selectedCells !== null && selectedCells.showTopFieldValues === true + selectedCells !== undefined && selectedCells.showTopFieldValues === true ? formatHumanReadableDateTime(timerange.earliestMs) : null, viewBySwimlaneFieldName, diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts index 76577ae557fe3b..8d083a396582aa 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/set_influencer_filter_settings.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EXPLORER_ACTION, VIEW_BY_JOB_LABEL } from '../../explorer_constants'; +import { VIEW_BY_JOB_LABEL } from '../../explorer_constants'; import { ActionPayload } from '../../explorer_dashboard_service'; -import { appStateReducer } from '../app_state_reducer'; - import { ExplorerState } from './state'; export function setInfluencerFilterSettings( @@ -43,21 +41,8 @@ export function setInfluencerFilterSettings( } } - const appState = appStateReducer(state.appState, { - type: EXPLORER_ACTION.APP_STATE_SAVE_INFLUENCER_FILTER_SETTINGS, - payload: { - influencersFilterQuery, - filterActive: true, - filteredFields, - queryString, - tableQueryString, - isAndOperator, - }, - }); - return { ...state, - appState, filterActive: true, filteredFields, influencersFilterQuery, diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index ce37605c3a926f..0a2dbf5bcff35f 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -15,16 +15,13 @@ import { getDefaultSwimlaneData, AnomaliesTableData, ExplorerJob, + AppStateSelectedCells, SwimlaneData, TimeRangeBounds, } from '../../explorer_utils'; -import { getExplorerDefaultAppState, ExplorerAppState } from '../app_state_reducer'; - export interface ExplorerState { annotationsData: any[]; - anomalyChartRecords: any[]; - appState: ExplorerAppState; bounds: TimeRangeBounds | undefined; chartsData: ExplorerChartsData; fieldFormatsLoading: boolean; @@ -40,15 +37,13 @@ export interface ExplorerState { noInfluencersConfigured: boolean; overallSwimlaneData: SwimlaneData; queryString: string; - selectedCells: any; + selectedCells: AppStateSelectedCells | undefined; selectedJobs: ExplorerJob[] | null; swimlaneBucketInterval: any; swimlaneContainerWidth: number; swimlaneLimit: number; tableData: AnomaliesTableData; - tableInterval: string; tableQueryString: string; - tableSeverity: number; viewByLoadedForTimeFormatted: string | null; viewBySwimlaneData: SwimlaneData; viewBySwimlaneDataLoading: boolean; @@ -63,8 +58,6 @@ function getDefaultIndexPattern() { export function getExplorerDefaultState(): ExplorerState { return { annotationsData: [], - anomalyChartRecords: [], - appState: getExplorerDefaultAppState(), bounds: undefined, chartsData: getDefaultChartsData(), fieldFormatsLoading: false, @@ -80,7 +73,7 @@ export function getExplorerDefaultState(): ExplorerState { noInfluencersConfigured: true, overallSwimlaneData: getDefaultSwimlaneData(), queryString: '', - selectedCells: null, + selectedCells: undefined, selectedJobs: null, swimlaneBucketInterval: undefined, swimlaneContainerWidth: 0, @@ -92,9 +85,7 @@ export function getExplorerDefaultState(): ExplorerState { jobIds: [], showViewSeriesLink: false, }, - tableInterval: 'auto', tableQueryString: '', - tableSeverity: 0, viewByLoadedForTimeFormatted: null, viewBySwimlaneData: getDefaultSwimlaneData(), viewBySwimlaneDataLoading: false, diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts index 98cc07e8f9449f..29787365923c8c 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/explorer/reducers/index.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { appStateReducer, getExplorerDefaultAppState, ExplorerAppState } from './app_state_reducer'; export { explorerReducer, getExplorerDefaultState, diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/index.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/index.ts similarity index 79% rename from x-pack/legacy/plugins/ml/public/application/explorer/select_limit/index.js rename to x-pack/legacy/plugins/ml/public/application/explorer/select_limit/index.ts index fa1b24e1181805..5b7040e5c3606a 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/index.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import './select_limit_service.js'; +export { useSwimlaneLimit, SelectLimit } from './select_limit'; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.js deleted file mode 100644 index 5971e7dcc82be6..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.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. - */ - -/* - * React component for rendering a select element with limit options. - */ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { BehaviorSubject } from 'rxjs'; - -import { EuiSelect } from '@elastic/eui'; - -import { injectObservablesAsProps } from '../../util/observable_utils'; - -const optionsMap = { - '5': 5, - '10': 10, - '25': 25, - '50': 50, -}; - -const LIMIT_OPTIONS = [ - { val: 5, display: '5' }, - { val: 10, display: '10' }, - { val: 25, display: '25' }, - { val: 50, display: '50' }, -]; - -function optionValueToLimit(value) { - // Get corresponding limit object with required display and val properties from the specified value. - let limit = LIMIT_OPTIONS.find(opt => opt.val === value); - - // Default to 10 if supplied value doesn't map to one of the options. - if (limit === undefined) { - limit = LIMIT_OPTIONS[1]; - } - - return limit; -} - -const EUI_OPTIONS = LIMIT_OPTIONS.map(({ display, val }) => ({ - value: display, - text: val, -})); - -export const limit$ = new BehaviorSubject(LIMIT_OPTIONS[1]); - -class SelectLimitUnwrapped extends Component { - onChange = e => { - const valueDisplay = e.target.value; - const limit = optionValueToLimit(optionsMap[valueDisplay]); - limit$.next(limit); - }; - - render() { - return ( - - ); - } -} - -SelectLimitUnwrapped.propTypes = { - limit: PropTypes.object, -}; - -SelectLimitUnwrapped.defaultProps = { - limit: LIMIT_OPTIONS[1], -}; - -const SelectLimit = injectObservablesAsProps( - { - limit: limit$, - }, - SelectLimitUnwrapped -); - -export { SelectLimit }; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx similarity index 59% rename from x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.js rename to x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx index 60543cfad2de45..657f1c6c7af2ed 100644 --- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.js +++ b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.test.tsx @@ -5,25 +5,27 @@ */ import React from 'react'; +import { act } from 'react-dom/test-utils'; import { shallow } from 'enzyme'; import { SelectLimit } from './select_limit'; +jest.useFakeTimers(); + describe('SelectLimit', () => { test('creates correct initial selected value', () => { const wrapper = shallow(); - const defaultSelectedValue = wrapper.props().limit.display; - expect(defaultSelectedValue).toBe('10'); + expect(wrapper.props().value).toEqual(10); }); test('state for currently selected value is updated correctly on click', () => { const wrapper = shallow(); - const select = wrapper.first().shallow(); + expect(wrapper.props().value).toEqual(10); - const defaultSelectedValue = wrapper.props().limit.display; - expect(defaultSelectedValue).toBe('10'); + act(() => { + wrapper.simulate('change', { target: { value: 25 } }); + }); + wrapper.update(); - select.simulate('change', { target: { value: '25' } }); - const updatedSelectedValue = wrapper.props().limit.display; - expect(updatedSelectedValue).toBe('25'); + expect(wrapper.props().value).toEqual(10); }); }); diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.tsx b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.tsx new file mode 100644 index 00000000000000..383d07eb7a9f60 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * React component for rendering a select element with limit options. + */ +import React from 'react'; +import useObservable from 'react-use/lib/useObservable'; +import { Subject } from 'rxjs'; + +import { EuiSelect } from '@elastic/eui'; + +const limitOptions = [5, 10, 25, 50]; + +const euiOptions = limitOptions.map(limit => ({ + value: limit, + text: `${limit}`, +})); + +export const limit$ = new Subject(); +export const defaultLimit = limitOptions[1]; + +export const useSwimlaneLimit = (): [number, (newLimit: number) => void] => { + const limit = useObservable(limit$, defaultLimit); + + return [limit, (newLimit: number) => limit$.next(newLimit)]; +}; + +export const SelectLimit = () => { + const [limit, setLimit] = useSwimlaneLimit(); + + function onChange(e: React.ChangeEvent) { + setLimit(parseInt(e.target.value, 10)); + } + + return ; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit_service.js b/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit_service.js deleted file mode 100644 index dc9d90d3c677e1..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/explorer/select_limit/select_limit_service.js +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* - * AngularJS service for storing limit values in AppState. - */ - -import { uiModules } from 'ui/modules'; -const module = uiModules.get('apps/ml'); - -import { subscribeAppStateToObservable } from '../../util/app_state_utils'; -import { limit$ } from './select_limit'; - -module.service('mlSelectLimitService', function(AppState, $rootScope) { - subscribeAppStateToObservable(AppState, 'mlSelectLimit', limit$, () => $rootScope.$applyAsync()); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx index 1b6b91026d6a5a..6aaad5294369b7 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/explorer.tsx @@ -4,27 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useEffect } from 'react'; +import moment from 'moment'; +import React, { FC, useEffect, useState } from 'react'; +import useObservable from 'react-use/lib/useObservable'; + import { i18n } from '@kbn/i18n'; -import { decode } from 'rison-node'; -import { Subscription } from 'rxjs'; -// @ts-ignore -import queryString from 'query-string'; import { timefilter } from 'ui/timefilter'; + +import { MlJobWithTimeRange } from '../../../../common/types/jobs'; + import { MlRoute, PageLoader, PageProps } from '../router'; +import { useRefresh } from '../use_refresh'; import { useResolver } from '../use_resolver'; import { basicResolvers } from '../resolvers'; import { Explorer } from '../../explorer'; +import { useSelectedCells } from '../../explorer/hooks/use_selected_cells'; import { mlJobService } from '../../services/job_service'; -import { getExplorerDefaultAppState, ExplorerAppState } from '../../explorer/reducers'; +import { ml } from '../../services/ml_api_service'; +import { useExplorerData } from '../../explorer/actions'; import { explorerService } from '../../explorer/explorer_dashboard_service'; -import { jobSelectServiceFactory } from '../../components/job_selector/job_select_service_utils'; -import { subscribeAppStateToObservable } from '../../util/app_state_utils'; - -import { interval$ } from '../../components/controls/select_interval'; -import { severity$ } from '../../components/controls/select_severity'; -import { showCharts$ } from '../../components/controls/checkbox_showcharts'; +import { getDateFormatTz } from '../../explorer/explorer_utils'; +import { useSwimlaneLimit } from '../../explorer/select_limit'; +import { useJobSelection } from '../../components/job_selector/use_job_selection'; +import { useShowCharts } from '../../components/controls/checkbox_showcharts'; +import { useTableInterval } from '../../components/controls/select_interval'; +import { useTableSeverity } from '../../components/controls/select_severity'; +import { useUrlState } from '../../util/url_state'; import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs'; const breadcrumbs = [ @@ -44,111 +50,140 @@ export const explorerRoute: MlRoute = { breadcrumbs, }; -const PageWrapper: FC = ({ location, config, deps }) => { - const { index } = queryString.parse(location.search); - const { context } = useResolver(index, undefined, config, { +const PageWrapper: FC = ({ config, deps }) => { + const { context, results } = useResolver(undefined, undefined, config, { ...basicResolvers(deps), jobs: mlJobService.loadJobsWrapper, + jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()), }); - const { _a, _g } = queryString.parse(location.search); - let appState: any = {}; - let globalState: any = {}; - try { - appState = decode(_a); - globalState = decode(_g); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Could not parse global or app state'); - } - - if (appState.mlExplorerSwimlane === undefined) { - appState.mlExplorerSwimlane = {}; - } - - if (appState.mlExplorerFilter === undefined) { - appState.mlExplorerFilter = {}; - } - - appState.fetch = () => {}; - appState.on = () => {}; - appState.off = () => {}; - appState.save = () => {}; - globalState.fetch = () => {}; - globalState.on = () => {}; - globalState.off = () => {}; - globalState.save = () => {}; return ( - + ); }; -class AppState { - fetch() {} - on() {} - off() {} - save() {} +interface ExplorerUrlStateManagerProps { + jobsWithTimeRange: MlJobWithTimeRange[]; } -const ExplorerWrapper: FC<{ globalState: any; appState: any }> = ({ globalState, appState }) => { - const subscriptions = new Subscription(); - - const { jobSelectService$, unsubscribeFromGlobalState } = jobSelectServiceFactory(globalState); - appState = getExplorerDefaultAppState(); - const { mlExplorerFilter, mlExplorerSwimlane } = appState; - window.setTimeout(() => { - // Pass the current URL AppState on to anomaly explorer's reactive state. - // After this hand-off, the appState stored in explorerState$ is the single - // source of truth. - explorerService.setAppState({ mlExplorerSwimlane, mlExplorerFilter }); - - // Now that appState in explorerState$ is the single source of truth, - // subscribe to it and update the actual URL appState on changes. - subscriptions.add( - explorerService.appState$.subscribe((appStateIn: ExplorerAppState) => { - // appState.fetch(); - appState.mlExplorerFilter = appStateIn.mlExplorerFilter; - appState.mlExplorerSwimlane = appStateIn.mlExplorerSwimlane; - // appState.save(); - }) - ); - }); +const ExplorerUrlStateManager: FC = ({ jobsWithTimeRange }) => { + const [appState, setAppState] = useUrlState('_a'); + const [globalState] = useUrlState('_g'); + const [lastRefresh, setLastRefresh] = useState(0); - subscriptions.add(subscribeAppStateToObservable(AppState, 'mlShowCharts', showCharts$, () => {})); - subscriptions.add( - subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => {}) - ); - subscriptions.add( - subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => {}) - ); + const { jobIds } = useJobSelection(jobsWithTimeRange, getDateFormatTz()); - if (globalState.time) { - timefilter.setTime({ - from: globalState.time.from, - to: globalState.time.to, - }); - } + const refresh = useRefresh(); + useEffect(() => { + if (refresh !== undefined) { + setLastRefresh(refresh?.lastRefresh); + const activeBounds = timefilter.getActiveBounds(); + if (activeBounds !== undefined) { + explorerService.setBounds(activeBounds); + } + } + }, [refresh?.lastRefresh]); useEffect(() => { - return () => { - subscriptions.unsubscribe(); - unsubscribeFromGlobalState(); - }; - }); + timefilter.enableTimeRangeSelector(); + timefilter.enableAutoRefreshSelector(); + + const viewByFieldName = appState?.mlExplorerSwimlane?.viewByFieldName; + if (viewByFieldName !== undefined) { + explorerService.setViewBySwimlaneFieldName(viewByFieldName); + } + + const filterData = appState?.mlExplorerFilter; + if (filterData !== undefined) { + explorerService.setFilterData(filterData); + } + }, []); + + useEffect(() => { + if (globalState?.time !== undefined) { + timefilter.setTime({ + from: globalState.time.from, + to: globalState.time.to, + }); + explorerService.setBounds({ + min: moment(globalState.time.from), + max: moment(globalState.time.to), + }); + } + }, [globalState?.time?.from, globalState?.time?.to]); + + useEffect(() => { + if (jobIds.length > 0) { + explorerService.updateJobSelection(jobIds); + } else { + explorerService.clearJobs(); + } + }, [JSON.stringify(jobIds)]); + + const [explorerData, loadExplorerData] = useExplorerData(); + useEffect(() => { + if (explorerData !== undefined && Object.keys(explorerData).length > 0) { + explorerService.setExplorerData(explorerData); + } + }, [explorerData]); + + const explorerAppState = useObservable(explorerService.appState$); + useEffect(() => { + if ( + explorerAppState !== undefined && + explorerAppState.mlExplorerSwimlane.viewByFieldName !== undefined + ) { + setAppState(explorerAppState); + } + }, [explorerAppState]); + + const explorerState = useObservable(explorerService.state$); + + const [showCharts] = useShowCharts(); + const [tableInterval] = useTableInterval(); + const [tableSeverity] = useTableSeverity(); + const [swimlaneLimit] = useSwimlaneLimit(); + useEffect(() => { + explorerService.setSwimlaneLimit(swimlaneLimit); + }, [swimlaneLimit]); + + const [selectedCells, setSelectedCells] = useSelectedCells(); + useEffect(() => { + explorerService.setSelectedCells(selectedCells); + }, [JSON.stringify(selectedCells)]); + + const loadExplorerDataConfig = + (explorerState !== undefined && { + bounds: explorerState.bounds, + lastRefresh, + influencersFilterQuery: explorerState.influencersFilterQuery, + noInfluencersConfigured: explorerState.noInfluencersConfigured, + selectedCells, + selectedJobs: explorerState.selectedJobs, + swimlaneBucketInterval: explorerState.swimlaneBucketInterval, + swimlaneLimit: explorerState.swimlaneLimit, + tableInterval: tableInterval.val, + tableSeverity: tableSeverity.val, + viewBySwimlaneFieldName: explorerState.viewBySwimlaneFieldName, + }) || + undefined; + useEffect(() => { + loadExplorerData(loadExplorerDataConfig); + }, [JSON.stringify(loadExplorerDataConfig)]); + + if (explorerState === undefined || refresh === undefined || showCharts === undefined) { + return null; + } return (
diff --git a/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index a40bbfa214b281..cbf54a70ea74f4 100644 --- a/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/legacy/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -4,24 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { decode } from 'rison-node'; +import { isEqual } from 'lodash'; +import React, { FC, useCallback, useEffect, useState } from 'react'; +import { usePrevious } from 'react-use'; import moment from 'moment'; -import { Subscription } from 'rxjs'; - // @ts-ignore import queryString from 'query-string'; + +import { i18n } from '@kbn/i18n'; + import { timefilter } from 'ui/timefilter'; -import { MlRoute, PageLoader, PageProps } from '../router'; -import { useResolver } from '../use_resolver'; -import { basicResolvers } from '../resolvers'; + +import { MlJobWithTimeRange } from '../../../../common/types/jobs'; + import { TimeSeriesExplorer } from '../../timeseriesexplorer'; +import { getDateFormatTz, TimeRangeBounds } from '../../explorer/explorer_utils'; +import { ml } from '../../services/ml_api_service'; import { mlJobService } from '../../services/job_service'; +import { mlForecastService } from '../../services/forecast_service'; import { APP_STATE_ACTION } from '../../timeseriesexplorer/timeseriesexplorer_constants'; -import { subscribeAppStateToObservable } from '../../util/app_state_utils'; -import { interval$ } from '../../components/controls/select_interval'; -import { severity$ } from '../../components/controls/select_severity'; +import { + createTimeSeriesJobData, + getAutoZoomDuration, +} from '../../timeseriesexplorer/timeseriesexplorer_utils'; +import { useUrlState } from '../../util/url_state'; +import { useTableInterval } from '../../components/controls/select_interval'; +import { useTableSeverity } from '../../components/controls/select_severity'; + +import { MlRoute, PageLoader, PageProps } from '../router'; +import { useRefresh } from '../use_refresh'; +import { useResolver } from '../use_resolver'; +import { basicResolvers } from '../resolvers'; import { ANOMALY_DETECTION_BREADCRUMB, ML_BREADCRUMB } from '../breadcrumbs'; export const timeSeriesExplorerRoute: MlRoute = { @@ -39,105 +52,207 @@ export const timeSeriesExplorerRoute: MlRoute = { ], }; -const PageWrapper: FC = ({ location, config, deps }) => { - const { context } = useResolver('', undefined, config, { +const PageWrapper: FC = ({ config, deps }) => { + const { context, results } = useResolver('', undefined, config, { ...basicResolvers(deps), jobs: mlJobService.loadJobsWrapper, + jobsWithTimeRange: () => ml.jobs.jobsWithTimerange(getDateFormatTz()), }); - const { _a, _g } = queryString.parse(location.search); - let appState: any = {}; - let globalState: any = {}; - try { - appState = decode(_a); - globalState = decode(_g); - } catch (error) { - // eslint-disable-next-line no-console - console.error('Could not parse global or app state'); - } - if (appState.mlTimeSeriesExplorer === undefined) { - appState.mlTimeSeriesExplorer = {}; - } - globalState.fetch = () => {}; - globalState.on = () => {}; - globalState.off = () => {}; - globalState.save = () => {}; return ( - + ); }; -class AppState { - fetch() {} - on() {} - off() {} - save() {} +interface TimeSeriesExplorerUrlStateManager { + config: any; + jobsWithTimeRange: MlJobWithTimeRange[]; } -const TimeSeriesExplorerWrapper: FC<{ globalState: any; appState: any; config: any }> = ({ - globalState, - appState, +const TimeSeriesExplorerUrlStateManager: FC = ({ config, + jobsWithTimeRange, }) => { - if (globalState.time) { - timefilter.setTime({ - from: globalState.time.from, - to: globalState.time.to, - }); - } + const [appState, setAppState] = useUrlState('_a'); + const [globalState, setGlobalState] = useUrlState('_g'); + const [lastRefresh, setLastRefresh] = useState(0); - const subscriptions = new Subscription(); - subscriptions.add( - subscribeAppStateToObservable(AppState, 'mlSelectInterval', interval$, () => {}) - ); - subscriptions.add( - subscribeAppStateToObservable(AppState, 'mlSelectSeverity', severity$, () => {}) - ); + const refresh = useRefresh(); + useEffect(() => { + if (refresh !== undefined) { + setLastRefresh(refresh?.lastRefresh); - const appStateHandler = (action: string, payload: any) => { - switch (action) { - case APP_STATE_ACTION.CLEAR: - delete appState.mlTimeSeriesExplorer.detectorIndex; - delete appState.mlTimeSeriesExplorer.entities; - delete appState.mlTimeSeriesExplorer.forecastId; - break; - - case APP_STATE_ACTION.GET_DETECTOR_INDEX: - return appState.mlTimeSeriesExplorer.detectorIndex; - case APP_STATE_ACTION.SET_DETECTOR_INDEX: - appState.mlTimeSeriesExplorer.detectorIndex = payload; - break; - - case APP_STATE_ACTION.GET_ENTITIES: - return appState.mlTimeSeriesExplorer.entities; - case APP_STATE_ACTION.SET_ENTITIES: - appState.mlTimeSeriesExplorer.entities = payload; - break; - - case APP_STATE_ACTION.GET_FORECAST_ID: - return appState.mlTimeSeriesExplorer.forecastId; - case APP_STATE_ACTION.SET_FORECAST_ID: - appState.mlTimeSeriesExplorer.forecastId = payload; - break; - - case APP_STATE_ACTION.GET_ZOOM: - return appState.mlTimeSeriesExplorer.zoom; - case APP_STATE_ACTION.SET_ZOOM: - appState.mlTimeSeriesExplorer.zoom = payload; - break; - case APP_STATE_ACTION.UNSET_ZOOM: - delete appState.mlTimeSeriesExplorer.zoom; - break; + if (refresh.timeRange !== undefined) { + const { start, end } = refresh.timeRange; + setGlobalState('time', { + from: start, + to: end, + }); + } } - }; + }, [refresh?.lastRefresh]); useEffect(() => { - return () => { - subscriptions.unsubscribe(); + timefilter.enableTimeRangeSelector(); + timefilter.enableAutoRefreshSelector(); + }, []); + + useEffect(() => { + if (globalState?.time !== undefined) { + timefilter.setTime({ + from: globalState.time.from, + to: globalState.time.to, + }); + } + }, [globalState?.time?.from, globalState?.time?.to]); + + let bounds: TimeRangeBounds | undefined; + if (globalState?.time !== undefined) { + bounds = { + min: moment(globalState.time.from), + max: moment(globalState.time.to), }; - }); + } + + const selectedJobIds = globalState?.ml?.jobIds; + // Sort selectedJobIds so we can be sure comparison works when stringifying. + if (Array.isArray(selectedJobIds)) { + selectedJobIds.sort(); + } + + // When changing jobs we'll clear appState (detectorIndex, entities, forecastId). + // To retore settings from the URL on initial load we also need to check against + // `previousSelectedJobIds` to avoid wiping appState. + const previousSelectedJobIds = usePrevious(selectedJobIds); + const isJobChange = !isEqual(previousSelectedJobIds, selectedJobIds); + + // Use a side effect to clear appState when changing jobs. + useEffect(() => { + if (selectedJobIds !== undefined && previousSelectedJobIds !== undefined) { + setLastRefresh(Date.now()); + appStateHandler(APP_STATE_ACTION.CLEAR); + } + }, [JSON.stringify(selectedJobIds)]); + + // Next we get globalState and appState information to pass it on as props later. + // If a job change is going on, we fall back to defaults (as if appState was already cleard), + // otherwise the page could break. + const selectedDetectorIndex = isJobChange + ? 0 + : +appState?.mlTimeSeriesExplorer?.detectorIndex || 0; + const selectedEntities = isJobChange ? undefined : appState?.mlTimeSeriesExplorer?.entities; + const selectedForecastId = isJobChange ? undefined : appState?.mlTimeSeriesExplorer?.forecastId; + const zoom = isJobChange ? undefined : appState?.mlTimeSeriesExplorer?.zoom; + + const selectedJob = selectedJobIds && mlJobService.getJob(selectedJobIds[0]); + + let autoZoomDuration: number | undefined; + if (selectedJobIds !== undefined && selectedJobIds.length === 1 && selectedJob !== undefined) { + autoZoomDuration = getAutoZoomDuration( + createTimeSeriesJobData(mlJobService.jobs), + mlJobService.getJob(selectedJobIds[0]) + ); + } + + const appStateHandler = useCallback( + (action: string, payload?: any) => { + const mlTimeSeriesExplorer = + appState?.mlTimeSeriesExplorer !== undefined ? { ...appState.mlTimeSeriesExplorer } : {}; + + switch (action) { + case APP_STATE_ACTION.CLEAR: + delete mlTimeSeriesExplorer.detectorIndex; + delete mlTimeSeriesExplorer.entities; + delete mlTimeSeriesExplorer.forecastId; + delete mlTimeSeriesExplorer.zoom; + break; + + case APP_STATE_ACTION.SET_DETECTOR_INDEX: + mlTimeSeriesExplorer.detectorIndex = payload; + break; + + case APP_STATE_ACTION.SET_ENTITIES: + mlTimeSeriesExplorer.entities = payload; + break; + + case APP_STATE_ACTION.SET_FORECAST_ID: + mlTimeSeriesExplorer.forecastId = payload; + break; + + case APP_STATE_ACTION.SET_ZOOM: + mlTimeSeriesExplorer.zoom = payload; + break; + + case APP_STATE_ACTION.UNSET_ZOOM: + delete mlTimeSeriesExplorer.zoom; + break; + } + + setAppState('mlTimeSeriesExplorer', mlTimeSeriesExplorer); + }, + [JSON.stringify([appState, globalState])] + ); + + const boundsMinMs = bounds?.min?.valueOf(); + const boundsMaxMs = bounds?.max?.valueOf(); + useEffect(() => { + if ( + autoZoomDuration !== undefined && + boundsMinMs !== undefined && + boundsMaxMs !== undefined && + selectedJob !== undefined && + selectedForecastId !== undefined + ) { + mlForecastService + .getForecastDateRange(selectedJob, selectedForecastId) + .then(resp => { + if (autoZoomDuration === undefined) { + return; + } + + const earliest = moment(resp.earliest || boundsMinMs); + const latest = moment(resp.latest || boundsMaxMs); + + // Set the zoom to centre on the start of the forecast range, depending + // on the time range of the forecast and data. + // const earliestDataDate = first(contextChartData).date; + const zoomLatestMs = Math.min( + earliest.valueOf() + autoZoomDuration / 2, + latest.valueOf() + ); + const zoomEarliestMs = zoomLatestMs - autoZoomDuration; + const zoomState = { + from: moment(zoomEarliestMs).toISOString(), + to: moment(zoomLatestMs).toISOString(), + }; + appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); + + if (earliest.isBefore(moment(boundsMinMs)) || latest.isAfter(moment(boundsMaxMs))) { + const earliestMs = Math.min(earliest.valueOf(), boundsMinMs); + const latestMs = Math.max(latest.valueOf(), boundsMaxMs); + setGlobalState('time', { + from: moment(earliestMs).toISOString(), + to: moment(latestMs).toISOString(), + }); + } + }) + .catch(resp => { + // eslint-disable-next-line no-console + console.error( + 'Time series explorer - error loading time range of forecast from elasticsearch:', + resp + ); + }); + } + }, [selectedForecastId]); + + const [tableInterval] = useTableInterval(); + const [tableSeverity] = useTableSeverity(); const tzConfig = config.get('dateFormat:tz'); const dateFormatTz = tzConfig !== 'Browser' ? tzConfig : moment.tz.guess(); @@ -146,9 +261,20 @@ const TimeSeriesExplorerWrapper: FC<{ globalState: any; appState: any; config: a ); diff --git a/x-pack/legacy/plugins/ml/public/application/routing/use_refresh.ts b/x-pack/legacy/plugins/ml/public/application/routing/use_refresh.ts new file mode 100644 index 00000000000000..f9f3bb66f14f31 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/routing/use_refresh.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useObservable } from 'react-use'; +import { merge, Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +import { annotationsRefresh$ } from '../services/annotations_service'; +import { + mlTimefilterRefresh$, + mlTimefilterTimeChange$, +} from '../services/timefilter_refresh_service'; + +export interface Refresh { + lastRefresh: number; + timeRange?: { start: string; end: string }; +} + +const refresh$: Observable = merge( + mlTimefilterRefresh$, + mlTimefilterTimeChange$, + annotationsRefresh$.pipe(map(d => ({ lastRefresh: d }))) +); + +export const useRefresh = () => { + return useObservable(refresh$); +}; diff --git a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx index d74c3802c2ed2e..2ba54d243ed1b3 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx +++ b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.test.tsx @@ -7,7 +7,7 @@ import mockAnnotations from '../components/annotations/annotations_table/__mocks__/mock_annotations.json'; import { Annotation } from '../../../common/types/annotations'; -import { annotation$, annotationsRefresh$ } from './annotations_service'; +import { annotation$, annotationsRefresh$, annotationsRefreshed } from './annotations_service'; describe('annotations_service', () => { test('annotation$', () => { @@ -34,7 +34,7 @@ describe('annotations_service', () => { expect(subscriber.mock.calls).toHaveLength(1); - annotationsRefresh$.next(true); + annotationsRefreshed(); expect(subscriber.mock.calls).toHaveLength(2); }); diff --git a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx index 6953232f0cc6c2..6493770156cb81 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx +++ b/x-pack/legacy/plugins/ml/public/application/services/annotations_service.tsx @@ -48,8 +48,8 @@ export type AnnotationState = Annotation | null; - To add it to a given components state, just use `annotation$.subscribe(annotation => this.setState({ annotation }));` in `componentDidMount()`. - 2. injectObservablesAsProps() from public/utils/observable_utils.tsx, as the name implies, offers - a way to wrap observables into another component which passes on updated values as props. + 2. useObservable() from 'react-use', offers a way to wrap observables + into another component which passes on updated values as props. - To subscribe to updates this way, wrap your component like: @@ -62,10 +62,13 @@ export type AnnotationState = Annotation | null; return {annotation.annotation}; } - export const MyObservableComponent = injectObservablesAsProps( - { annotation: annotaton$ }, - MyOriginalComponent - ); + export const MyObservableComponent = (props) => { + const annotationProp = useObservable(annotation$); + if (annotationProp === undefined) { + return null; + } + return ; + }; */ export const annotation$ = new BehaviorSubject(null); @@ -74,4 +77,5 @@ export const annotation$ = new BehaviorSubject(null); Instead of passing around callbacks or deeply nested props, it can be imported for both angularjs controllers/directives and React components. */ -export const annotationsRefresh$ = new BehaviorSubject(false); +export const annotationsRefresh$ = new BehaviorSubject(Date.now()); +export const annotationsRefreshed = () => annotationsRefresh$.next(Date.now()); diff --git a/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts b/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts index 19f77d97a57080..8de903a422f340 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/forecast_service.d.ts @@ -12,6 +12,11 @@ export interface ForecastData { results: any; } +export interface ForecastDateRange { + earliest: number; + latest: number; +} + export const mlForecastService: { getForecastData: ( job: Job, @@ -23,4 +28,6 @@ export const mlForecastService: { interval: string, aggType: any ) => Observable; + + getForecastDateRange: (job: Job, forecastId: string) => Promise; }; diff --git a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts index 2ad2a148f05d1a..bca32e9528f64a 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/services/ml_api_service/index.d.ts @@ -6,11 +6,12 @@ import { Observable } from 'rxjs'; import { Annotation } from '../../../../common/types/annotations'; +import { Dictionary } from '../../../../common/types/common'; import { AggFieldNamePair } from '../../../../common/types/fields'; import { Category } from '../../../../common/types/categories'; import { ExistingJobsAndGroups } from '../job_service'; import { PrivilegesResponse } from '../../../../common/types/privileges'; -import { MlSummaryJobs } from '../../../../common/types/jobs'; +import { MlJobWithTimeRange, MlSummaryJobs } from '../../../../common/types/jobs'; import { MlServerDefaults, MlServerLimits } from '../ml_server_info'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; import { DataFrameAnalyticsStats } from '../../data_frame_analytics/pages/analytics_management/components/analytics_list/common'; @@ -135,6 +136,9 @@ declare interface Ml { jobs: { jobsSummary(jobIds: string[]): Promise; + jobsWithTimerange( + dateFormatTz: string + ): Promise<{ jobs: MlJobWithTimeRange[]; jobsMap: Dictionary }>; jobs(jobIds: string[]): Promise; groups(): Promise; updateGroups(updatedJobs: string[]): Promise; diff --git a/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx b/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx index 2085c2a5dc77f2..86c07a3577f7b6 100644 --- a/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx +++ b/x-pack/legacy/plugins/ml/public/application/services/timefilter_refresh_service.tsx @@ -6,4 +6,7 @@ import { Subject } from 'rxjs'; -export const mlTimefilterRefresh$ = new Subject(); +import { Refresh } from '../routing/use_refresh'; + +export const mlTimefilterRefresh$ = new Subject>(); +export const mlTimefilterTimeChange$ = new Subject>(); diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js deleted file mode 100644 index 32b4fa3df3cf01..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/__tests__/timeseriesexplorer_directive.js +++ /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 ngMock from 'ng_mock'; -import expect from '@kbn/expect'; - -describe('ML - Time Series Explorer Directive', () => { - let $scope; - let $compile; - let $element; - - beforeEach(ngMock.module('kibana')); - beforeEach(() => { - ngMock.inject(function($injector) { - $compile = $injector.get('$compile'); - const $rootScope = $injector.get('$rootScope'); - $scope = $rootScope.$new(); - }); - }); - - afterEach(() => { - $scope.$destroy(); - }); - - it('Initialize Time Series Explorer Directive', done => { - ngMock.inject(function() { - expect(() => { - $element = $compile('')($scope); - }).to.not.throwError(); - - // directive has scope: false - const scope = $element.isolateScope(); - expect(scope).to.eql(undefined); - done(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx index bc6896a1a66ba0..df5412e609a9c9 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx @@ -22,21 +22,13 @@ export interface Entity { fieldValues: any; } -function getEntityControlOptions(entity: Entity): EuiComboBoxOptionProps[] { - if (!Array.isArray(entity.fieldValues)) { - return []; - } - - return entity.fieldValues.map(value => { - return { label: value }; - }); -} - interface EntityControlProps { entity: Entity; entityFieldValueChanged: (entity: Entity, fieldValue: any) => void; + isLoading: boolean; onSearchChange: (entity: Entity, queryTerm: string) => void; forceSelection: boolean; + options: EuiComboBoxOptionProps[]; } interface EntityControlState { @@ -55,17 +47,11 @@ export class EntityControl extends Component 0) || @@ -79,11 +65,13 @@ export class EntityControl extends Component { - this.props.loadForForecastId(forecastId); + this.props.setForecastId(forecastId); this.closeModal(); }; @@ -279,7 +279,7 @@ export const ForecastingModal = injectI18n( this.setState({ jobClosingState: PROGRESS_STATES.DONE, }); - this.props.loadForForecastId(forecastId); + this.props.setForecastId(forecastId); this.closeAfterRunningForecast(); }) .catch(response => { @@ -297,10 +297,10 @@ export const ForecastingModal = injectI18n( this.setState({ jobClosingState: PROGRESS_STATES.ERROR, }); - this.props.loadForForecastId(forecastId); + this.props.setForecastId(forecastId); }); } else { - this.props.loadForForecastId(forecastId); + this.props.setForecastId(forecastId); this.closeAfterRunningForecast(); } } else { @@ -327,7 +327,7 @@ export const ForecastingModal = injectI18n( ); // Try and load any results which may have been created. - this.props.loadForForecastId(forecastId); + this.props.setForecastId(forecastId); this.setState({ forecastProgress: PROGRESS_STATES.ERROR }); clearInterval(this.forecastChecker); } diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js index 4d10d73bcc0489..d8e9e4379395a5 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.js @@ -11,7 +11,7 @@ import PropTypes from 'prop-types'; import React from 'react'; - +import useObservable from 'react-use/lib/useObservable'; import _ from 'lodash'; import d3 from 'd3'; import moment from 'moment'; @@ -23,7 +23,6 @@ import { getMultiBucketImpactLabel, } from '../../../../../common/util/anomaly_utils'; import { annotation$ } from '../../../services/annotations_service'; -import { injectObservablesAsProps } from '../../../util/observable_utils'; import { formatValue } from '../../../formatters/format_value'; import { LINE_CHART_ANOMALY_RADIUS, @@ -97,16 +96,16 @@ const TimeseriesChartIntl = injectI18n( static propTypes = { annotation: PropTypes.object, autoZoomDuration: PropTypes.number, + bounds: PropTypes.object, contextAggregationInterval: PropTypes.object, contextChartData: PropTypes.array, contextForecastData: PropTypes.array, contextChartSelected: PropTypes.func.isRequired, - detectorIndex: PropTypes.string, + detectorIndex: PropTypes.number, focusAggregationInterval: PropTypes.object, focusAnnotationData: PropTypes.array, focusChartData: PropTypes.array, focusForecastData: PropTypes.array, - skipRefresh: PropTypes.bool.isRequired, modelPlotEnabled: PropTypes.bool.isRequired, renderFocusChartOnly: PropTypes.bool.isRequired, selectedJob: PropTypes.object, @@ -114,7 +113,6 @@ const TimeseriesChartIntl = injectI18n( showModelBounds: PropTypes.bool.isRequired, svgWidth: PropTypes.number.isRequired, swimlaneData: PropTypes.array, - timefilter: PropTypes.object.isRequired, zoomFrom: PropTypes.object, zoomTo: PropTypes.object, zoomFromFocusLoaded: PropTypes.object, @@ -234,10 +232,6 @@ const TimeseriesChartIntl = injectI18n( } componentDidUpdate() { - if (this.props.skipRefresh) { - return; - } - if (this.props.renderFocusChartOnly === false) { this.renderChart(); this.drawContextChartSelection(); @@ -887,13 +881,12 @@ const TimeseriesChartIntl = injectI18n( } createZoomInfoElements(zoomGroup, fcsWidth) { - const { autoZoomDuration, modelPlotEnabled, timefilter, intl } = this.props; + const { autoZoomDuration, bounds, modelPlotEnabled, intl } = this.props; const setZoomInterval = this.setZoomInterval.bind(this); // Create zoom duration links applicable for the current time span. // Don't add links for any durations which would give a brush extent less than 10px. - const bounds = timefilter.getActiveBounds(); const boundsSecs = bounds.max.unix() - bounds.min.unix(); const minSecs = (10 / this.vizWidth) * boundsSecs; @@ -968,7 +961,7 @@ const TimeseriesChartIntl = injectI18n( } drawContextElements(cxtGroup, cxtWidth, cxtChartHeight, swlHeight) { - const { contextChartData, contextForecastData, modelPlotEnabled, timefilter } = this.props; + const { bounds, contextChartData, contextForecastData, modelPlotEnabled } = this.props; const data = contextChartData; @@ -1034,7 +1027,6 @@ const TimeseriesChartIntl = injectI18n( .attr('y2', cxtChartHeight + swlHeight); // Add x axis. - const bounds = timefilter.getActiveBounds(); const timeBuckets = new TimeBuckets(); timeBuckets.setInterval('auto'); timeBuckets.setBounds(bounds); @@ -1362,13 +1354,12 @@ const TimeseriesChartIntl = injectI18n( }; calculateContextXAxisDomain = () => { - const { contextAggregationInterval, swimlaneData, timefilter } = this.props; + const { bounds, contextAggregationInterval, swimlaneData } = this.props; // Calculates the x axis domain for the context elements. // Elasticsearch aggregation returns points at start of bucket, // so set the x-axis min to the start of the first aggregation interval, // and the x-axis max to the end of the last aggregation interval. // Context chart and swimlane use the same aggregation interval. - const bounds = timefilter.getActiveBounds(); let earliest = bounds.min.valueOf(); if (swimlaneData !== undefined && swimlaneData.length > 0) { @@ -1406,9 +1397,8 @@ const TimeseriesChartIntl = injectI18n( }; setZoomInterval(ms) { - const { timefilter, zoomTo } = this.props; + const { bounds, zoomTo } = this.props; - const bounds = timefilter.getActiveBounds(); const minBoundsMs = bounds.min.valueOf(); const maxBoundsMs = bounds.max.valueOf(); @@ -1726,7 +1716,10 @@ const TimeseriesChartIntl = injectI18n( } ); -export const TimeseriesChart = injectObservablesAsProps( - { annotation: annotation$ }, - TimeseriesChartIntl -); +export const TimeseriesChart = props => { + const annotationProp = useObservable(annotation$); + if (annotationProp === undefined) { + return null; + } + return ; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js index fb52d191013f75..cc77ad9f1a9859 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/components/timeseries_chart/timeseries_chart.test.js @@ -46,7 +46,6 @@ function getTimeseriesChartPropsMock() { showModelBounds: true, svgWidth: 1600, timefilter: {}, - skipRefresh: false, }; } diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts index ac4bc6186e5b47..3edbbc1af23237 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.d.ts @@ -10,6 +10,12 @@ import { FC } from 'react'; declare const TimeSeriesExplorer: FC<{ appStateHandler: (action: string, payload: any) => void; dateFormatTz: string; - globalState: any; + selectedJobIds: string[]; + selectedDetectorIndex: number; + selectedEntities: any[]; + selectedForecastId: string; + setGlobalState: (arg: any) => void; + tableInterval: string; + tableSeverity: number; timefilter: Timefilter; }>; diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 0ab10c4fe69cd9..807a368fc9b349 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -8,7 +8,7 @@ * React component for rendering Single Metric Viewer. */ -import { debounce, difference, each, find, first, get, has, isEqual, without } from 'lodash'; +import { debounce, difference, each, find, get, has, isEqual, without } from 'lodash'; import moment from 'moment-timezone'; import { Subject, Subscription, forkJoin } from 'rxjs'; import { map, debounceTime, switchMap, tap, withLatestFrom } from 'rxjs/operators'; @@ -36,42 +36,34 @@ import { toastNotifications } from 'ui/notify'; import { ResizeChecker } from '../../../../../../../src/plugins/kibana_utils/public'; import { ANOMALIES_TABLE_DEFAULT_QUERY_SIZE } from '../../../common/constants/search'; -import { parseInterval } from '../../../common/util/parse_interval'; import { isModelPlotEnabled, isSourceDataChartableForDetector, - isTimeSeriesViewJob, isTimeSeriesViewDetector, mlFunctionToESAggregation, } from '../../../common/util/job_utils'; -import { ChartTooltip } from '../components/chart_tooltip'; -import { - jobSelectServiceFactory, - setGlobalState, - getSelectedJobIds, -} from '../components/job_selector/job_select_service_utils'; import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; import { AnnotationsTable } from '../components/annotations/annotations_table'; import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; +import { ChartTooltip } from '../components/chart_tooltip'; import { EntityControl } from './components/entity_control'; import { ForecastingModal } from './components/forecasting_modal/forecasting_modal'; import { JobSelector } from '../components/job_selector'; +import { getTimeRangeFromSelection } from '../components/job_selector/job_select_service_utils'; import { LoadingIndicator } from '../components/loading_indicator/loading_indicator'; import { NavigationMenu } from '../components/navigation_menu'; -import { severity$, SelectSeverity } from '../components/controls/select_severity/select_severity'; -import { interval$, SelectInterval } from '../components/controls/select_interval/select_interval'; +import { SelectInterval } from '../components/controls/select_interval/select_interval'; +import { SelectSeverity } from '../components/controls/select_severity/select_severity'; import { TimeseriesChart } from './components/timeseries_chart/timeseries_chart'; import { TimeseriesexplorerNoJobsFound } from './components/timeseriesexplorer_no_jobs_found'; import { TimeseriesexplorerNoChartData } from './components/timeseriesexplorer_no_chart_data'; -import { annotationsRefresh$ } from '../services/annotations_service'; import { ml } from '../services/ml_api_service'; import { mlFieldFormatService } from '../services/field_format_service'; import { mlForecastService } from '../services/forecast_service'; import { mlJobService } from '../services/job_service'; import { mlResultsService } from '../services/results_service'; -import { mlTimefilterRefresh$ } from '../services/timefilter_refresh_service'; import { getBoundsRoundedToInterval } from '../util/time_buckets'; @@ -86,7 +78,6 @@ import { calculateDefaultFocusRange, calculateInitialFocusRange, createTimeSeriesJobData, - getAutoZoomDuration, processForecastResults, processMetricPlotResults, processRecordScoreResults, @@ -102,33 +93,57 @@ const allValuesLabel = i18n.translate('xpack.ml.timeSeriesExplorer.allPartitionV defaultMessage: 'all', }); +function getEntityControlOptions(fieldValues) { + if (!Array.isArray(fieldValues)) { + return []; + } + + return fieldValues.map(value => { + return { label: value }; + }); +} + +function getViewableDetectors(selectedJob) { + const jobDetectors = selectedJob.analysis_config.detectors; + const viewableDetectors = []; + each(jobDetectors, (dtr, index) => { + if (isTimeSeriesViewDetector(selectedJob, index)) { + viewableDetectors.push({ + index, + detector_description: dtr.detector_description, + }); + } + }); + return viewableDetectors; +} + function getTimeseriesexplorerDefaultState() { return { chartDetails: undefined, + contextAggregationInterval: undefined, contextChartData: undefined, contextForecastData: undefined, // Not chartable if e.g. model plot with terms for a varp detector dataNotChartable: false, - detectorId: undefined, - detectors: [], - entities: [], + entitiesLoading: false, + entityValues: {}, focusAnnotationData: [], focusChartData: undefined, focusForecastData: undefined, fullRefresh: true, hasResults: false, - jobs: [], // Counter to keep track of what data sets have been loaded. loadCounter: 0, loading: false, modelPlotEnabled: false, - selectedJob: undefined, // Toggles display of annotations in the focus chart showAnnotations: mlAnnotationsEnabled, showAnnotationsCheckbox: mlAnnotationsEnabled, // Toggles display of forecast data in the focus chart showForecast: true, showForecastCheckbox: false, + // Toggles display of model bounds in the focus chart + showModelBounds: true, showModelBoundsCheckbox: false, svgWidth: 0, tableData: undefined, @@ -136,9 +151,6 @@ function getTimeseriesexplorerDefaultState() { zoomTo: undefined, zoomFromFocusLoaded: undefined, zoomToFocusLoaded: undefined, - - // Toggles display of model bounds in the focus chart - showModelBounds: true, }; } @@ -174,26 +186,23 @@ const containerPadding = 24; export class TimeSeriesExplorer extends React.Component { static propTypes = { appStateHandler: PropTypes.func.isRequired, + autoZoomDuration: PropTypes.number, + bounds: PropTypes.object, dateFormatTz: PropTypes.string.isRequired, - globalState: PropTypes.object.isRequired, - timefilter: PropTypes.object.isRequired, + jobsWithTimeRange: PropTypes.array.isRequired, + lastRefresh: PropTypes.number.isRequired, + selectedJobIds: PropTypes.arrayOf(PropTypes.string), + selectedDetectorIndex: PropTypes.number, + selectedEntities: PropTypes.object, + selectedForecastId: PropTypes.string, + tableInterval: PropTypes.string, + tableSeverity: PropTypes.number, }; state = getTimeseriesexplorerDefaultState(); subscriptions = new Subscription(); - _criteriaFields = null; - - constructor(props) { - super(props); - const { jobSelectService$, unsubscribeFromGlobalState } = jobSelectServiceFactory( - props.globalState - ); - this.jobSelectService$ = jobSelectService$; - this.unsubscribeFromGlobalState = unsubscribeFromGlobalState; - } - resizeRef = createRef(); resizeChecker = undefined; resizeHandler = () => { @@ -209,13 +218,10 @@ export class TimeSeriesExplorer extends React.Component { contextChart$ = new Subject(); detectorIndexChangeHandler = e => { + const { appStateHandler } = this.props; const id = e.target.value; if (id !== undefined) { - this.setState({ detectorId: id }, () => { - this.updateControlsForDetector(() => - this.loadEntityValues(() => this.saveSeriesPropertiesAndRefresh()) - ); - }); + appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, +id); } }; @@ -245,7 +251,7 @@ export class TimeSeriesExplorer extends React.Component { previousShowModelBounds = undefined; tableFilter = (field, value, operator) => { - const { entities } = this.state; + const entities = this.getControlsForDetector(); const entity = entities.find(({ fieldName }) => fieldName === field); if (entity === undefined) { @@ -272,35 +278,14 @@ export class TimeSeriesExplorer extends React.Component { }; appStateHandler(APP_STATE_ACTION.SET_ENTITIES, resultEntities); - - this.updateControlsForDetector(() => { - this.refresh(); - }); }; contextChartSelectedInitCallDone = false; - /** - * Gets default range from component state. - */ - getDefaultRangeFromState() { - const { - autoZoomDuration, - contextAggregationInterval, - contextChartData, - contextForecastData, - } = this.state; - - return calculateDefaultFocusRange( - autoZoomDuration, - contextAggregationInterval, - contextChartData, - contextForecastData - ); - } - getFocusAggregationInterval(selection) { - const { jobs, selectedJob } = this.state; + const { selectedJobIds } = this.props; + const jobs = createTimeSeriesJobData(mlJobService.jobs); + const selectedJob = mlJobService.getJob(selectedJobIds[0]); // Calculate the aggregation interval for the focus chart. const bounds = { min: moment(selection.from), max: moment(selection.to) }; @@ -312,13 +297,13 @@ export class TimeSeriesExplorer extends React.Component { * Gets focus data for the current component state/ */ getFocusData(selection) { - const { detectorId, entities, modelPlotEnabled, selectedJob } = this.state; - - const { appStateHandler } = this.props; + const { selectedJobIds, selectedForecastId, selectedDetectorIndex } = this.props; + const { modelPlotEnabled } = this.state; + const selectedJob = mlJobService.getJob(selectedJobIds[0]); + const entityControls = this.getControlsForDetector(); // Calculate the aggregation interval for the focus chart. const bounds = { min: moment(selection.from), max: moment(selection.to) }; - const focusAggregationInterval = this.getFocusAggregationInterval(selection); // Ensure the search bounds align to the bucketing interval so that the first and last buckets are complete. @@ -327,12 +312,12 @@ export class TimeSeriesExplorer extends React.Component { const searchBounds = getBoundsRoundedToInterval(bounds, focusAggregationInterval, false); return getFocusData( - this._criteriaFields, - +detectorId, + this.getCriteriaFields(selectedDetectorIndex, entityControls), + selectedDetectorIndex, focusAggregationInterval, - appStateHandler(APP_STATE_ACTION.GET_FORECAST_ID), + selectedForecastId, modelPlotEnabled, - entities.filter(entity => entity.fieldValue.length > 0), + entityControls.filter(entity => entity.fieldValue.length > 0), searchBounds, selectedJob, TIME_FIELD_NAME @@ -345,10 +330,10 @@ export class TimeSeriesExplorer extends React.Component { entityFieldValueChanged = (entity, fieldValue) => { const { appStateHandler } = this.props; - const { entities } = this.state; + const entityControls = this.getControlsForDetector(); const resultEntities = { - ...entities.reduce((appStateEntities, appStateEntity) => { + ...entityControls.reduce((appStateEntities, appStateEntity) => { appStateEntities[appStateEntity.fieldName] = appStateEntity.fieldValue; return appStateEntities; }, {}), @@ -356,29 +341,33 @@ export class TimeSeriesExplorer extends React.Component { }; appStateHandler(APP_STATE_ACTION.SET_ENTITIES, resultEntities); - - this.updateControlsForDetector(() => { - this.refresh(); - }); }; entityFieldSearchChanged = debounce((entity, queryTerm) => { - this.loadEntityValues({ + const entityControls = this.getControlsForDetector(); + this.loadEntityValues(entityControls, { [entity.fieldType]: queryTerm, }); }, 500); loadAnomaliesTableData = (earliestMs, latestMs) => { - const { dateFormatTz } = this.props; - const { selectedJob } = this.state; + const { + dateFormatTz, + selectedDetectorIndex, + selectedJobIds, + tableInterval, + tableSeverity, + } = this.props; + const selectedJob = mlJobService.getJob(selectedJobIds[0]); + const entityControls = this.getControlsForDetector(); return ml.results .getAnomaliesTableData( [selectedJob.job_id], - this._criteriaFields, + this.getCriteriaFields(selectedDetectorIndex, entityControls), [], - interval$.getValue().val, - severity$.getValue().val, + tableInterval, + tableSeverity, earliestMs, latestMs, dateFormatTz, @@ -427,16 +416,18 @@ export class TimeSeriesExplorer extends React.Component { /** * Loads available entity values. + * @param {Array} entities - Entity controls configuration * @param {Object} searchTerm - Search term for partition, e.g. { partition_field: 'partition' } - * @param callback - Callback to execute after component state update. */ - loadEntityValues = async (searchTerm = {}, callback = () => {}) => { - const { timefilter } = this.props; - const { detectorId, entities, selectedJob } = this.state; + loadEntityValues = async (entities, searchTerm = {}) => { + this.setState({ entitiesLoading: true }); + + const { bounds, selectedJobIds, selectedDetectorIndex } = this.props; + const selectedJob = mlJobService.getJob(selectedJobIds[0]); - // Populate the entity input datalists with aggregated values. No need to pass through finish(). - const bounds = timefilter.getActiveBounds(); - const detectorIndex = +detectorId; + // Populate the entity input datalists with the values from the top records by score + // for the selected detector across the full time range. No need to pass through finish(). + const detectorIndex = selectedDetectorIndex; const { partition_field: partitionField, @@ -457,98 +448,46 @@ export class TimeSeriesExplorer extends React.Component { ) .toPromise(); - this.setState( - { - entities: entities.map(entity => { - const newEntity = { ...entity }; - if (partitionField?.name === entity.fieldName) { - newEntity.fieldValues = partitionField.values; - } - if (overField?.name === entity.fieldName) { - newEntity.fieldValues = overField.values; - } - if (byField?.name === entity.fieldName) { - newEntity.fieldValues = byField.values; - } - return newEntity; - }), - }, - callback - ); - }; - - loadForForecastId = forecastId => { - const { appStateHandler, timefilter } = this.props; - const { autoZoomDuration, contextChartData, selectedJob } = this.state; - - mlForecastService - .getForecastDateRange(selectedJob, forecastId) - .then(resp => { - const bounds = timefilter.getActiveBounds(); - const earliest = moment(resp.earliest || timefilter.getTime().from); - const latest = moment(resp.latest || timefilter.getTime().to); - - // Store forecast ID in the appState. - appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId); - - // Set the zoom to centre on the start of the forecast range, depending - // on the time range of the forecast and data. - const earliestDataDate = first(contextChartData).date; - const zoomLatestMs = Math.min(earliest + autoZoomDuration / 2, latest.valueOf()); - const zoomEarliestMs = Math.max( - zoomLatestMs - autoZoomDuration, - earliestDataDate.getTime() - ); + const entityValues = {}; + entities.forEach(entity => { + let fieldValues; - const zoomState = { - from: moment(zoomEarliestMs).toISOString(), - to: moment(zoomLatestMs).toISOString(), - }; - appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); - - // Ensure the forecast data will be shown if hidden previously. - this.setState({ showForecast: true }); + if (partitionField?.name === entity.fieldName) { + fieldValues = partitionField.values; + } + if (overField?.name === entity.fieldName) { + fieldValues = overField.values; + } + if (byField?.name === entity.fieldName) { + fieldValues = byField.values; + } + entityValues[entity.fieldName] = fieldValues; + }); - if (earliest.isBefore(bounds.min) || latest.isAfter(bounds.max)) { - const earliestMs = Math.min(earliest.valueOf(), bounds.min.valueOf()); - const latestMs = Math.max(latest.valueOf(), bounds.max.valueOf()); + this.setState({ entitiesLoading: false, entityValues }); + }; - timefilter.setTime({ - from: moment(earliestMs).toISOString(), - to: moment(latestMs).toISOString(), - }); - } else { - // Refresh to show the requested forecast data. - this.refresh(); - } - }) - .catch(resp => { - console.log( - 'Time series explorer - error loading time range of forecast from elasticsearch:', - resp - ); - }); + setForecastId = forecastId => { + this.props.appStateHandler(APP_STATE_ACTION.SET_FORECAST_ID, forecastId); }; - refresh = (fullRefresh = true) => { - // Skip the refresh if: - // a) The global state's `skipRefresh` was set to true by the job selector to avoid race conditions - // when loading the Single Metric Viewer after a job/group and time range update. - // b) A 'soft' refresh without a full page reload is already happening. - if ( - get(this.props.globalState, 'ml.skipRefresh') || - (this.state.loading && fullRefresh === false) - ) { + loadSingleMetricData = (fullRefresh = true) => { + const { + autoZoomDuration, + bounds, + selectedDetectorIndex, + selectedForecastId, + selectedJobIds, + zoom, + } = this.props; + + if (selectedJobIds === undefined) { return; } - const { appStateHandler, timefilter } = this.props; - const { - detectorId: currentDetectorId, - entities: currentEntities, - loadCounter: currentLoadCounter, - selectedJob: currentSelectedJob, - } = this.state; + const { loadCounter: currentLoadCounter } = this.state; + + const currentSelectedJob = mlJobService.getJob(selectedJobIds[0]); if (currentSelectedJob === undefined) { return; @@ -558,6 +497,7 @@ export class TimeSeriesExplorer extends React.Component { // Only when `fullRefresh` is true we'll reset all data // and show the loading spinner within the page. + const entityControls = this.getControlsForDetector(); this.setState( { fullRefresh, @@ -572,8 +512,8 @@ export class TimeSeriesExplorer extends React.Component { focusForecastData: undefined, modelPlotEnabled: isModelPlotEnabled( currentSelectedJob, - +currentDetectorId, - currentEntities + selectedDetectorIndex, + entityControls ), hasResults: false, dataNotChartable: false, @@ -581,15 +521,11 @@ export class TimeSeriesExplorer extends React.Component { : {}), }, () => { - const { - detectorId, - entities, - loadCounter, - jobs, - modelPlotEnabled, - selectedJob, - } = this.state; - const detectorIndex = +detectorId; + const { loadCounter, modelPlotEnabled } = this.state; + + const jobs = createTimeSeriesJobData(mlJobService.jobs); + const selectedJob = mlJobService.getJob(selectedJobIds[0]); + const detectorIndex = selectedDetectorIndex; let awaitingCount = 3; @@ -609,19 +545,16 @@ export class TimeSeriesExplorer extends React.Component { // Set zoomFrom/zoomTo attributes in scope which will result in the metric chart automatically // selecting the specified range in the context chart, and so loading that date range in the focus chart. if (stateUpdate.contextChartData.length) { - // Calculate the 'auto' zoom duration which shows data at bucket span granularity. - stateUpdate.autoZoomDuration = getAutoZoomDuration(jobs, selectedJob); - // Check for a zoom parameter in the appState (URL). let focusRange = calculateInitialFocusRange( - appStateHandler(APP_STATE_ACTION.GET_ZOOM), + zoom, stateUpdate.contextAggregationInterval, - timefilter + bounds ); if (focusRange === undefined) { focusRange = calculateDefaultFocusRange( - stateUpdate.autoZoomDuration, + autoZoomDuration, stateUpdate.contextAggregationInterval, stateUpdate.contextChartData, stateUpdate.contextForecastData @@ -636,7 +569,7 @@ export class TimeSeriesExplorer extends React.Component { } }; - const nonBlankEntities = currentEntities.filter(entity => { + const nonBlankEntities = entityControls.filter(entity => { return entity.fieldValue.length > 0; }); @@ -654,8 +587,6 @@ export class TimeSeriesExplorer extends React.Component { return; } - const bounds = timefilter.getActiveBounds(); - // Calculate the aggregation interval for the context chart. // Context chart swimlane will display bucket anomaly score at the same interval. stateUpdate.contextAggregationInterval = calculateAggregationInterval( @@ -706,7 +637,7 @@ export class TimeSeriesExplorer extends React.Component { mlResultsService .getRecordMaxScoreByTime( selectedJob.job_id, - this._criteriaFields, + this.getCriteriaFields(detectorIndex, entityControls), searchBounds.min.valueOf(), searchBounds.max.valueOf(), stateUpdate.contextAggregationInterval.expression @@ -728,7 +659,7 @@ export class TimeSeriesExplorer extends React.Component { .getChartDetails( selectedJob, detectorIndex, - entities, + entityControls, searchBounds.min.valueOf(), searchBounds.max.valueOf() ) @@ -744,8 +675,7 @@ export class TimeSeriesExplorer extends React.Component { }); // Plus query for forecast data if there is a forecastId stored in the appState. - const forecastId = appStateHandler(APP_STATE_ACTION.GET_FORECAST_ID); - if (forecastId !== undefined) { + if (selectedForecastId !== undefined) { awaitingCount++; let aggType = undefined; const detector = selectedJob.analysis_config.detectors[detectorIndex]; @@ -757,7 +687,7 @@ export class TimeSeriesExplorer extends React.Component { .getForecastData( selectedJob, detectorIndex, - forecastId, + selectedForecastId, nonBlankEntities, searchBounds.min.valueOf(), searchBounds.max.valueOf(), @@ -771,13 +701,11 @@ export class TimeSeriesExplorer extends React.Component { }) .catch(resp => { console.log( - `Time series explorer - error loading data for forecast ID ${forecastId}`, + `Time series explorer - error loading data for forecast ID ${selectedForecastId}`, resp ); }); } - - this.loadEntityValues(); } ); }; @@ -786,15 +714,21 @@ export class TimeSeriesExplorer extends React.Component { * Updates local state of detector related controls from the global state. * @param callback to invoke after a state update. */ - updateControlsForDetector = (callback = () => {}) => { - const { appStateHandler } = this.props; - const { detectorId, selectedJob } = this.state; + getControlsForDetector = () => { + const { selectedDetectorIndex, selectedEntities, selectedJobIds } = this.props; + const selectedJob = mlJobService.getJob(selectedJobIds[0]); + + const entities = []; + + if (selectedJob === undefined) { + return entities; + } + // Update the entity dropdown control(s) according to the partitioning fields for the selected detector. - const detectorIndex = +detectorId; + const detectorIndex = selectedDetectorIndex; const detector = selectedJob.analysis_config.detectors[detectorIndex]; - const entities = []; - const entitiesState = appStateHandler(APP_STATE_ACTION.GET_ENTITIES); + const entitiesState = selectedEntities; const partitionFieldName = get(detector, 'partition_field_name'); const overFieldName = get(detector, 'over_field_name'); const byFieldName = get(detector, 'by_field_name'); @@ -825,9 +759,7 @@ export class TimeSeriesExplorer extends React.Component { entities.push({ fieldType: 'by_field', fieldName: byFieldName, fieldValue: byFieldValue }); } - this.updateCriteriaFields(detectorIndex, entities); - - this.setState({ entities }, callback); + return entities; }; /** @@ -835,10 +767,10 @@ export class TimeSeriesExplorer extends React.Component { * @param detectorIndex * @param entities */ - updateCriteriaFields(detectorIndex, entities) { + getCriteriaFields(detectorIndex, entities) { // Only filter on the entity if the field has a value. const nonBlankEntities = entities.filter(entity => entity.fieldValue.length > 0); - this._criteriaFields = [ + return [ { fieldName: 'detector_index', fieldValue: detectorIndex, @@ -847,47 +779,21 @@ export class TimeSeriesExplorer extends React.Component { ]; } - loadForJobId(jobId, jobs) { - const { appStateHandler } = this.props; - - // Validation that the ID is for a time series job must already have been performed. - // Check if the job was created since the page was first loaded. - let jobPickerSelectedJob = find(jobs, { id: jobId }); - if (jobPickerSelectedJob === undefined) { - const newJobs = []; - each(mlJobService.jobs, job => { - if (isTimeSeriesViewJob(job) === true) { - const bucketSpan = parseInterval(job.analysis_config.bucket_span); - newJobs.push({ - id: job.job_id, - selected: false, - bucketSpanSeconds: bucketSpan.asSeconds(), - }); - } - }); - this.setState({ jobs: newJobs }); - jobPickerSelectedJob = find(newJobs, { id: jobId }); - } + loadForJobId(jobId) { + const { appStateHandler, selectedDetectorIndex } = this.props; const selectedJob = mlJobService.getJob(jobId); - // Read the detector index and entities out of the AppState. - const jobDetectors = selectedJob.analysis_config.detectors; - const viewableDetectors = []; - each(jobDetectors, (dtr, index) => { - if (isTimeSeriesViewDetector(selectedJob, index)) { - viewableDetectors.push({ - index: '' + index, - detector_description: dtr.detector_description, - }); - } - }); - const detectors = viewableDetectors; + if (selectedJob === undefined) { + return; + } + + const detectors = getViewableDetectors(selectedJob); // Check the supplied index is valid. - const appStateDtrIdx = appStateHandler(APP_STATE_ACTION.GET_DETECTOR_INDEX); - let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : +viewableDetectors[0].index; - if (find(viewableDetectors, { index: '' + detectorIndex }) === undefined) { + const appStateDtrIdx = selectedDetectorIndex; + let detectorIndex = appStateDtrIdx !== undefined ? appStateDtrIdx : detectors[0].index; + if (find(detectors, { index: detectorIndex }) === undefined) { const warningText = i18n.translate( 'xpack.ml.timeSeriesExplorer.requestedDetectorIndexNotValidWarningMessage', { @@ -899,179 +805,22 @@ export class TimeSeriesExplorer extends React.Component { } ); toastNotifications.addWarning(warningText); - detectorIndex = +viewableDetectors[0].index; - appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, detectorIndex); + detectorIndex = detectors[0].index; } - // Store the detector index as a string so it can be used as ng-model in a select control. - const detectorId = '' + detectorIndex; + const detectorId = detectorIndex; - this.setState({ detectorId, detectors, selectedJob }, () => { - this.updateControlsForDetector(() => { - // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh. - mlFieldFormatService - .populateFormats([jobId]) - .catch(err => { - console.log('Error populating field formats:', err); - }) - // Load the data - if the FieldFormats failed to populate - // the default formatting will be used for metric values. - .then(() => { - this.refresh(); - }); - }); + if (detectorId !== selectedDetectorIndex) { + appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, detectorId); + } + + // Populate the map of jobs / detectors / field formatters for the selected IDs and refresh. + mlFieldFormatService.populateFormats([jobId]).catch(err => { + console.log('Error populating field formats:', err); }); } - saveSeriesPropertiesAndRefresh = () => { - const { appStateHandler } = this.props; - const { detectorId, entities } = this.state; - - appStateHandler(APP_STATE_ACTION.SET_DETECTOR_INDEX, +detectorId); - appStateHandler( - APP_STATE_ACTION.SET_ENTITIES, - entities.reduce((appStateEntities, entity) => { - appStateEntities[entity.fieldName] = entity.fieldValue; - return appStateEntities; - }, {}) - ); - - this.refresh(); - }; - componentDidMount() { - const { appStateHandler, globalState, timefilter } = this.props; - - this.setState({ jobs: [] }); - - // Get the job info needed by the visualization, then do the first load. - if (mlJobService.jobs.length > 0) { - const jobs = createTimeSeriesJobData(mlJobService.jobs); - this.setState({ jobs }); - } else { - this.setState({ loading: false }); - } - - // Reload the anomalies table if the Interval or Threshold controls are changed. - const tableControlsListener = () => { - const { zoomFrom, zoomTo } = this.state; - if (zoomFrom !== undefined && zoomTo !== undefined) { - this.loadAnomaliesTableData(zoomFrom.getTime(), zoomTo.getTime()).subscribe(res => - this.setState(res) - ); - } - }; - - this.subscriptions.add(annotationsRefresh$.subscribe(this.refresh)); - this.subscriptions.add(interval$.subscribe(tableControlsListener)); - this.subscriptions.add(severity$.subscribe(tableControlsListener)); - this.subscriptions.add( - mlTimefilterRefresh$.subscribe(() => { - this.refresh(true); - }) - ); - - // Listen for changes to job selection. - this.subscriptions.add( - this.jobSelectService$.subscribe(({ selection: selectedJobIds }) => { - const jobs = createTimeSeriesJobData(mlJobService.jobs); - - this.contextChartSelectedInitCallDone = false; - this.setState({ fullRefresh: false, loading: true, showForecastCheckbox: false }); - - const timeSeriesJobIds = jobs.map(j => j.id); - - // Check if any of the jobs set in the URL are not time series jobs - // (e.g. if switching to this view straight from the Anomaly Explorer). - const invalidIds = difference(selectedJobIds, timeSeriesJobIds); - selectedJobIds = without(selectedJobIds, ...invalidIds); - if (invalidIds.length > 0) { - let warningText = i18n.translate( - 'xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', - { - defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`, - values: { - invalidIdsCount: invalidIds.length, - invalidIds, - }, - } - ); - if (selectedJobIds.length === 0 && timeSeriesJobIds.length > 0) { - warningText += i18n.translate('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', { - defaultMessage: ', auto selecting first job', - }); - } - toastNotifications.addWarning(warningText); - } - - if (selectedJobIds.length > 1) { - // if more than one job or a group has been loaded from the URL - if (selectedJobIds.length > 1) { - // if more than one job, select the first job from the selection. - toastNotifications.addWarning( - i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { - defaultMessage: 'You can only view one job at a time in this dashboard', - }) - ); - - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - this.jobSelectService$.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else { - // if a group has been loaded - if (selectedJobIds.length > 0) { - // if the group contains valid jobs, select the first - toastNotifications.addWarning( - i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { - defaultMessage: 'You can only view one job at a time in this dashboard', - }) - ); - - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - this.jobSelectService$.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else if (jobs.length > 0) { - // if there are no valid jobs in the group but there are valid jobs - // in the list of all jobs, select the first - setGlobalState(globalState, { selectedIds: [jobs[0].id] }); - this.jobSelectService$.next({ selection: [jobs[0].id], resetSelection: true }); - } else { - // if there are no valid jobs left. - this.setState({ loading: false }); - } - } - } else if (invalidIds.length > 0 && selectedJobIds.length > 0) { - // if some ids have been filtered out because they were invalid. - // refresh the URL with the first valid id - setGlobalState(globalState, { selectedIds: [selectedJobIds[0]] }); - this.jobSelectService$.next({ selection: [selectedJobIds[0]], resetSelection: true }); - } else if (selectedJobIds.length > 0) { - // normal behavior. a job ID has been loaded from the URL - if ( - this.state.selectedJob !== undefined && - selectedJobIds[0] !== this.state.selectedJob.job_id - ) { - // Clear the detectorIndex, entities and forecast info. - appStateHandler(APP_STATE_ACTION.CLEAR); - } - this.loadForJobId(selectedJobIds[0], jobs); - } else { - if (selectedJobIds.length === 0 && jobs.length > 0) { - // no jobs were loaded from the URL, so add the first job - // from the full jobs list. - setGlobalState(globalState, { selectedIds: [jobs[0].id] }); - this.jobSelectService$.next({ selection: [jobs[0].id], resetSelection: true }); - } else { - // Jobs exist, but no time series jobs. - this.setState({ loading: false }); - } - } - }) - ); - - timefilter.enableTimeRangeSelector(); - timefilter.enableAutoRefreshSelector(); - - this.subscriptions.add(timefilter.getTimeUpdate$().subscribe(() => this.refresh(false))); - // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', () => { @@ -1106,23 +855,6 @@ export class TimeSeriesExplorer extends React.Component { return; } - const defaultRange = this.getDefaultRangeFromState(); - - if ( - (selection.from.getTime() !== defaultRange[0].getTime() || - selection.to.getTime() !== defaultRange[1].getTime()) && - isNaN(Date.parse(selection.from)) === false && - isNaN(Date.parse(selection.to)) === false - ) { - const zoomState = { - from: selection.from.toISOString(), - to: selection.to.toISOString(), - }; - appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); - } else { - appStateHandler(APP_STATE_ACTION.UNSET_ZOOM); - } - if ( (this.contextChartSelectedInitCallDone === false && focusChartData === undefined) || zoomFromFocusLoaded.getTime() !== selection.from.getTime() || @@ -1137,7 +869,9 @@ export class TimeSeriesExplorer extends React.Component { } }), switchMap(selection => { - const { jobs, selectedJob } = this.state; + const { selectedJobIds } = this.props; + const jobs = createTimeSeriesJobData(mlJobService.jobs); + const selectedJob = mlJobService.getJob(selectedJobIds[0]); // Calculate the aggregation interval for the focus chart. const bounds = { min: moment(selection.from), max: moment(selection.to) }; @@ -1180,39 +914,267 @@ export class TimeSeriesExplorer extends React.Component { ...refreshFocusData, ...tableData, }); + const zoomState = { + from: selection.from.toISOString(), + to: selection.to.toISOString(), + }; + this.props.appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); }) ); + + this.componentDidUpdate(); + } + + /** + * returns true/false if setGlobalState has been triggered + * or returns the job id which should be loaded. + */ + checkJobSelection() { + const { jobsWithTimeRange, selectedJobIds, setGlobalState } = this.props; + + const jobs = createTimeSeriesJobData(mlJobService.jobs); + const timeSeriesJobIds = jobs.map(j => j.id); + + // Check if any of the jobs set in the URL are not time series jobs + // (e.g. if switching to this view straight from the Anomaly Explorer). + const invalidIds = difference(selectedJobIds, timeSeriesJobIds); + const validSelectedJobIds = without(selectedJobIds, ...invalidIds); + if (invalidIds.length > 0) { + let warningText = i18n.translate( + 'xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage', + { + defaultMessage: `You can't view requested {invalidIdsCount, plural, one {job} other {jobs}} {invalidIds} in this dashboard`, + values: { + invalidIdsCount: invalidIds.length, + invalidIds, + }, + } + ); + if (validSelectedJobIds.length === 0 && timeSeriesJobIds.length > 0) { + warningText += i18n.translate('xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText', { + defaultMessage: ', auto selecting first job', + }); + } + toastNotifications.addWarning(warningText); + } + + if (validSelectedJobIds.length > 1) { + // if more than one job or a group has been loaded from the URL + if (validSelectedJobIds.length > 1) { + // if more than one job, select the first job from the selection. + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { + defaultMessage: 'You can only view one job at a time in this dashboard', + }) + ); + setGlobalState('ml', { jobIds: [validSelectedJobIds[0]] }); + return true; + } else { + // if a group has been loaded + if (selectedJobIds.length > 0) { + // if the group contains valid jobs, select the first + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.youCanViewOneJobAtTimeWarningMessage', { + defaultMessage: 'You can only view one job at a time in this dashboard', + }) + ); + setGlobalState('ml', { jobIds: [validSelectedJobIds[0]] }); + return true; + } else if (jobs.length > 0) { + // if there are no valid jobs in the group but there are valid jobs + // in the list of all jobs, select the first + const jobIds = [jobs[0].id]; + const time = getTimeRangeFromSelection(jobsWithTimeRange, jobIds); + setGlobalState({ + ...{ ml: { jobIds } }, + ...(time !== undefined ? { time } : {}), + }); + return true; + } else { + // if there are no valid jobs left. + return false; + } + } + } else if (invalidIds.length > 0 && validSelectedJobIds.length > 0) { + // if some ids have been filtered out because they were invalid. + // refresh the URL with the first valid id + setGlobalState('ml', { jobIds: [validSelectedJobIds[0]] }); + return true; + } else if (validSelectedJobIds.length > 0) { + // normal behavior. a job ID has been loaded from the URL + // Clear the detectorIndex, entities and forecast info. + return validSelectedJobIds[0]; + } else { + if (validSelectedJobIds.length === 0 && jobs.length > 0) { + // no jobs were loaded from the URL, so add the first job + // from the full jobs list. + const jobIds = [jobs[0].id]; + const time = getTimeRangeFromSelection(jobsWithTimeRange, jobIds); + setGlobalState({ + ...{ ml: { jobIds } }, + ...(time !== undefined ? { time } : {}), + }); + return true; + } else { + // Jobs exist, but no time series jobs. + return false; + } + } + } + + componentDidUpdate(previousProps) { + if ( + previousProps === undefined || + !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds) + ) { + const update = this.checkJobSelection(); + // - true means a setGlobalState got triggered and + // we'll just wait for the next React render. + // - false means there are either no jobs or no time based jobs present. + // - if we get back a string it means we got back a job id we can load. + if (update === true) { + return; + } else if (update === false) { + this.setState({ loading: false }); + return; + } else if (typeof update === 'string') { + this.contextChartSelectedInitCallDone = false; + this.setState({ fullRefresh: false, loading: true }, () => { + this.loadForJobId(update); + }); + } + } + + if ( + this.props.bounds !== undefined && + this.props.selectedJobIds !== undefined && + (previousProps === undefined || + !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds) || + previousProps.selectedDetectorIndex !== this.props.selectedDetectorIndex || + !isEqual(previousProps.selectedEntities, this.props.selectedEntities)) + ) { + const entityControls = this.getControlsForDetector(); + this.loadEntityValues(entityControls); + } + + if ( + previousProps === undefined || + previousProps.selectedForecastId !== this.props.selectedForecastId + ) { + if (this.props.selectedForecastId !== undefined) { + // Ensure the forecast data will be shown if hidden previously. + this.setState({ showForecast: true }); + } + } + + if ( + previousProps === undefined || + !isEqual(previousProps.bounds, this.props.bounds) || + !isEqual(previousProps.lastRefresh, this.props.lastRefresh) || + !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) || + !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || + !isEqual(previousProps.selectedForecastId, this.props.selectedForecastId) || + !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds) || + !isEqual(previousProps.zoom, this.props.zoom) + ) { + const fullRefresh = + previousProps === undefined || + !isEqual(previousProps.bounds, this.props.bounds) || + !isEqual(previousProps.lastRefresh, this.props.lastRefresh) || + !isEqual(previousProps.selectedDetectorIndex, this.props.selectedDetectorIndex) || + !isEqual(previousProps.selectedEntities, this.props.selectedEntities) || + !isEqual(previousProps.selectedForecastId, this.props.selectedForecastId) || + !isEqual(previousProps.selectedJobIds, this.props.selectedJobIds); + this.loadSingleMetricData(fullRefresh); + } + + if (previousProps === undefined) { + return; + } + + // Reload the anomalies table if the Interval or Threshold controls are changed. + const tableControlsListener = () => { + const { zoomFrom, zoomTo } = this.state; + if (zoomFrom !== undefined && zoomTo !== undefined) { + this.loadAnomaliesTableData(zoomFrom.getTime(), zoomTo.getTime()).subscribe(res => + this.setState(res) + ); + } + }; + + if ( + previousProps.tableInterval !== this.props.tableInterval || + previousProps.tableSeverity !== this.props.tableSeverity + ) { + tableControlsListener(); + } + + if ( + this.props.autoZoomDuration === undefined || + this.props.selectedForecastId !== undefined || + this.state.contextAggregationInterval === undefined || + this.state.contextChartData === undefined || + this.state.contextChartData.length === 0 + ) { + return; + } + + const defaultRange = calculateDefaultFocusRange( + this.props.autoZoomDuration, + this.state.contextAggregationInterval, + this.state.contextChartData, + this.state.contextForecastData + ); + + const selection = { + from: this.state.zoomFrom, + to: this.state.zoomTo, + }; + + if ( + (selection.from.getTime() !== defaultRange[0].getTime() || + selection.to.getTime() !== defaultRange[1].getTime()) && + isNaN(Date.parse(selection.from)) === false && + isNaN(Date.parse(selection.to)) === false + ) { + const zoomState = { + from: selection.from.toISOString(), + to: selection.to.toISOString(), + }; + this.props.appStateHandler(APP_STATE_ACTION.SET_ZOOM, zoomState); + } } componentWillUnmount() { this.subscriptions.unsubscribe(); this.resizeChecker.destroy(); - this.unsubscribeFromGlobalState(); } render() { - const { dateFormatTz, globalState, timefilter } = this.props; - const { autoZoomDuration, + bounds, + dateFormatTz, + lastRefresh, + selectedDetectorIndex, + selectedJobIds, + } = this.props; + + const { chartDetails, contextAggregationInterval, contextChartData, contextForecastData, dataNotChartable, - detectors, - detectorId, - entities, + entityValues, focusAggregationInterval, focusAnnotationData, focusChartData, focusForecastData, fullRefresh, hasResults, - jobs, loading, modelPlotEnabled, - selectedJob, showAnnotations, showAnnotationsCheckbox, showForecast, @@ -1228,11 +1190,6 @@ export class TimeSeriesExplorer extends React.Component { zoomToFocusLoaded, } = this.state; - const fieldNamesWithEmptyValues = entities - .filter(({ fieldValue }) => !fieldValue) - .map(({ fieldName }) => fieldName); - const arePartitioningFieldsProvided = fieldNamesWithEmptyValues.length === 0; - const chartProps = { modelPlotEnabled, contextChartData, @@ -1244,7 +1201,6 @@ export class TimeSeriesExplorer extends React.Component { focusChartData, focusForecastData, focusAggregationInterval, - skipRefresh: loading || !!get(this.props.globalState, 'ml.skipRefresh'), svgWidth, zoomFrom, zoomTo, @@ -1253,17 +1209,14 @@ export class TimeSeriesExplorer extends React.Component { autoZoomDuration, }; - const { jobIds: selectedJobIds, selectedGroups } = getSelectedJobIds(globalState); const jobSelectorProps = { dateFormatTz, - globalState, - jobSelectService$: this.jobSelectService$, - selectedJobIds, - selectedGroups, singleSelection: true, timeseriesOnly: true, }; + const jobs = createTimeSeriesJobData(mlJobService.jobs); + if (jobs.length === 0) { return ( @@ -1272,7 +1225,27 @@ export class TimeSeriesExplorer extends React.Component { ); } - const detectorSelectOptions = detectors.map(d => ({ + if ( + selectedJobIds === undefined || + selectedJobIds.length > 1 || + selectedDetectorIndex === undefined || + mlJobService.getJob(selectedJobIds[0]) === undefined + ) { + return ( + + ); + } + + const selectedJob = mlJobService.getJob(selectedJobIds[0]); + const entityControls = this.getControlsForDetector(); + + const fieldNamesWithEmptyValues = entityControls + .filter(({ fieldValue }) => !fieldValue) + .map(({ fieldName }) => fieldName); + + const arePartitioningFieldsProvided = fieldNamesWithEmptyValues.length === 0; + + const detectorSelectOptions = getViewableDetectors(selectedJob).map(d => ({ value: d.index, text: d.detector_description, })); @@ -1285,12 +1258,14 @@ export class TimeSeriesExplorer extends React.Component { isEqual(this.previousChartProps.focusAnnotationData, chartProps.focusAnnotationData) && this.previousShowAnnotations === showAnnotations && this.previousShowForecast === showForecast && - this.previousShowModelBounds === showModelBounds + this.previousShowModelBounds === showModelBounds && + this.previousLastRefresh === lastRefresh ) { renderFocusChartOnly = false; } this.previousChartProps = chartProps; + this.previousLastRefresh = lastRefresh; this.previousShowAnnotations = showAnnotations; this.previousShowForecast = showForecast; this.previousShowModelBounds = showModelBounds; @@ -1337,12 +1312,12 @@ export class TimeSeriesExplorer extends React.Component { > - {entities.map(entity => { + {entityControls.map(entity => { const entityKey = `${entity.fieldName}`; const forceSelection = !hasEmptyFieldValues && !entity.fieldValue; hasEmptyFieldValues = !hasEmptyFieldValues && forceSelection; @@ -1350,9 +1325,11 @@ export class TimeSeriesExplorer extends React.Component { ); })} @@ -1361,9 +1338,9 @@ export class TimeSeriesExplorer extends React.Component { @@ -1386,7 +1363,7 @@ export class TimeSeriesExplorer extends React.Component { hasResults === false && ( )} @@ -1488,13 +1465,13 @@ export class TimeSeriesExplorer extends React.Component {
{showAnnotations && focusAnnotationData.length > 0 && ( @@ -1547,8 +1524,8 @@ export class TimeSeriesExplorer extends React.Component { )} - {arePartitioningFieldsProvided && jobs.length > 0 && ( - + {arePartitioningFieldsProvided && jobs.length > 0 && hasResults === true && ( + )}
); diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts index 29a5facf64c0fc..a801a1c5ce6f58 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_constants.ts @@ -10,13 +10,9 @@ export const APP_STATE_ACTION = { CLEAR: 'CLEAR', - GET_DETECTOR_INDEX: 'GET_DETECTOR_INDEX', SET_DETECTOR_INDEX: 'SET_DETECTOR_INDEX', - GET_ENTITIES: 'GET_ENTITIES', SET_ENTITIES: 'SET_ENTITIES', - GET_FORECAST_ID: 'GET_FORECAST_ID', SET_FORECAST_ID: 'SET_FORECAST_ID', - GET_ZOOM: 'GET_ZOOM', SET_ZOOM: 'SET_ZOOM', UNSET_ZOOM: 'UNSET_ZOOM', }; diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts index 1528ac887ad76e..1b7a740d90dde1 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.d.ts @@ -46,7 +46,7 @@ export function calculateDefaultFocusRange( export function calculateInitialFocusRange( zoomState: any, contextAggregationInterval: any, - timefilter: any + bounds: any ): any; export function getAutoZoomDuration(jobs: any, selectedJob: any): any; diff --git a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js index 8e8b31ede86a88..b4706e6f609dc6 100644 --- a/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js +++ b/x-pack/legacy/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer_utils/timeseriesexplorer_utils.js @@ -340,14 +340,13 @@ export function calculateDefaultFocusRange( return [new Date(rangeEarliestMs), new Date(rangeLatestMs)]; } -export function calculateInitialFocusRange(zoomState, contextAggregationInterval, timefilter) { +export function calculateInitialFocusRange(zoomState, contextAggregationInterval, bounds) { if (zoomState !== undefined) { // Check that the zoom times are valid. // zoomFrom must be at or after context chart search bounds earliest, // zoomTo must be at or before context chart search bounds latest. const zoomFrom = moment(zoomState.from, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); const zoomTo = moment(zoomState.to, 'YYYY-MM-DDTHH:mm:ss.SSSZ', true); - const bounds = timefilter.getActiveBounds(); const searchBounds = getBoundsRoundedToInterval(bounds, contextAggregationInterval, true); const earliest = searchBounds.min; const latest = searchBounds.max; diff --git a/x-pack/legacy/plugins/ml/public/application/util/__snapshots__/observable_utils.test.tsx.snap b/x-pack/legacy/plugins/ml/public/application/util/__snapshots__/observable_utils.test.tsx.snap deleted file mode 100644 index b93a4702b7c3d8..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/__snapshots__/observable_utils.test.tsx.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`observable_utils injectObservablesAsProps() 1`] = ` - -`; - -exports[`observable_utils injectObservablesAsProps() 2`] = ` - -`; diff --git a/x-pack/legacy/plugins/ml/public/application/util/__tests__/app_state_utils.js b/x-pack/legacy/plugins/ml/public/application/util/__tests__/app_state_utils.js deleted file mode 100644 index 2ab428f979f537..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/__tests__/app_state_utils.js +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import ngMock from 'ng_mock'; - -import { BehaviorSubject } from 'rxjs'; - -import { initializeAppState, subscribeAppStateToObservable } from '../app_state_utils'; - -describe('ML - initializeAppState', () => { - let AppState; - - beforeEach( - ngMock.module('kibana', stateManagementConfigProvider => { - stateManagementConfigProvider.enable(); - }) - ); - - beforeEach( - ngMock.inject($injector => { - AppState = $injector.get('AppState'); - }) - ); - - it('Throws an error when called without arguments.', () => { - expect(() => initializeAppState()).to.throwError(); - }); - - it('Initializes an appstate, gets a test value.', () => { - const appState = initializeAppState(AppState, 'mlTest', { value: 10 }); - expect(appState.mlTest.value).to.be(10); - }); -}); - -describe('ML - subscribeAppStateToObservable', () => { - let AppState; - let $rootScope; - - beforeEach( - ngMock.module('kibana', stateManagementConfigProvider => { - stateManagementConfigProvider.enable(); - }) - ); - - beforeEach( - ngMock.inject($injector => { - AppState = $injector.get('AppState'); - $rootScope = $injector.get('$rootScope'); - }) - ); - - it('Initializes a custom state store, sets and gets a test value using events.', done => { - const o$ = new BehaviorSubject({ value: 10 }); - - subscribeAppStateToObservable(AppState, 'mlTest', o$, () => $rootScope.$applyAsync()); - - o$.subscribe(payload => { - const appState = new AppState(); - appState.fetch(); - - expect(payload.value).to.be(10); - expect(appState.mlTest.value).to.be(10); - - done(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.d.ts b/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.d.ts deleted file mode 100644 index 454ea55210dccc..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.d.ts +++ /dev/null @@ -1,16 +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 { Observable } from 'rxjs'; - -export const initializeAppState: (AppState: any, stateName: any, defaultState: any) => any; - -export const subscribeAppStateToObservable: ( - AppState: any, - appStateName: string, - o$: Observable, - callback: (payload: any) => void -) => any; diff --git a/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.js b/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.js deleted file mode 100644 index 2875a6fa3ce19e..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/app_state_utils.js +++ /dev/null @@ -1,70 +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 { cloneDeep, isEqual } from 'lodash'; - -import { distinctUntilChanged } from 'rxjs/operators'; - -function hasEqualKeys(a, b) { - return isEqual(Object.keys(a).sort(), Object.keys(b).sort()); -} - -export function initializeAppState(AppState, stateName, defaultState) { - const appState = new AppState(); - appState.fetch(); - - // Store the state to the AppState so that it's - // restored on page refresh. - if (appState[stateName] === undefined) { - appState[stateName] = cloneDeep(defaultState); - appState.save(); - } - - // if defaultState isn't defined or if defaultState matches the current value - // stored in the URL in appState then return appState as is. - if (defaultState === undefined || appState[stateName] === defaultState) { - return appState; - } - - // If defaultState is defined, check if the keys of the defaultState - // match the one from appState, if not, fall back to the defaultState. - // If we didn't do this, the structure of an out-of-date appState - // might break some follow up code. Note that this will not catch any - // deeper nested inconsistencies. this does two checks: - // - if defaultState is an object, check if current appState has the same keys. - // - if it's not an object, check if defaultState and current appState are of the same type. - if ( - (typeof defaultState === 'object' && !hasEqualKeys(defaultState, appState[stateName])) || - typeof defaultState !== typeof appState[stateName] - ) { - appState[stateName] = cloneDeep(defaultState); - appState.save(); - } - - return appState; -} - -// Some components like the show-chart-checkbox or severity/interval-dropdowns -// emit their state change to an observable. This utility function can be used -// to persist these state changes to AppState and save the state to the url. -// distinctUntilChanged() makes sure the callback is only triggered upon changes -// of the state and filters consecutive triggers of the same value. -export function subscribeAppStateToObservable(AppState, appStateName, o$, callback) { - const appState = initializeAppState(AppState, appStateName, o$.getValue()); - - o$.next(appState[appStateName]); - - const subscription = o$.pipe(distinctUntilChanged()).subscribe(payload => { - appState.fetch(); - appState[appStateName] = payload; - appState.save(); - if (typeof callback === 'function') { - callback(payload); - } - }); - - return subscription; -} diff --git a/x-pack/legacy/plugins/ml/public/application/util/observable_utils.test.tsx b/x-pack/legacy/plugins/ml/public/application/util/observable_utils.test.tsx deleted file mode 100644 index c95824fc5dc4d9..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/observable_utils.test.tsx +++ /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 { shallow } from 'enzyme'; -import React, { ComponentType } from 'react'; -import { BehaviorSubject } from 'rxjs'; - -import { injectObservablesAsProps } from './observable_utils'; - -interface Props { - testProp: string; -} - -describe('observable_utils', () => { - test('injectObservablesAsProps()', () => { - // an observable that allows us to trigger updating some text. - const observable$ = new BehaviorSubject('initial text'); - - // a simple stateless component that just renders some text - const TestComponent: React.FC = ({ testProp }) => { - return {testProp}; - }; - - // injectObservablesAsProps wraps the observable in a new component - const ObservableComponent = injectObservablesAsProps( - { testProp: observable$ }, - (TestComponent as any) as ComponentType - ); - - const wrapper = shallow(); - - // the component should render with "initial text" - expect(wrapper).toMatchSnapshot(); - - observable$.next('updated text'); - - // the component should render with "updated text" - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx b/x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx deleted file mode 100644 index 4b8027260ab9a6..00000000000000 --- a/x-pack/legacy/plugins/ml/public/application/util/observable_utils.tsx +++ /dev/null @@ -1,67 +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 { isEqual } from 'lodash'; -import React, { Component, ComponentType } from 'react'; -import { BehaviorSubject, Subscription } from 'rxjs'; -import { distinctUntilChanged } from 'rxjs/operators'; -import { Dictionary } from '../../../common/types/common'; - -// Sets up a ObservableComponent which subscribes to given observable updates and -// and passes them on as prop values to the given WrappedComponent. -// This give us the benefit of abstracting away the need to set up subscribers and callbacks, -// and the passed down props can be used in pure/functional components without -// the need for their own state management. -export function injectObservablesAsProps( - observables: Dictionary>, - WrappedComponent: ComponentType -): ComponentType { - const observableKeys = Object.keys(observables); - - class ObservableComponent extends Component { - public state = observableKeys.reduce((reducedState: Dictionary, key: string) => { - reducedState[key] = observables[key].value; - return reducedState; - }, {}); - - public subscriptions = {} as Dictionary; - - public componentDidMount() { - observableKeys.forEach(k => { - this.subscriptions[k] = observables[k] - .pipe(distinctUntilChanged(isEqual)) - .subscribe(v => this.setState({ [k]: v })); - }); - } - - public componentWillUnmount() { - Object.keys(this.subscriptions).forEach((key: string) => - this.subscriptions[key].unsubscribe() - ); - } - - public render() { - // All injected observables are expected to provide initial state. - // If an observable has undefined as its current value, rendering - // the wrapped component will be skipped. - if ( - Object.keys(this.state) - .map(k => this.state[k]) - .some(v => v === undefined) - ) { - return null; - } - - return ( - - {this.props.children} - - ); - } - } - - return ObservableComponent as ComponentType; -} diff --git a/x-pack/legacy/plugins/ml/public/application/util/url_state.ts b/x-pack/legacy/plugins/ml/public/application/util/url_state.ts new file mode 100644 index 00000000000000..4402155815a5b1 --- /dev/null +++ b/x-pack/legacy/plugins/ml/public/application/util/url_state.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback } from 'react'; +import { isEqual } from 'lodash'; +// @ts-ignore +import queryString from 'query-string'; +import { decode, encode } from 'rison-node'; +import { useHistory, useLocation } from 'react-router-dom'; + +import { Dictionary } from '../../../common/types/common'; + +import { getNestedProperty } from './object_utils'; + +export type SetUrlState = (attribute: string | Dictionary, value?: any) => void; +export type UrlState = [Dictionary, SetUrlState]; + +function getUrlState(search: string) { + const urlState: Dictionary = {}; + const parsedQueryString = queryString.parse(search); + + try { + Object.keys(parsedQueryString).forEach(a => { + urlState[a] = decode(parsedQueryString[a]) as Dictionary; + }); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Could not read url state', error); + } + + return urlState; +} + +// Compared to the original appState/globalState, +// this no longer makes use of fetch/save methods. +// - Reading from `location.search` is the successor of `fetch`. +// - `history.push()` is the successor of `save`. +// - The exposed state and set call make use of the above and make sure that +// different urlStates(e.g. `_a` / `_g`) don't overwrite each other. +export const useUrlState = (accessor: string): UrlState => { + const history = useHistory(); + const { search } = useLocation(); + + const setUrlState = useCallback( + (attribute: string | Dictionary, value?: any) => { + const urlState = getUrlState(search); + const parsedQueryString = queryString.parse(search); + + if (!Object.prototype.hasOwnProperty.call(urlState, accessor)) { + urlState[accessor] = {}; + } + + if (typeof attribute === 'string') { + if (isEqual(getNestedProperty(urlState, `${accessor}.${attribute}`), value)) { + return; + } + + urlState[accessor][attribute] = value; + } else { + const attributes = attribute; + Object.keys(attributes).forEach(a => { + urlState[accessor][a] = attributes[a]; + }); + } + + try { + const oldLocationSearch = queryString.stringify(parsedQueryString, { encode: false }); + + Object.keys(urlState).forEach(a => { + parsedQueryString[a] = encode(urlState[a]); + }); + const newLocationSearch = queryString.stringify(parsedQueryString, { encode: false }); + + if (oldLocationSearch !== newLocationSearch) { + history.push({ + search: queryString.stringify(parsedQueryString), + }); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Could not save url state', error); + } + }, + [search] + ); + + return [getUrlState(search)[accessor], setUrlState]; +}; diff --git a/x-pack/package.json b/x-pack/package.json index 1e20157831ba58..3f826030ac16b1 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -42,6 +42,7 @@ "@storybook/react": "^5.2.6", "@storybook/theming": "^5.2.6", "@testing-library/react": "^9.3.2", + "@testing-library/react-hooks": "^3.2.1", "@testing-library/jest-dom": "4.2.0", "@types/angular": "^1.6.56", "@types/archiver": "^3.0.0", @@ -309,6 +310,7 @@ "react-shortcuts": "^2.0.0", "react-sticky": "^6.0.3", "react-syntax-highlighter": "^5.7.0", + "react-use": "^13.13.0", "react-vis": "^1.8.1", "react-visibility-sensor": "^5.1.1", "recompose": "^0.26.0", From e54a7175dacff31fd2fbf8522029af7fffa6f931 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 13 Jan 2020 17:15:08 +0000 Subject: [PATCH 18/62] pass previousStartedAt as Date into Alert executor (#54576) Corrects how we pass previousStartedAt into Alert executor --- .../server/task_runner/task_runner.test.ts | 16 +++++++++------- .../alerting/server/task_runner/task_runner.ts | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts index 87fa33a9cea587..e4d8c78d122656 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts @@ -38,9 +38,7 @@ describe('Task Runner', () => { scheduledAt: new Date(), startedAt: new Date(), retryAt: new Date(Date.now() + 5 * 60 * 1000), - state: { - startedAt: new Date(Date.now() - 5 * 60 * 1000), - }, + state: {}, taskType: 'alerting:test', params: { alertId: '1', @@ -110,7 +108,13 @@ describe('Task Runner', () => { test('successfully executes the task', async () => { const taskRunner = new TaskRunner( alertType, - mockedTaskInstance, + { + ...mockedTaskInstance, + state: { + ...mockedTaskInstance.state, + previousStartedAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), + }, + }, taskRunnerFactoryInitializerParams ); savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); @@ -141,6 +145,7 @@ describe('Task Runner', () => { } `); expect(call.startedAt).toMatchInlineSnapshot(`1970-01-01T00:00:00.000Z`); + expect(call.previousStartedAt).toMatchInlineSnapshot(`1969-12-31T23:55:00.000Z`); expect(call.state).toMatchInlineSnapshot(`Object {}`); expect(call.name).toBe('alert-name'); expect(call.tags).toEqual(['alert-', '-tags']); @@ -261,7 +266,6 @@ describe('Task Runner', () => { "runAt": 1970-01-01T00:00:10.000Z, "state": Object { "previousStartedAt": 1970-01-01T00:00:00.000Z, - "startedAt": 1969-12-31T23:55:00.000Z, }, } `); @@ -293,7 +297,6 @@ describe('Task Runner', () => { "runAt": 1970-01-01T00:00:10.000Z, "state": Object { "previousStartedAt": 1970-01-01T00:00:00.000Z, - "startedAt": 1969-12-31T23:55:00.000Z, }, } `); @@ -400,7 +403,6 @@ describe('Task Runner', () => { "runAt": 1970-01-01T00:00:10.000Z, "state": Object { "previousStartedAt": 1970-01-01T00:00:00.000Z, - "startedAt": 1969-12-31T23:55:00.000Z, }, } `); diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts index 42c332e82e0343..2589313acc76bb 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -152,7 +152,7 @@ export class TaskRunner { params, state: alertTypeState, startedAt: this.taskInstance.startedAt!, - previousStartedAt, + previousStartedAt: previousStartedAt && new Date(previousStartedAt), spaceId, namespace, name, From 71dfdea7ae757010547db95cbca935511edbdbfa Mon Sep 17 00:00:00 2001 From: Poff Poffenberger Date: Mon, 13 Jan 2020 17:16:12 +0000 Subject: [PATCH 19/62] [Canvas] Fix expression updating bug (#54297) * Fix expression updating bug * Add functional test for expression editor * Add page object helper to open expression editor Co-authored-by: Elastic Machine --- .../public/components/expression/index.js | 11 +++ .../public/components/toolbar/toolbar.tsx | 3 +- .../test/functional/apps/canvas/expression.ts | 70 +++++++++++++++++++ x-pack/test/functional/apps/canvas/index.js | 1 + .../functional/page_objects/canvas_page.ts | 4 ++ 5 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 x-pack/test/functional/apps/canvas/expression.ts diff --git a/x-pack/legacy/plugins/canvas/public/components/expression/index.js b/x-pack/legacy/plugins/canvas/public/components/expression/index.js index 806ef388bc4f6f..d6eefca4e14614 100644 --- a/x-pack/legacy/plugins/canvas/public/components/expression/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/expression/index.js @@ -55,6 +55,17 @@ const mergeProps = (stateProps, dispatchProps, ownProps) => { }; const expressionLifecycle = lifecycle({ + componentDidUpdate({ expression }) { + if ( + this.props.expression !== expression && + this.props.expression !== this.props.formState.expression + ) { + this.props.setFormState({ + expression: this.props.expression, + dirty: false, + }); + } + }, componentDidMount() { const { functionDefinitionsPromise, setFunctionDefinitions } = this.props; functionDefinitionsPromise.then(defs => setFunctionDefinitions(defs)); diff --git a/x-pack/legacy/plugins/canvas/public/components/toolbar/toolbar.tsx b/x-pack/legacy/plugins/canvas/public/components/toolbar/toolbar.tsx index da3475eceb18d9..089f021ccdc325 100644 --- a/x-pack/legacy/plugins/canvas/public/components/toolbar/toolbar.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/toolbar/toolbar.tsx @@ -97,7 +97,7 @@ export const Toolbar = (props: Props) => { const trays = { pageManager: , - expression: !elementIsSelected ? null : , + expression: !elementIsSelected ? null : , }; return ( @@ -141,6 +141,7 @@ export const Toolbar = (props: Props) => { color="text" iconType="editorCodeBlock" onClick={() => showHideTray(TrayType.expression)} + data-test-subj="canvasExpressionEditorButton" > {strings.getEditorButtonLabel()} diff --git a/x-pack/test/functional/apps/canvas/expression.ts b/x-pack/test/functional/apps/canvas/expression.ts new file mode 100644 index 00000000000000..fc6b80468b9f2c --- /dev/null +++ b/x-pack/test/functional/apps/canvas/expression.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function canvasExpressionTest({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const testSubjects = getService('testSubjects'); + // const browser = getService('browser'); + const retry = getService('retry'); + const PageObjects = getPageObjects(['canvas', 'common']); + const find = getService('find'); + + describe('expression editor', function() { + // there is an issue with FF not properly clicking on workpad elements + this.tags('skipFirefox'); + + before(async () => { + // init data + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.load('canvas/default'); + + // load test workpad + await PageObjects.common.navigateToApp('canvas', { + hash: '/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31/page/1', + }); + }); + + it('updates when element is changed via side bar', async () => { + // wait for all our elements to load up + await retry.try(async () => { + const elements = await testSubjects.findAll( + 'canvasWorkpadPage > canvasWorkpadPageElementContent' + ); + expect(elements).to.have.length(4); + }); + + // find the first workpad element (a markdown element) and click it to select it + await testSubjects.click('canvasWorkpadPage > canvasWorkpadPageElementContent', 20000); + + // open the expression editor + await PageObjects.canvas.openExpressionEditor(); + + // select markdown content and clear it + const mdBox = await find.byCssSelector('.canvasSidebar__panel .canvasTextArea__code'); + const oldMd = await mdBox.getVisibleText(); + await mdBox.clearValueWithKeyboard(); + + // type the new text + const newMd = `${oldMd} and this is a test`; + await mdBox.type(newMd); + await find.clickByCssSelector('.canvasArg--controls .euiButton'); + + // make sure the open expression editor also has the changes + const editor = await find.byCssSelector('.monaco-editor .view-lines'); + const editorText = await editor.getVisibleText(); + expect(editorText).to.contain('Orange: Timelion, Server function and this is a test'); + + // reset the markdown + await mdBox.clearValueWithKeyboard(); + await mdBox.type(oldMd); + await find.clickByCssSelector('.canvasArg--controls .euiButton'); + }); + }); +} diff --git a/x-pack/test/functional/apps/canvas/index.js b/x-pack/test/functional/apps/canvas/index.js index bc33161cc4e970..fa4e362b6bc590 100644 --- a/x-pack/test/functional/apps/canvas/index.js +++ b/x-pack/test/functional/apps/canvas/index.js @@ -8,6 +8,7 @@ export default function canvasApp({ loadTestFile }) { describe('Canvas app', function canvasAppTestSuite() { this.tags('ciGroup2'); // CI requires tags ヽ(゜Q。)ノ? loadTestFile(require.resolve('./smoke_test')); + loadTestFile(require.resolve('./expression')); loadTestFile(require.resolve('./feature_controls/canvas_security')); loadTestFile(require.resolve('./feature_controls/canvas_spaces')); }); diff --git a/x-pack/test/functional/page_objects/canvas_page.ts b/x-pack/test/functional/page_objects/canvas_page.ts index a4b4f500b8832c..fa117dbea393de 100644 --- a/x-pack/test/functional/page_objects/canvas_page.ts +++ b/x-pack/test/functional/page_objects/canvas_page.ts @@ -23,6 +23,10 @@ export function CanvasPageProvider({ getService }: FtrProviderContext) { await browser.pressKeys(browser.keys.ESCAPE); }, + async openExpressionEditor() { + await testSubjects.click('canvasExpressionEditorButton'); + }, + async waitForWorkpadElements() { await testSubjects.findAll('canvasWorkpadPage > canvasWorkpadPageElementContent'); }, From e8b2b28aef1b1591ec850b090996da25d91818e5 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 13 Jan 2020 17:16:25 +0000 Subject: [PATCH 20/62] [alerting] gracefully handle error in initialization of Alert TaskRunner (#54335) Prevents an edge cases where Alerts can end up in a zombie state. 1. Decrypting attributes throws an error 2. Fetching an Api Key throws an error 3. Getting Services with user permissions throws an error --- .../alerting/server/lib/result_type.ts | 8 ++ .../server/task_runner/task_runner.test.ts | 93 +++++++++++++++++++ .../server/task_runner/task_runner.ts | 75 ++++++++++++--- 3 files changed, 162 insertions(+), 14 deletions(-) diff --git a/x-pack/legacy/plugins/alerting/server/lib/result_type.ts b/x-pack/legacy/plugins/alerting/server/lib/result_type.ts index 644ae51292249d..52843f63623032 100644 --- a/x-pack/legacy/plugins/alerting/server/lib/result_type.ts +++ b/x-pack/legacy/plugins/alerting/server/lib/result_type.ts @@ -15,6 +15,10 @@ export interface Err { } export type Result = Ok | Err; +export type Resultable = { + [P in keyof T]: Result; +}; + export function asOk(value: T): Ok { return { tag: 'ok', @@ -52,3 +56,7 @@ export function map( ): Resolution { return isOk(result) ? onOk(result.value) : onErr(result.error); } + +export function resolveErr(result: Result, onErr: (error: E) => T): T { + return isOk(result) ? result.value : onErr(result.error); +} diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts index e4d8c78d122656..dc220067bd35a8 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts @@ -407,4 +407,97 @@ describe('Task Runner', () => { } `); }); + + test('recovers gracefully when the Alert Task Runner throws an exception when fetching the encrypted attributes', async () => { + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockImplementation(() => { + throw new Error('OMG'); + }); + + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + + const runnerResult = await taskRunner.run(); + + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:05:00.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + "startedAt": 1969-12-31T23:55:00.000Z, + }, + } + `); + }); + + test('recovers gracefully when the Alert Task Runner throws an exception when getting internal Services', async () => { + taskRunnerFactoryInitializerParams.getServices.mockImplementation(() => { + throw new Error('OMG'); + }); + + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + savedObjectsClient.get.mockResolvedValueOnce(mockedAlertTypeSavedObject); + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + const runnerResult = await taskRunner.run(); + + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:05:00.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + "startedAt": 1969-12-31T23:55:00.000Z, + }, + } + `); + }); + + test('recovers gracefully when the Alert Task Runner throws an exception when fetching attributes', async () => { + savedObjectsClient.get.mockImplementation(() => { + throw new Error('OMG'); + }); + + const taskRunner = new TaskRunner( + alertType, + mockedTaskInstance, + taskRunnerFactoryInitializerParams + ); + + encryptedSavedObjectsPlugin.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + apiKey: Buffer.from('123:abc').toString('base64'), + }, + references: [], + }); + + const runnerResult = await taskRunner.run(); + + expect(runnerResult).toMatchInlineSnapshot(` + Object { + "runAt": 1970-01-01T00:05:00.000Z, + "state": Object { + "previousStartedAt": 1970-01-01T00:00:00.000Z, + "startedAt": 1969-12-31T23:55:00.000Z, + }, + } + `); + }); }); diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts index 2589313acc76bb..c6f1a02da8dcd6 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -14,10 +14,17 @@ import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; import { getNextRunAt } from './get_next_run_at'; import { validateAlertTypeParams } from '../lib'; import { AlertType, RawAlert, IntervalSchedule, Services, State, AlertInfoParams } from '../types'; -import { promiseResult, map } from '../lib/result_type'; +import { promiseResult, map, Resultable, asOk, asErr, resolveErr } from '../lib/result_type'; type AlertInstances = Record; +const FALLBACK_RETRY_INTERVAL: IntervalSchedule = { interval: '5m' }; + +interface AlertTaskRunResult { + state: State; + runAt: Date; +} + export class TaskRunner { private context: TaskRunnerContext; private logger: Logger; @@ -190,7 +197,7 @@ export class TaskRunner { }; } - async validateAndRunAlert( + async validateAndExecuteAlert( services: Services, apiKey: string | null, attributes: RawAlert, @@ -217,11 +224,9 @@ export class TaskRunner { ); } - async run() { + async loadAlertAttributesAndRun(): Promise> { const { params: { alertId, spaceId }, - startedAt: previousStartedAt, - state: originalState, } = this.taskInstance; const apiKey = await this.getApiKeyForAlertPermissions(alertId, spaceId); @@ -233,11 +238,34 @@ export class TaskRunner { alertId ); + return { + state: await promiseResult( + this.validateAndExecuteAlert(services, apiKey, attributes, references) + ), + runAt: asOk( + getNextRunAt( + new Date(this.taskInstance.startedAt!), + // we do not currently have a good way of returning the type + // from SavedObjectsClient, and as we currenrtly require a schedule + // and we only support `interval`, we can cast this safely + attributes.schedule as IntervalSchedule + ) + ), + }; + } + + async run(): Promise { + const { + params: { alertId }, + startedAt: previousStartedAt, + state: originalState, + } = this.taskInstance; + + const { state, runAt } = await errorAsAlertTaskRunResult(this.loadAlertAttributesAndRun()); + return { state: map( - await promiseResult( - this.validateAndRunAlert(services, apiKey, attributes, references) - ), + state, (stateUpdates: State) => { return { ...stateUpdates, @@ -252,13 +280,32 @@ export class TaskRunner { }; } ), - runAt: getNextRunAt( - new Date(this.taskInstance.startedAt!), - // we do not currently have a good way of returning the type - // from SavedObjectsClient, and as we currenrtly require a schedule - // and we only support `interval`, we can cast this safely - attributes.schedule as IntervalSchedule + runAt: resolveErr(runAt, () => + getNextRunAt( + new Date(), + // if we fail at this point we wish to recover but don't have access to the Alert's + // attributes, so we'll use a default interval to prevent the underlying task from + // falling into a failed state + FALLBACK_RETRY_INTERVAL + ) ), }; } } + +/** + * If an error is thrown, wrap it in an AlertTaskRunResult + * so that we can treat each field independantly + */ +async function errorAsAlertTaskRunResult( + future: Promise> +): Promise> { + try { + return await future; + } catch (e) { + return { + state: asErr(e), + runAt: asErr(e), + }; + } +} From 7543b0c7b2ddb074cc75b0f0f0c9dc5f400609ce Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Mon, 13 Jan 2020 17:38:47 +0000 Subject: [PATCH 21/62] [Lens][Dashboard] Adding Lens to Dashboard (#53110) * First version of adding Lens to dashboard * Fix failing unit test * Replacing explicit Lens query param with a more generic one * Fixing failing unit test * Adding a unit test for redirect * Do not show Save New if adding from Dashboard * Adding functional test * Adding functional test * Fixing type issues * Renaming query params * Fixing failing unit test * Removing unused constants * Fixing erroneous imports * Fixing erroneous import * Fixing import * Fix failing typecheck * Removing timefilter from Dashboard URL * Fixing type error * Replacing time parsing with rison * Replacing URL regex parsing with legacy URLs * Fixing failing test Co-authored-by: Elastic Machine --- .../dashboard/__tests__/url_helper.test.ts | 117 ++++++++++++++++++ .../kibana/public/dashboard/legacy_imports.ts | 1 + .../np_ready/dashboard_app_controller.tsx | 14 +-- .../dashboard/np_ready/dashboard_constants.ts | 3 +- .../public/dashboard/np_ready/url_helper.ts | 102 +++++++++++++++ .../visualize/np_ready/editor/editor.js | 8 +- .../np_ready/wizard/new_vis_modal.test.tsx | 4 +- .../np_ready/wizard/new_vis_modal.tsx | 7 +- .../functional/page_objects/visualize_page.ts | 4 + .../lens/public/app_plugin/app.test.tsx | 25 ++++ .../plugins/lens/public/app_plugin/app.tsx | 18 ++- .../plugins/lens/public/app_plugin/plugin.tsx | 76 ++++++++++-- .../dashboard_mode/dashboard_empty_screen.js | 68 ++++++++++ .../functional/apps/dashboard_mode/index.js | 1 + 14 files changed, 420 insertions(+), 28 deletions(-) create mode 100644 src/legacy/core_plugins/kibana/public/dashboard/__tests__/url_helper.test.ts create mode 100644 src/legacy/core_plugins/kibana/public/dashboard/np_ready/url_helper.ts create mode 100644 x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js diff --git a/src/legacy/core_plugins/kibana/public/dashboard/__tests__/url_helper.test.ts b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/url_helper.test.ts new file mode 100644 index 00000000000000..16773c02f5a7b1 --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/__tests__/url_helper.test.ts @@ -0,0 +1,117 @@ +/* + * 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. + */ + +jest.mock('../', () => ({ + DashboardConstants: { + ADD_EMBEDDABLE_ID: 'addEmbeddableId', + ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', + }, +})); + +jest.mock('../legacy_imports', () => { + return { + absoluteToParsedUrl: jest.fn(() => { + return { + basePath: '/pep', + appId: 'kibana', + appPath: '/dashboard?addEmbeddableType=lens&addEmbeddableId=123eb456cd&x=1&y=2&z=3', + hostname: 'localhost', + port: 5601, + protocol: 'http:', + addQueryParameter: () => {}, + getAbsoluteUrl: () => { + return 'http://localhost:5601/pep/app/kibana#/dashboard?addEmbeddableType=lens&addEmbeddableId=123eb456cd&x=1&y=2&z=3'; + }, + }; + }), + }; +}); + +import { + addEmbeddableToDashboardUrl, + getLensUrlFromDashboardAbsoluteUrl, + getUrlVars, +} from '../np_ready/url_helper'; + +describe('Dashboard URL Helper', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('addEmbeddableToDashboardUrl', () => { + const id = '123eb456cd'; + const type = 'lens'; + const urlVars = { + x: '1', + y: '2', + z: '3', + }; + const basePath = '/pep'; + const url = + "http://localhost:5601/pep/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!()"; + expect(addEmbeddableToDashboardUrl(url, basePath, id, urlVars, type)).toEqual( + `http://localhost:5601/pep/app/kibana#/dashboard?addEmbeddableType=${type}&addEmbeddableId=${id}&x=1&y=2&z=3` + ); + }); + + it('getUrlVars', () => { + let url = + "http://localhost:5601/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!()"; + expect(getUrlVars(url)).toEqual({ + _g: '(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))', + _a: "(description:'',filters:!()", + }); + url = 'http://mybusiness.mydomain.com/app/kibana#/dashboard?x=y&y=z'; + expect(getUrlVars(url)).toEqual({ + x: 'y', + y: 'z', + }); + url = 'http://notDashboardUrl'; + expect(getUrlVars(url)).toEqual({}); + url = 'http://localhost:5601/app/kibana#/dashboard/777182'; + expect(getUrlVars(url)).toEqual({}); + }); + + it('getLensUrlFromDashboardAbsoluteUrl', () => { + const id = '1244'; + const basePath = '/wev'; + let url = + "http://localhost:5601/wev/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!()"; + expect(getLensUrlFromDashboardAbsoluteUrl(url, basePath, id)).toEqual( + 'http://localhost:5601/wev/app/kibana#/lens/edit/1244' + ); + + url = + "http://localhost:5601/wev/app/kibana#/dashboard/625357282?_a=(description:'',filters:!()&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))"; + expect(getLensUrlFromDashboardAbsoluteUrl(url, basePath, id)).toEqual( + 'http://localhost:5601/wev/app/kibana#/lens/edit/1244' + ); + + url = 'http://myserver.mydomain.com:5601/wev/app/kibana#/dashboard/777182'; + expect(getLensUrlFromDashboardAbsoluteUrl(url, basePath, id)).toEqual( + 'http://myserver.mydomain.com:5601/wev/app/kibana#/lens/edit/1244' + ); + + url = + "http://localhost:5601/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&_a=(description:'',filters:!()"; + expect(getLensUrlFromDashboardAbsoluteUrl(url, '', id)).toEqual( + 'http://localhost:5601/app/kibana#/lens/edit/1244' + ); + }); +}); diff --git a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts index ec0913e5fb3e77..ba019194310807 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/legacy_imports.ts @@ -67,3 +67,4 @@ export { IInjector } from 'ui/chrome'; export { SavedObjectLoader } from 'ui/saved_objects'; export { VISUALIZE_EMBEDDABLE_TYPE } from '../visualize_embeddable'; export { registerTimefilterWithGlobalStateFactory } from 'ui/timefilter/setup_router'; +export { absoluteToParsedUrl } from 'ui/url/absolute_to_parsed_url'; diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx index 2523d1e60a7412..2706b588a2ec4f 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx @@ -37,7 +37,6 @@ import { KbnUrl, SavedObjectSaveOpts, unhashUrl, - VISUALIZE_EMBEDDABLE_TYPE, } from '../legacy_imports'; import { FilterStateManager } from '../../../../data/public'; import { @@ -334,13 +333,12 @@ export class DashboardAppController { // This code needs to be replaced with a better mechanism for adding new embeddables of // any type from the add panel. Likely this will happen via creating a visualization "inline", // without navigating away from the UX. - if ($routeParams[DashboardConstants.NEW_VISUALIZATION_ID_PARAM]) { - container.addSavedObjectEmbeddable( - VISUALIZE_EMBEDDABLE_TYPE, - $routeParams[DashboardConstants.NEW_VISUALIZATION_ID_PARAM] - ); - kbnUrl.removeParam(DashboardConstants.ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM); - kbnUrl.removeParam(DashboardConstants.NEW_VISUALIZATION_ID_PARAM); + if ($routeParams[DashboardConstants.ADD_EMBEDDABLE_TYPE]) { + const type = $routeParams[DashboardConstants.ADD_EMBEDDABLE_TYPE]; + const id = $routeParams[DashboardConstants.ADD_EMBEDDABLE_ID]; + container.addSavedObjectEmbeddable(type, id); + kbnUrl.removeParam(DashboardConstants.ADD_EMBEDDABLE_TYPE); + kbnUrl.removeParam(DashboardConstants.ADD_EMBEDDABLE_ID); } } diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts index b76b3f309874ab..fe42e079127999 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_constants.ts @@ -19,9 +19,10 @@ export const DashboardConstants = { ADD_VISUALIZATION_TO_DASHBOARD_MODE_PARAM: 'addToDashboard', - NEW_VISUALIZATION_ID_PARAM: 'addVisualization', LANDING_PAGE_PATH: '/dashboards', CREATE_NEW_DASHBOARD_URL: '/dashboard', + ADD_EMBEDDABLE_ID: 'addEmbeddableId', + ADD_EMBEDDABLE_TYPE: 'addEmbeddableType', }; export function createDashboardEditUrl(id: string) { diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/url_helper.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/url_helper.ts new file mode 100644 index 00000000000000..ee9e3c4ef4781e --- /dev/null +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/url_helper.ts @@ -0,0 +1,102 @@ +/* + * 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 { parse } from 'url'; +import { absoluteToParsedUrl } from '../legacy_imports'; +import { DashboardConstants } from './dashboard_constants'; +/** + * Return query params from URL + * @param url given url + */ +export function getUrlVars(url: string): Record { + const vars: Record = {}; + // @ts-ignore + url.replace(/[?&]+([^=&]+)=([^&]*)/gi, function(_, key, value) { + // @ts-ignore + vars[key] = value; + }); + return vars; +} + +/** * + * Returns dashboard URL with added embeddableType and embeddableId query params + * eg. + * input: url: http://localhost:5601/lib/app/kibana#/dashboard?_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)), embeddableId: 12345, embeddableType: 'lens' + * output: http://localhost:5601/lib/app/kibana#dashboard?addEmbeddableType=lens&addEmbeddableId=12345&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now)) + * @param url dasbhoard absolute url + * @param embeddableId id of the saved visualization + * @param basePath current base path + * @param urlVars url query params (optional) + * @param embeddableType 'lens' or 'visualization' (optional, default is 'lens') + */ +export function addEmbeddableToDashboardUrl( + url: string | undefined, + basePath: string, + embeddableId: string, + urlVars?: Record, + embeddableType?: string +): string | null { + if (!url) { + return null; + } + const dashboardUrl = getUrlWithoutQueryParams(url); + const dashboardParsedUrl = absoluteToParsedUrl(dashboardUrl, basePath); + if (urlVars) { + const keys = Object.keys(urlVars).sort(); + keys.forEach(key => { + dashboardParsedUrl.addQueryParameter(key, urlVars[key]); + }); + } + dashboardParsedUrl.addQueryParameter( + DashboardConstants.ADD_EMBEDDABLE_TYPE, + embeddableType || 'lens' + ); + dashboardParsedUrl.addQueryParameter(DashboardConstants.ADD_EMBEDDABLE_ID, embeddableId); + return dashboardParsedUrl.getAbsoluteUrl(); +} + +/** + * Return Lens URL from dashboard absolute URL + * @param dashboardAbsoluteUrl + * @param basePath current base path + * @param id Lens id + */ +export function getLensUrlFromDashboardAbsoluteUrl( + dashboardAbsoluteUrl: string | undefined | null, + basePath: string | null | undefined, + id: string +): string | null { + if (!dashboardAbsoluteUrl || basePath === null || basePath === undefined) { + return null; + } + const { host, protocol } = parse(dashboardAbsoluteUrl); + return `${protocol}//${host}${basePath}/app/kibana#/lens/edit/${id}`; +} + +/** + * Returns the portion of the URL without query params + * eg. + * input: http://localhost:5601/lib/app/kibana#/dashboard?param1=x¶m2=y¶m3=z + * output:http://localhost:5601/lib/app/kibana#/dashboard + * input: http://localhost:5601/lib/app/kibana#/dashboard/39292992?param1=x¶m2=y¶m3=z + * output: http://localhost:5601/lib/app/kibana#/dashboard/39292992 + * @param url url to parse + */ +function getUrlWithoutQueryParams(url: string): string { + return url.split('?')[0]; +} diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js index ed9bec9db41126..64653730473cdd 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/editor/editor.js @@ -35,8 +35,8 @@ import { unhashUrl } from '../../../../../../../plugins/kibana_utils/public'; import { initVisEditorDirective } from './visualization_editor'; import { initVisualizationDirective } from './visualization'; - import { + VISUALIZE_EMBEDDABLE_TYPE, subscribeWithScope, absoluteToParsedUrl, KibanaParsedUrl, @@ -588,7 +588,11 @@ function VisualizeAppController( getBasePath() ); dashboardParsedUrl.addQueryParameter( - DashboardConstants.NEW_VISUALIZATION_ID_PARAM, + DashboardConstants.ADD_EMBEDDABLE_TYPE, + VISUALIZE_EMBEDDABLE_TYPE + ); + dashboardParsedUrl.addQueryParameter( + DashboardConstants.ADD_EMBEDDABLE_ID, savedVis.id ); kbnUrl.change(dashboardParsedUrl.appPath); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.test.tsx b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.test.tsx index 2005133e6d03e4..0ef1b711eafc82 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.test.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.test.tsx @@ -144,7 +144,7 @@ describe('NewVisModal', () => { expect(window.location.assign).toBeCalledWith('#/visualize/create?type=vis&foo=true&bar=42'); }); - it('closes if visualization with aliasUrl and addToDashboard in editorParams', () => { + it('closes and redirects properly if visualization with aliasUrl and addToDashboard in editorParams', () => { const onClose = jest.fn(); window.location.assign = jest.fn(); const wrapper = mountWithIntl( @@ -160,7 +160,7 @@ describe('NewVisModal', () => { ); const visButton = wrapper.find('button[data-test-subj="visType-visWithAliasUrl"]'); visButton.simulate('click'); - expect(window.location.assign).toBeCalledWith('testbasepath/aliasUrl'); + expect(window.location.assign).toBeCalledWith('testbasepath/aliasUrl?addToDashboard'); expect(onClose).toHaveBeenCalled(); }); }); diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.tsx b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.tsx index 9e8f46407f5918..082fc3bc36b6b5 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/new_vis_modal.tsx @@ -143,15 +143,18 @@ class NewVisModal extends React.Component (await globalNav.getLastBreadcrumb()) === vizName ); } + + public async clickLensWidget() { + await this.clickVisType('lens'); + } } return new VisualizePage(); diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx index 1cdae05833b981..794128832461ba 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.test.tsx @@ -80,6 +80,7 @@ describe('Lens App', () => { docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + addToDashboardMode?: boolean; }> { return ({ editorFrame: createMockFrame(), @@ -126,6 +127,7 @@ describe('Lens App', () => { docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + addToDashboardMode?: boolean; }>; } @@ -306,6 +308,7 @@ describe('Lens App', () => { docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + addToDashboardMode?: boolean; }>; beforeEach(() => { @@ -344,14 +347,19 @@ describe('Lens App', () => { async function save({ initialDocId, + addToDashboardMode, ...saveProps }: SaveProps & { initialDocId?: string; + addToDashboardMode?: boolean; }) { const args = { ...defaultArgs, docId: initialDocId, }; + if (addToDashboardMode) { + args.addToDashboardMode = addToDashboardMode; + } args.editorFrame = frame; (args.docStorage.load as jest.Mock).mockResolvedValue({ id: '1234', @@ -543,6 +551,23 @@ describe('Lens App', () => { expect(getButton(instance).disableButton).toEqual(false); }); + + it('saves new doc and redirects to dashboard', async () => { + const { args } = await save({ + initialDocId: undefined, + addToDashboardMode: true, + newCopyOnSave: false, + newTitle: 'hello there', + }); + + expect(args.docStorage.save).toHaveBeenCalledWith({ + expression: 'kibana 3', + id: undefined, + title: 'hello there', + }); + + expect(args.redirectTo).toHaveBeenCalledWith('aaa'); + }); }); }); diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx index cb57f2c884e387..f33cd41f46a115 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/app.tsx @@ -13,6 +13,7 @@ import { SavedObjectSaveModal } from 'ui/saved_objects/components/saved_object_s import { AppMountContext, NotificationsStart } from 'src/core/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { npStart } from 'ui/new_platform'; +import { FormattedMessage } from '@kbn/i18n/react'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { Document, SavedObjectStore } from '../persistence'; import { EditorFrameInstance } from '../types'; @@ -50,6 +51,7 @@ export function App({ docId, docStorage, redirectTo, + addToDashboardMode, }: { editorFrame: EditorFrameInstance; data: DataPublicPluginStart; @@ -58,6 +60,7 @@ export function App({ docId?: string; docStorage: SavedObjectStore; redirectTo: (id?: string) => void; + addToDashboardMode?: boolean; }) { const language = storage.get('kibana.userQueryLanguage') || core.uiSettings.get('search:queryLanguage'); @@ -166,6 +169,13 @@ export function App({ const { TopNavMenu } = npStart.plugins.navigation.ui; + const confirmButton = addToDashboardMode ? ( + + ) : null; + return ( { + .catch(e => { + // eslint-disable-next-line no-console + console.dir(e); trackUiEvent('save_failed'); core.notifications.toasts.addDanger( i18n.translate('xpack.lens.app.docSavingError', { @@ -337,10 +348,11 @@ export function App({ }} onClose={() => setState(s => ({ ...s, isSaveModalVisible: false }))} title={lastKnownDoc.title || ''} - showCopyOnSave={true} + showCopyOnSave={!addToDashboardMode} objectType={i18n.translate('xpack.lens.app.saveModalType', { defaultMessage: 'Lens visualization', })} + confirmButtonLabel={confirmButton} /> )} diff --git a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx index b1eac8e287bd89..7465de2dba7f1f 100644 --- a/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx +++ b/x-pack/legacy/plugins/lens/public/app_plugin/plugin.tsx @@ -14,11 +14,13 @@ import 'uiExports/visResponseHandlers'; import 'uiExports/savedObjectTypes'; import React from 'react'; -import { I18nProvider, FormattedMessage } from '@kbn/i18n/react'; -import { HashRouter, Switch, Route, RouteComponentProps } from 'react-router-dom'; +import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; +import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom'; import { render, unmountComponentAtNode } from 'react-dom'; import { CoreSetup, CoreStart, SavedObjectsClientContract } from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; +import rison, { RisonObject, RisonValue } from 'rison-node'; +import { isObject } from 'lodash'; import { DataStart } from '../../../../../../src/legacy/core_plugins/data/public'; import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; import { editorFrameSetup, editorFrameStart, editorFrameStop } from '../editor_frame_plugin'; @@ -41,6 +43,11 @@ import { import { NOT_INTERNATIONALIZED_PRODUCT_NAME } from '../../common'; import { KibanaLegacySetup } from '../../../../../../src/plugins/kibana_legacy/public'; import { EditorFrameStart } from '../types'; +import { + addEmbeddableToDashboardUrl, + getUrlVars, + getLensUrlFromDashboardAbsoluteUrl, +} from '../../../../../../src/legacy/core_plugins/kibana/public/dashboard/np_ready/url_helper'; export interface LensPluginSetupDependencies { kibana_legacy: KibanaLegacySetup; @@ -51,6 +58,9 @@ export interface LensPluginStartDependencies { dataShim: DataStart; } +export const isRisonObject = (value: RisonValue): value is RisonObject => { + return isObject(value); +}; export class AppPlugin { private startDependencies: { data: DataPublicPluginStart; @@ -84,7 +94,6 @@ export class AppPlugin { } const { data, savedObjectsClient, editorFrame } = this.startDependencies; addHelpMenuToAppChrome(context.core.chrome); - const instance = editorFrame.createInstance({}); setReportManager( @@ -93,9 +102,60 @@ export class AppPlugin { http: core.http, }) ); + const updateUrlTime = (urlVars: Record): void => { + const decoded: RisonObject = rison.decode(urlVars._g) as RisonObject; + if (!decoded) { + return; + } + // @ts-ignore + decoded.time = data.query.timefilter.timefilter.getTime(); + urlVars._g = rison.encode((decoded as unknown) as RisonObject); + }; + const redirectTo = ( + routeProps: RouteComponentProps<{ id?: string }>, + addToDashboardMode: boolean, + id?: string + ) => { + if (!id) { + routeProps.history.push('/lens'); + } else if (!addToDashboardMode) { + routeProps.history.push(`/lens/edit/${id}`); + } else if (addToDashboardMode && id) { + routeProps.history.push(`/lens/edit/${id}`); + const url = context.core.chrome.navLinks.get('kibana:dashboard'); + if (!url) { + throw new Error('Cannot get last dashboard url'); + } + const lastDashboardAbsoluteUrl = url.url; + const basePath = context.core.http.basePath.get(); + const lensUrl = getLensUrlFromDashboardAbsoluteUrl( + lastDashboardAbsoluteUrl, + basePath, + id + ); + if (!lastDashboardAbsoluteUrl || !lensUrl) { + throw new Error('Cannot get last dashboard url'); + } + window.history.pushState({}, '', lensUrl); + const urlVars = getUrlVars(lastDashboardAbsoluteUrl); + updateUrlTime(urlVars); // we need to pass in timerange in query params directly + const dashboardParsedUrl = addEmbeddableToDashboardUrl( + lastDashboardAbsoluteUrl, + basePath, + id, + urlVars + ); + if (!dashboardParsedUrl) { + throw new Error('Problem parsing dashboard url'); + } + window.history.pushState({}, '', dashboardParsedUrl); + } + }; const renderEditor = (routeProps: RouteComponentProps<{ id?: string }>) => { trackUiEvent('loaded'); + const addToDashboardMode = + !!routeProps.location.search && routeProps.location.search.includes('addToDashboard'); return ( { - if (!id) { - routeProps.history.push('/lens'); - } else { - routeProps.history.push(`/lens/edit/${id}`); - } - }} + redirectTo={id => redirectTo(routeProps, addToDashboardMode, id)} + addToDashboardMode={addToDashboardMode} /> ); }; @@ -119,6 +174,7 @@ export class AppPlugin { trackUiEvent('loaded_404'); return ; } + render( diff --git a/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js new file mode 100644 index 00000000000000..c90a0ae6d19fc4 --- /dev/null +++ b/x-pack/test/functional/apps/dashboard_mode/dashboard_empty_screen.js @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export default function({ getPageObjects, getService }) { + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const esArchiver = getService('esArchiver'); + const dashboardVisualizations = getService('dashboardVisualizations'); + const PageObjects = getPageObjects(['common', 'dashboard', 'visualize', 'lens']); + + describe('empty dashboard', function() { + before(async () => { + await esArchiver.loadIfNeeded('logstash_functional'); + await esArchiver.loadIfNeeded('lens/basic'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + await PageObjects.dashboard.clickNewDashboard(); + }); + + after(async () => { + await PageObjects.dashboard.gotoDashboardLandingPage(); + }); + + async function createAndAddLens(title) { + log.debug(`createAndAddLens(${title})`); + const inViewMode = await PageObjects.dashboard.getIsInViewMode(); + if (inViewMode) { + await PageObjects.dashboard.switchToEditMode(); + } + await PageObjects.visualize.clickLensWidget(); + await PageObjects.lens.goToTimeRange(); + await PageObjects.lens.configureDimension({ + dimension: + '[data-test-subj="lnsXY_xDimensionPanel"] [data-test-subj="indexPattern-configure-dimension"]', + operation: 'date_histogram', + field: '@timestamp', + }); + + await PageObjects.lens.configureDimension({ + dimension: + '[data-test-subj="lnsXY_yDimensionPanel"] [data-test-subj="indexPattern-configure-dimension"]', + operation: 'avg', + field: 'bytes', + }); + + await PageObjects.lens.configureDimension({ + dimension: + '[data-test-subj="lnsXY_splitDimensionPanel"] [data-test-subj="indexPattern-configure-dimension"]', + operation: 'terms', + field: 'ip', + }); + await PageObjects.lens.save(title); + } + + it('adds Lens visualization to empty dashboard', async () => { + const title = 'Dashboard Test Lens'; + await testSubjects.exists('addVisualizationButton'); + await testSubjects.click('addVisualizationButton'); + await dashboardVisualizations.ensureNewVisualizationDialogIsShowing(); + await createAndAddLens(title); + await PageObjects.dashboard.waitForRenderComplete(); + await testSubjects.exists(`embeddablePanelHeading-${title}`); + }); + }); +} diff --git a/x-pack/test/functional/apps/dashboard_mode/index.js b/x-pack/test/functional/apps/dashboard_mode/index.js index 2a98634ba40d5d..09b9717ea9f02b 100644 --- a/x-pack/test/functional/apps/dashboard_mode/index.js +++ b/x-pack/test/functional/apps/dashboard_mode/index.js @@ -9,5 +9,6 @@ export default function({ loadTestFile }) { this.tags('ciGroup7'); loadTestFile(require.resolve('./dashboard_view_mode')); + loadTestFile(require.resolve('./dashboard_empty_screen')); }); } From 70aa7b3c5cb20c93b5be671d37dff12261117d3a Mon Sep 17 00:00:00 2001 From: Catherine Liu Date: Mon, 13 Jan 2020 10:50:00 -0700 Subject: [PATCH 22/62] Migrates ES Fields Route to NP (#54398) * Migrated es fields route to NP and added tests * Removed extraneous import * Removed check for index query * Fixed broken test --- .../routes/es_fields/get_es_field_types.js | 33 ---- .../canvas/server/routes/es_fields/index.ts | 40 ----- .../plugins/canvas/server/routes/index.ts | 2 - .../server/routes/es_fields/es_fields.test.ts | 164 ++++++++++++++++++ .../server/routes/es_fields/es_fields.ts | 58 +++++++ .../canvas/server/routes/es_fields/index.ts | 12 ++ x-pack/plugins/canvas/server/routes/index.ts | 2 + 7 files changed, 236 insertions(+), 75 deletions(-) delete mode 100644 x-pack/legacy/plugins/canvas/server/routes/es_fields/get_es_field_types.js delete mode 100644 x-pack/legacy/plugins/canvas/server/routes/es_fields/index.ts create mode 100644 x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts create mode 100644 x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts create mode 100644 x-pack/plugins/canvas/server/routes/es_fields/index.ts diff --git a/x-pack/legacy/plugins/canvas/server/routes/es_fields/get_es_field_types.js b/x-pack/legacy/plugins/canvas/server/routes/es_fields/get_es_field_types.js deleted file mode 100644 index 36f7399ecd031d..00000000000000 --- a/x-pack/legacy/plugins/canvas/server/routes/es_fields/get_es_field_types.js +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { mapValues, keys } from 'lodash'; -import { normalizeType } from '../../lib/normalize_type'; - -export function getESFieldTypes(index, fields, elasticsearchClient) { - const config = { - index: index, - fields: fields || '*', - }; - - if (fields && fields.length === 0) { - return Promise.resolve({}); - } - - return elasticsearchClient('fieldCaps', config).then(resp => { - return mapValues(resp.fields, types => { - if (keys(types).length > 1) { - return 'conflict'; - } - - try { - return normalizeType(keys(types)[0]); - } catch (e) { - return 'unsupported'; - } - }); - }); -} diff --git a/x-pack/legacy/plugins/canvas/server/routes/es_fields/index.ts b/x-pack/legacy/plugins/canvas/server/routes/es_fields/index.ts deleted file mode 100644 index 6c1dd723299c67..00000000000000 --- a/x-pack/legacy/plugins/canvas/server/routes/es_fields/index.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 { partial } from 'lodash'; -import { API_ROUTE } from '../../../common/lib/constants'; -import { CoreSetup } from '../../shim'; -// @ts-ignore untyped local -import { getESFieldTypes } from './get_es_field_types'; - -// TODO: Error handling, note: esErrors - -interface ESFieldsRequest { - query: { - index: string; - fields: string[]; - }; -} - -export function esFields( - route: CoreSetup['http']['route'], - elasticsearch: CoreSetup['elasticsearch'] -) { - const { callWithRequest } = elasticsearch.getCluster('data'); - - route({ - method: 'GET', - path: `${API_ROUTE}/es_fields`, - handler(request: ESFieldsRequest, h: any) { - const { index, fields } = request.query; - if (!index) { - return h.response({ error: '"index" query is required' }).code(400); - } - - return getESFieldTypes(index, fields, partial(callWithRequest, request)); - }, - }); -} diff --git a/x-pack/legacy/plugins/canvas/server/routes/index.ts b/x-pack/legacy/plugins/canvas/server/routes/index.ts index 2f6b706fc7edbc..6898a3c459e3d4 100644 --- a/x-pack/legacy/plugins/canvas/server/routes/index.ts +++ b/x-pack/legacy/plugins/canvas/server/routes/index.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { esFields } from './es_fields'; import { shareableWorkpads } from './shareables'; import { CoreSetup } from '../shim'; export function routes(setup: CoreSetup): void { - esFields(setup.http.route, setup.elasticsearch); shareableWorkpads(setup.http.route); } diff --git a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts new file mode 100644 index 00000000000000..c96856d09256b0 --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.test.ts @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { initializeESFieldsRoute } from './es_fields'; +import { + IRouter, + kibanaResponseFactory, + RequestHandlerContext, + RequestHandler, +} from 'src/core/server'; +import { + httpServiceMock, + httpServerMock, + loggingServiceMock, + elasticsearchServiceMock, +} from 'src/core/server/mocks'; + +const mockRouteContext = ({ + core: { + elasticsearch: { dataClient: elasticsearchServiceMock.createScopedClusterClient() }, + }, +} as unknown) as RequestHandlerContext; + +const path = `api/canvas/workpad/find`; + +describe('Retrieve ES Fields', () => { + let routeHandler: RequestHandler; + + beforeEach(() => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter('') as jest.Mocked; + initializeESFieldsRoute({ + router, + logger: loggingServiceMock.create().get(), + }); + + routeHandler = router.get.mock.calls[0][1]; + }); + + it(`returns 200 with fields from existing index/index pattern`, async () => { + const index = 'test'; + const mockResults = { + indices: ['test'], + fields: { + '@timestamp': { + date: { + type: 'date', + searchable: true, + aggregatable: true, + }, + }, + name: { + text: { + type: 'text', + searchable: true, + aggregatable: false, + }, + }, + products: { + object: { + type: 'object', + searchable: false, + aggregatable: false, + }, + }, + }, + }; + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path, + query: { + index, + }, + }); + + const callAsCurrentUserMock = mockRouteContext.core.elasticsearch.dataClient + .callAsCurrentUser as jest.Mock; + + callAsCurrentUserMock.mockResolvedValueOnce(mockResults); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toMatchInlineSnapshot(` + Object { + "@timestamp": "date", + "name": "string", + "products": "unsupported", + } + `); + }); + + it(`returns 200 with empty object when index/index pattern has no fields`, async () => { + const index = 'test'; + const mockResults = { indices: [index], fields: {} }; + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path, + query: { + index, + }, + }); + + const callAsCurrentUserMock = mockRouteContext.core.elasticsearch.dataClient + .callAsCurrentUser as jest.Mock; + + callAsCurrentUserMock.mockResolvedValueOnce(mockResults); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toMatchInlineSnapshot('Object {}'); + }); + + it(`returns 200 with empty object when index/index pattern does not have specified field(s)`, async () => { + const index = 'test'; + + const mockResults = { + indices: [index], + fields: {}, + }; + + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path, + query: { + index, + fields: ['foo', 'bar'], + }, + }); + + const callAsCurrentUserMock = mockRouteContext.core.elasticsearch.dataClient + .callAsCurrentUser as jest.Mock; + + callAsCurrentUserMock.mockResolvedValueOnce(mockResults); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(200); + expect(response.payload).toMatchInlineSnapshot(`Object {}`); + }); + + it(`returns 500 when index does not exist`, async () => { + const request = httpServerMock.createKibanaRequest({ + method: 'get', + path, + query: { + index: 'foo', + }, + }); + + const callAsCurrentUserMock = mockRouteContext.core.elasticsearch.dataClient + .callAsCurrentUser as jest.Mock; + + callAsCurrentUserMock.mockRejectedValueOnce(new Error('Index not found')); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(response.status).toBe(500); + }); +}); diff --git a/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts new file mode 100644 index 00000000000000..b82f84b931d73f --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/es_fields/es_fields.ts @@ -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. + */ + +import { mapValues, keys } from 'lodash'; +import { schema } from '@kbn/config-schema'; +import { API_ROUTE } from '../../../../../legacy/plugins/canvas/common/lib'; +import { catchErrorHandler } from '../catch_error_handler'; +// @ts-ignore unconverted lib +import { normalizeType } from '../../../../../legacy/plugins/canvas/server/lib/normalize_type'; +import { RouteInitializerDeps } from '..'; + +const ESFieldsRequestSchema = schema.object({ + index: schema.string(), + fields: schema.maybe(schema.arrayOf(schema.string())), +}); + +export function initializeESFieldsRoute(deps: RouteInitializerDeps) { + const { router } = deps; + + router.get( + { + path: `${API_ROUTE}/es_fields`, + validate: { + query: ESFieldsRequestSchema, + }, + }, + catchErrorHandler(async (context, request, response) => { + const { callAsCurrentUser } = context.core.elasticsearch.dataClient; + const { index, fields } = request.query; + + const config = { + index, + fields: fields || '*', + }; + + const esFields = await callAsCurrentUser('fieldCaps', config).then(resp => { + return mapValues(resp.fields, types => { + if (keys(types).length > 1) { + return 'conflict'; + } + + try { + return normalizeType(keys(types)[0]); + } catch (e) { + return 'unsupported'; + } + }); + }); + + return response.ok({ + body: esFields, + }); + }) + ); +} diff --git a/x-pack/plugins/canvas/server/routes/es_fields/index.ts b/x-pack/plugins/canvas/server/routes/es_fields/index.ts new file mode 100644 index 00000000000000..fa44f09747d6ce --- /dev/null +++ b/x-pack/plugins/canvas/server/routes/es_fields/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { initializeESFieldsRoute } from './es_fields'; +import { RouteInitializerDeps } from '..'; + +export function initESFieldsRoutes(deps: RouteInitializerDeps) { + initializeESFieldsRoute(deps); +} diff --git a/x-pack/plugins/canvas/server/routes/index.ts b/x-pack/plugins/canvas/server/routes/index.ts index 8b2d77d6347609..e9afab5680332d 100644 --- a/x-pack/plugins/canvas/server/routes/index.ts +++ b/x-pack/plugins/canvas/server/routes/index.ts @@ -7,6 +7,7 @@ import { IRouter, Logger } from 'src/core/server'; import { initWorkpadRoutes } from './workpad'; import { initCustomElementsRoutes } from './custom_elements'; +import { initESFieldsRoutes } from './es_fields'; export interface RouteInitializerDeps { router: IRouter; @@ -16,4 +17,5 @@ export interface RouteInitializerDeps { export function initRoutes(deps: RouteInitializerDeps) { initWorkpadRoutes(deps); initCustomElementsRoutes(deps); + initESFieldsRoutes(deps); } From 79ee978fc46279095f85e0e5709a31e685c8d6f6 Mon Sep 17 00:00:00 2001 From: Jimmy Kuang Date: Mon, 13 Jan 2020 09:58:20 -0800 Subject: [PATCH 23/62] [SR] Support capitalized date formats in snapshot names (#53751) Snapshot names that contain date math may require capital letters, e.g. "". This change fixes a bug which complained that capital letters are not allowed in snapshot names, by scoping this validation to only the name part of this pattern, ignoring the date math part. --- .../policy_form/steps/step_logistics.tsx | 2 +- .../app/services/validation/validate_policy.ts | 18 ++++++++++++++++++ .../models/action/webhook_action.js | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_logistics.tsx b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_logistics.tsx index 2206d6de341c84..111b46d596e565 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_logistics.tsx +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/components/policy_form/steps/step_logistics.tsx @@ -347,7 +347,7 @@ export const PolicyStepLogistics: React.FunctionComponent = ({ onChange={e => { updatePolicy( { - snapshotName: e.target.value.toLowerCase(), + snapshotName: e.target.value, }, { managedRepository, diff --git a/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts b/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts index 7d44979e697a7f..0720994ca76693 100644 --- a/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts +++ b/x-pack/legacy/plugins/snapshot_restore/public/app/services/validation/validate_policy.ts @@ -15,6 +15,16 @@ const isStringEmpty = (str: string | null): boolean => { return str ? !Boolean(str.trim()) : true; }; +// strExcludeDate is the concat results of the SnapshotName ...{...}>... without the date +// This way we can check only the SnapshotName portion for lowercasing +// For example: would give strExcludeDate = + +const isSnapshotNameNotLowerCase = (str: string): boolean => { + const strExcludeDate = + str.substring(0, str.search('{')) + str.substring(str.search('}>') + 1, str.length); + return strExcludeDate !== strExcludeDate.toLowerCase() ? true : false; +}; + export const validatePolicy = ( policy: SlmPolicyPayload, validationHelperData: { @@ -61,6 +71,14 @@ export const validatePolicy = ( ); } + if (isSnapshotNameNotLowerCase(snapshotName)) { + validation.errors.snapshotName.push( + i18n.translate('xpack.snapshotRestore.policyValidation.snapshotNameLowerCaseErrorMessage', { + defaultMessage: 'Snapshot name needs to be lowercase.', + }) + ); + } + if (isStringEmpty(schedule)) { validation.errors.schedule.push( i18n.translate('xpack.snapshotRestore.policyValidation.scheduleRequiredErrorMessage', { diff --git a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js index 6f496dd9ee1381..3225653acbb3d5 100644 --- a/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js +++ b/x-pack/legacy/plugins/watcher/public/np_ready/application/models/action/webhook_action.js @@ -25,6 +25,7 @@ export class WebhookAction extends BaseAction { this.username = get(props, 'username'); this.password = get(props, 'password'); this.contentType = get(props, 'contentType'); + this.fullPath = `${this.host}:${this.port}${this.path ? '/' + this.path : ''}`; } From f7ba36279e468bd8c738bc7d947453a5a3c39e93 Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Mon, 13 Jan 2020 10:19:20 -0800 Subject: [PATCH 24/62] [DOCS] Removes dashboard search batching setting (#54594) * [DOCS] Removes dashboard search batching setting * [DOCS] Keeps content for search setting and adds deprecation notice * [DOCS] Fixes version notice in deprecation notice --- docs/management/advanced-options.asciidoc | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 977a65f62202d2..757c6f10f2a999 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -187,7 +187,8 @@ Refresh the page to apply the changes. === Search settings [horizontal] -`courier:batchSearches`:: When disabled, dashboard panels will load individually, and search requests will terminate when +`courier:batchSearches`:: **Deprecated in 7.6. Starting in 8.0, this setting will be optimized internally.** +When disabled, dashboard panels will load individually, and search requests will terminate when users navigate away or update the query. When enabled, dashboard panels will load together when all of the data is loaded, and searches will not terminate. `courier:customRequestPreference`:: {ref}/search-request-body.html#request-body-search-preference[Request preference] From ec69443ca27d4196bd5111e2d099b848fe37f94f Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Mon, 13 Jan 2020 12:37:39 -0600 Subject: [PATCH 25/62] [docs] load balancing kibana (#52659) * [docs] multiple kibanas * fix * capital title * Update docs/setup/production.asciidoc Co-Authored-By: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/setup/production.asciidoc Co-Authored-By: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/setup/production.asciidoc Co-Authored-By: gchaps <33642766+gchaps@users.noreply.github.com> * title and actions * fix reference * fix merge * case fix * plural Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- docs/setup/production.asciidoc | 40 ++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/setup/production.asciidoc b/docs/setup/production.asciidoc index fed4ba4886bf94..eef2b11e53d85f 100644 --- a/docs/setup/production.asciidoc +++ b/docs/setup/production.asciidoc @@ -4,7 +4,8 @@ * <> * <> * <> -* <> +* <> +* <> * <> * <> @@ -18,7 +19,7 @@ While Kibana isn't terribly resource intensive, we still recommend running Kiban separate from your Elasticsearch data or master nodes. To distribute Kibana traffic across the nodes in your Elasticsearch cluster, you can run Kibana and an Elasticsearch client node on the same machine. For more information, see -<>. +<>. [float] [[configuring-kibana-shield]] @@ -63,7 +64,7 @@ csp.strict: true See <>. [float] -[[load-balancing]] +[[load-balancing-es]] === Load Balancing Across Multiple Elasticsearch Nodes If you have multiple nodes in your Elasticsearch cluster, the easiest way to distribute Kibana requests across the nodes is to run an Elasticsearch _Coordinating only_ node on the same machine as Kibana. @@ -110,9 +111,40 @@ transport.tcp.port: 9300 - 9400 elasticsearch.hosts: ["http://localhost:9200"] -------- +[float] +[[load-balancing-kibana]] +=== Load balancing across multiple Kibana instances +To serve multiple Kibana installations behind a load balancer, you must change the configuration. See {kibana-ref}/settings.html[Configuring Kibana] for details on each setting. + +Settings unique across each Kibana instance: +-------- +server.uuid +server.name +-------- + +Settings unique across each host (for example, running multiple installations on the same virtual machine): +-------- +logging.dest +path.data +pid.file +server.port +-------- + +Settings that must be the same: +-------- +xpack.security.encryptionKey //decrypting session cookies +xpack.reporting.encryptionKey //decrypting reports stored in Elasticsearch +-------- + +Separate configuration files can be used from the command line by using the `-c` flag: +-------- +bin/kibana -c config/instance1.yml +bin/kibana -c config/instance2.yml +-------- + [float] [[high-availability]] -=== High Availability Across Multiple Elasticsearch Nodes +=== High availability across multiple Elasticsearch nodes Kibana can be configured to connect to multiple Elasticsearch nodes in the same cluster. In situations where a node becomes unavailable, Kibana will transparently connect to an available node and continue operating. Requests to available hosts will be routed in a round robin fashion. From ea9a7b8a16abbee97ddf39f91e4cf3c3b7253b42 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Mon, 13 Jan 2020 19:09:57 +0000 Subject: [PATCH 26/62] migrate TaskManager Plugin to the Kibana Platform (#53869) Migrates the existing TaskManager plugin from Legacy to Kibana Platform. We retain the Legacy API to prevent a breaking change, but under the hood, the legacy plugin is now using the Kibana Platform plugin. Another reason we retain the Legacy plugin to support several features that the Platform team has yet to migrate to Kibana Platform (mapping, SO schema and migrations). --- src/core/server/mocks.ts | 1 + .../server/action_type_registry.test.ts | 4 +- .../actions/server/action_type_registry.ts | 4 +- .../actions/server/actions_client.test.ts | 4 +- .../server/builtin_action_types/index.test.ts | 4 +- .../server/create_execute_function.test.ts | 4 +- .../actions/server/create_execute_function.ts | 2 +- x-pack/legacy/plugins/actions/server/init.ts | 5 +- .../server/lib/task_runner_factory.test.ts | 2 +- .../actions/server/lib/task_runner_factory.ts | 2 +- .../legacy/plugins/actions/server/plugin.ts | 4 +- x-pack/legacy/plugins/actions/server/shim.ts | 31 +- .../server/alert_type_registry.test.ts | 5 +- .../alerting/server/alert_type_registry.ts | 3 +- .../alerting/server/alerts_client.test.ts | 6 +- .../plugins/alerting/server/alerts_client.ts | 2 +- .../server/alerts_client_factory.test.ts | 4 +- .../alerting/server/alerts_client_factory.ts | 3 +- .../legacy/plugins/alerting/server/plugin.ts | 4 +- x-pack/legacy/plugins/alerting/server/shim.ts | 23 +- .../server/task_runner/task_runner.test.ts | 2 +- .../server/task_runner/task_runner.ts | 2 +- .../task_runner/task_runner_factory.test.ts | 2 +- .../server/task_runner/task_runner_factory.ts | 2 +- x-pack/legacy/plugins/lens/index.ts | 7 + x-pack/legacy/plugins/lens/server/plugin.tsx | 35 +- .../plugins/lens/server/usage/collectors.ts | 39 +-- .../legacy/plugins/lens/server/usage/task.ts | 36 +-- .../plugins/maps/server/test_utils/index.js | 21 -- x-pack/legacy/plugins/oss_telemetry/index.ts | 20 +- .../server/lib/collectors/index.ts | 4 +- .../get_usage_collector.test.ts | 53 +-- .../visualizations/get_usage_collector.ts | 8 +- .../register_usage_collector.ts | 4 +- .../oss_telemetry/server/lib/tasks/index.ts | 11 +- .../tasks/visualizations/task_runner.test.ts | 2 +- .../lib/tasks/visualizations/task_runner.ts | 2 +- .../plugins/oss_telemetry/server/plugin.ts | 23 +- .../plugins/oss_telemetry/test_utils/index.ts | 48 ++- .../plugins/task_manager/server/index.ts | 105 +++--- .../plugins/task_manager/server/legacy.ts | 57 ++++ .../task_manager/server/plugin.test.ts | 73 ----- .../plugins/task_manager/server/plugin.ts | 82 ----- .../task_manager/server/task_manager.mock.ts | 43 ++- x-pack/plugins/task_manager/kibana.json | 8 + .../plugins/task_manager/server/README.md | 305 +++++++++++------- .../task_manager/server/config.test.ts | 33 ++ x-pack/plugins/task_manager/server/config.ts | 44 +++ .../server/create_task_manager.test.ts | 58 ++++ .../server/create_task_manager.ts | 46 +++ x-pack/plugins/task_manager/server/index.ts | 29 ++ .../lib/correct_deprecated_fields.test.ts | 0 .../server/lib/correct_deprecated_fields.ts | 0 .../task_manager/server/lib/fill_pool.test.ts | 0 .../task_manager/server/lib/fill_pool.ts | 0 .../server/lib/get_template_version.test.ts | 0 .../server/lib/get_template_version.ts | 0 .../server/lib/identify_es_error.test.ts | 0 .../server/lib/identify_es_error.ts | 0 .../task_manager/server/lib/intervals.test.ts | 0 .../task_manager/server/lib/intervals.ts | 0 .../server/lib/middleware.test.ts | 0 .../task_manager/server/lib/middleware.ts | 0 .../server/lib/pull_from_set.test.ts | 0 .../task_manager/server/lib/pull_from_set.ts | 0 .../task_manager/server/lib/result_type.ts | 0 .../lib/sanitize_task_definitions.test.ts | 0 .../server/lib/sanitize_task_definitions.ts | 0 x-pack/plugins/task_manager/server/plugin.ts | 83 +++++ .../mark_available_tasks_as_claimed.test.ts | 0 .../mark_available_tasks_as_claimed.ts | 0 .../server/queries/query_clauses.ts | 0 .../plugins/task_manager/server/task.ts | 0 .../task_manager/server/task_events.ts | 0 .../task_manager/server/task_manager.mock.ts | 32 ++ .../task_manager/server/task_manager.test.ts | 53 +-- .../task_manager/server/task_manager.ts | 28 +- .../task_manager/server/task_poller.test.ts | 0 .../task_manager/server/task_poller.ts | 0 .../task_manager/server/task_pool.test.ts | 0 .../plugins/task_manager/server/task_pool.ts | 0 .../task_manager/server/task_runner.test.ts | 2 +- .../task_manager/server/task_runner.ts | 0 .../task_manager/server/task_store.test.ts | 8 +- .../plugins/task_manager/server/task_store.ts | 8 +- .../task_manager/server/test_utils/index.ts | 0 .../plugins/task_manager/server/types.ts | 0 .../fixtures/plugins/task_manager/index.ts | 4 +- .../plugins/task_manager/index.js | 8 +- .../plugins/task_manager/init_routes.js | 43 ++- .../task_manager/task_manager_integration.js | 19 ++ .../plugins/task_manager_performance/index.js | 5 +- .../task_manager_performance/init_routes.js | 5 +- x-pack/test/typings/hapi.d.ts | 2 - x-pack/typings/hapi.d.ts | 4 +- 95 files changed, 1006 insertions(+), 619 deletions(-) create mode 100644 x-pack/legacy/plugins/task_manager/server/legacy.ts delete mode 100644 x-pack/legacy/plugins/task_manager/server/plugin.test.ts delete mode 100644 x-pack/legacy/plugins/task_manager/server/plugin.ts create mode 100644 x-pack/plugins/task_manager/kibana.json rename x-pack/{legacy => }/plugins/task_manager/server/README.md (68%) create mode 100644 x-pack/plugins/task_manager/server/config.test.ts create mode 100644 x-pack/plugins/task_manager/server/config.ts create mode 100644 x-pack/plugins/task_manager/server/create_task_manager.test.ts create mode 100644 x-pack/plugins/task_manager/server/create_task_manager.ts create mode 100644 x-pack/plugins/task_manager/server/index.ts rename x-pack/{legacy => }/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/correct_deprecated_fields.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/fill_pool.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/fill_pool.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/get_template_version.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/get_template_version.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/identify_es_error.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/identify_es_error.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/intervals.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/intervals.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/middleware.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/middleware.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/pull_from_set.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/pull_from_set.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/result_type.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/lib/sanitize_task_definitions.ts (100%) create mode 100644 x-pack/plugins/task_manager/server/plugin.ts rename x-pack/{legacy => }/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/queries/query_clauses.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/task.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/task_events.ts (100%) create mode 100644 x-pack/plugins/task_manager/server/task_manager.mock.ts rename x-pack/{legacy => }/plugins/task_manager/server/task_manager.test.ts (94%) rename x-pack/{legacy => }/plugins/task_manager/server/task_manager.ts (95%) rename x-pack/{legacy => }/plugins/task_manager/server/task_poller.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/task_poller.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/task_pool.test.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/task_pool.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/task_runner.test.ts (99%) rename x-pack/{legacy => }/plugins/task_manager/server/task_runner.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/task_store.test.ts (99%) rename x-pack/{legacy => }/plugins/task_manager/server/task_store.ts (98%) rename x-pack/{legacy => }/plugins/task_manager/server/test_utils/index.ts (100%) rename x-pack/{legacy => }/plugins/task_manager/server/types.ts (100%) diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 073d380d3aa67b..c7082d46313ae3 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -37,6 +37,7 @@ export { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service. export { httpServiceMock } from './http/http_service.mock'; export { loggingServiceMock } from './logging/logging_service.mock'; export { savedObjectsClientMock } from './saved_objects/service/saved_objects_client.mock'; +export { savedObjectsRepositoryMock } from './saved_objects/service/lib/repository.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { uuidServiceMock } from './uuid/uuid_service.mock'; diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts index 2f15ae1c0a2b36..63f1b545179c73 100644 --- a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { ActionTypeRegistry } from './action_type_registry'; import { ExecutorType } from './types'; import { ActionExecutor, ExecutorError, TaskRunnerFactory } from './lib'; import { configUtilsMock } from './actions_config.mock'; -const mockTaskManager = taskManagerMock.create(); +const mockTaskManager = taskManagerMock.setup(); const actionTypeRegistryParams = { taskManager: mockTaskManager, taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor()), diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.ts index f66d1947c2b8b9..351c1add7b4513 100644 --- a/x-pack/legacy/plugins/actions/server/action_type_registry.ts +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.ts @@ -6,11 +6,11 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; -import { TaskManagerSetupContract } from './shim'; -import { RunContext } from '../../task_manager/server'; +import { RunContext, TaskManagerSetupContract } from '../../../../plugins/task_manager/server'; import { ExecutorError, TaskRunnerFactory } from './lib'; import { ActionType } from './types'; import { ActionsConfigurationUtilities } from './actions_config'; + interface ConstructorOptions { taskManager: TaskManagerSetupContract; taskRunnerFactory: TaskRunnerFactory; diff --git a/x-pack/legacy/plugins/actions/server/actions_client.test.ts b/x-pack/legacy/plugins/actions/server/actions_client.test.ts index 9e75248c56caee..dfbd2db4b68423 100644 --- a/x-pack/legacy/plugins/actions/server/actions_client.test.ts +++ b/x-pack/legacy/plugins/actions/server/actions_client.test.ts @@ -10,7 +10,7 @@ import { ActionTypeRegistry } from './action_type_registry'; import { ActionsClient } from './actions_client'; import { ExecutorType } from './types'; import { ActionExecutor, TaskRunnerFactory } from './lib'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { configUtilsMock } from './actions_config.mock'; import { getActionsConfigurationUtilities } from './actions_config'; @@ -23,7 +23,7 @@ const defaultKibanaIndex = '.kibana'; const savedObjectsClient = savedObjectsClientMock.create(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); -const mockTaskManager = taskManagerMock.create(); +const mockTaskManager = taskManagerMock.setup(); const actionTypeRegistryParams = { taskManager: mockTaskManager, diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts index 3a0c9f415cc2ba..5fcf39c2e8fdd0 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts @@ -6,7 +6,7 @@ import { ActionExecutor, TaskRunnerFactory } from '../lib'; import { ActionTypeRegistry } from '../action_type_registry'; -import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../../plugins/task_manager/server/task_manager.mock'; import { registerBuiltInActionTypes } from './index'; import { Logger } from '../../../../../../src/core/server'; import { loggingServiceMock } from '../../../../../../src/core/server/mocks'; @@ -20,7 +20,7 @@ export function createActionTypeRegistry(): { } { const logger = loggingServiceMock.create().get() as jest.Mocked; const actionTypeRegistry = new ActionTypeRegistry({ - taskManager: taskManagerMock.create(), + taskManager: taskManagerMock.setup(), taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor()), actionsConfigUtils: configUtilsMock, }); diff --git a/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts b/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts index 6de446ee2da767..7dbcfce5ee3359 100644 --- a/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { createExecuteFunction } from './create_execute_function'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -const mockTaskManager = taskManagerMock.create(); +const mockTaskManager = taskManagerMock.start(); const savedObjectsClient = savedObjectsClientMock.create(); const getBasePath = jest.fn(); diff --git a/x-pack/legacy/plugins/actions/server/create_execute_function.ts b/x-pack/legacy/plugins/actions/server/create_execute_function.ts index 8ff12b8c3fa4b6..ddd8b1df2327b2 100644 --- a/x-pack/legacy/plugins/actions/server/create_execute_function.ts +++ b/x-pack/legacy/plugins/actions/server/create_execute_function.ts @@ -5,7 +5,7 @@ */ import { SavedObjectsClientContract } from 'src/core/server'; -import { TaskManagerStartContract } from './shim'; +import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; import { GetBasePathFunction } from './types'; interface CreateExecuteFunctionOptions { diff --git a/x-pack/legacy/plugins/actions/server/init.ts b/x-pack/legacy/plugins/actions/server/init.ts index 5eab3418467bce..6f221b08c4bc55 100644 --- a/x-pack/legacy/plugins/actions/server/init.ts +++ b/x-pack/legacy/plugins/actions/server/init.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Legacy } from 'kibana'; import { Plugin } from './plugin'; -import { shim, Server } from './shim'; +import { shim } from './shim'; import { ActionsPlugin } from './types'; -export async function init(server: Server) { +export async function init(server: Legacy.Server) { const { initializerContext, coreSetup, coreStart, pluginsSetup, pluginsStart } = shim(server); const plugin = new Plugin(initializerContext); diff --git a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts index 5b60696c42d523..ad2b74da0d7d44 100644 --- a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts @@ -7,7 +7,7 @@ import sinon from 'sinon'; import { ExecutorError } from './executor_error'; import { ActionExecutor } from './action_executor'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager/server'; +import { ConcreteTaskInstance, TaskStatus } from '../../../../../plugins/task_manager/server'; import { TaskRunnerFactory } from './task_runner_factory'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { actionExecutorMock } from './action_executor.mock'; diff --git a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts index ca6a726f40e148..2dc3d1161399e6 100644 --- a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts @@ -6,7 +6,7 @@ import { ActionExecutorContract } from './action_executor'; import { ExecutorError } from './executor_error'; -import { RunContext } from '../../../task_manager/server'; +import { RunContext } from '../../../../../plugins/task_manager/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; import { ActionTaskParams, GetBasePathFunction, SpaceIdToNamespaceFunction } from '../types'; diff --git a/x-pack/legacy/plugins/actions/server/plugin.ts b/x-pack/legacy/plugins/actions/server/plugin.ts index 48f99ba5135b78..ffc4a9cf90e54c 100644 --- a/x-pack/legacy/plugins/actions/server/plugin.ts +++ b/x-pack/legacy/plugins/actions/server/plugin.ts @@ -93,7 +93,7 @@ export class Plugin { const actionsConfigUtils = getActionsConfigurationUtilities(config as ActionsConfigType); const actionTypeRegistry = new ActionTypeRegistry({ taskRunnerFactory, - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, actionsConfigUtils, }); this.taskRunnerFactory = taskRunnerFactory; @@ -164,7 +164,7 @@ export class Plugin { }); const executeFn = createExecuteFunction({ - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, getScopedSavedObjectsClient: core.savedObjects.getScopedSavedObjectsClient, getBasePath, }); diff --git a/x-pack/legacy/plugins/actions/server/shim.ts b/x-pack/legacy/plugins/actions/server/shim.ts index f8aa9b8d7a25ce..8077dc67c92c4a 100644 --- a/x-pack/legacy/plugins/actions/server/shim.ts +++ b/x-pack/legacy/plugins/actions/server/shim.ts @@ -8,7 +8,11 @@ import Hapi from 'hapi'; import { Legacy } from 'kibana'; import * as Rx from 'rxjs'; import { ActionsConfigType } from './types'; -import { TaskManager } from '../../task_manager/server'; +import { + TaskManagerStartContract, + TaskManagerSetupContract, +} from '../../../../plugins/task_manager/server'; +import { getTaskManagerSetup, getTaskManagerStart } from '../../task_manager/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../spaces'; @@ -24,16 +28,6 @@ import { } from '../../../../../src/core/server'; import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; -// Extend PluginProperties to indicate which plugins are guaranteed to exist -// due to being marked as dependencies -interface Plugins extends Hapi.PluginProperties { - task_manager: TaskManager; -} - -export interface Server extends Legacy.Server { - plugins: Plugins; -} - export interface KibanaConfig { index: string; } @@ -41,14 +35,9 @@ export interface KibanaConfig { /** * Shim what we're thinking setup and start contracts will look like */ -export type TaskManagerStartContract = Pick; export type XPackMainPluginSetupContract = Pick; export type SecurityPluginSetupContract = Pick; export type SecurityPluginStartContract = Pick; -export type TaskManagerSetupContract = Pick< - TaskManager, - 'addMiddleware' | 'registerTaskDefinitions' ->; /** * New platform interfaces @@ -74,7 +63,7 @@ export interface ActionsCoreStart { } export interface ActionsPluginsSetup { security?: SecurityPluginSetupContract; - task_manager: TaskManagerSetupContract; + taskManager: TaskManagerSetupContract; xpack_main: XPackMainPluginSetupContract; encryptedSavedObjects: EncryptedSavedObjectsSetupContract; licensing: LicensingPluginSetup; @@ -83,7 +72,7 @@ export interface ActionsPluginsStart { security?: SecurityPluginStartContract; spaces: () => SpacesPluginStartContract | undefined; encryptedSavedObjects: EncryptedSavedObjectsStartContract; - task_manager: TaskManagerStartContract; + taskManager: TaskManagerStartContract; } /** @@ -92,7 +81,7 @@ export interface ActionsPluginsStart { * @param server Hapi server instance */ export function shim( - server: Server + server: Legacy.Server ): { initializerContext: ActionsPluginInitializerContext; coreSetup: ActionsCoreSetup; @@ -132,7 +121,7 @@ export function shim( const pluginsSetup: ActionsPluginsSetup = { security: newPlatform.setup.plugins.security as SecurityPluginSetupContract | undefined, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerSetup(server)!, xpack_main: server.plugins.xpack_main, encryptedSavedObjects: newPlatform.setup.plugins .encryptedSavedObjects as EncryptedSavedObjectsSetupContract, @@ -146,7 +135,7 @@ export function shim( spaces: () => server.plugins.spaces, encryptedSavedObjects: newPlatform.start.plugins .encryptedSavedObjects as EncryptedSavedObjectsStartContract, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerStart(server)!, }; return { diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts index 8e96ad8dae31cc..e1a05d6460e254 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts @@ -6,10 +6,9 @@ import { TaskRunnerFactory } from './task_runner'; import { AlertTypeRegistry } from './alert_type_registry'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; - -const taskManager = taskManagerMock.create(); +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; +const taskManager = taskManagerMock.setup(); const alertTypeRegistryParams = { taskManager, taskRunnerFactory: new TaskRunnerFactory(), diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts index 2003e810a05b53..1e9007202c4521 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts @@ -6,9 +6,8 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; +import { RunContext, TaskManagerSetupContract } from '../../../../plugins/task_manager/server'; import { TaskRunnerFactory } from './task_runner'; -import { RunContext } from '../../task_manager'; -import { TaskManagerSetupContract } from './shim'; import { AlertType } from './types'; interface ConstructorOptions { diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts index 32293d9755a2a7..2af66059d9fed2 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts @@ -7,14 +7,14 @@ import uuid from 'uuid'; import { schema } from '@kbn/config-schema'; import { AlertsClient } from './alerts_client'; import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; -import { TaskStatus } from '../../task_manager/server'; +import { TaskStatus } from '../../../../plugins/task_manager/server'; import { IntervalSchedule } from './types'; import { resolvable } from './test_utils'; import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks'; -const taskManager = taskManagerMock.create(); +const taskManager = taskManagerMock.start(); const alertTypeRegistry = alertTypeRegistryMock.create(); const savedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createStart(); diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts index 33a6b716e9b8a1..fe96a233b8663f 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -22,7 +22,6 @@ import { AlertType, IntervalSchedule, } from './types'; -import { TaskManagerStartContract } from './shim'; import { validateAlertTypeParams } from './lib'; import { InvalidateAPIKeyParams, @@ -30,6 +29,7 @@ import { InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult, } from '../../../../plugins/security/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../plugins/encrypted_saved_objects/server'; +import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; type NormalizedAlertAction = Omit; export type CreateAPIKeyResult = diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts index 519001d07e089c..754e02a3f1e5ec 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts @@ -7,7 +7,7 @@ import { Request } from 'hapi'; import { AlertsClientFactory, ConstructorOpts } from './alerts_client_factory'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { KibanaRequest } from '../../../../../src/core/server'; import { loggingServiceMock } from '../../../../../src/core/server/mocks'; import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks'; @@ -23,7 +23,7 @@ const securityPluginSetup = { }; const alertsClientFactoryParams: jest.Mocked = { logger: loggingServiceMock.create().get(), - taskManager: taskManagerMock.create(), + taskManager: taskManagerMock.start(), alertTypeRegistry: alertTypeRegistryMock.create(), getSpaceId: jest.fn(), spaceIdToNamespace: jest.fn(), diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts index 94a396fbaa8063..eab1cc3ce627b4 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts @@ -8,10 +8,11 @@ import Hapi from 'hapi'; import uuid from 'uuid'; import { AlertsClient } from './alerts_client'; import { AlertTypeRegistry, SpaceIdToNamespaceFunction } from './types'; -import { SecurityPluginStartContract, TaskManagerStartContract } from './shim'; +import { SecurityPluginStartContract } from './shim'; import { KibanaRequest, Logger } from '../../../../../src/core/server'; import { InvalidateAPIKeyParams } from '../../../../plugins/security/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../plugins/encrypted_saved_objects/server'; +import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; export interface ConstructorOpts { logger: Logger; diff --git a/x-pack/legacy/plugins/alerting/server/plugin.ts b/x-pack/legacy/plugins/alerting/server/plugin.ts index fb16f579d4c701..357db9e3df97ed 100644 --- a/x-pack/legacy/plugins/alerting/server/plugin.ts +++ b/x-pack/legacy/plugins/alerting/server/plugin.ts @@ -79,7 +79,7 @@ export class Plugin { }); const alertTypeRegistry = new AlertTypeRegistry({ - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, taskRunnerFactory: this.taskRunnerFactory, }); this.alertTypeRegistry = alertTypeRegistry; @@ -116,7 +116,7 @@ export class Plugin { const alertsClientFactory = new AlertsClientFactory({ alertTypeRegistry: this.alertTypeRegistry!, logger: this.logger, - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, securityPluginSetup: plugins.security, encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects, spaceIdToNamespace, diff --git a/x-pack/legacy/plugins/alerting/server/shim.ts b/x-pack/legacy/plugins/alerting/server/shim.ts index ae29048d83dd9e..ccc10f929e123f 100644 --- a/x-pack/legacy/plugins/alerting/server/shim.ts +++ b/x-pack/legacy/plugins/alerting/server/shim.ts @@ -7,7 +7,11 @@ import Hapi from 'hapi'; import { Legacy } from 'kibana'; import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../spaces'; -import { TaskManager } from '../../task_manager/server'; +import { + TaskManagerStartContract, + TaskManagerSetupContract, +} from '../../../../plugins/task_manager/server'; +import { getTaskManagerSetup, getTaskManagerStart } from '../../task_manager/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; import { @@ -31,7 +35,6 @@ import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; // due to being marked as dependencies interface Plugins extends Hapi.PluginProperties { actions: ActionsPlugin; - task_manager: TaskManager; } export interface Server extends Legacy.Server { @@ -41,17 +44,9 @@ export interface Server extends Legacy.Server { /** * Shim what we're thinking setup and start contracts will look like */ -export type TaskManagerStartContract = Pick< - TaskManager, - 'schedule' | 'fetch' | 'remove' | 'runNow' ->; export type SecurityPluginSetupContract = Pick; export type SecurityPluginStartContract = Pick; export type XPackMainPluginSetupContract = Pick; -export type TaskManagerSetupContract = Pick< - TaskManager, - 'addMiddleware' | 'registerTaskDefinitions' ->; /** * New platform interfaces @@ -73,7 +68,7 @@ export interface AlertingCoreStart { } export interface AlertingPluginsSetup { security?: SecurityPluginSetupContract; - task_manager: TaskManagerSetupContract; + taskManager: TaskManagerSetupContract; actions: ActionsPluginSetupContract; xpack_main: XPackMainPluginSetupContract; encryptedSavedObjects: EncryptedSavedObjectsSetupContract; @@ -84,7 +79,7 @@ export interface AlertingPluginsStart { security?: SecurityPluginStartContract; spaces: () => SpacesPluginStartContract | undefined; encryptedSavedObjects: EncryptedSavedObjectsStartContract; - task_manager: TaskManagerStartContract; + taskManager: TaskManagerStartContract; } /** @@ -121,7 +116,7 @@ export function shim( const pluginsSetup: AlertingPluginsSetup = { security: newPlatform.setup.plugins.security as SecurityPluginSetupContract | undefined, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerSetup(server)!, actions: server.plugins.actions.setup, xpack_main: server.plugins.xpack_main, encryptedSavedObjects: newPlatform.setup.plugins @@ -137,7 +132,7 @@ export function shim( spaces: () => server.plugins.spaces, encryptedSavedObjects: newPlatform.start.plugins .encryptedSavedObjects as EncryptedSavedObjectsStartContract, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerStart(server)!, }; return { diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts index dc220067bd35a8..45ee13e2370d22 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts @@ -7,7 +7,7 @@ import sinon from 'sinon'; import { schema } from '@kbn/config-schema'; import { AlertExecutorOptions } from '../types'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager'; +import { ConcreteTaskInstance, TaskStatus } from '../../../../../plugins/task_manager/server'; import { TaskRunnerContext } from './task_runner_factory'; import { TaskRunner } from './task_runner'; import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts index c6f1a02da8dcd6..0f643e3d3121cc 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -8,7 +8,7 @@ import { pick, mapValues, omit } from 'lodash'; import { Logger } from '../../../../../../src/core/server'; import { SavedObject } from '../../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; -import { ConcreteTaskInstance } from '../../../task_manager'; +import { ConcreteTaskInstance } from '../../../../../plugins/task_manager/server'; import { createExecutionHandler } from './create_execution_handler'; import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; import { getNextRunAt } from './get_next_run_at'; diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 2ea1256352bec9..543b9e7d32e12a 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -5,7 +5,7 @@ */ import sinon from 'sinon'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager'; +import { ConcreteTaskInstance, TaskStatus } from '../../../../../plugins/task_manager/server'; import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; import { diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts index 7186e1e729bda4..7178fa4f01282c 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { Logger } from '../../../../../../src/core/server'; -import { RunContext } from '../../../task_manager'; +import { RunContext } from '../../../../../plugins/task_manager/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; import { PluginStartContract as ActionsPluginStartContract } from '../../../actions'; import { diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index c4a684381b17ce..a4eb24d4a4de4f 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -11,6 +11,7 @@ import KbnServer, { Server } from 'src/legacy/server/kbn_server'; import mappings from './mappings.json'; import { PLUGIN_ID, getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from './common'; import { lensServerPlugin } from './server'; +import { getTaskManagerSetup, getTaskManagerStart } from '../task_manager/server'; export const lens: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ @@ -64,6 +65,12 @@ export const lens: LegacyPluginInitializer = kibana => { savedObjects: server.savedObjects, config: server.config(), server, + taskManager: getTaskManagerSetup(server)!, + }); + + plugin.start(kbnServer.newPlatform.start.core, { + server, + taskManager: getTaskManagerStart(server)!, }); server.events.on('stop', () => { diff --git a/x-pack/legacy/plugins/lens/server/plugin.tsx b/x-pack/legacy/plugins/lens/server/plugin.tsx index 0223b90c370469..f80d52248b4849 100644 --- a/x-pack/legacy/plugins/lens/server/plugin.tsx +++ b/x-pack/legacy/plugins/lens/server/plugin.tsx @@ -5,28 +5,51 @@ */ import { Server, KibanaConfig } from 'src/legacy/server/kbn_server'; -import { Plugin, CoreSetup, SavedObjectsLegacyService } from 'src/core/server'; +import { Plugin, CoreSetup, CoreStart, SavedObjectsLegacyService } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { Subject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; import { setupRoutes } from './routes'; -import { registerLensUsageCollector, initializeLensTelemetry } from './usage'; +import { + registerLensUsageCollector, + initializeLensTelemetry, + scheduleLensTelemetry, +} from './usage'; export interface PluginSetupContract { savedObjects: SavedObjectsLegacyService; usageCollection: UsageCollectionSetup; config: KibanaConfig; server: Server; + taskManager: TaskManagerSetupContract; } +export interface PluginStartContract { + server: Server; + taskManager: TaskManagerStartContract; +} + +const taskManagerStartContract$ = new Subject(); + export class LensServer implements Plugin<{}, {}, {}, {}> { setup(core: CoreSetup, plugins: PluginSetupContract) { setupRoutes(core, plugins); - registerLensUsageCollector(plugins.usageCollection, plugins.server); - initializeLensTelemetry(core, plugins.server); - + registerLensUsageCollector( + plugins.usageCollection, + taskManagerStartContract$.pipe(first()).toPromise() + ); + initializeLensTelemetry(plugins.server, plugins.taskManager); return {}; } - start() { + start(core: CoreStart, plugins: PluginStartContract) { + scheduleLensTelemetry(plugins.server, plugins.taskManager); + taskManagerStartContract$.next(plugins.taskManager); + taskManagerStartContract$.complete(); return {}; } diff --git a/x-pack/legacy/plugins/lens/server/usage/collectors.ts b/x-pack/legacy/plugins/lens/server/usage/collectors.ts index 274b72c33e59ac..666b3718d5125d 100644 --- a/x-pack/legacy/plugins/lens/server/usage/collectors.ts +++ b/x-pack/legacy/plugins/lens/server/usage/collectors.ts @@ -6,32 +6,25 @@ import moment from 'moment'; import { get } from 'lodash'; -import { Server } from 'src/legacy/server/kbn_server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { TaskManagerStartContract } from '../../../../../plugins/task_manager/server'; import { LensUsage, LensTelemetryState } from './types'; -export function registerLensUsageCollector(usageCollection: UsageCollectionSetup, server: Server) { +export function registerLensUsageCollector( + usageCollection: UsageCollectionSetup, + taskManager: Promise +) { let isCollectorReady = false; - async function determineIfTaskManagerIsReady() { - let isReady = false; - try { - isReady = await isTaskManagerReady(server); - } catch (err) {} // eslint-disable-line - - if (isReady) { - isCollectorReady = true; - } else { - setTimeout(determineIfTaskManagerIsReady, 500); - } - } - determineIfTaskManagerIsReady(); - + taskManager.then(() => { + // mark lensUsageCollector as ready to collect when the TaskManager is ready + isCollectorReady = true; + }); const lensUsageCollector = usageCollection.makeUsageCollector({ type: 'lens', fetch: async (): Promise => { try { - const docs = await getLatestTaskState(server); + const docs = await getLatestTaskState(await taskManager); // get the accumulated state from the recurring task const state: LensTelemetryState = get(docs, '[0].state'); @@ -73,17 +66,7 @@ function addEvents(prevEvents: Record, newEvents: Record Promise; -export function initializeLensTelemetry(core: CoreSetup, server: Server) { - registerLensTelemetryTask(core, server); - scheduleTasks(server); -} - -function registerLensTelemetryTask(core: CoreSetup, server: Server) { - const taskManager = server.plugins.task_manager; - +export function initializeLensTelemetry(server: Server, taskManager?: TaskManagerSetupContract) { if (!taskManager) { server.log(['debug', 'telemetry'], `Task manager is not available`); - return; + } else { + registerLensTelemetryTask(server, taskManager); } +} +export function scheduleLensTelemetry(server: Server, taskManager?: TaskManagerStartContract) { + if (taskManager) { + scheduleTasks(server, taskManager); + } +} + +function registerLensTelemetryTask(server: Server, taskManager: TaskManagerSetupContract) { taskManager.registerTaskDefinitions({ [TELEMETRY_TASK_TYPE]: { title: 'Lens telemetry fetch task', @@ -62,17 +68,11 @@ function registerLensTelemetryTask(core: CoreSetup, server: Server) { }); } -function scheduleTasks(server: Server) { - const taskManager = server.plugins.task_manager; +function scheduleTasks(server: Server, taskManager: TaskManagerStartContract) { const { kbnServer } = (server.plugins.xpack_main as XPackMainPlugin & { status: { plugin: { kbnServer: KbnServer } }; }).status.plugin; - if (!taskManager) { - server.log(['debug', 'telemetry'], `Task manager is not available`); - return; - } - kbnServer.afterPluginsInit(() => { // The code block below can't await directly within "afterPluginsInit" // callback due to circular dependency The server isn't "ready" until diff --git a/x-pack/legacy/plugins/maps/server/test_utils/index.js b/x-pack/legacy/plugins/maps/server/test_utils/index.js index 944d65a21aae2e..f208917e209244 100644 --- a/x-pack/legacy/plugins/maps/server/test_utils/index.js +++ b/x-pack/legacy/plugins/maps/server/test_utils/index.js @@ -25,24 +25,3 @@ export const getMockCallWithInternal = (hits = defaultMockSavedObjects) => { export const getMockTaskFetch = (docs = defaultMockTaskDocs) => { return () => Promise.resolve({ docs }); }; - -export const getMockKbnServer = ( - mockCallWithInternal = getMockCallWithInternal(), - mockTaskFetch = getMockTaskFetch() -) => ({ - plugins: { - elasticsearch: { - getCluster: () => ({ - callWithInternalUser: mockCallWithInternal, - }), - }, - xpack_main: {}, - task_manager: { - registerTaskDefinitions: () => undefined, - schedule: () => Promise.resolve(), - fetch: mockTaskFetch, - }, - }, - config: () => ({ get: () => '' }), - log: () => undefined, -}); diff --git a/x-pack/legacy/plugins/oss_telemetry/index.ts b/x-pack/legacy/plugins/oss_telemetry/index.ts index 8b16c7cf13cada..fce861c7d3f460 100644 --- a/x-pack/legacy/plugins/oss_telemetry/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/index.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger, PluginInitializerContext } from 'kibana/server'; +import { Logger, PluginInitializerContext, CoreStart } from 'kibana/server'; +import { Legacy } from 'kibana'; import { PLUGIN_ID } from './constants'; import { OssTelemetryPlugin } from './server/plugin'; import { LegacyPluginInitializer } from '../../../../src/legacy/plugin_discovery/types'; +import { getTaskManagerSetup, getTaskManagerStart } from '../task_manager/server'; export const ossTelemetry: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ @@ -15,7 +17,7 @@ export const ossTelemetry: LegacyPluginInitializer = kibana => { require: ['elasticsearch', 'xpack_main'], configPrefix: 'xpack.oss_telemetry', - init(server) { + init(server: Legacy.Server) { const plugin = new OssTelemetryPlugin({ logger: { get: () => @@ -27,14 +29,24 @@ export const ossTelemetry: LegacyPluginInitializer = kibana => { } as Logger), }, } as PluginInitializerContext); - plugin.setup(server.newPlatform.setup.core, { + + const deps = { usageCollection: server.newPlatform.setup.plugins.usageCollection, - taskManager: server.plugins.task_manager, __LEGACY: { config: server.config(), xpackMainStatus: ((server.plugins.xpack_main as unknown) as { status: any }).status .plugin, }, + }; + + plugin.setup(server.newPlatform.setup.core, { + ...deps, + taskManager: getTaskManagerSetup(server), + }); + + plugin.start((server.newPlatform.setup.core as unknown) as CoreStart, { + ...deps, + taskManager: getTaskManagerStart(server), }); }, }); diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts index 3b47099fdc4624..9d547c1b22099b 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts @@ -5,8 +5,8 @@ */ import { registerVisualizationsCollector } from './visualizations/register_usage_collector'; -import { OssTelemetrySetupDependencies } from '../../plugin'; +import { OssTelemetryStartDependencies } from '../../plugin'; -export function registerCollectors(deps: OssTelemetrySetupDependencies) { +export function registerCollectors(deps: OssTelemetryStartDependencies) { registerVisualizationsCollector(deps.usageCollection, deps.taskManager); } diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts index ec352666466501..ce106d1a64fd65 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts @@ -4,29 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getMockTaskFetch, getMockTaskManager } from '../../../../test_utils'; +import { + getMockTaskFetch, + getMockThrowingTaskFetch, + getMockTaskInstance, +} from '../../../../test_utils'; +import { taskManagerMock } from '../../../../../../../plugins/task_manager/server/task_manager.mock'; import { getUsageCollector } from './get_usage_collector'; describe('getVisualizationsCollector#fetch', () => { test('can return empty stats', async () => { - const { type, fetch } = getUsageCollector(getMockTaskManager()); + const { type, fetch } = getUsageCollector(taskManagerMock.start(getMockTaskFetch())); expect(type).toBe('visualization_types'); const fetchResult = await fetch(); expect(fetchResult).toEqual({}); }); test('provides known stats', async () => { - const mockTaskFetch = getMockTaskFetch([ - { - state: { - runs: 1, - stats: { comic_books: { total: 16, max: 12, min: 2, avg: 6 } }, - }, - taskType: 'test', - params: {}, - }, - ]); - const { type, fetch } = getUsageCollector(getMockTaskManager(mockTaskFetch)); + const { type, fetch } = getUsageCollector( + taskManagerMock.start( + getMockTaskFetch([ + getMockTaskInstance({ + state: { + runs: 1, + stats: { comic_books: { total: 16, max: 12, min: 2, avg: 6 } }, + }, + taskType: 'test', + params: {}, + }), + ]) + ) + ); expect(type).toBe('visualization_types'); const fetchResult = await fetch(); expect(fetchResult).toEqual({ comic_books: { avg: 6, max: 12, min: 2, total: 16 } }); @@ -34,20 +42,21 @@ describe('getVisualizationsCollector#fetch', () => { describe('Error handling', () => { test('Silently handles Task Manager NotInitialized', async () => { - const mockTaskFetch = jest.fn(() => { - throw new Error('NotInitialized taskManager is still waiting for plugins to load'); - }); - const { fetch } = getUsageCollector(getMockTaskManager(mockTaskFetch)); + const { fetch } = getUsageCollector( + taskManagerMock.start( + getMockThrowingTaskFetch( + new Error('NotInitialized taskManager is still waiting for plugins to load') + ) + ) + ); const result = await fetch(); expect(result).toBe(undefined); }); // In real life, the CollectorSet calls fetch and handles errors test('defers the errors', async () => { - const mockTaskFetch = jest.fn(() => { - throw new Error('BOOM'); - }); - - const { fetch } = getUsageCollector(getMockTaskManager(mockTaskFetch)); + const { fetch } = getUsageCollector( + taskManagerMock.start(getMockThrowingTaskFetch(new Error('BOOM'))) + ); await expect(fetch()).rejects.toThrowErrorMatchingInlineSnapshot(`"BOOM"`); }); }); diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts index 11dbddc00f8300..bc0d10860a667b 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts @@ -5,15 +5,15 @@ */ import { get } from 'lodash'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../../../../task_manager/server/plugin'; import { PLUGIN_ID, VIS_TELEMETRY_TASK, VIS_USAGE_TYPE } from '../../../../constants'; +import { TaskManagerStartContract } from '../../../../../../../plugins/task_manager/server'; -async function isTaskManagerReady(taskManager: TaskManagerPluginSetupContract | undefined) { +async function isTaskManagerReady(taskManager?: TaskManagerStartContract) { const result = await fetch(taskManager); return result !== null; } -async function fetch(taskManager: TaskManagerPluginSetupContract | undefined) { +async function fetch(taskManager?: TaskManagerStartContract) { if (!taskManager) { return null; } @@ -38,7 +38,7 @@ async function fetch(taskManager: TaskManagerPluginSetupContract | undefined) { return docs; } -export function getUsageCollector(taskManager: TaskManagerPluginSetupContract | undefined) { +export function getUsageCollector(taskManager?: TaskManagerStartContract) { let isCollectorReady = false; async function determineIfTaskManagerIsReady() { let isReady = false; diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts index 46b86091c9db15..657f1c725f4e06 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts @@ -5,12 +5,12 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../../../../task_manager/server/plugin'; +import { TaskManagerStartContract } from '../../../../../../../plugins/task_manager/server'; import { getUsageCollector } from './get_usage_collector'; export function registerVisualizationsCollector( collectorSet: UsageCollectionSetup, - taskManager: TaskManagerPluginSetupContract | undefined + taskManager?: TaskManagerStartContract ): void { const collector = collectorSet.makeUsageCollector(getUsageCollector(taskManager)); collectorSet.registerCollector(collector); diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts index c9714306d73c52..cf7295f67a2317 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts @@ -5,12 +5,15 @@ */ import { CoreSetup, Logger } from 'kibana/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../../../task_manager/server/plugin'; import { PLUGIN_ID, VIS_TELEMETRY_TASK } from '../../../constants'; import { visualizationsTaskRunner } from './visualizations/task_runner'; import KbnServer from '../../../../../../../src/legacy/server/kbn_server'; import { LegacyConfig } from '../../plugin'; -import { TaskInstance } from '../../../../task_manager/server'; +import { + TaskInstance, + TaskManagerStartContract, + TaskManagerSetupContract, +} from '../../../../../../plugins/task_manager/server'; export function registerTasks({ taskManager, @@ -18,7 +21,7 @@ export function registerTasks({ elasticsearch, config, }: { - taskManager?: TaskManagerPluginSetupContract; + taskManager?: TaskManagerSetupContract; logger: Logger; elasticsearch: CoreSetup['elasticsearch']; config: LegacyConfig; @@ -46,7 +49,7 @@ export function scheduleTasks({ xpackMainStatus, logger, }: { - taskManager?: TaskManagerPluginSetupContract; + taskManager?: TaskManagerStartContract; xpackMainStatus: { kbnServer: KbnServer }; logger: Logger; }) { diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts index af3eed2496f5d5..ef03e857de8efc 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts @@ -12,7 +12,7 @@ import { getMockTaskInstance, } from '../../../../test_utils'; import { visualizationsTaskRunner } from './task_runner'; -import { TaskInstance } from '../../../../../task_manager/server'; +import { TaskInstance } from '../../../../../../../plugins/task_manager/server'; describe('visualizationsTaskRunner', () => { let mockTaskInstance: TaskInstance; diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts index 8fb2da5627ee85..0b7b301df12bf8 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts @@ -8,7 +8,7 @@ import _, { countBy, groupBy, mapValues } from 'lodash'; import { APICaller, CoreSetup } from 'kibana/server'; import { getNextMidnight } from '../../get_next_midnight'; import { VisState } from '../../../../../../../../src/legacy/core_plugins/visualizations/public'; -import { TaskInstance } from '../../../../../task_manager/server'; +import { TaskInstance } from '../../../../../../../plugins/task_manager/server'; import { ESSearchHit } from '../../../../../apm/typings/elasticsearch'; import { LegacyConfig } from '../../../plugin'; diff --git a/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts b/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts index 209c73eb0eb62d..0aac319cf58180 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../task_manager/server/plugin'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; import { registerCollectors } from './lib/collectors'; import { registerTasks, scheduleTasks } from './lib/tasks'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; @@ -15,13 +18,18 @@ export interface LegacyConfig { get: (key: string) => string | number | boolean; } -export interface OssTelemetrySetupDependencies { +interface OssTelemetryDependencies { usageCollection: UsageCollectionSetup; __LEGACY: { config: LegacyConfig; xpackMainStatus: { kbnServer: KbnServer }; }; - taskManager?: TaskManagerPluginSetupContract; +} +export interface OssTelemetrySetupDependencies extends OssTelemetryDependencies { + taskManager?: TaskManagerSetupContract; +} +export interface OssTelemetryStartDependencies extends OssTelemetryDependencies { + taskManager?: TaskManagerStartContract; } export class OssTelemetryPlugin implements Plugin { @@ -32,19 +40,20 @@ export class OssTelemetryPlugin implements Plugin { } public setup(core: CoreSetup, deps: OssTelemetrySetupDependencies) { - registerCollectors(deps); registerTasks({ taskManager: deps.taskManager, logger: this.logger, elasticsearch: core.elasticsearch, config: deps.__LEGACY.config, }); + } + + public start(core: CoreStart, deps: OssTelemetryStartDependencies) { + registerCollectors(deps); scheduleTasks({ taskManager: deps.taskManager, xpackMainStatus: deps.__LEGACY.xpackMainStatus, logger: this.logger, }); } - - public start() {} } diff --git a/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts b/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts index c6046eb648bf42..0695fda3c2c948 100644 --- a/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts @@ -6,13 +6,28 @@ import { APICaller, CoreSetup } from 'kibana/server'; -import { TaskInstance } from '../../task_manager/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../task_manager/server/plugin'; +import { + ConcreteTaskInstance, + TaskStatus, + TaskManagerStartContract, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../plugins/task_manager/server'; -export const getMockTaskInstance = (): TaskInstance => ({ +export const getMockTaskInstance = ( + overrides: Partial = {} +): ConcreteTaskInstance => ({ state: { runs: 0, stats: {} }, taskType: 'test', params: {}, + id: '', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + ownerId: null, + ...overrides, }); const defaultMockSavedObjects = [ @@ -38,8 +53,24 @@ export const getMockCallWithInternal = (hits: unknown[] = defaultMockSavedObject }) as unknown) as APICaller; }; -export const getMockTaskFetch = (docs: TaskInstance[] = defaultMockTaskDocs) => { - return () => Promise.resolve({ docs }); +export const getMockTaskFetch = ( + docs: ConcreteTaskInstance[] = defaultMockTaskDocs +): Partial> => { + return { + fetch: jest.fn(fetchOpts => { + return Promise.resolve({ docs, searchAfter: [] }); + }), + } as Partial>; +}; + +export const getMockThrowingTaskFetch = ( + throws: Error +): Partial> => { + return { + fetch: jest.fn(fetchOpts => { + throw throws; + }), + } as Partial>; }; export const getMockConfig = () => { @@ -48,13 +79,6 @@ export const getMockConfig = () => { }; }; -export const getMockTaskManager = (fetch: any = getMockTaskFetch()) => - (({ - registerTaskDefinitions: () => undefined, - ensureScheduled: () => Promise.resolve(), - fetch, - } as unknown) as TaskManagerPluginSetupContract); - export const getCluster = () => ({ callWithInternalUser: getMockCallWithInternal(), }); diff --git a/x-pack/legacy/plugins/task_manager/server/index.ts b/x-pack/legacy/plugins/task_manager/server/index.ts index 67b85af324f3d4..56135bb27326ba 100644 --- a/x-pack/legacy/plugins/task_manager/server/index.ts +++ b/x-pack/legacy/plugins/task_manager/server/index.ts @@ -6,19 +6,26 @@ import { Root } from 'joi'; import { Legacy } from 'kibana'; -import { Plugin, PluginSetupContract } from './plugin'; -import { SavedObjectsSerializer, SavedObjectsSchema } from '../../../../../src/core/server'; import mappings from './mappings.json'; import { migrations } from './migrations'; -export { PluginSetupContract as TaskManager }; -export { - TaskInstance, - ConcreteTaskInstance, - TaskRunCreatorFunction, - TaskStatus, - RunContext, -} from './task'; +import { createLegacyApi, getTaskManagerSetup } from './legacy'; +export { LegacyTaskManagerApi, getTaskManagerSetup, getTaskManagerStart } from './legacy'; + +// Once all plugins are migrated to NP, this can be removed +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { TaskManager } from '../../../../plugins/task_manager/server/task_manager'; + +const savedObjectSchemas = { + task: { + hidden: true, + isNamespaceAgnostic: true, + convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, + indexPattern(config: any) { + return config.get('xpack.task_manager.index'); + }, + }, +}; export function taskManager(kibana: any) { return new kibana.Plugin({ @@ -28,73 +35,41 @@ export function taskManager(kibana: any) { config(Joi: Root) { return Joi.object({ enabled: Joi.boolean().default(true), - max_attempts: Joi.number() - .description( - 'The maximum number of times a task will be attempted before being abandoned as failed' - ) - .min(1) - .default(3), - poll_interval: Joi.number() - .description('How often, in milliseconds, the task manager will look for more work.') - .min(100) - .default(3000), - request_capacity: Joi.number() - .description('How many requests can Task Manager buffer before it rejects new requests.') - .min(1) - // a nice round contrived number, feel free to change as we learn how it behaves - .default(1000), index: Joi.string() .description('The name of the index used to store task information.') .default('.kibana_task_manager') .invalid(['.tasks']), - max_workers: Joi.number() - .description( - 'The maximum number of tasks that this Kibana instance will run simultaneously.' - ) - .min(1) // disable the task manager rather than trying to specify it with 0 workers - .default(10), }).default(); }, init(server: Legacy.Server) { - const plugin = new Plugin({ - logger: { - get: () => ({ - info: (message: string) => server.log(['info', 'task_manager'], message), - debug: (message: string) => server.log(['debug', 'task_manager'], message), - warn: (message: string) => server.log(['warn', 'task_manager'], message), - error: (message: string) => server.log(['error', 'task_manager'], message), - }), - }, - }); - const schema = new SavedObjectsSchema(this.kbnServer.uiExports.savedObjectSchemas); - const serializer = new SavedObjectsSerializer(schema); - const setupContract = plugin.setup( - {}, - { - serializer, - config: server.config(), - elasticsearch: server.plugins.elasticsearch, - savedObjects: server.savedObjects, - } + /* + * We must expose the New Platform Task Manager Plugin via the legacy Api + * as removing it now would be a breaking change - we'll remove this in v8.0.0 + */ + server.expose( + createLegacyApi( + getTaskManagerSetup(server)! + .registerLegacyAPI({ + savedObjectSchemas, + }) + .then((taskManagerPlugin: TaskManager) => { + // we can't tell the Kibana Platform Task Manager plugin to + // to wait to `start` as that happens before legacy plugins + // instead we will start the internal Task Manager plugin when + // all legacy plugins have finished initializing + // Once all plugins are migrated to NP, this can be removed + this.kbnServer.afterPluginsInit(() => { + taskManagerPlugin.start(); + }); + return taskManagerPlugin; + }) + ) ); - this.kbnServer.afterPluginsInit(() => { - plugin.start(); - }); - server.expose(setupContract); }, uiExports: { mappings, migrations, - savedObjectSchemas: { - task: { - hidden: true, - isNamespaceAgnostic: true, - convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, - indexPattern(config: any) { - return config.get('xpack.task_manager.index'); - }, - }, - }, + savedObjectSchemas, }, }); } diff --git a/x-pack/legacy/plugins/task_manager/server/legacy.ts b/x-pack/legacy/plugins/task_manager/server/legacy.ts new file mode 100644 index 00000000000000..772309d67c334f --- /dev/null +++ b/x-pack/legacy/plugins/task_manager/server/legacy.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Server } from 'src/legacy/server/kbn_server'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; + +import { Middleware } from '../../../../plugins/task_manager/server/lib/middleware.js'; +import { + TaskDictionary, + TaskInstanceWithDeprecatedFields, + TaskInstanceWithId, + TaskDefinition, +} from '../../../../plugins/task_manager/server/task.js'; +import { FetchOpts } from '../../../../plugins/task_manager/server/task_store.js'; + +// Once all plugins are migrated to NP and we can remove Legacy TaskManager in version 8.0.0, +// this can be removed +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { TaskManager } from '../../../../plugins/task_manager/server/task_manager'; + +export type LegacyTaskManagerApi = Pick< + TaskManagerSetupContract, + 'addMiddleware' | 'registerTaskDefinitions' +> & + TaskManagerStartContract; + +export function getTaskManagerSetup(server: Server): TaskManagerSetupContract | undefined { + return server?.newPlatform?.setup?.plugins?.taskManager as TaskManagerSetupContract; +} + +export function getTaskManagerStart(server: Server): TaskManagerStartContract | undefined { + return server?.newPlatform?.start?.plugins?.taskManager as TaskManagerStartContract; +} + +export function createLegacyApi(legacyTaskManager: Promise): LegacyTaskManagerApi { + return { + addMiddleware: (middleware: Middleware) => { + legacyTaskManager.then((tm: TaskManager) => tm.addMiddleware(middleware)); + }, + registerTaskDefinitions: (taskDefinitions: TaskDictionary) => { + legacyTaskManager.then((tm: TaskManager) => tm.registerTaskDefinitions(taskDefinitions)); + }, + fetch: (opts: FetchOpts) => legacyTaskManager.then((tm: TaskManager) => tm.fetch(opts)), + remove: (id: string) => legacyTaskManager.then((tm: TaskManager) => tm.remove(id)), + schedule: (taskInstance: TaskInstanceWithDeprecatedFields, options?: any) => + legacyTaskManager.then((tm: TaskManager) => tm.schedule(taskInstance, options)), + runNow: (taskId: string) => legacyTaskManager.then((tm: TaskManager) => tm.runNow(taskId)), + ensureScheduled: (taskInstance: TaskInstanceWithId, options?: any) => + legacyTaskManager.then((tm: TaskManager) => tm.ensureScheduled(taskInstance, options)), + }; +} diff --git a/x-pack/legacy/plugins/task_manager/server/plugin.test.ts b/x-pack/legacy/plugins/task_manager/server/plugin.test.ts deleted file mode 100644 index f7c5b35da50c2e..00000000000000 --- a/x-pack/legacy/plugins/task_manager/server/plugin.test.ts +++ /dev/null @@ -1,73 +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 { Plugin, LegacyDeps } from './plugin'; -import { mockLogger } from './test_utils'; -import { TaskManager } from './task_manager'; - -jest.mock('./task_manager'); - -describe('Task Manager Plugin', () => { - let plugin: Plugin; - const mockCoreSetup = {}; - const mockLegacyDeps: LegacyDeps = { - config: { - get: jest.fn(), - }, - serializer: {}, - elasticsearch: { - getCluster: jest.fn(), - }, - savedObjects: { - getSavedObjectsRepository: jest.fn(), - }, - }; - - beforeEach(() => { - jest.resetAllMocks(); - mockLegacyDeps.elasticsearch.getCluster.mockReturnValue({ callWithInternalUser: jest.fn() }); - plugin = new Plugin({ - logger: { - get: mockLogger, - }, - }); - }); - - describe('setup()', () => { - test('exposes proper contract', async () => { - const setupResult = plugin.setup(mockCoreSetup, mockLegacyDeps); - expect(setupResult).toMatchInlineSnapshot(` - Object { - "addMiddleware": [Function], - "ensureScheduled": [Function], - "fetch": [Function], - "registerTaskDefinitions": [Function], - "remove": [Function], - "runNow": [Function], - "schedule": [Function], - } - `); - }); - }); - - describe('start()', () => { - test('properly starts up the task manager', async () => { - plugin.setup(mockCoreSetup, mockLegacyDeps); - plugin.start(); - const taskManager = (TaskManager as any).mock.instances[0]; - expect(taskManager.start).toHaveBeenCalled(); - }); - }); - - describe('stop()', () => { - test('properly stops up the task manager', async () => { - plugin.setup(mockCoreSetup, mockLegacyDeps); - plugin.stop(); - const taskManager = (TaskManager as any).mock.instances[0]; - expect(taskManager.stop).toHaveBeenCalled(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/task_manager/server/plugin.ts b/x-pack/legacy/plugins/task_manager/server/plugin.ts deleted file mode 100644 index 08382d1d825b6b..00000000000000 --- a/x-pack/legacy/plugins/task_manager/server/plugin.ts +++ /dev/null @@ -1,82 +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 { Logger } from './types'; -import { TaskManager } from './task_manager'; - -export interface PluginSetupContract { - fetch: TaskManager['fetch']; - remove: TaskManager['remove']; - schedule: TaskManager['schedule']; - runNow: TaskManager['runNow']; - ensureScheduled: TaskManager['ensureScheduled']; - addMiddleware: TaskManager['addMiddleware']; - registerTaskDefinitions: TaskManager['registerTaskDefinitions']; -} - -export interface LegacyDeps { - config: any; - serializer: any; - elasticsearch: any; - savedObjects: any; -} - -interface PluginInitializerContext { - logger: { - get: () => Logger; - }; -} - -export class Plugin { - private logger: Logger; - private taskManager?: TaskManager; - - constructor(initializerContext: PluginInitializerContext) { - this.logger = initializerContext.logger.get(); - } - - // TODO: Make asynchronous like new platform - public setup( - core: {}, - { config, serializer, elasticsearch, savedObjects }: LegacyDeps - ): PluginSetupContract { - const { callWithInternalUser } = elasticsearch.getCluster('admin'); - const savedObjectsRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser, [ - 'task', - ]); - - const taskManager = new TaskManager({ - config, - savedObjectsRepository, - serializer, - callWithInternalUser, - logger: this.logger, - }); - this.taskManager = taskManager; - - return { - fetch: (...args) => taskManager.fetch(...args), - remove: (...args) => taskManager.remove(...args), - schedule: (...args) => taskManager.schedule(...args), - runNow: (...args) => taskManager.runNow(...args), - ensureScheduled: (...args) => taskManager.ensureScheduled(...args), - addMiddleware: (...args) => taskManager.addMiddleware(...args), - registerTaskDefinitions: (...args) => taskManager.registerTaskDefinitions(...args), - }; - } - - public start() { - if (this.taskManager) { - this.taskManager.start(); - } - } - - public stop() { - if (this.taskManager) { - this.taskManager.stop(); - } - } -} diff --git a/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts b/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts index 4837e75fd31604..a4b80d902d0984 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts +++ b/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts @@ -4,23 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TaskManager } from './types'; - -const createTaskManagerMock = () => { - const mocked: jest.Mocked = { - registerTaskDefinitions: jest.fn(), - addMiddleware: jest.fn(), - ensureScheduled: jest.fn(), - schedule: jest.fn(), - fetch: jest.fn(), - runNow: jest.fn(), - remove: jest.fn(), - start: jest.fn(), - stop: jest.fn(), - }; - return mocked; -}; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; +import { Subject } from 'rxjs'; export const taskManagerMock = { - create: createTaskManagerMock, + setup(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + registerTaskDefinitions: jest.fn(), + addMiddleware: jest.fn(), + config$: new Subject(), + registerLegacyAPI: jest.fn(), + ...overrides, + }; + return mocked; + }, + start(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + ensureScheduled: jest.fn(), + schedule: jest.fn(), + fetch: jest.fn(), + runNow: jest.fn(), + remove: jest.fn(), + ...overrides, + }; + return mocked; + }, }; diff --git a/x-pack/plugins/task_manager/kibana.json b/x-pack/plugins/task_manager/kibana.json new file mode 100644 index 00000000000000..ad2d5d00ae0be5 --- /dev/null +++ b/x-pack/plugins/task_manager/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "taskManager", + "server": true, + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack", "task_manager"], + "ui": false +} diff --git a/x-pack/legacy/plugins/task_manager/server/README.md b/x-pack/plugins/task_manager/server/README.md similarity index 68% rename from x-pack/legacy/plugins/task_manager/server/README.md rename to x-pack/plugins/task_manager/server/README.md index 3afcb758260c01..a067358dc8841e 100644 --- a/x-pack/legacy/plugins/task_manager/server/README.md +++ b/x-pack/plugins/task_manager/server/README.md @@ -55,51 +55,61 @@ Plugins define tasks by calling the `registerTaskDefinitions` method on the `ser A sample task can be found in the [x-pack/test/plugin_api_integration/plugins/task_manager](../../test/plugin_api_integration/plugins/task_manager/index.js) folder. ```js -const taskManager = server.plugins.task_manager; -taskManager.registerTaskDefinitions({ - // clusterMonitoring is the task type, and must be unique across the entire system - clusterMonitoring: { - // Human friendly name, used to represent this task in logs, UI, etc - title: 'Human friendly name', - - // Optional, human-friendly, more detailed description - description: 'Amazing!!', - - // Optional, how long, in minutes or seconds, the system should wait before - // a running instance of this task is considered to be timed out. - // This defaults to 5 minutes. - timeout: '5m', - - // Optional, how many attempts before marking task as failed. - // This defaults to what is configured at the task manager level. - maxAttempts: 5, - - // The clusterMonitoring task occupies 2 workers, so if the system has 10 worker slots, - // 5 clusterMonitoring tasks could run concurrently per Kibana instance. This value is - // overridden by the `override_num_workers` config value, if specified. - numWorkers: 2, - - // The createTaskRunner function / method returns an object that is responsible for - // performing the work of the task. context: { taskInstance }, is documented below. - createTaskRunner(context) { - return { - // Perform the work of the task. The return value should fit the TaskResult interface, documented - // below. Invalid return values will result in a logged warning. - async run() { - // Do some work - // Conditionally send some alerts - // Return some result or other... +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + taskManager.registerTaskDefinitions({ + // clusterMonitoring is the task type, and must be unique across the entire system + clusterMonitoring: { + // Human friendly name, used to represent this task in logs, UI, etc + title: 'Human friendly name', + + // Optional, human-friendly, more detailed description + description: 'Amazing!!', + + // Optional, how long, in minutes or seconds, the system should wait before + // a running instance of this task is considered to be timed out. + // This defaults to 5 minutes. + timeout: '5m', + + // Optional, how many attempts before marking task as failed. + // This defaults to what is configured at the task manager level. + maxAttempts: 5, + + // The clusterMonitoring task occupies 2 workers, so if the system has 10 worker slots, + // 5 clusterMonitoring tasks could run concurrently per Kibana instance. This value is + // overridden by the `override_num_workers` config value, if specified. + numWorkers: 2, + + // The createTaskRunner function / method returns an object that is responsible for + // performing the work of the task. context: { taskInstance }, is documented below. + createTaskRunner(context) { + return { + // Perform the work of the task. The return value should fit the TaskResult interface, documented + // below. Invalid return values will result in a logged warning. + async run() { + // Do some work + // Conditionally send some alerts + // Return some result or other... + }, + + // Optional, will be called if a running instance of this task times out, allowing the task + // to attempt to clean itself up. + async cancel() { + // Do whatever is required to cancel this task, such as killing any spawned processes + }, + }; }, + }, + }); + } - // Optional, will be called if a running instance of this task times out, allowing the task - // to attempt to clean itself up. - async cancel() { - // Do whatever is required to cancel this task, such as killing any spawned processes - }, - }; - }, - }, -}); + public start(core: CoreStart, plugins: { taskManager }) { + + } +} ``` When Kibana attempts to claim and run a task instance, it looks its definition up, and executes its createTaskRunner's method, passing it a run context which looks like this: @@ -222,67 +232,129 @@ The data stored for a task instance looks something like this: The task manager mixin exposes a taskManager object on the Kibana server which plugins can use to manage scheduled tasks. Each method takes an optional `scope` argument and ensures that only tasks with the specified scope(s) will be affected. -### schedule -Using `schedule` you can instruct TaskManger to schedule an instance of a TaskType at some point in the future. +### Overview +Interaction with the TaskManager Plugin is done via the Kibana Platform Plugin system. +When developing your Plugin, you're asked to define a `setup` method and a `start` method. +These methods are handed Kibana's Plugin APIs for these two stages, which means you'll have access to the following apis in these two stages: + +#### Setup +The _Setup_ Plugin api includes methods which configure Task Manager to support your Plugin's requirements, such as defining custom Middleware and Task Definitions. +```js +{ + addMiddleware: (middleware: Middleware) => { + // ... + }, + registerTaskDefinitions: (taskDefinitions: TaskDictionary) => { + // ... + }, +} +``` + +#### Start +The _Start_ Plugin api allow you to use Task Manager to facilitate your Plugin's behaviour, such as scheduling tasks. ```js -const taskManager = server.plugins.task_manager; -// Schedules a task. All properties are as documented in the previous -// storage section, except that here, params is an object, not a JSON -// string. -const task = await taskManager.schedule({ - taskType, - runAt, - schedule, - params, - scope: ['my-fanci-app'], -}); - -// Removes the specified task -await manager.remove(task.id); - -// Fetches tasks, supports pagination, via the search-after API: -// https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-search-after.html -// If scope is not specified, all tasks are returned, otherwise only tasks -// with the given scope are returned. -const results = await manager.find({ scope: 'my-fanci-app', searchAfter: ['ids'] }); - -// results look something like this: { - searchAfter: ['233322'], - // Tasks is an array of task instances - tasks: [{ - id: '3242342', - taskType: 'reporting', - // etc - }] + fetch: (opts: FetchOpts) => { + // ... + }, + remove: (id: string) => { + // ... + }, + schedule: (taskInstance: TaskInstanceWithDeprecatedFields, options?: any) => { + // ... + }, + runNow: (taskId: string) => { + // ... + }, + ensureScheduled: (taskInstance: TaskInstanceWithId, options?: any) => { + // ... + }, } ``` -### ensureScheduling +### Detailed APIs + +#### schedule +Using `schedule` you can instruct TaskManger to schedule an instance of a TaskType at some point in the future. + + +```js +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + } + + public start(core: CoreStart, plugins: { taskManager }) { + // Schedules a task. All properties are as documented in the previous + // storage section, except that here, params is an object, not a JSON + // string. + const task = await taskManager.schedule({ + taskType, + runAt, + schedule, + params, + scope: ['my-fanci-app'], + }); + + // Removes the specified task + await taskManager.remove(task.id); + + // Fetches tasks, supports pagination, via the search-after API: + // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-search-after.html + // If scope is not specified, all tasks are returned, otherwise only tasks + // with the given scope are returned. + const results = await taskManager.find({ scope: 'my-fanci-app', searchAfter: ['ids'] }); + } +} +``` +*results* then look something like this: + +```json + { + "searchAfter": ["233322"], + // Tasks is an array of task instances + "tasks": [{ + "id": "3242342", + "taskType": "reporting", + // etc + }] + } +``` + +#### ensureScheduling When using the `schedule` api to schedule a Task you can provide a hard coded `id` on the Task. This tells TaskManager to use this `id` to identify the Task Instance rather than generate an `id` on its own. The danger is that in such a situation, a Task with that same `id` might already have been scheduled at some earlier point, and this would result in an error. In some cases, this is the expected behavior, but often you only care about ensuring the task has been _scheduled_ and don't need it to be scheduled a fresh. To achieve this you should use the `ensureScheduling` api which has the exact same behavior as `schedule`, except it allows the scheduling of a Task with an `id` that's already in assigned to another Task and it will assume that the existing Task is the one you wished to `schedule`, treating this as a successful operation. -### runNow +#### runNow Using `runNow` you can instruct TaskManger to run an existing task on-demand, without waiting for its scheduled time to be reached. ```js -const taskManager = server.plugins.task_manager; - -try { - const taskRunResult = await taskManager.runNow('91760f10-ba42-de9799'); - // If no error is thrown, the task has completed successfully. -} catch(err: Error) { - // If running the task has failed, we throw an error with an appropriate message. - // For example, if the requested task doesnt exist: `Error: failed to run task "91760f10-ba42-de9799" as it does not exist` - // Or if, for example, the task is already running: `Error: failed to run task "91760f10-ba42-de9799" as it is currently running` +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + } + + public start(core: CoreStart, plugins: { taskManager }) { + try { + const taskRunResult = await taskManager.runNow('91760f10-ba42-de9799'); + // If no error is thrown, the task has completed successfully. + } catch(err: Error) { + // If running the task has failed, we throw an error with an appropriate message. + // For example, if the requested task doesnt exist: `Error: failed to run task "91760f10-ba42-de9799" as it does not exist` + // Or if, for example, the task is already running: `Error: failed to run task "91760f10-ba42-de9799" as it is currently running` + } + } } ``` - -### more options +#### more options More custom access to the tasks can be done directly via Elasticsearch, though that won't be officially supported, as we can change the document structure at any time. @@ -291,35 +363,44 @@ More custom access to the tasks can be done directly via Elasticsearch, though t The task manager exposes a middleware layer that allows modifying tasks before they are scheduled / persisted to the task manager index, and modifying tasks / the run context before a task is run. For example: - ```js -// In your plugin's init -server.plugins.task_manager.addMiddleware({ - async beforeSave({ taskInstance, ...opts }) { - console.log(`About to save a task of type ${taskInstance.taskType}`); - - return { - ...opts, - taskInstance: { - ...taskInstance, - params: { - ...taskInstance.params, - example: 'Added to params!', - }, +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + taskManager.addMiddleware({ + async beforeSave({ taskInstance, ...opts }) { + console.log(`About to save a task of type ${taskInstance.taskType}`); + + return { + ...opts, + taskInstance: { + ...taskInstance, + params: { + ...taskInstance.params, + example: 'Added to params!', + }, + }, + }; }, - }; - }, - async beforeRun({ taskInstance, ...opts }) { - console.log(`About to run ${taskInstance.taskType} ${taskInstance.id}`); - const { example, ...taskWithoutExampleProp } = taskInstance; + async beforeRun({ taskInstance, ...opts }) { + console.log(`About to run ${taskInstance.taskType} ${taskInstance.id}`); + const { example, ...taskWithoutExampleProp } = taskInstance; - return { - ...opts, - taskInstance: taskWithoutExampleProp, - }; - }, -}); + return { + ...opts, + taskInstance: taskWithoutExampleProp, + }; + }, + }); + } + + public start(core: CoreStart, plugins: { taskManager }) { + + } +} ``` ## Task Poller: polling for work diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts new file mode 100644 index 00000000000000..f7962f7011f34d --- /dev/null +++ b/x-pack/plugins/task_manager/server/config.test.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 { configSchema } from './config'; + +describe('config validation', () => { + test('task manager defaults', () => { + const config: Record = {}; + expect(configSchema.validate(config)).toMatchInlineSnapshot(` + Object { + "enabled": true, + "index": ".kibana_task_manager", + "max_attempts": 3, + "max_workers": 10, + "poll_interval": 3000, + "request_capacity": 1000, + } + `); + }); + + test('the ElastiSearch Tasks index cannot be used for task manager', () => { + const config: Record = { + index: '.tasks', + }; + expect(() => { + configSchema.validate(config); + }).toThrowErrorMatchingInlineSnapshot( + `"[index]: \\".tasks\\" is an invalid Kibana Task Manager index, as it is already in use by the ElasticSearch Tasks Manager"` + ); + }); +}); diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts new file mode 100644 index 00000000000000..06e6ad3e62282c --- /dev/null +++ b/x-pack/plugins/task_manager/server/config.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + /* The maximum number of times a task will be attempted before being abandoned as failed */ + max_attempts: schema.number({ + defaultValue: 3, + min: 1, + }), + /* How often, in milliseconds, the task manager will look for more work. */ + poll_interval: schema.number({ + defaultValue: 3000, + min: 100, + }), + /* How many requests can Task Manager buffer before it rejects new requests. */ + request_capacity: schema.number({ + // a nice round contrived number, feel free to change as we learn how it behaves + defaultValue: 1000, + min: 1, + }), + /* The name of the index used to store task information. */ + index: schema.string({ + defaultValue: '.kibana_task_manager', + validate: val => { + if (val.toLowerCase() === '.tasks') { + return `"${val}" is an invalid Kibana Task Manager index, as it is already in use by the ElasticSearch Tasks Manager`; + } + }, + }), + /* The maximum number of tasks that this Kibana instance will run simultaneously. */ + max_workers: schema.number({ + defaultValue: 10, + // disable the task manager rather than trying to specify it with 0 workers + min: 1, + }), +}); + +export type TaskManagerConfig = TypeOf; diff --git a/x-pack/plugins/task_manager/server/create_task_manager.test.ts b/x-pack/plugins/task_manager/server/create_task_manager.test.ts new file mode 100644 index 00000000000000..f4deeb1ea02eda --- /dev/null +++ b/x-pack/plugins/task_manager/server/create_task_manager.test.ts @@ -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. + */ + +import { createTaskManager, LegacyDeps } from './create_task_manager'; +import { mockLogger } from './test_utils'; +import { CoreSetup, UuidServiceSetup } from 'kibana/server'; +import { savedObjectsRepositoryMock } from '../../../../src/core/server/mocks'; + +jest.mock('./task_manager'); + +describe('createTaskManager', () => { + const uuid: UuidServiceSetup = { + getInstanceUuid() { + return 'some-uuid'; + }, + }; + const mockCoreSetup = { + uuid, + } as CoreSetup; + + const getMockLegacyDeps = (): LegacyDeps => ({ + config: {}, + savedObjectSchemas: {}, + elasticsearch: { + callAsInternalUser: jest.fn(), + }, + savedObjectsRepository: savedObjectsRepositoryMock.create(), + logger: mockLogger(), + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('exposes the underlying TaskManager', async () => { + const mockLegacyDeps = getMockLegacyDeps(); + const setupResult = createTaskManager(mockCoreSetup, mockLegacyDeps); + expect(setupResult).toMatchInlineSnapshot(` + TaskManager { + "addMiddleware": [MockFunction], + "assertUninitialized": [MockFunction], + "attemptToRun": [MockFunction], + "ensureScheduled": [MockFunction], + "fetch": [MockFunction], + "registerTaskDefinitions": [MockFunction], + "remove": [MockFunction], + "runNow": [MockFunction], + "schedule": [MockFunction], + "start": [MockFunction], + "stop": [MockFunction], + "waitUntilStarted": [MockFunction], + } + `); + }); +}); diff --git a/x-pack/plugins/task_manager/server/create_task_manager.ts b/x-pack/plugins/task_manager/server/create_task_manager.ts new file mode 100644 index 00000000000000..5c66b8ba5bd58f --- /dev/null +++ b/x-pack/plugins/task_manager/server/create_task_manager.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + IClusterClient, + SavedObjectsSerializer, + SavedObjectsSchema, + CoreSetup, + ISavedObjectsRepository, +} from '../../../../src/core/server'; +import { TaskManager } from './task_manager'; +import { Logger } from './types'; + +export interface LegacyDeps { + config: any; + savedObjectSchemas: any; + elasticsearch: Pick; + savedObjectsRepository: ISavedObjectsRepository; + logger: Logger; +} + +export function createTaskManager( + core: CoreSetup, + { + logger, + config, + savedObjectSchemas, + elasticsearch: { callAsInternalUser }, + savedObjectsRepository, + }: LegacyDeps +) { + // as we use this Schema solely to interact with Tasks, we + // can initialise it with solely the Tasks schema + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema(savedObjectSchemas)); + return new TaskManager({ + taskManagerId: core.uuid.getInstanceUuid(), + config, + savedObjectsRepository, + serializer, + callAsInternalUser, + logger, + }); +} diff --git a/x-pack/plugins/task_manager/server/index.ts b/x-pack/plugins/task_manager/server/index.ts new file mode 100644 index 00000000000000..7eba218e16fed9 --- /dev/null +++ b/x-pack/plugins/task_manager/server/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; +import { TaskManagerPlugin } from './plugin'; +import { configSchema } from './config'; + +export const plugin = (initContext: PluginInitializerContext) => new TaskManagerPlugin(initContext); + +export { + TaskInstance, + ConcreteTaskInstance, + TaskRunCreatorFunction, + TaskStatus, + RunContext, +} from './task'; + +export { + TaskManagerPlugin as TaskManager, + TaskManagerSetupContract, + TaskManagerStartContract, +} from './plugin'; + +export const config = { + schema: configSchema, +}; diff --git a/x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts b/x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts rename to x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.ts b/x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.ts rename to x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/fill_pool.test.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/fill_pool.test.ts rename to x-pack/plugins/task_manager/server/lib/fill_pool.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/fill_pool.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/fill_pool.ts rename to x-pack/plugins/task_manager/server/lib/fill_pool.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/get_template_version.test.ts b/x-pack/plugins/task_manager/server/lib/get_template_version.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/get_template_version.test.ts rename to x-pack/plugins/task_manager/server/lib/get_template_version.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/get_template_version.ts b/x-pack/plugins/task_manager/server/lib/get_template_version.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/get_template_version.ts rename to x-pack/plugins/task_manager/server/lib/get_template_version.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.test.ts b/x-pack/plugins/task_manager/server/lib/identify_es_error.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.test.ts rename to x-pack/plugins/task_manager/server/lib/identify_es_error.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.ts b/x-pack/plugins/task_manager/server/lib/identify_es_error.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.ts rename to x-pack/plugins/task_manager/server/lib/identify_es_error.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/intervals.test.ts b/x-pack/plugins/task_manager/server/lib/intervals.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/intervals.test.ts rename to x-pack/plugins/task_manager/server/lib/intervals.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/intervals.ts b/x-pack/plugins/task_manager/server/lib/intervals.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/intervals.ts rename to x-pack/plugins/task_manager/server/lib/intervals.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/middleware.test.ts b/x-pack/plugins/task_manager/server/lib/middleware.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/middleware.test.ts rename to x-pack/plugins/task_manager/server/lib/middleware.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/middleware.ts b/x-pack/plugins/task_manager/server/lib/middleware.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/middleware.ts rename to x-pack/plugins/task_manager/server/lib/middleware.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.test.ts b/x-pack/plugins/task_manager/server/lib/pull_from_set.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.test.ts rename to x-pack/plugins/task_manager/server/lib/pull_from_set.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.ts b/x-pack/plugins/task_manager/server/lib/pull_from_set.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.ts rename to x-pack/plugins/task_manager/server/lib/pull_from_set.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/result_type.ts b/x-pack/plugins/task_manager/server/lib/result_type.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/result_type.ts rename to x-pack/plugins/task_manager/server/lib/result_type.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts b/x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts rename to x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.ts b/x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.ts rename to x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.ts diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts new file mode 100644 index 00000000000000..9bdd1ce6d87485 --- /dev/null +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -0,0 +1,83 @@ +/* + * 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 { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server'; +import { Observable, Subject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { once } from 'lodash'; +import { TaskDictionary, TaskDefinition } from './task'; +import { TaskManager } from './task_manager'; +import { createTaskManager, LegacyDeps } from './create_task_manager'; +import { TaskManagerConfig } from './config'; +import { Middleware } from './lib/middleware'; + +export type PluginLegacyDependencies = Pick; +export type TaskManagerSetupContract = { + config$: Observable; + registerLegacyAPI: (legacyDependencies: PluginLegacyDependencies) => Promise; +} & Pick; + +export type TaskManagerStartContract = Pick< + TaskManager, + 'fetch' | 'remove' | 'schedule' | 'runNow' | 'ensureScheduled' +>; + +export class TaskManagerPlugin + implements Plugin { + legacyTaskManager$: Subject = new Subject(); + taskManager: Promise = this.legacyTaskManager$.pipe(first()).toPromise(); + currentConfig: TaskManagerConfig; + + constructor(private readonly initContext: PluginInitializerContext) { + this.initContext = initContext; + this.currentConfig = {} as TaskManagerConfig; + } + + public setup(core: CoreSetup, plugins: any): TaskManagerSetupContract { + const logger = this.initContext.logger.get('taskManager'); + const config$ = this.initContext.config.create(); + const savedObjectsRepository = core.savedObjects.createInternalRepository(['task']); + const elasticsearch = core.elasticsearch.adminClient; + return { + config$, + registerLegacyAPI: once((__LEGACY: PluginLegacyDependencies) => { + config$.subscribe(async config => { + this.legacyTaskManager$.next( + createTaskManager(core, { + logger, + config, + elasticsearch, + savedObjectsRepository, + ...__LEGACY, + }) + ); + this.legacyTaskManager$.complete(); + }); + return this.taskManager; + }), + addMiddleware: (middleware: Middleware) => { + this.taskManager.then(tm => tm.addMiddleware(middleware)); + }, + registerTaskDefinitions: (taskDefinition: TaskDictionary) => { + this.taskManager.then(tm => tm.registerTaskDefinitions(taskDefinition)); + }, + }; + } + + public start(): TaskManagerStartContract { + return { + fetch: (...args) => this.taskManager.then(tm => tm.fetch(...args)), + remove: (...args) => this.taskManager.then(tm => tm.remove(...args)), + schedule: (...args) => this.taskManager.then(tm => tm.schedule(...args)), + runNow: (...args) => this.taskManager.then(tm => tm.runNow(...args)), + ensureScheduled: (...args) => this.taskManager.then(tm => tm.ensureScheduled(...args)), + }; + } + public stop() { + this.taskManager.then(tm => { + tm.stop(); + }); + } +} diff --git a/x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts rename to x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts rename to x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts diff --git a/x-pack/legacy/plugins/task_manager/server/queries/query_clauses.ts b/x-pack/plugins/task_manager/server/queries/query_clauses.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/queries/query_clauses.ts rename to x-pack/plugins/task_manager/server/queries/query_clauses.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task.ts b/x-pack/plugins/task_manager/server/task.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task.ts rename to x-pack/plugins/task_manager/server/task.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_events.ts b/x-pack/plugins/task_manager/server/task_events.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_events.ts rename to x-pack/plugins/task_manager/server/task_events.ts diff --git a/x-pack/plugins/task_manager/server/task_manager.mock.ts b/x-pack/plugins/task_manager/server/task_manager.mock.ts new file mode 100644 index 00000000000000..9750dd14100d9b --- /dev/null +++ b/x-pack/plugins/task_manager/server/task_manager.mock.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TaskManagerSetupContract, TaskManagerStartContract } from './plugin'; +import { Subject } from 'rxjs'; + +export const taskManagerMock = { + setup(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + registerTaskDefinitions: jest.fn(), + addMiddleware: jest.fn(), + config$: new Subject(), + registerLegacyAPI: jest.fn(), + ...overrides, + }; + return mocked; + }, + start(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + ensureScheduled: jest.fn(), + schedule: jest.fn(), + fetch: jest.fn(), + runNow: jest.fn(), + remove: jest.fn(), + ...overrides, + }; + return mocked; + }, +}; diff --git a/x-pack/legacy/plugins/task_manager/server/task_manager.test.ts b/x-pack/plugins/task_manager/server/task_manager.test.ts similarity index 94% rename from x-pack/legacy/plugins/task_manager/server/task_manager.test.ts rename to x-pack/plugins/task_manager/server/task_manager.test.ts index 51c3e5b81d7649..a65723b2e8de7f 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_manager.test.ts +++ b/x-pack/plugins/task_manager/server/task_manager.test.ts @@ -20,39 +20,33 @@ import { awaitTaskRunResult, TaskLifecycleEvent, } from './task_manager'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { SavedObjectsSerializer, SavedObjectsSchema } from '../../../../../src/core/server'; +import { savedObjectsRepositoryMock } from '../../../../src/core/server/mocks'; +import { SavedObjectsSerializer, SavedObjectsSchema } from '../../../../src/core/server'; import { mockLogger } from './test_utils'; import { asErr, asOk } from './lib/result_type'; import { ConcreteTaskInstance, TaskLifecycleResult, TaskStatus } from './task'; -const savedObjectsClient = savedObjectsClientMock.create(); +const savedObjectsClient = savedObjectsRepositoryMock.create(); const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); describe('TaskManager', () => { let clock: sinon.SinonFakeTimers; - const defaultConfig = { - xpack: { - task_manager: { - max_workers: 10, - index: 'foo', - max_attempts: 9, - poll_interval: 6000000, - }, - }, - server: { - uuid: 'some-uuid', - }, - }; + const config = { - get: (path: string) => _.get(defaultConfig, path), + enabled: true, + max_workers: 10, + index: 'foo', + max_attempts: 9, + poll_interval: 6000000, + request_capacity: 1000, }; const taskManagerOpts = { config, savedObjectsRepository: savedObjectsClient, serializer, - callWithInternalUser: jest.fn(), + callAsInternalUser: jest.fn(), logger: mockLogger(), + taskManagerId: 'some-uuid', }; beforeEach(() => { @@ -63,21 +57,9 @@ describe('TaskManager', () => { test('throws if no valid UUID is available', async () => { expect(() => { - const configWithoutServerUUID = { - xpack: { - task_manager: { - max_workers: 10, - index: 'foo', - max_attempts: 9, - poll_interval: 6000000, - }, - }, - }; new TaskManager({ ...taskManagerOpts, - config: { - get: (path: string) => _.get(configWithoutServerUUID, path), - }, + taskManagerId: '', }); }).toThrowErrorMatchingInlineSnapshot( `"TaskManager is unable to start as Kibana has no valid UUID assigned to it."` @@ -234,7 +216,7 @@ describe('TaskManager', () => { test('allows and queues fetching tasks before starting', async () => { const client = new TaskManager(taskManagerOpts); - taskManagerOpts.callWithInternalUser.mockResolvedValue({ + taskManagerOpts.callAsInternalUser.mockResolvedValue({ hits: { total: { value: 0, @@ -245,13 +227,13 @@ describe('TaskManager', () => { const promise = client.fetch({}); client.start(); await promise; - expect(taskManagerOpts.callWithInternalUser).toHaveBeenCalled(); + expect(taskManagerOpts.callAsInternalUser).toHaveBeenCalled(); }); test('allows fetching tasks after starting', async () => { const client = new TaskManager(taskManagerOpts); client.start(); - taskManagerOpts.callWithInternalUser.mockResolvedValue({ + taskManagerOpts.callAsInternalUser.mockResolvedValue({ hits: { total: { value: 0, @@ -260,7 +242,7 @@ describe('TaskManager', () => { }, }); await client.fetch({}); - expect(taskManagerOpts.callWithInternalUser).toHaveBeenCalled(); + expect(taskManagerOpts.callAsInternalUser).toHaveBeenCalled(); }); test('allows middleware registration before starting', () => { @@ -282,7 +264,6 @@ describe('TaskManager', () => { }; client.start(); - expect(() => client.addMiddleware(middleware)).toThrow( /Cannot add middleware after the task manager is initialized/i ); diff --git a/x-pack/legacy/plugins/task_manager/server/task_manager.ts b/x-pack/plugins/task_manager/server/task_manager.ts similarity index 95% rename from x-pack/legacy/plugins/task_manager/server/task_manager.ts rename to x-pack/plugins/task_manager/server/task_manager.ts index 6c9191ffe3d0e8..c0baed3708a0a0 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_manager.ts +++ b/x-pack/plugins/task_manager/server/task_manager.ts @@ -10,8 +10,13 @@ import { performance } from 'perf_hooks'; import { pipe } from 'fp-ts/lib/pipeable'; import { Option, none, some, map as mapOptional } from 'fp-ts/lib/Option'; -import { SavedObjectsClientContract, SavedObjectsSerializer } from '../../../../../src/core/server'; +import { + SavedObjectsSerializer, + IScopedClusterClient, + ISavedObjectsRepository, +} from '../../../../src/core/server'; import { Result, asErr, either, map, mapErr, promiseResult } from './lib/result_type'; +import { TaskManagerConfig } from './config'; import { Logger } from './types'; import { @@ -56,10 +61,11 @@ const VERSION_CONFLICT_STATUS = 409; export interface TaskManagerOpts { logger: Logger; - config: any; - callWithInternalUser: any; - savedObjectsRepository: SavedObjectsClientContract; + config: TaskManagerConfig; + callAsInternalUser: IScopedClusterClient['callAsInternalUser']; + savedObjectsRepository: ISavedObjectsRepository; serializer: SavedObjectsSerializer; + taskManagerId: string; } interface RunNowResult { @@ -110,7 +116,7 @@ export class TaskManager { constructor(opts: TaskManagerOpts) { this.logger = opts.logger; - const taskManagerId = opts.config.get('server.uuid'); + const { taskManagerId } = opts; if (!taskManagerId) { this.logger.error( `TaskManager is unable to start as there the Kibana UUID is invalid (value of the "server.uuid" configuration is ${taskManagerId})` @@ -123,9 +129,9 @@ export class TaskManager { this.store = new TaskStore({ serializer: opts.serializer, savedObjectsRepository: opts.savedObjectsRepository, - callCluster: opts.callWithInternalUser, - index: opts.config.get('xpack.task_manager.index'), - maxAttempts: opts.config.get('xpack.task_manager.max_attempts'), + callCluster: opts.callAsInternalUser, + index: opts.config.index, + maxAttempts: opts.config.max_attempts, definitions: this.definitions, taskManagerId: `kibana:${taskManagerId}`, }); @@ -134,12 +140,12 @@ export class TaskManager { this.pool = new TaskPool({ logger: this.logger, - maxWorkers: opts.config.get('xpack.task_manager.max_workers'), + maxWorkers: opts.config.max_workers, }); this.poller$ = createTaskPoller({ - pollInterval: opts.config.get('xpack.task_manager.poll_interval'), - bufferCapacity: opts.config.get('xpack.task_manager.request_capacity'), + pollInterval: opts.config.poll_interval, + bufferCapacity: opts.config.request_capacity, getCapacity: () => this.pool.availableWorkers, pollRequests$: this.claimRequests$, work: this.pollForWork, diff --git a/x-pack/legacy/plugins/task_manager/server/task_poller.test.ts b/x-pack/plugins/task_manager/server/task_poller.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_poller.test.ts rename to x-pack/plugins/task_manager/server/task_poller.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_poller.ts b/x-pack/plugins/task_manager/server/task_poller.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_poller.ts rename to x-pack/plugins/task_manager/server/task_poller.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_pool.test.ts b/x-pack/plugins/task_manager/server/task_pool.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_pool.test.ts rename to x-pack/plugins/task_manager/server/task_pool.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_pool.ts b/x-pack/plugins/task_manager/server/task_pool.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_pool.ts rename to x-pack/plugins/task_manager/server/task_pool.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_runner.test.ts similarity index 99% rename from x-pack/legacy/plugins/task_manager/server/task_runner.test.ts rename to x-pack/plugins/task_manager/server/task_runner.test.ts index 3f7877aa4c20f8..3f0132105347e7 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_runner.test.ts @@ -12,7 +12,7 @@ import { TaskEvent, asTaskRunEvent, asTaskMarkRunningEvent } from './task_events import { ConcreteTaskInstance, TaskStatus } from './task'; import { TaskManagerRunner } from './task_runner'; import { mockLogger } from './test_utils'; -import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import { SavedObjectsErrorHelpers } from '../../../../src/core/server'; let fakeTimer: sinon.SinonFakeTimers; diff --git a/x-pack/legacy/plugins/task_manager/server/task_runner.ts b/x-pack/plugins/task_manager/server/task_runner.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_runner.ts rename to x-pack/plugins/task_manager/server/task_runner.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts similarity index 99% rename from x-pack/legacy/plugins/task_manager/server/task_store.test.ts rename to x-pack/plugins/task_manager/server/task_store.test.ts index c7a0a1a0207215..f47cc41c2d0457 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -17,13 +17,13 @@ import { TaskLifecycleResult, } from './task'; import { FetchOpts, StoreOpts, OwnershipClaimingOpts, TaskStore } from './task_store'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { savedObjectsRepositoryMock } from '../../../../src/core/server/mocks'; import { SavedObjectsSerializer, SavedObjectsSchema, SavedObjectAttributes, -} from '../../../../../src/core/server'; -import { SavedObjectsErrorHelpers } from '../../../../../src/core/server/saved_objects/service/lib/errors'; +} from '../../../../src/core/server'; +import { SavedObjectsErrorHelpers } from '../../../../src/core/server/saved_objects/service/lib/errors'; import { asTaskClaimEvent, TaskEvent } from './task_events'; import { asOk, asErr } from './lib/result_type'; @@ -45,7 +45,7 @@ const taskDefinitions: TaskDictionary = { }, }; -const savedObjectsClient = savedObjectsClientMock.create(); +const savedObjectsClient = savedObjectsRepositoryMock.create(); const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); beforeEach(() => jest.resetAllMocks()); diff --git a/x-pack/legacy/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts similarity index 98% rename from x-pack/legacy/plugins/task_manager/server/task_store.ts rename to x-pack/plugins/task_manager/server/task_store.ts index e8fc0ccb909367..f4695b152237a2 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -11,12 +11,12 @@ import { Subject, Observable } from 'rxjs'; import { omit, difference } from 'lodash'; import { - SavedObjectsClientContract, SavedObject, SavedObjectAttributes, SavedObjectsSerializer, SavedObjectsRawDoc, -} from '../../../../../src/core/server'; + ISavedObjectsRepository, +} from '../../../../src/core/server'; import { asOk, asErr } from './lib/result_type'; @@ -60,7 +60,7 @@ export interface StoreOpts { taskManagerId: string; maxAttempts: number; definitions: TaskDictionary; - savedObjectsRepository: SavedObjectsClientContract; + savedObjectsRepository: ISavedObjectsRepository; serializer: SavedObjectsSerializer; } @@ -123,7 +123,7 @@ export class TaskStore { private callCluster: ElasticJs; private definitions: TaskDictionary; - private savedObjectsRepository: SavedObjectsClientContract; + private savedObjectsRepository: ISavedObjectsRepository; private serializer: SavedObjectsSerializer; private events$: Subject; diff --git a/x-pack/legacy/plugins/task_manager/server/test_utils/index.ts b/x-pack/plugins/task_manager/server/test_utils/index.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/test_utils/index.ts rename to x-pack/plugins/task_manager/server/test_utils/index.ts diff --git a/x-pack/legacy/plugins/task_manager/server/types.ts b/x-pack/plugins/task_manager/server/types.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/types.ts rename to x-pack/plugins/task_manager/server/types.ts diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts index 3bfad59b71166a..29708f86b0a9b0 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TaskManagerStartContract } from '../../../../../../plugins/task_manager/server'; + const taskManagerQuery = (...filters: any[]) => ({ bool: { filter: { @@ -38,7 +40,7 @@ export default function(kibana: any) { }, init(server: any) { - const taskManager = server.plugins.task_manager; + const taskManager = server.newPlatform.start.plugins.taskManager as TaskManagerStartContract; server.route({ path: '/api/alerting_tasks/{taskId}', diff --git a/x-pack/test/plugin_api_integration/plugins/task_manager/index.js b/x-pack/test/plugin_api_integration/plugins/task_manager/index.js index b0e46543b4e763..50fb9571c26879 100644 --- a/x-pack/test/plugin_api_integration/plugins/task_manager/index.js +++ b/x-pack/test/plugin_api_integration/plugins/task_manager/index.js @@ -28,7 +28,11 @@ export default function TaskTestingAPI(kibana) { }, init(server) { - const taskManager = server.plugins.task_manager; + const taskManager = { + ...server.newPlatform.setup.plugins.taskManager, + ...server.newPlatform.start.plugins.taskManager, + }; + const legacyTaskManager = server.plugins.task_manager; const defaultSampleTaskConfig = { timeout: '1m', @@ -128,7 +132,7 @@ export default function TaskTestingAPI(kibana) { }, }); - initRoutes(server, taskTestingEvents); + initRoutes(server, taskManager, legacyTaskManager, taskTestingEvents); }, }); } diff --git a/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js b/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js index 3330d08dfd0d2b..c0dcd99525915b 100644 --- a/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js +++ b/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js @@ -23,9 +23,7 @@ const taskManagerQuery = { }, }; -export function initRoutes(server, taskTestingEvents) { - const taskManager = server.plugins.task_manager; - +export function initRoutes(server, taskManager, legacyTaskManager, taskTestingEvents) { server.route({ path: '/api/sample_tasks/schedule', method: 'POST', @@ -62,6 +60,45 @@ export function initRoutes(server, taskTestingEvents) { }, }); + /* + Schedule using legacy Api + */ + server.route({ + path: '/api/sample_tasks/schedule_legacy', + method: 'POST', + config: { + validate: { + payload: Joi.object({ + task: Joi.object({ + taskType: Joi.string().required(), + schedule: Joi.object({ + interval: Joi.string(), + }).optional(), + interval: Joi.string().optional(), + params: Joi.object().required(), + state: Joi.object().optional(), + id: Joi.string().optional(), + }), + }), + }, + }, + async handler(request) { + try { + const { task: taskFields } = request.payload; + const task = { + ...taskFields, + scope: [scope], + }; + + const taskResult = await legacyTaskManager.schedule(task, { request }); + + return taskResult; + } catch (err) { + return err; + } + }, + }); + server.route({ path: '/api/sample_tasks/run_now', method: 'POST', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js index ff06bee83d51d8..0b1c1cbb5af29c 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js @@ -74,6 +74,15 @@ export default function({ getService }) { .then(response => response.body); } + function scheduleTaskUsingLegacyApi(task) { + return supertest + .post('/api/sample_tasks/schedule_legacy') + .set('kbn-xsrf', 'xxx') + .send({ task }) + .expect(200) + .then(response => response.body); + } + function runTaskNow(task) { return supertest .post('/api/sample_tasks/run_now') @@ -494,5 +503,15 @@ export default function({ getService }) { expect(getTaskById(tasks, longRunningTask.id).state.count).to.eql(1); }); }); + + it('should retain the legacy api until v8.0.0', async () => { + const result = await scheduleTaskUsingLegacyApi({ + id: 'task-with-legacy-api', + taskType: 'sampleTask', + params: {}, + }); + + expect(result.id).to.be('task-with-legacy-api'); + }); }); } diff --git a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js index c3cd582fd59c42..87e3b3b66a2019 100644 --- a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js +++ b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js @@ -23,7 +23,10 @@ export default function TaskManagerPerformanceAPI(kibana) { }, init(server) { - const taskManager = server.plugins.task_manager; + const taskManager = { + ...server.newPlatform.setup.plugins.taskManager, + ...server.newPlatform.start.plugins.taskManager, + }; const performanceState = resetPerfState({}); let lastFlush = new Date(); diff --git a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js index ca6d8707f5c58f..6cd706a6ebecdf 100644 --- a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js +++ b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js @@ -9,7 +9,10 @@ import { range, chunk } from 'lodash'; const scope = 'perf-testing'; export function initRoutes(server, performanceState) { - const taskManager = server.plugins.task_manager; + const taskManager = { + ...server.newPlatform.setup.plugins.taskManager, + ...server.newPlatform.start.plugins.taskManager, + }; server.route({ path: '/api/perf_tasks', diff --git a/x-pack/test/typings/hapi.d.ts b/x-pack/test/typings/hapi.d.ts index 0400c1b7d8f232..fc5ce09e5e6188 100644 --- a/x-pack/test/typings/hapi.d.ts +++ b/x-pack/test/typings/hapi.d.ts @@ -9,7 +9,6 @@ import 'hapi'; import { XPackMainPlugin } from '../../legacy/plugins/xpack_main/server/xpack_main'; import { SecurityPlugin } from '../../legacy/plugins/security'; import { ActionsPlugin, ActionsClient } from '../../legacy/plugins/actions'; -import { TaskManager } from '../../legacy/plugins/task_manager/server'; import { AlertingPlugin, AlertsClient } from '../../legacy/plugins/alerting'; declare module 'hapi' { @@ -22,6 +21,5 @@ declare module 'hapi' { security?: SecurityPlugin; actions?: ActionsPlugin; alerting?: AlertingPlugin; - task_manager?: TaskManager; } } diff --git a/x-pack/typings/hapi.d.ts b/x-pack/typings/hapi.d.ts index cfc1a641550fcd..a739d5f884f6e9 100644 --- a/x-pack/typings/hapi.d.ts +++ b/x-pack/typings/hapi.d.ts @@ -9,8 +9,8 @@ import 'hapi'; import { XPackMainPlugin } from '../legacy/plugins/xpack_main/server/xpack_main'; import { SecurityPlugin } from '../legacy/plugins/security'; import { ActionsPlugin, ActionsClient } from '../legacy/plugins/actions'; -import { TaskManager } from '../legacy/plugins/task_manager/server'; import { AlertingPlugin, AlertsClient } from '../legacy/plugins/alerting'; +import { LegacyTaskManagerApi } from '../legacy/plugins/task_manager/server'; declare module 'hapi' { interface Request { @@ -22,6 +22,6 @@ declare module 'hapi' { security?: SecurityPlugin; actions?: ActionsPlugin; alerting?: AlertingPlugin; - task_manager?: TaskManager; + task_manager?: LegacyTaskManagerApi; } } From 5ef4aa10e7af43041abf89aa22baf8108e656a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Mon, 13 Jan 2020 20:15:15 +0100 Subject: [PATCH 27/62] [Logs UI] Add categories table to the categorization tab (#53004) This renders the log entry categories after the ML jobs have been set up previously. closes #42776 closes #42065 --- .../http_api/log_analysis/results/index.ts | 2 + .../results/log_entry_categories.ts | 109 ++++++ .../results/log_entry_category_datasets.ts | 63 +++ .../infra/common/http_api/shared/index.ts | 1 + .../infra/common/http_api/shared/timing.ts | 13 + .../infra/common/log_analysis/index.ts | 3 + .../infra/common/log_analysis/log_analysis.ts | 8 - .../log_analysis/log_analysis_results.ts | 46 +++ .../log_entry_categories_analysis.ts | 17 + .../log_analysis/log_entry_rate_analysis.ts | 15 + .../infra/common/performance_tracing.ts | 33 ++ .../plugins/infra/common/runtime_types.ts | 15 +- .../plugins/infra/public/apps/start_app.tsx | 1 + .../logging/log_analysis_job_status/index.ts | 1 + .../log_analysis_job_problem_indicator.tsx | 15 +- .../recreate_job_button.tsx | 18 + .../recreate_job_callout.tsx | 12 +- .../first_use_callout.tsx | 27 ++ .../logging/log_analysis_results/index.ts | 1 + .../api/ml_get_jobs_summary_api.ts | 1 + .../log_analysis_module_status.tsx | 6 +- .../log_entry_categories/module_descriptor.ts | 17 +- .../log_entry_categories/page_content.tsx | 4 +- .../page_results_content.tsx | 240 ++++++++++++ .../anomaly_severity_indicator.tsx | 31 ++ .../top_categories/category_expression.tsx | 65 ++++ .../sections/top_categories/datasets_list.tsx | 20 + .../top_categories/datasets_selector.tsx | 60 +++ .../sections/top_categories/index.ts | 7 + .../log_entry_count_sparkline.tsx | 50 +++ .../single_metric_comparison.tsx | 57 +++ .../single_metric_sparkline.tsx | 65 ++++ .../top_categories/top_categories_section.tsx | 82 ++++ .../top_categories/top_categories_table.tsx | 106 +++++ .../get_log_entry_category_datasets.ts | 46 +++ .../get_top_log_entry_categories.ts | 67 ++++ .../use_log_entry_categories_results.ts | 116 ++++++ ...log_entry_categories_results_url_state.tsx | 64 +++ .../pages/logs/log_entry_rate/first_use.tsx | 30 -- .../logs/log_entry_rate/module_descriptor.ts | 23 +- .../log_entry_rate/page_results_content.tsx | 12 +- .../sections/anomalies/chart.tsx | 13 +- .../sections/anomalies/index.tsx | 13 +- .../sections/anomalies/table.tsx | 12 +- .../sections/helpers/data_formatters.tsx | 38 +- .../infra/public/utils/use_tracked_promise.ts | 4 +- .../plugins/infra/server/infra_server.ts | 4 + .../infra/server/lib/compose/kibana.ts | 8 +- .../plugins/infra/server/lib/infra_types.ts | 5 +- .../infra/server/lib/log_analysis/errors.ts | 2 +- .../infra/server/lib/log_analysis/index.ts | 3 +- .../log_entry_categories_analysis.ts | 363 ++++++++++++++++++ ...analysis.ts => log_entry_rate_analysis.ts} | 10 +- .../server/lib/log_analysis/queries/common.ts | 37 ++ .../server/lib/log_analysis/queries/index.ts | 1 + .../queries/log_entry_categories.ts | 53 +++ .../queries/log_entry_category_histograms.ts | 125 ++++++ .../queries/log_entry_data_sets.ts | 93 +++++ .../log_analysis/queries/log_entry_rate.ts | 9 +- .../queries/top_log_entry_categories.ts | 160 ++++++++ .../infra/server/new_platform_plugin.ts | 8 +- .../routes/log_analysis/results/index.ts | 2 + .../results/log_entry_categories.ts | 93 +++++ .../results/log_entry_category_datasets.ts | 82 ++++ .../log_analysis/results/log_entry_rate.ts | 12 +- .../utils/elasticsearch_runtime_types.ts | 18 + .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 68 files changed, 2581 insertions(+), 160 deletions(-) create mode 100644 x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts create mode 100644 x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_category_datasets.ts create mode 100644 x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts create mode 100644 x-pack/legacy/plugins/infra/common/log_analysis/log_analysis_results.ts create mode 100644 x-pack/legacy/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts create mode 100644 x-pack/legacy/plugins/infra/common/log_analysis/log_entry_rate_analysis.ts create mode 100644 x-pack/legacy/plugins/infra/common/performance_tracing.ts create mode 100644 x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx create mode 100644 x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/first_use_callout.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_comparison.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_datasets.ts create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts create mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results_url_state.tsx delete mode 100644 x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/first_use.tsx create mode 100644 x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts rename x-pack/legacy/plugins/infra/server/lib/log_analysis/{log_analysis.ts => log_entry_rate_analysis.ts} (95%) create mode 100644 x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/common.ts create mode 100644 x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts create mode 100644 x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts create mode 100644 x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts create mode 100644 x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts create mode 100644 x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts create mode 100644 x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts create mode 100644 x-pack/legacy/plugins/infra/server/utils/elasticsearch_runtime_types.ts diff --git a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts index 17494212777198..d9ca9a96ffe51e 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './log_entry_categories'; +export * from './log_entry_category_datasets'; export * from './log_entry_rate'; diff --git a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts new file mode 100644 index 00000000000000..66823c25237aca --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts @@ -0,0 +1,109 @@ +/* + * 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 * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH = + '/api/infra/log_analysis/results/log_entry_categories'; + +/** + * request + */ + +const logEntryCategoriesHistogramParametersRT = rt.type({ + id: rt.string, + timeRange: timeRangeRT, + bucketCount: rt.number, +}); + +export type LogEntryCategoriesHistogramParameters = rt.TypeOf< + typeof logEntryCategoriesHistogramParametersRT +>; + +export const getLogEntryCategoriesRequestPayloadRT = rt.type({ + data: rt.intersection([ + rt.type({ + // the number of categories to fetch + categoryCount: rt.number, + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the categories from + timeRange: timeRangeRT, + // a list of histograms to create + histograms: rt.array(logEntryCategoriesHistogramParametersRT), + }), + rt.partial({ + // the datasets to filter for (optional, unfiltered if not present) + datasets: rt.array(rt.string), + }), + ]), +}); + +export type GetLogEntryCategoriesRequestPayload = rt.TypeOf< + typeof getLogEntryCategoriesRequestPayloadRT +>; + +/** + * response + */ + +export const logEntryCategoryHistogramBucketRT = rt.type({ + startTime: rt.number, + bucketDuration: rt.number, + logEntryCount: rt.number, +}); + +export type LogEntryCategoryHistogramBucket = rt.TypeOf; + +export const logEntryCategoryHistogramRT = rt.type({ + histogramId: rt.string, + buckets: rt.array(logEntryCategoryHistogramBucketRT), +}); + +export type LogEntryCategoryHistogram = rt.TypeOf; + +export const logEntryCategoryRT = rt.type({ + categoryId: rt.number, + datasets: rt.array(rt.string), + histograms: rt.array(logEntryCategoryHistogramRT), + logEntryCount: rt.number, + maximumAnomalyScore: rt.number, + regularExpression: rt.string, +}); + +export type LogEntryCategory = rt.TypeOf; + +export const getLogEntryCategoriesSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + categories: rt.array(logEntryCategoryRT), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryCategoriesSuccessResponsePayload = rt.TypeOf< + typeof getLogEntryCategoriesSuccessReponsePayloadRT +>; + +export const getLogEntryCategoriesResponsePayloadRT = rt.union([ + getLogEntryCategoriesSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryCategoriesReponsePayload = rt.TypeOf< + typeof getLogEntryCategoriesResponsePayloadRT +>; diff --git a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_category_datasets.ts b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_category_datasets.ts new file mode 100644 index 00000000000000..934d1052fa29fc --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_category_datasets.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH = + '/api/infra/log_analysis/results/log_entry_category_datasets'; + +/** + * request + */ + +export const getLogEntryCategoryDatasetsRequestPayloadRT = rt.type({ + data: rt.type({ + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the category datasets from + timeRange: timeRangeRT, + }), +}); + +export type GetLogEntryCategoryDatasetsRequestPayload = rt.TypeOf< + typeof getLogEntryCategoryDatasetsRequestPayloadRT +>; + +/** + * response + */ + +export const getLogEntryCategoryDatasetsSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + datasets: rt.array(rt.string), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryCategoryDatasetsSuccessResponsePayload = rt.TypeOf< + typeof getLogEntryCategoryDatasetsSuccessReponsePayloadRT +>; + +export const getLogEntryCategoryDatasetsResponsePayloadRT = rt.union([ + getLogEntryCategoryDatasetsSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryCategoryDatasetsReponsePayload = rt.TypeOf< + typeof getLogEntryCategoryDatasetsResponsePayloadRT +>; diff --git a/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts b/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts index 1047ca2f2a01a6..caeb1914cb8a2c 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts @@ -7,3 +7,4 @@ export * from './errors'; export * from './metric_statistics'; export * from './time_range'; +export * from './timing'; diff --git a/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts b/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts new file mode 100644 index 00000000000000..a208921c03d6fe --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { tracingSpanRT } from '../../performance_tracing'; + +export const routeTimingMetadataRT = rt.type({ + spans: rt.array(tracingSpanRT), +}); diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/index.ts b/x-pack/legacy/plugins/infra/common/log_analysis/index.ts index 79913f829191d2..22137e63ab7e72 100644 --- a/x-pack/legacy/plugins/infra/common/log_analysis/index.ts +++ b/x-pack/legacy/plugins/infra/common/log_analysis/index.ts @@ -5,4 +5,7 @@ */ export * from './log_analysis'; +export * from './log_analysis_results'; +export * from './log_entry_rate_analysis'; +export * from './log_entry_categories_analysis'; export * from './job_parameters'; diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts index 4a6f20d549799e..9b2f1a55eb8c1f 100644 --- a/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts @@ -4,14 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as rt from 'io-ts'; - -export const jobTypeRT = rt.keyof({ - 'log-entry-rate': null, -}); - -export type JobType = rt.TypeOf; - // combines and abstracts job and datafeed status export type JobStatus = | 'unknown' diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis_results.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis_results.ts new file mode 100644 index 00000000000000..1dcd4a10fc4e3b --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis_results.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ML_SEVERITY_SCORES = { + warning: 3, + minor: 25, + major: 50, + critical: 75, +}; + +export type MLSeverityScoreCategories = keyof typeof ML_SEVERITY_SCORES; + +export const ML_SEVERITY_COLORS = { + critical: 'rgb(228, 72, 72)', + major: 'rgb(229, 113, 0)', + minor: 'rgb(255, 221, 0)', + warning: 'rgb(125, 180, 226)', +}; + +export const getSeverityCategoryForScore = ( + score: number +): MLSeverityScoreCategories | undefined => { + if (score >= ML_SEVERITY_SCORES.critical) { + return 'critical'; + } else if (score >= ML_SEVERITY_SCORES.major) { + return 'major'; + } else if (score >= ML_SEVERITY_SCORES.minor) { + return 'minor'; + } else if (score >= ML_SEVERITY_SCORES.warning) { + return 'warning'; + } else { + // Category is too low to include + return undefined; + } +}; + +export const formatAnomalyScore = (score: number) => { + return Math.round(score); +}; + +export const getFriendlyNameForPartitionId = (partitionId: string) => { + return partitionId !== '' ? partitionId : 'unknown'; +}; diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts new file mode 100644 index 00000000000000..0957126ee52e3b --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_categories_analysis.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 * as rt from 'io-ts'; + +export const logEntryCategoriesJobTypeRT = rt.keyof({ + 'log-entry-categories-count': null, +}); + +export type LogEntryCategoriesJobType = rt.TypeOf; + +export const logEntryCategoriesJobTypes: LogEntryCategoriesJobType[] = [ + 'log-entry-categories-count', +]; diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_rate_analysis.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_rate_analysis.ts new file mode 100644 index 00000000000000..7fd668dc4ebce6 --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_rate_analysis.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const logEntryRateJobTypeRT = rt.keyof({ + 'log-entry-rate': null, +}); + +export type LogEntryRateJobType = rt.TypeOf; + +export const logEntryRateJobTypes: LogEntryRateJobType[] = ['log-entry-rate']; diff --git a/x-pack/legacy/plugins/infra/common/performance_tracing.ts b/x-pack/legacy/plugins/infra/common/performance_tracing.ts new file mode 100644 index 00000000000000..3e96f3c19d06d4 --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/performance_tracing.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 * as rt from 'io-ts'; +import uuid from 'uuid'; + +export const tracingSpanRT = rt.type({ + duration: rt.number, + id: rt.string, + name: rt.string, + start: rt.number, +}); + +export type TracingSpan = rt.TypeOf; + +export type ActiveTrace = (endTime?: number) => TracingSpan; + +export const startTracingSpan = (name: string): ActiveTrace => { + const initialState: TracingSpan = { + duration: Number.POSITIVE_INFINITY, + id: uuid.v4(), + name, + start: Date.now(), + }; + + return (endTime: number = Date.now()) => ({ + ...initialState, + duration: endTime - initialState.start, + }); +}; diff --git a/x-pack/legacy/plugins/infra/common/runtime_types.ts b/x-pack/legacy/plugins/infra/common/runtime_types.ts index 297743f9b3456e..d5b858df38def1 100644 --- a/x-pack/legacy/plugins/infra/common/runtime_types.ts +++ b/x-pack/legacy/plugins/infra/common/runtime_types.ts @@ -4,11 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Errors } from 'io-ts'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { Errors, Type } from 'io-ts'; import { failure } from 'io-ts/lib/PathReporter'; +type ErrorFactory = (message: string) => Error; + export const createPlainError = (message: string) => new Error(message); -export const throwErrors = (createError: (message: string) => Error) => (errors: Errors) => { +export const throwErrors = (createError: ErrorFactory) => (errors: Errors) => { throw createError(failure(errors).join('\n')); }; + +export const decodeOrThrow = ( + runtimeType: Type, + createError: ErrorFactory = createPlainError +) => (inputValue: I) => + pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity)); diff --git a/x-pack/legacy/plugins/infra/public/apps/start_app.tsx b/x-pack/legacy/plugins/infra/public/apps/start_app.tsx index 8ccb051724ede5..dbdc827478a45b 100644 --- a/x-pack/legacy/plugins/infra/public/apps/start_app.tsx +++ b/x-pack/legacy/plugins/infra/public/apps/start_app.tsx @@ -27,6 +27,7 @@ import { KibanaContextProvider, } from '../../../../../../src/plugins/kibana_react/public'; import { ROOT_ELEMENT_ID } from '../app'; + // NP_TODO: Type plugins export async function startApp(libs: InfraFrontendLibs, core: CoreStart, plugins: any) { const history = createHashHistory(); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts index 06229a26afd192..e954cf21229ee0 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts @@ -5,3 +5,4 @@ */ export * from './log_analysis_job_problem_indicator'; +export * from './recreate_job_button'; diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx index 018c5f5e0570da..8a16d819e12c2a 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx @@ -17,13 +17,22 @@ export const LogAnalysisJobProblemIndicator: React.FC<{ onRecreateMlJobForReconfiguration: () => void; onRecreateMlJobForUpdate: () => void; }> = ({ jobStatus, setupStatus, onRecreateMlJobForReconfiguration, onRecreateMlJobForUpdate }) => { - if (jobStatus === 'stopped') { + if (isStopped(jobStatus)) { return ; - } else if (setupStatus === 'skippedButUpdatable') { + } else if (isUpdatable(setupStatus)) { return ; - } else if (setupStatus === 'skippedButReconfigurable') { + } else if (isReconfigurable(setupStatus)) { return ; } return null; // no problem to indicate }; + +const isStopped = (jobStatus: JobStatus) => jobStatus === 'stopped'; + +const isUpdatable = (setupStatus: SetupStatus) => setupStatus === 'skippedButUpdatable'; + +const isReconfigurable = (setupStatus: SetupStatus) => setupStatus === 'skippedButReconfigurable'; + +export const jobHasProblem = (jobStatus: JobStatus, setupStatus: SetupStatus) => + isStopped(jobStatus) || isUpdatable(setupStatus) || isReconfigurable(setupStatus); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx new file mode 100644 index 00000000000000..74e8d197ef4555 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, PropsOf } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; + +export const RecreateJobButton: React.FunctionComponent> = props => ( + + + +); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx index b95054bbd6a9b0..5b872d4ee5147f 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx @@ -5,8 +5,9 @@ */ import React from 'react'; -import { EuiCallOut, EuiButton } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut } from '@elastic/eui'; + +import { RecreateJobButton } from './recreate_job_button'; export const RecreateJobCallout: React.FC<{ onRecreateMlJob: () => void; @@ -14,11 +15,6 @@ export const RecreateJobCallout: React.FC<{ }> = ({ children, onRecreateMlJob, title }) => (

{children}

- - - +
); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/first_use_callout.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/first_use_callout.tsx new file mode 100644 index 00000000000000..7fcdcc89a633ad --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/first_use_callout.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export const FirstUseCallout = () => { + return ( + +

+ {i18n.translate('xpack.infra.logs.analysis.onboardingSuccessContent', { + defaultMessage: + 'Please allow a few minutes for our machine learning robots to begin collecting data.', + })} +

+
+ ); +}; diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts index 8a4ceb70252a3b..a3139124e6c9ff 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts @@ -5,3 +5,4 @@ */ export * from './analyze_in_ml_button'; +export * from './first_use_callout'; diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts b/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts index 41c155e185c3a5..a067285026e33e 100644 --- a/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts +++ b/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts @@ -41,6 +41,7 @@ export type FetchJobStatusRequestPayload = rt.TypeOf ( jobSummaries .filter(jobSummary => jobSummary.id === jobId) .every( - jobSummary => - jobSummary.fullJob && - jobSummary.fullJob.custom_settings && - jobSummary.fullJob.custom_settings.job_revision && - jobSummary.fullJob.custom_settings.job_revision >= currentRevision + jobSummary => (jobSummary?.fullJob?.custom_settings?.job_revision ?? 0) >= currentRevision ); const isJobConfigurationConsistent = ( diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts index 5910dc54dfc902..be7547f2e74cb9 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts @@ -8,6 +8,8 @@ import { bucketSpan, categoriesMessageField, getJobId, + LogEntryCategoriesJobType, + logEntryCategoriesJobTypes, partitionField, } from '../../../../common/log_analysis'; @@ -21,22 +23,19 @@ import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; -const jobTypes = ['log-entry-categories-count']; const moduleId = 'logs_ui_categories'; -type JobType = typeof jobTypes[0]; - const getJobIds = (spaceId: string, sourceId: string) => - jobTypes.reduce( + logEntryCategoriesJobTypes.reduce( (accumulatedJobIds, jobType) => ({ ...accumulatedJobIds, [jobType]: getJobId(spaceId, sourceId, jobType), }), - {} as Record + {} as Record ); const getJobSummary = async (spaceId: string, sourceId: string) => { - const response = await callJobsSummaryAPI(spaceId, sourceId, jobTypes); + const response = await callJobsSummaryAPI(spaceId, sourceId, logEntryCategoriesJobTypes); const jobIds = Object.values(getJobIds(spaceId, sourceId)); return response.filter(jobSummary => jobIds.includes(jobSummary.id)); @@ -83,7 +82,7 @@ const setUpModule = async ( }; const cleanUpModule = async (spaceId: string, sourceId: string) => { - return await cleanUpJobsAndDatafeeds(spaceId, sourceId, jobTypes); + return await cleanUpJobsAndDatafeeds(spaceId, sourceId, logEntryCategoriesJobTypes); }; const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceConfiguration) => { @@ -103,9 +102,9 @@ const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceCon ]); }; -export const logEntryCategoriesModule: ModuleDescriptor = { +export const logEntryCategoriesModule: ModuleDescriptor = { moduleId, - jobTypes, + jobTypes: logEntryCategoriesJobTypes, bucketSpan, getJobIds, getJobSummary, diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx index 9a50acf622ee13..cc59d730557968 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx @@ -14,6 +14,7 @@ import { MlUnavailablePrompt, } from '../../../components/logging/log_analysis_setup'; import { LogAnalysisCapabilities } from '../../../containers/logs/log_analysis'; +import { LogEntryCategoriesResultsContent } from './page_results_content'; import { LogEntryCategoriesSetupContent } from './page_setup_content'; import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; @@ -44,8 +45,7 @@ export const LogEntryCategoriesPageContent = () => { } else if (setupStatus === 'unknown') { return ; } else if (isSetupStatusWithResults(setupStatus)) { - return null; - // return ; + return ; } else { return ; } diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx new file mode 100644 index 00000000000000..ffffba0691749d --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -0,0 +1,240 @@ +/* + * 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 datemath from '@elastic/datemath'; +import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import euiStyled from '../../../../../../common/eui_styled_components'; +import { TimeRange } from '../../../../common/http_api/shared/time_range'; +import { + LogAnalysisJobProblemIndicator, + jobHasProblem, +} from '../../../components/logging/log_analysis_job_status'; +import { FirstUseCallout } from '../../../components/logging/log_analysis_results'; +import { useInterval } from '../../../hooks/use_interval'; +import { useTrackPageview } from '../../../hooks/use_track_metric'; +import { TopCategoriesSection } from './sections/top_categories'; +import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; +import { useLogEntryCategoriesResults } from './use_log_entry_categories_results'; +import { + StringTimeRange, + useLogEntryCategoriesResultsUrlState, +} from './use_log_entry_categories_results_url_state'; + +const JOB_STATUS_POLLING_INTERVAL = 30000; + +export const LogEntryCategoriesResultsContent: React.FunctionComponent = () => { + useTrackPageview({ app: 'infra_logs', path: 'log_entry_categories_results' }); + useTrackPageview({ app: 'infra_logs', path: 'log_entry_categories_results', delay: 15000 }); + + const { + fetchJobStatus, + jobStatus, + setupStatus, + viewSetupForReconfiguration, + viewSetupForUpdate, + jobIds, + sourceConfiguration: { sourceId }, + } = useLogEntryCategoriesModuleContext(); + + const { + timeRange: selectedTimeRange, + setTimeRange: setSelectedTimeRange, + autoRefresh, + setAutoRefresh, + } = useLogEntryCategoriesResultsUrlState(); + + const [categoryQueryTimeRange, setCategoryQueryTimeRange] = useState<{ + lastChangedTime: number; + timeRange: TimeRange; + }>(() => ({ + lastChangedTime: Date.now(), + timeRange: stringToNumericTimeRange(selectedTimeRange), + })); + + const [categoryQueryDatasets, setCategoryQueryDatasets] = useState([]); + + const { services } = useKibana<{}>(); + + const showLoadDataErrorNotification = useCallback( + (error: Error) => { + // eslint-disable-next-line no-unused-expressions + services.notifications?.toasts.addError(error, { + title: loadDataErrorTitle, + }); + }, + [services.notifications] + ); + + const { + getLogEntryCategoryDatasets, + getTopLogEntryCategories, + isLoadingLogEntryCategoryDatasets, + isLoadingTopLogEntryCategories, + logEntryCategoryDatasets, + topLogEntryCategories, + } = useLogEntryCategoriesResults({ + categoriesCount: 25, + endTime: categoryQueryTimeRange.timeRange.endTime, + filteredDatasets: categoryQueryDatasets, + onGetTopLogEntryCategoriesError: showLoadDataErrorNotification, + sourceId, + startTime: categoryQueryTimeRange.timeRange.startTime, + }); + + const handleQueryTimeRangeChange = useCallback( + ({ start: startTime, end: endTime }: { start: string; end: string }) => { + setCategoryQueryTimeRange(previousQueryParameters => ({ + ...previousQueryParameters, + timeRange: stringToNumericTimeRange({ startTime, endTime }), + lastChangedTime: Date.now(), + })); + }, + [setCategoryQueryTimeRange] + ); + + const handleSelectedTimeRangeChange = useCallback( + (selectedTime: { start: string; end: string; isInvalid: boolean }) => { + if (selectedTime.isInvalid) { + return; + } + setSelectedTimeRange({ + startTime: selectedTime.start, + endTime: selectedTime.end, + }); + handleQueryTimeRangeChange(selectedTime); + }, + [setSelectedTimeRange, handleQueryTimeRangeChange] + ); + + const handleAutoRefreshChange = useCallback( + ({ isPaused, refreshInterval: interval }: { isPaused: boolean; refreshInterval: number }) => { + setAutoRefresh({ + isPaused, + interval, + }); + }, + [setAutoRefresh] + ); + + const isFirstUse = useMemo(() => setupStatus === 'hiddenAfterSuccess', [setupStatus]); + + const hasResults = useMemo(() => topLogEntryCategories.length > 0, [ + topLogEntryCategories.length, + ]); + + useEffect(() => { + getTopLogEntryCategories(); + }, [getTopLogEntryCategories, categoryQueryDatasets, categoryQueryTimeRange.lastChangedTime]); + + useEffect(() => { + getLogEntryCategoryDatasets(); + }, [getLogEntryCategoryDatasets, categoryQueryTimeRange.lastChangedTime]); + + useInterval(() => { + fetchJobStatus(); + }, JOB_STATUS_POLLING_INTERVAL); + + useInterval( + () => { + handleQueryTimeRangeChange({ + start: selectedTimeRange.startTime, + end: selectedTimeRange.endTime, + }); + }, + autoRefresh.isPaused ? null : autoRefresh.interval + ); + + return ( + + + + + + + + + + + + + {jobHasProblem(jobStatus['log-entry-categories-count'], setupStatus) ? ( + + + + ) : null} + {isFirstUse && !hasResults ? ( + + + + ) : null} + + + + + + + + ); +}; + +const stringToNumericTimeRange = (timeRange: StringTimeRange): TimeRange => ({ + startTime: moment( + datemath.parse(timeRange.startTime, { + momentInstance: moment, + }) + ).valueOf(), + endTime: moment( + datemath.parse(timeRange.endTime, { + momentInstance: moment, + roundUp: true, + }) + ).valueOf(), +}); + +// This is needed due to the flex-basis: 100% !important; rule that +// kicks in on small screens via media queries breaking when using direction="column" +export const ResultsContentPage = euiStyled(EuiPage)` + flex: 1 0 0%; + flex-direction: column; + + .euiFlexGroup--responsive > .euiFlexItem { + flex-basis: auto !important; + } +`; + +const loadDataErrorTitle = i18n.translate( + 'xpack.infra.logs.logEntryCategories.loadDataErrorTitle', + { + defaultMessage: 'Failed to load category data', + } +); diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx new file mode 100644 index 00000000000000..e50231316fb5af --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiHealth } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { + formatAnomalyScore, + getSeverityCategoryForScore, + ML_SEVERITY_COLORS, +} from '../../../../../../common/log_analysis'; + +export const AnomalySeverityIndicator: React.FunctionComponent<{ + anomalyScore: number; +}> = ({ anomalyScore }) => { + const severityColor = useMemo(() => getColorForAnomalyScore(anomalyScore), [anomalyScore]); + + return {formatAnomalyScore(anomalyScore)}; +}; + +const getColorForAnomalyScore = (anomalyScore: number) => { + const severityCategory = getSeverityCategoryForScore(anomalyScore); + + if (severityCategory != null && severityCategory in ML_SEVERITY_COLORS) { + return ML_SEVERITY_COLORS[severityCategory]; + } else { + return 'subdued'; + } +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx new file mode 100644 index 00000000000000..5c8b18528cae60 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx @@ -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 { i18n } from '@kbn/i18n'; +import React, { memo } from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; + +export const RegularExpressionRepresentation: React.FunctionComponent<{ + maximumSegmentCount?: number; + regularExpression: string; +}> = memo(({ maximumSegmentCount = 30, regularExpression }) => { + const segments = regularExpression.split(collapsedRegularExpressionCharacters); + + return ( + + {segments + .slice(0, maximumSegmentCount) + .map((segment, segmentIndex) => [ + segmentIndex > 0 ? ( + + ) : null, + + {segment.replace(escapedRegularExpressionCharacters, '$1')} + , + ])} + {segments.length > maximumSegmentCount ? ( + + … + + ) : null} + + ); +}); + +const CategoryPattern = euiStyled.span` + font-family: ${props => props.theme.eui.euiCodeFontFamily}; + word-break: break-all; +`; + +const CategoryPatternWildcard = euiStyled.span` + color: ${props => props.theme.eui.euiColorMediumShade}; +`; + +const CategoryPatternSegment = euiStyled.span` + font-weight: bold; +`; + +const collapsedRegularExpressionCharacters = /\.[+*]\??/g; + +const escapedRegularExpressionCharacters = /\\([\\^$*+?.()\[\]])/g; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx new file mode 100644 index 00000000000000..c30612f54be00b --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.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 { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; + +export const DatasetsList: React.FunctionComponent<{ + datasets: string[]; +}> = ({ datasets }) => ( +
    + {datasets.sort().map(dataset => { + const datasetLabel = getFriendlyNameForPartitionId(dataset); + return
  • {datasetLabel}
  • ; + })} +
+); diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx new file mode 100644 index 00000000000000..9c22caa4b34654 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useMemo } from 'react'; + +import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; + +type DatasetOptionProps = EuiComboBoxOptionProps; + +export const DatasetsSelector: React.FunctionComponent<{ + availableDatasets: string[]; + isLoading?: boolean; + onChangeDatasetSelection: (datasets: string[]) => void; + selectedDatasets: string[]; +}> = ({ availableDatasets, isLoading = false, onChangeDatasetSelection, selectedDatasets }) => { + const options = useMemo( + () => + availableDatasets.map(dataset => ({ + value: dataset, + label: getFriendlyNameForPartitionId(dataset), + })), + [availableDatasets] + ); + + const selectedOptions = useMemo( + () => options.filter(({ value }) => value != null && selectedDatasets.includes(value)), + [options, selectedDatasets] + ); + + const handleChange = useCallback( + (newSelectedOptions: DatasetOptionProps[]) => + onChangeDatasetSelection(newSelectedOptions.map(({ value }) => value).filter(isDefined)), + [onChangeDatasetSelection] + ); + + return ( + + ); +}; + +const datasetFilterPlaceholder = i18n.translate( + 'xpack.infra.logs.logEntryCategories.datasetFilterPlaceholder', + { + defaultMessage: 'Filter by datasets', + } +); + +const isDefined = (value: Value): value is NonNullable => value != null; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts new file mode 100644 index 00000000000000..e699bbf956f94b --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './top_categories_section'; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx new file mode 100644 index 00000000000000..7a29ea9aa0ebc8 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.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, { useMemo } from 'react'; + +import { LogEntryCategoryHistogram } from '../../../../../../common/http_api/log_analysis'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { SingleMetricComparison } from './single_metric_comparison'; +import { SingleMetricSparkline } from './single_metric_sparkline'; + +export const LogEntryCountSparkline: React.FunctionComponent<{ + currentCount: number; + histograms: LogEntryCategoryHistogram[]; + timeRange: TimeRange; +}> = ({ currentCount, histograms, timeRange }) => { + const metric = useMemo( + () => + histograms + .find(histogram => histogram.histogramId === 'history') + ?.buckets?.map(({ startTime: timestamp, logEntryCount: value }) => ({ + timestamp, + value, + })) ?? [], + [histograms] + ); + const referenceCount = useMemo( + () => + histograms.find(histogram => histogram.histogramId === 'reference')?.buckets?.[0] + ?.logEntryCount ?? 0, + [histograms] + ); + + const overallTimeRange = useMemo( + () => ({ + endTime: timeRange.endTime, + startTime: timeRange.startTime - (timeRange.endTime - timeRange.startTime), + }), + [timeRange.endTime, timeRange.startTime] + ); + + return ( + <> + + + + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_comparison.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_comparison.tsx new file mode 100644 index 00000000000000..1352afb60a5057 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_comparison.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 { EuiIcon, EuiTextColor } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; + +export const SingleMetricComparison: React.FunctionComponent<{ + currentValue: number; + previousValue: number; +}> = ({ currentValue, previousValue }) => { + const changeFactor = currentValue / previousValue - 1; + + if (changeFactor < 0) { + return ( + + + {formatPercentage(changeFactor)} + + ); + } else if (changeFactor > 0 && Number.isFinite(changeFactor)) { + return ( + + + {formatPercentage(changeFactor)} + + ); + } else if (changeFactor > 0 && !Number.isFinite(changeFactor)) { + return ( + + + {newCategoryTrendLabel} + + ); + } + + return null; +}; + +const formatPercentage = (value: number) => numeral(value).format('+0,0 %'); + +const newCategoryTrendLabel = i18n.translate( + 'xpack.infra.logs.logEntryCategories.newCategoryTrendLabel', + { + defaultMessage: 'new', + } +); + +const NoWrapSpan = euiStyled.span` + white-space: nowrap; +`; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx new file mode 100644 index 00000000000000..5fb8e3380f23f7 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx @@ -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 React, { useMemo } from 'react'; +import { Chart, Settings, AreaSeries } from '@elastic/charts'; +import { + EUI_CHARTS_THEME_LIGHT, + EUI_SPARKLINE_THEME_PARTIAL, + EUI_CHARTS_THEME_DARK, +} from '@elastic/eui/dist/eui_charts_theme'; + +import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; +import { TimeRange } from '../../../../../../common/http_api/shared'; + +interface TimeSeriesPoint { + timestamp: number; + value: number; +} + +const timestampAccessor = 'timestamp'; +const valueAccessor = ['value']; +const sparklineSize = { + height: 20, + width: 100, +}; + +export const SingleMetricSparkline: React.FunctionComponent<{ + metric: TimeSeriesPoint[]; + timeRange: TimeRange; +}> = ({ metric, timeRange }) => { + const [isDarkMode] = useKibanaUiSetting('theme:darkMode'); + + const theme = useMemo( + () => [ + // localThemeOverride, + EUI_SPARKLINE_THEME_PARTIAL, + isDarkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme, + ], + [isDarkMode] + ); + + const xDomain = useMemo( + () => ({ + max: timeRange.endTime, + min: timeRange.startTime, + }), + [timeRange] + ); + + return ( + + + + + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx new file mode 100644 index 00000000000000..0281615a59c785 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +import { LogEntryCategory } from '../../../../../../common/http_api/log_analysis'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; +import { RecreateJobButton } from '../../../../../components/logging/log_analysis_job_status'; +import { AnalyzeInMlButton } from '../../../../../components/logging/log_analysis_results'; +import { DatasetsSelector } from './datasets_selector'; +import { TopCategoriesTable } from './top_categories_table'; + +export const TopCategoriesSection: React.FunctionComponent<{ + availableDatasets: string[]; + isLoadingDatasets?: boolean; + isLoadingTopCategories?: boolean; + jobId: string; + onChangeDatasetSelection: (datasets: string[]) => void; + onRequestRecreateMlJob: () => void; + selectedDatasets: string[]; + timeRange: TimeRange; + topCategories: LogEntryCategory[]; +}> = ({ + availableDatasets, + isLoadingDatasets = false, + isLoadingTopCategories = false, + jobId, + onChangeDatasetSelection, + onRequestRecreateMlJob, + selectedDatasets, + timeRange, + topCategories, +}) => { + return ( + <> + + + +

{title}

+
+
+ + + + + + +
+ + + + } + > + + + + ); +}; + +const title = i18n.translate('xpack.infra.logs.logEntryCategories.topCategoriesSectionTitle', { + defaultMessage: 'Log message categories', +}); + +const loadingAriaLabel = i18n.translate( + 'xpack.infra.logs.logEntryCategories.topCategoriesSectionLoadingAriaLabel', + { defaultMessage: 'Loading message categories' } +); + +const LoadingOverlayContent = () => ; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx new file mode 100644 index 00000000000000..3d20aef03ff158 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx @@ -0,0 +1,106 @@ +/* + * 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 { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; +import { + LogEntryCategory, + LogEntryCategoryHistogram, +} from '../../../../../../common/http_api/log_analysis'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { AnomalySeverityIndicator } from './anomaly_severity_indicator'; +import { RegularExpressionRepresentation } from './category_expression'; +import { DatasetsList } from './datasets_list'; +import { LogEntryCountSparkline } from './log_entry_count_sparkline'; + +export const TopCategoriesTable = euiStyled( + ({ + className, + timeRange, + topCategories, + }: { + className?: string; + timeRange: TimeRange; + topCategories: LogEntryCategory[]; + }) => { + const columns = useMemo(() => createColumns(timeRange), [timeRange]); + + return ( + + ); + } +)` + &.euiTableRow--topAligned .euiTableRowCell { + vertical-align: top; + } +`; + +const createColumns = (timeRange: TimeRange): Array> => [ + { + align: 'right', + field: 'logEntryCount', + name: i18n.translate('xpack.infra.logs.logEntryCategories.countColumnTitle', { + defaultMessage: 'Message count', + }), + render: (logEntryCount: number) => { + return numeral(logEntryCount).format('0,0'); + }, + width: '120px', + }, + { + field: 'histograms', + name: i18n.translate('xpack.infra.logs.logEntryCategories.trendColumnTitle', { + defaultMessage: 'Trend', + }), + render: (histograms: LogEntryCategoryHistogram[], item) => { + return ( + + ); + }, + width: '220px', + }, + { + field: 'regularExpression', + name: i18n.translate('xpack.infra.logs.logEntryCategories.categoryColumnTitle', { + defaultMessage: 'Category', + }), + truncateText: true, + render: (regularExpression: string) => ( + + ), + }, + { + field: 'datasets', + name: i18n.translate('xpack.infra.logs.logEntryCategories.datasetColumnTitle', { + defaultMessage: 'Datasets', + }), + render: (datasets: string[]) => , + width: '200px', + }, + { + align: 'right', + field: 'maximumAnomalyScore', + name: i18n.translate('xpack.infra.logs.logEntryCategories.maximumAnomalyScoreColumnTitle', { + defaultMessage: 'Maximum anomaly score', + }), + render: (maximumAnomalyScore: number) => ( + + ), + width: '160px', + }, +]; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_datasets.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_datasets.ts new file mode 100644 index 00000000000000..942ded4230e974 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_datasets.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from 'ui/new_platform'; + +import { + getLogEntryCategoryDatasetsRequestPayloadRT, + getLogEntryCategoryDatasetsSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; + +export const callGetLogEntryCategoryDatasetsAPI = async ( + sourceId: string, + startTime: number, + endTime: number +) => { + const response = await npStart.core.http.fetch( + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, + { + method: 'POST', + body: JSON.stringify( + getLogEntryCategoryDatasetsRequestPayloadRT.encode({ + data: { + sourceId, + timeRange: { + startTime, + endTime, + }, + }, + }) + ), + } + ); + + return pipe( + getLogEntryCategoryDatasetsSuccessReponsePayloadRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts new file mode 100644 index 00000000000000..35d6f1ec4f893f --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from 'ui/new_platform'; + +import { + getLogEntryCategoriesRequestPayloadRT, + getLogEntryCategoriesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; + +export const callGetTopLogEntryCategoriesAPI = async ( + sourceId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets?: string[] +) => { + const intervalDuration = endTime - startTime; + + const response = await npStart.core.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, { + method: 'POST', + body: JSON.stringify( + getLogEntryCategoriesRequestPayloadRT.encode({ + data: { + sourceId, + timeRange: { + startTime, + endTime, + }, + categoryCount, + datasets, + histograms: [ + { + id: 'history', + timeRange: { + startTime: startTime - intervalDuration, + endTime, + }, + bucketCount: 10, + }, + { + id: 'reference', + timeRange: { + startTime: startTime - intervalDuration, + endTime: startTime, + }, + bucketCount: 1, + }, + ], + }, + }) + ), + }); + + return pipe( + getLogEntryCategoriesSuccessReponsePayloadRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts new file mode 100644 index 00000000000000..2282582dc2bd61 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.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 { useMemo, useState } from 'react'; + +import { + GetLogEntryCategoriesSuccessResponsePayload, + GetLogEntryCategoryDatasetsSuccessResponsePayload, +} from '../../../../common/http_api/log_analysis'; +import { useTrackedPromise, CanceledPromiseError } from '../../../utils/use_tracked_promise'; +import { callGetTopLogEntryCategoriesAPI } from './service_calls/get_top_log_entry_categories'; +import { callGetLogEntryCategoryDatasetsAPI } from './service_calls/get_log_entry_category_datasets'; + +type TopLogEntryCategories = GetLogEntryCategoriesSuccessResponsePayload['data']['categories']; +type LogEntryCategoryDatasets = GetLogEntryCategoryDatasetsSuccessResponsePayload['data']['datasets']; + +export const useLogEntryCategoriesResults = ({ + categoriesCount, + filteredDatasets: filteredDatasets, + endTime, + onGetLogEntryCategoryDatasetsError, + onGetTopLogEntryCategoriesError, + sourceId, + startTime, +}: { + categoriesCount: number; + filteredDatasets: string[]; + endTime: number; + onGetLogEntryCategoryDatasetsError?: (error: Error) => void; + onGetTopLogEntryCategoriesError?: (error: Error) => void; + sourceId: string; + startTime: number; +}) => { + const [topLogEntryCategories, setTopLogEntryCategories] = useState([]); + const [logEntryCategoryDatasets, setLogEntryCategoryDatasets] = useState< + LogEntryCategoryDatasets + >([]); + + const [getTopLogEntryCategoriesRequest, getTopLogEntryCategories] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetTopLogEntryCategoriesAPI( + sourceId, + startTime, + endTime, + categoriesCount, + filteredDatasets + ); + }, + onResolve: ({ data: { categories } }) => { + setTopLogEntryCategories(categories); + }, + onReject: error => { + if ( + error instanceof Error && + !(error instanceof CanceledPromiseError) && + onGetTopLogEntryCategoriesError + ) { + onGetTopLogEntryCategoriesError(error); + } + }, + }, + [categoriesCount, endTime, filteredDatasets, sourceId, startTime] + ); + + const [getLogEntryCategoryDatasetsRequest, getLogEntryCategoryDatasets] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetLogEntryCategoryDatasetsAPI(sourceId, startTime, endTime); + }, + onResolve: ({ data: { datasets } }) => { + setLogEntryCategoryDatasets(datasets); + }, + onReject: error => { + if ( + error instanceof Error && + !(error instanceof CanceledPromiseError) && + onGetLogEntryCategoryDatasetsError + ) { + onGetLogEntryCategoryDatasetsError(error); + } + }, + }, + [categoriesCount, endTime, sourceId, startTime] + ); + + const isLoadingTopLogEntryCategories = useMemo( + () => getTopLogEntryCategoriesRequest.state === 'pending', + [getTopLogEntryCategoriesRequest.state] + ); + + const isLoadingLogEntryCategoryDatasets = useMemo( + () => getLogEntryCategoryDatasetsRequest.state === 'pending', + [getLogEntryCategoryDatasetsRequest.state] + ); + + const isLoading = useMemo( + () => isLoadingTopLogEntryCategories || isLoadingLogEntryCategoryDatasets, + [isLoadingLogEntryCategoryDatasets, isLoadingTopLogEntryCategories] + ); + + return { + getLogEntryCategoryDatasets, + getTopLogEntryCategories, + isLoading, + isLoadingLogEntryCategoryDatasets, + isLoadingTopLogEntryCategories, + logEntryCategoryDatasets, + topLogEntryCategories, + }; +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results_url_state.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results_url_state.tsx new file mode 100644 index 00000000000000..bf30f96e4b7419 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results_url_state.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; + +import { useUrlState } from '../../../utils/use_url_state'; + +const autoRefreshRT = rt.union([ + rt.type({ + interval: rt.number, + isPaused: rt.boolean, + }), + rt.undefined, +]); + +export const stringTimeRangeRT = rt.type({ + startTime: rt.string, + endTime: rt.string, +}); +export type StringTimeRange = rt.TypeOf; + +const urlTimeRangeRT = rt.union([stringTimeRangeRT, rt.undefined]); + +const TIME_RANGE_URL_STATE_KEY = 'timeRange'; +const AUTOREFRESH_URL_STATE_KEY = 'autoRefresh'; + +export const useLogEntryCategoriesResultsUrlState = () => { + const [timeRange, setTimeRange] = useUrlState({ + defaultState: { + startTime: 'now-2w', + endTime: 'now', + }, + decodeUrlState: (value: unknown) => + pipe(urlTimeRangeRT.decode(value), fold(constant(undefined), identity)), + encodeUrlState: urlTimeRangeRT.encode, + urlStateKey: TIME_RANGE_URL_STATE_KEY, + writeDefaultState: true, + }); + + const [autoRefresh, setAutoRefresh] = useUrlState({ + defaultState: { + isPaused: false, + interval: 60000, + }, + decodeUrlState: (value: unknown) => + pipe(autoRefreshRT.decode(value), fold(constant(undefined), identity)), + encodeUrlState: autoRefreshRT.encode, + urlStateKey: AUTOREFRESH_URL_STATE_KEY, + writeDefaultState: true, + }); + + return { + timeRange, + setTimeRange, + autoRefresh, + setAutoRefresh, + }; +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/first_use.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/first_use.tsx deleted file mode 100644 index 1ab9356a69e2a1..00000000000000 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/first_use.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; - -export const FirstUseCallout = () => { - return ( - <> - -

- {i18n.translate('xpack.infra.logs.logsAnalysisResults.onboardingSuccessContent', { - defaultMessage: - 'Please allow a few minutes for our machine learning robots to begin collecting data.', - })} -

-
- - - ); -}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts index 52be3132643353..52ba3101dbc38e 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { bucketSpan, getJobId, partitionField } from '../../../../common/log_analysis'; +import { + bucketSpan, + getJobId, + LogEntryRateJobType, + logEntryRateJobTypes, + partitionField, +} from '../../../../common/log_analysis'; import { ModuleDescriptor, @@ -16,22 +22,19 @@ import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; -const jobTypes = ['log-entry-rate']; const moduleId = 'logs_ui_analysis'; -type JobType = typeof jobTypes[0]; - const getJobIds = (spaceId: string, sourceId: string) => - jobTypes.reduce( + logEntryRateJobTypes.reduce( (accumulatedJobIds, jobType) => ({ ...accumulatedJobIds, [jobType]: getJobId(spaceId, sourceId, jobType), }), - {} as Record + {} as Record ); const getJobSummary = async (spaceId: string, sourceId: string) => { - const response = await callJobsSummaryAPI(spaceId, sourceId, jobTypes); + const response = await callJobsSummaryAPI(spaceId, sourceId, logEntryRateJobTypes); const jobIds = Object.values(getJobIds(spaceId, sourceId)); return response.filter(jobSummary => jobIds.includes(jobSummary.id)); @@ -78,7 +81,7 @@ const setUpModule = async ( }; const cleanUpModule = async (spaceId: string, sourceId: string) => { - return await cleanUpJobsAndDatafeeds(spaceId, sourceId, jobTypes); + return await cleanUpJobsAndDatafeeds(spaceId, sourceId, logEntryRateJobTypes); }; const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceConfiguration) => { @@ -94,9 +97,9 @@ const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceCon ]); }; -export const logEntryRateModule: ModuleDescriptor = { +export const logEntryRateModule: ModuleDescriptor = { moduleId, - jobTypes, + jobTypes: logEntryRateJobTypes, bucketSpan, getJobIds, getJobSummary, diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index b6ab8acdea5b20..693444c02ce5f3 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -11,6 +11,7 @@ import { EuiFlexItem, EuiPage, EuiPanel, + EuiSpacer, EuiSuperDatePicker, EuiText, } from '@elastic/eui'; @@ -26,7 +27,6 @@ import { LoadingOverlayWrapper } from '../../../components/loading_overlay_wrapp import { useInterval } from '../../../hooks/use_interval'; import { useTrackPageview } from '../../../hooks/use_track_metric'; import { useKibanaUiSetting } from '../../../utils/use_kibana_ui_setting'; -import { FirstUseCallout } from './first_use'; import { AnomaliesResults } from './sections/anomalies'; import { LogRateResults } from './sections/log_rate'; import { useLogEntryRateModuleContext } from './use_log_entry_rate_module'; @@ -35,6 +35,7 @@ import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; +import { FirstUseCallout } from '../../../components/logging/log_analysis_results'; const JOB_STATUS_POLLING_INTERVAL = 30000; @@ -196,7 +197,12 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { - {isFirstUse && !hasResults ? : null} + {isFirstUse && !hasResults ? ( + <> + + + + ) : null} { // This is needed due to the flex-basis: 100% !important; rule that // kicks in on small screens via media queries breaking when using direction="column" export const ResultsContentPage = euiStyled(EuiPage)` + flex: 1 0 0%; + .euiFlexGroup--responsive > .euiFlexItem { flex-basis: auto !important; } diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx index a75e6c50ab03fe..1a3a7d9e2b572e 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx @@ -22,8 +22,11 @@ import moment from 'moment'; import React, { useCallback, useMemo } from 'react'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { + MLSeverityScoreCategories, + ML_SEVERITY_COLORS, +} from '../../../../../../common/log_analysis'; import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; -import { MLSeverityScoreCategories } from '../helpers/data_formatters'; export const AnomaliesChart: React.FunctionComponent<{ chartId: string; @@ -109,19 +112,19 @@ interface SeverityConfig { const severityConfigs: Record = { warning: { id: `anomalies-warning`, - style: { fill: 'rgb(125, 180, 226)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.warning, opacity: 0.7 }, }, minor: { id: `anomalies-minor`, - style: { fill: 'rgb(255, 221, 0)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.minor, opacity: 0.7 }, }, major: { id: `anomalies-major`, - style: { fill: 'rgb(229, 113, 0)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.major, opacity: 0.7 }, }, critical: { id: `anomalies-critical`, - style: { fill: 'rgb(228, 72, 72)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.critical, opacity: 0.7 }, }, }; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx index e5e719c2d69f6a..4aff907cfad66d 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx @@ -12,7 +12,6 @@ import { EuiStat, EuiTitle, EuiLoadingSpinner, - EuiButton, } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; @@ -21,16 +20,18 @@ import React, { useMemo } from 'react'; import euiStyled from '../../../../../../../../common/eui_styled_components'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { JobStatus, SetupStatus } from '../../../../../../common/log_analysis'; +import { formatAnomalyScore, JobStatus, SetupStatus } from '../../../../../../common/log_analysis'; import { - formatAnomalyScore, getAnnotationsForAll, getLogEntryRateCombinedSeries, getTopAnomalyScoreAcrossAllPartitions, } from '../helpers/data_formatters'; import { AnomaliesChart } from './chart'; import { AnomaliesTable } from './table'; -import { LogAnalysisJobProblemIndicator } from '../../../../../components/logging/log_analysis_job_status'; +import { + LogAnalysisJobProblemIndicator, + RecreateJobButton, +} from '../../../../../components/logging/log_analysis_job_status'; import { AnalyzeInMlButton } from '../../../../../components/logging/log_analysis_results'; import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; @@ -99,9 +100,7 @@ export const AnomaliesResults: React.FunctionComponent<{ - - Recreate jobs - + diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index 45893315c73619..3e86b45fadfdd6 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useState, useCallback } from 'react'; import { EuiBasicTable, EuiButtonIcon } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useMemo, useState } from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { + formatAnomalyScore, + getFriendlyNameForPartitionId, +} from '../../../../../../common/log_analysis'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { AnomaliesTableExpandedRow } from './expanded_row'; -import { formatAnomalyScore, getFriendlyNameForPartitionId } from '../helpers/data_formatters'; -import euiStyled from '../../../../../../../../common/eui_styled_components'; interface TableItem { id: string; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx index f9b85fc4e20c23..e8e4c18e7420c4 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx @@ -7,17 +7,14 @@ import { RectAnnotationDatum } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { + formatAnomalyScore, + getFriendlyNameForPartitionId, + getSeverityCategoryForScore, + MLSeverityScoreCategories, +} from '../../../../../../common/log_analysis'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; -const ML_SEVERITY_SCORES = { - warning: 3, - minor: 25, - major: 50, - critical: 75, -}; - -export type MLSeverityScoreCategories = keyof typeof ML_SEVERITY_SCORES; - export const getLogEntryRatePartitionedSeries = (results: LogEntryRateResults) => { return results.histogramBuckets.reduce>( (buckets, bucket) => { @@ -182,26 +179,3 @@ export const getTopAnomalyScoreAcrossAllPartitions = (results: LogEntryRateResul ); return Math.max(...allTopScores); }; - -const getSeverityCategoryForScore = (score: number): MLSeverityScoreCategories | undefined => { - if (score >= ML_SEVERITY_SCORES.critical) { - return 'critical'; - } else if (score >= ML_SEVERITY_SCORES.major) { - return 'major'; - } else if (score >= ML_SEVERITY_SCORES.minor) { - return 'minor'; - } else if (score >= ML_SEVERITY_SCORES.warning) { - return 'warning'; - } else { - // Category is too low to include - return undefined; - } -}; - -export const formatAnomalyScore = (score: number) => { - return Math.round(score); -}; - -export const getFriendlyNameForPartitionId = (partitionId: string) => { - return partitionId !== '' ? partitionId : 'unknown'; -}; diff --git a/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts b/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts index c23bab7026aaad..e9a966b97e4dd7 100644 --- a/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts +++ b/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts @@ -248,7 +248,7 @@ interface CancelablePromise { promise: Promise; } -class CanceledPromiseError extends Error { +export class CanceledPromiseError extends Error { public isCanceled = true; constructor(message?: string) { @@ -257,6 +257,6 @@ class CanceledPromiseError extends Error { } } -class SilentCanceledPromiseError extends CanceledPromiseError {} +export class SilentCanceledPromiseError extends CanceledPromiseError {} const noOp = () => undefined; diff --git a/x-pack/legacy/plugins/infra/server/infra_server.ts b/x-pack/legacy/plugins/infra/server/infra_server.ts index f99589e1b52bd7..4f290cb05f0561 100644 --- a/x-pack/legacy/plugins/infra/server/infra_server.ts +++ b/x-pack/legacy/plugins/infra/server/infra_server.ts @@ -12,6 +12,8 @@ import { createSourceStatusResolvers } from './graphql/source_status'; import { createSourcesResolvers } from './graphql/sources'; import { InfraBackendLibs } from './lib/infra_types'; import { + initGetLogEntryCategoriesRoute, + initGetLogEntryCategoryDatasetsRoute, initGetLogEntryRateRoute, initValidateLogAnalysisIndicesRoute, } from './routes/log_analysis'; @@ -41,6 +43,8 @@ export const initInfraServer = (libs: InfraBackendLibs) => { libs.framework.registerGraphQLEndpoint('/graphql', schema); initIpToHostName(libs); + initGetLogEntryCategoriesRoute(libs); + initGetLogEntryCategoryDatasetsRoute(libs); initGetLogEntryRateRoute(libs); initSnapshotRoute(libs); initNodeDetailsRoute(libs); diff --git a/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts b/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts index 305841aa52d36c..d8a39a6b9c16f6 100644 --- a/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts +++ b/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts @@ -12,7 +12,7 @@ import { InfraFieldsDomain } from '../domains/fields_domain'; import { InfraLogEntriesDomain } from '../domains/log_entries_domain'; import { InfraMetricsDomain } from '../domains/metrics_domain'; import { InfraBackendLibs, InfraDomainLibs } from '../infra_types'; -import { InfraLogAnalysis } from '../log_analysis'; +import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from '../log_analysis'; import { InfraSnapshot } from '../snapshot'; import { InfraSourceStatus } from '../source_status'; import { InfraSources } from '../sources'; @@ -29,7 +29,8 @@ export function compose(core: CoreSetup, config: InfraConfig, plugins: InfraServ sources, }); const snapshot = new InfraSnapshot({ sources, framework }); - const logAnalysis = new InfraLogAnalysis({ framework }); + const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); + const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); // TODO: separate these out individually and do away with "domains" as a temporary group const domainLibs: InfraDomainLibs = { @@ -45,7 +46,8 @@ export function compose(core: CoreSetup, config: InfraConfig, plugins: InfraServ const libs: InfraBackendLibs = { configuration: config, // NP_TODO: Do we ever use this anywhere? framework, - logAnalysis, + logEntryCategoriesAnalysis, + logEntryRateAnalysis, snapshot, sources, sourceStatus, diff --git a/x-pack/legacy/plugins/infra/server/lib/infra_types.ts b/x-pack/legacy/plugins/infra/server/lib/infra_types.ts index 46d32885600dfd..d52416b39596b8 100644 --- a/x-pack/legacy/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/legacy/plugins/infra/server/lib/infra_types.ts @@ -8,7 +8,7 @@ import { InfraSourceConfiguration } from '../../public/graphql/types'; import { InfraFieldsDomain } from './domains/fields_domain'; import { InfraLogEntriesDomain } from './domains/log_entries_domain'; import { InfraMetricsDomain } from './domains/metrics_domain'; -import { InfraLogAnalysis } from './log_analysis/log_analysis'; +import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from './log_analysis'; import { InfraSnapshot } from './snapshot'; import { InfraSources } from './sources'; import { InfraSourceStatus } from './source_status'; @@ -31,7 +31,8 @@ export interface InfraDomainLibs { export interface InfraBackendLibs extends InfraDomainLibs { configuration: InfraConfig; framework: KibanaFramework; - logAnalysis: InfraLogAnalysis; + logEntryCategoriesAnalysis: LogEntryCategoriesAnalysis; + logEntryRateAnalysis: LogEntryRateAnalysis; snapshot: InfraSnapshot; sources: InfraSources; sourceStatus: InfraSourceStatus; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts index dc5c87c61fdcee..d1c8316ad061b2 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export class NoLogRateResultsIndexError extends Error { +export class NoLogAnalysisResultsIndexError extends Error { constructor(message?: string) { super(message); Object.setPrototypeOf(this, new.target.prototype); diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts index 0b58c71c1db7bf..44c2bafce4194e 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts @@ -5,4 +5,5 @@ */ export * from './errors'; -export * from './log_analysis'; +export * from './log_entry_categories_analysis'; +export * from './log_entry_rate_analysis'; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts new file mode 100644 index 00000000000000..f2b6c468df69f6 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -0,0 +1,363 @@ +/* + * 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 { KibanaRequest, RequestHandlerContext } from '../../../../../../../src/core/server'; +import { getJobId, logEntryCategoriesJobTypes } from '../../../common/log_analysis'; +import { startTracingSpan, TracingSpan } from '../../../common/performance_tracing'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; +import { NoLogAnalysisResultsIndexError } from './errors'; +import { + createLogEntryCategoriesQuery, + logEntryCategoriesResponseRT, + LogEntryCategoryHit, +} from './queries/log_entry_categories'; +import { + createLogEntryCategoryHistogramsQuery, + logEntryCategoryHistogramsResponseRT, +} from './queries/log_entry_category_histograms'; +import { + CompositeDatasetKey, + createLogEntryDatasetsQuery, + LogEntryDatasetBucket, + logEntryDatasetsResponseRT, +} from './queries/log_entry_data_sets'; +import { + createTopLogEntryCategoriesQuery, + topLogEntryCategoriesResponseRT, +} from './queries/top_log_entry_categories'; + +const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; + +export class LogEntryCategoriesAnalysis { + constructor( + private readonly libs: { + framework: KibanaFramework; + } + ) {} + + public async getTopLogEntryCategories( + requestContext: RequestHandlerContext, + request: KibanaRequest, + sourceId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets: string[], + histograms: HistogramParameters[] + ) { + const finalizeTopLogEntryCategoriesSpan = startTracingSpan('get top categories'); + + const logEntryCategoriesCountJobId = getJobId( + this.libs.framework.getSpaceId(request), + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const { + topLogEntryCategories, + timing: { spans: fetchTopLogEntryCategoriesAggSpans }, + } = await this.fetchTopLogEntryCategories( + requestContext, + logEntryCategoriesCountJobId, + startTime, + endTime, + categoryCount, + datasets + ); + + const categoryIds = topLogEntryCategories.map(({ categoryId }) => categoryId); + + const { + logEntryCategoriesById, + timing: { spans: fetchTopLogEntryCategoryPatternsSpans }, + } = await this.fetchLogEntryCategories( + requestContext, + logEntryCategoriesCountJobId, + categoryIds + ); + + const { + categoryHistogramsById, + timing: { spans: fetchTopLogEntryCategoryHistogramsSpans }, + } = await this.fetchTopLogEntryCategoryHistograms( + requestContext, + logEntryCategoriesCountJobId, + categoryIds, + histograms + ); + + const topLogEntryCategoriesSpan = finalizeTopLogEntryCategoriesSpan(); + + return { + data: topLogEntryCategories.map(topCategory => ({ + ...topCategory, + regularExpression: logEntryCategoriesById[topCategory.categoryId]?._source.regex ?? '', + histograms: categoryHistogramsById[topCategory.categoryId] ?? [], + })), + timing: { + spans: [ + topLogEntryCategoriesSpan, + ...fetchTopLogEntryCategoriesAggSpans, + ...fetchTopLogEntryCategoryPatternsSpans, + ...fetchTopLogEntryCategoryHistogramsSpans, + ], + }, + }; + } + + public async getLogEntryCategoryDatasets( + requestContext: RequestHandlerContext, + request: KibanaRequest, + sourceId: string, + startTime: number, + endTime: number + ) { + const finalizeLogEntryDatasetsSpan = startTracingSpan('get data sets'); + + const logEntryCategoriesCountJobId = getJobId( + this.libs.framework.getSpaceId(request), + sourceId, + logEntryCategoriesJobTypes[0] + ); + + let logEntryDatasetBuckets: LogEntryDatasetBucket[] = []; + let afterLatestBatchKey: CompositeDatasetKey | undefined; + let esSearchSpans: TracingSpan[] = []; + + while (true) { + const finalizeEsSearchSpan = startTracingSpan('fetch category dataset batch from ES'); + + const logEntryDatasetsResponse = decodeOrThrow(logEntryDatasetsResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'search', + createLogEntryDatasetsQuery( + logEntryCategoriesCountJobId, + startTime, + endTime, + COMPOSITE_AGGREGATION_BATCH_SIZE, + afterLatestBatchKey + ) + ) + ); + + if (logEntryDatasetsResponse._shards.total === 0) { + throw new NoLogAnalysisResultsIndexError( + `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` + ); + } + + const { + after_key: afterKey, + buckets: latestBatchBuckets, + } = logEntryDatasetsResponse.aggregations.dataset_buckets; + + logEntryDatasetBuckets = [...logEntryDatasetBuckets, ...latestBatchBuckets]; + afterLatestBatchKey = afterKey; + esSearchSpans = [...esSearchSpans, finalizeEsSearchSpan()]; + + if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { + break; + } + } + + const logEntryDatasetsSpan = finalizeLogEntryDatasetsSpan(); + + return { + data: logEntryDatasetBuckets.map(logEntryDatasetBucket => logEntryDatasetBucket.key.dataset), + timing: { + spans: [logEntryDatasetsSpan, ...esSearchSpans], + }, + }; + } + + private async fetchTopLogEntryCategories( + requestContext: RequestHandlerContext, + logEntryCategoriesCountJobId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets: string[] + ) { + const finalizeEsSearchSpan = startTracingSpan('Fetch top categories from ES'); + + const topLogEntryCategoriesResponse = decodeOrThrow(topLogEntryCategoriesResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'search', + createTopLogEntryCategoriesQuery( + logEntryCategoriesCountJobId, + startTime, + endTime, + categoryCount, + datasets + ) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + if (topLogEntryCategoriesResponse._shards.total === 0) { + throw new NoLogAnalysisResultsIndexError( + `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` + ); + } + + const topLogEntryCategories = topLogEntryCategoriesResponse.aggregations.terms_category_id.buckets.map( + topCategoryBucket => ({ + categoryId: parseCategoryId(topCategoryBucket.key), + logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, + datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets.map( + datasetBucket => datasetBucket.key + ), + maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, + }) + ); + + return { + topLogEntryCategories, + timing: { + spans: [esSearchSpan], + }, + }; + } + + private async fetchLogEntryCategories( + requestContext: RequestHandlerContext, + logEntryCategoriesCountJobId: string, + categoryIds: number[] + ) { + if (categoryIds.length === 0) { + return { + logEntryCategoriesById: {}, + timing: { spans: [] }, + }; + } + + const finalizeEsSearchSpan = startTracingSpan('Fetch category patterns from ES'); + + const logEntryCategoriesResponse = decodeOrThrow(logEntryCategoriesResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'search', + createLogEntryCategoriesQuery(logEntryCategoriesCountJobId, categoryIds) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + const logEntryCategoriesById = logEntryCategoriesResponse.hits.hits.reduce< + Record + >( + (accumulatedCategoriesById, categoryHit) => ({ + ...accumulatedCategoriesById, + [categoryHit._source.category_id]: categoryHit, + }), + {} + ); + + return { + logEntryCategoriesById, + timing: { + spans: [esSearchSpan], + }, + }; + } + + private async fetchTopLogEntryCategoryHistograms( + requestContext: RequestHandlerContext, + logEntryCategoriesCountJobId: string, + categoryIds: number[], + histograms: HistogramParameters[] + ) { + if (categoryIds.length === 0 || histograms.length === 0) { + return { + categoryHistogramsById: {}, + timing: { spans: [] }, + }; + } + + const finalizeEsSearchSpan = startTracingSpan('Fetch category histograms from ES'); + + const categoryHistogramsReponses = await Promise.all( + histograms.map(({ bucketCount, endTime, id: histogramId, startTime }) => + this.libs.framework + .callWithRequest( + requestContext, + 'search', + createLogEntryCategoryHistogramsQuery( + logEntryCategoriesCountJobId, + categoryIds, + startTime, + endTime, + bucketCount + ) + ) + .then(decodeOrThrow(logEntryCategoryHistogramsResponseRT)) + .then(response => ({ + histogramId, + histogramBuckets: response.aggregations.filters_categories.buckets, + })) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + const categoryHistogramsById = Object.values(categoryHistogramsReponses).reduce< + Record< + number, + Array<{ + histogramId: string; + buckets: Array<{ + bucketDuration: number; + logEntryCount: number; + startTime: number; + }>; + }> + > + >( + (outerAccumulatedHistograms, { histogramId, histogramBuckets }) => + Object.entries(histogramBuckets).reduce( + (innerAccumulatedHistograms, [categoryBucketKey, categoryBucket]) => { + const categoryId = parseCategoryId(categoryBucketKey); + return { + ...innerAccumulatedHistograms, + [categoryId]: [ + ...(innerAccumulatedHistograms[categoryId] ?? []), + { + histogramId, + buckets: categoryBucket.histogram_timestamp.buckets.map(bucket => ({ + bucketDuration: categoryBucket.histogram_timestamp.meta.bucketDuration, + logEntryCount: bucket.sum_actual.value, + startTime: bucket.key, + })), + }, + ], + }; + }, + outerAccumulatedHistograms + ), + {} + ); + + return { + categoryHistogramsById, + timing: { + spans: [esSearchSpan], + }, + }; + } +} + +const parseCategoryId = (rawCategoryId: string) => parseInt(rawCategoryId, 10); + +interface HistogramParameters { + id: string; + startTime: number; + endTime: number; + bucketCount: number; +} diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_analysis.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts similarity index 95% rename from x-pack/legacy/plugins/infra/server/lib/log_analysis/log_analysis.ts rename to x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts index fac49a7980f264..515856fa6be8af 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_analysis.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts @@ -10,7 +10,7 @@ import { identity } from 'fp-ts/lib/function'; import { getJobId } from '../../../common/log_analysis'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; -import { NoLogRateResultsIndexError } from './errors'; +import { NoLogAnalysisResultsIndexError } from './errors'; import { logRateModelPlotResponseRT, createLogEntryRateQuery, @@ -21,7 +21,7 @@ import { RequestHandlerContext, KibanaRequest } from '../../../../../../../src/c const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; -export class InfraLogAnalysis { +export class LogEntryRateAnalysis { constructor( private readonly libs: { framework: KibanaFramework; @@ -36,11 +36,11 @@ export class InfraLogAnalysis { public async getLogEntryRateBuckets( requestContext: RequestHandlerContext, + request: KibanaRequest, sourceId: string, startTime: number, endTime: number, - bucketDuration: number, - request: KibanaRequest + bucketDuration: number ) { const logRateJobId = this.getJobIds(request, sourceId).logEntryRate; let mlModelPlotBuckets: LogRateModelPlotBucket[] = []; @@ -61,7 +61,7 @@ export class InfraLogAnalysis { ); if (mlModelPlotResponse._shards.total === 0) { - throw new NoLogRateResultsIndexError( + throw new NoLogAnalysisResultsIndexError( `Failed to find ml result index for job ${logRateJobId}.` ); } diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/common.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/common.ts new file mode 100644 index 00000000000000..92ef4fb4e35c94 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/common.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const ML_ANOMALY_INDEX_PREFIX = '.ml-anomalies-'; + +export const getMlResultIndex = (jobId: string) => `${ML_ANOMALY_INDEX_PREFIX}${jobId}`; + +export const defaultRequestParameters = { + allowNoIndices: true, + ignoreUnavailable: true, + trackScores: false, + trackTotalHits: false, +}; + +export const createTimeRangeFilters = (startTime: number, endTime: number) => [ + { + range: { + timestamp: { + gte: startTime, + lte: endTime, + }, + }, + }, +]; + +export const createResultTypeFilters = (resultType: 'model_plot' | 'record') => [ + { + term: { + result_type: { + value: resultType, + }, + }, + }, +]; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts index 17494212777198..8c470acbf02fb0 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts @@ -5,3 +5,4 @@ */ export * from './log_entry_rate'; +export * from './top_log_entry_categories'; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts new file mode 100644 index 00000000000000..63b3632f037844 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { defaultRequestParameters, getMlResultIndex } from './common'; + +export const createLogEntryCategoriesQuery = ( + logEntryCategoriesJobId: string, + categoryIds: number[] +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + { + terms: { + category_id: categoryIds, + }, + }, + ], + }, + }, + _source: ['category_id', 'regex'], + }, + index: getMlResultIndex(logEntryCategoriesJobId), + size: categoryIds.length, +}); + +export const logEntryCategoryHitRT = rt.type({ + _source: rt.type({ + category_id: rt.number, + regex: rt.string, + }), +}); + +export type LogEntryCategoryHit = rt.TypeOf; + +export const logEntryCategoriesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + hits: rt.type({ + hits: rt.array(logEntryCategoryHitRT), + }), + }), +]); + +export type logEntryCategoriesResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts new file mode 100644 index 00000000000000..67087f3b4775b6 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts @@ -0,0 +1,125 @@ +/* + * 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 * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { + createResultTypeFilters, + createTimeRangeFilters, + defaultRequestParameters, + getMlResultIndex, +} from './common'; + +export const createLogEntryCategoryHistogramsQuery = ( + logEntryCategoriesJobId: string, + categoryIds: number[], + startTime: number, + endTime: number, + bucketCount: number +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + ...createTimeRangeFilters(startTime, endTime), + ...createResultTypeFilters('model_plot'), + ...createCategoryFilters(categoryIds), + ], + }, + }, + aggs: { + filters_categories: { + filters: createCategoryFiltersAggregation(categoryIds), + aggs: { + histogram_timestamp: createHistogramAggregation(startTime, endTime, bucketCount), + }, + }, + }, + }, + index: getMlResultIndex(logEntryCategoriesJobId), + size: 0, +}); + +const createCategoryFilters = (categoryIds: number[]) => [ + { + terms: { + by_field_value: categoryIds, + }, + }, +]; + +const createCategoryFiltersAggregation = (categoryIds: number[]) => ({ + filters: categoryIds.reduce>( + (categoryFilters, categoryId) => ({ + ...categoryFilters, + [`${categoryId}`]: { + term: { + by_field_value: categoryId, + }, + }, + }), + {} + ), +}); + +const createHistogramAggregation = (startTime: number, endTime: number, bucketCount: number) => { + const bucketDuration = Math.round((endTime - startTime) / bucketCount); + + return { + histogram: { + field: 'timestamp', + interval: bucketDuration, + offset: startTime, + }, + meta: { + bucketDuration, + }, + aggs: { + sum_actual: { + sum: { + field: 'actual', + }, + }, + }, + }; +}; + +export const logEntryCategoryFilterBucketRT = rt.type({ + doc_count: rt.number, + histogram_timestamp: rt.type({ + meta: rt.type({ + bucketDuration: rt.number, + }), + buckets: rt.array( + rt.type({ + key: rt.number, + doc_count: rt.number, + sum_actual: rt.type({ + value: rt.number, + }), + }) + ), + }), +}); + +export type LogEntryCategoryFilterBucket = rt.TypeOf; + +export const logEntryCategoryHistogramsResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + aggregations: rt.type({ + filters_categories: rt.type({ + buckets: rt.record(rt.string, logEntryCategoryFilterBucketRT), + }), + }), + }), +]); + +export type LogEntryCategorHistogramsResponse = rt.TypeOf< + typeof logEntryCategoryHistogramsResponseRT +>; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts new file mode 100644 index 00000000000000..b41a21a21b6a6e --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { defaultRequestParameters, getMlResultIndex } from './common'; + +export const createLogEntryDatasetsQuery = ( + logEntryAnalysisJobId: string, + startTime: number, + endTime: number, + size: number, + afterKey?: CompositeDatasetKey +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + { + range: { + timestamp: { + gte: startTime, + lt: endTime, + }, + }, + }, + { + term: { + result_type: { + value: 'model_plot', + }, + }, + }, + ], + }, + }, + aggs: { + dataset_buckets: { + composite: { + after: afterKey, + size, + sources: [ + { + dataset: { + terms: { + field: 'partition_field_value', + order: 'asc', + }, + }, + }, + ], + }, + }, + }, + }, + index: getMlResultIndex(logEntryAnalysisJobId), + size: 0, +}); + +const compositeDatasetKeyRT = rt.type({ + dataset: rt.string, +}); + +export type CompositeDatasetKey = rt.TypeOf; + +const logEntryDatasetBucketRT = rt.type({ + key: compositeDatasetKeyRT, +}); + +export type LogEntryDatasetBucket = rt.TypeOf; + +export const logEntryDatasetsResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + aggregations: rt.type({ + dataset_buckets: rt.intersection([ + rt.type({ + buckets: rt.array(logEntryDatasetBucketRT), + }), + rt.partial({ + after_key: compositeDatasetKeyRT, + }), + ]), + }), + }), +]); + +export type LogEntryDatasetsResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts index 2dd0880cbf8cb7..def7caf578b94f 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts @@ -6,7 +6,7 @@ import * as rt from 'io-ts'; -const ML_ANOMALY_INDEX_PREFIX = '.ml-anomalies-'; +import { defaultRequestParameters, getMlResultIndex } from './common'; export const createLogEntryRateQuery = ( logRateJobId: string, @@ -16,7 +16,7 @@ export const createLogEntryRateQuery = ( size: number, afterKey?: CompositeTimestampPartitionKey ) => ({ - allowNoIndices: true, + ...defaultRequestParameters, body: { query: { bool: { @@ -118,11 +118,8 @@ export const createLogEntryRateQuery = ( }, }, }, - ignoreUnavailable: true, - index: `${ML_ANOMALY_INDEX_PREFIX}${logRateJobId}`, + index: getMlResultIndex(logRateJobId), size: 0, - trackScores: false, - trackTotalHits: false, }); const logRateMlRecordRT = rt.type({ diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts new file mode 100644 index 00000000000000..22b0ef748f5f81 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts @@ -0,0 +1,160 @@ +/* + * 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 * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { + createResultTypeFilters, + createTimeRangeFilters, + defaultRequestParameters, + getMlResultIndex, +} from './common'; + +export const createTopLogEntryCategoriesQuery = ( + logEntryCategoriesJobId: string, + startTime: number, + endTime: number, + size: number, + datasets: string[], + sortDirection: 'asc' | 'desc' = 'desc' +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + ...createTimeRangeFilters(startTime, endTime), + ...createDatasetsFilters(datasets), + { + bool: { + should: [ + { + bool: { + filter: [ + ...createResultTypeFilters('model_plot'), + { + range: { + actual: { + gt: 0, + }, + }, + }, + ], + }, + }, + { + bool: { + filter: createResultTypeFilters('record'), + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + aggs: { + terms_category_id: { + terms: { + field: 'by_field_value', + size, + order: { + 'filter_model_plot>sum_actual': sortDirection, + }, + }, + aggs: { + filter_model_plot: { + filter: { + term: { + result_type: 'model_plot', + }, + }, + aggs: { + sum_actual: { + sum: { + field: 'actual', + }, + }, + terms_dataset: { + terms: { + field: 'partition_field_value', + size: 1000, + }, + }, + }, + }, + filter_record: { + filter: { + term: { + result_type: 'record', + }, + }, + aggs: { + maximum_record_score: { + max: { + field: 'record_score', + }, + }, + }, + }, + }, + }, + }, + }, + index: getMlResultIndex(logEntryCategoriesJobId), + size: 0, +}); + +const createDatasetsFilters = (datasets: string[]) => + datasets.length > 0 + ? [ + { + terms: { + partition_field_value: datasets, + }, + }, + ] + : []; + +const metricAggregationRT = rt.type({ + value: rt.union([rt.number, rt.null]), +}); + +export const logEntryCategoryBucketRT = rt.type({ + key: rt.string, + doc_count: rt.number, + filter_record: rt.type({ + maximum_record_score: metricAggregationRT, + }), + filter_model_plot: rt.type({ + sum_actual: metricAggregationRT, + terms_dataset: rt.type({ + buckets: rt.array( + rt.type({ + key: rt.string, + doc_count: rt.number, + }) + ), + }), + }), +}); + +export type LogEntryCategoryBucket = rt.TypeOf; + +export const topLogEntryCategoriesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + aggregations: rt.type({ + terms_category_id: rt.type({ + buckets: rt.array(logEntryCategoryBucketRT), + }), + }), + }), +]); + +export type TopLogEntryCategoriesResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts b/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts index 147729a1d0b3e6..d3c6f7a5f70a1b 100644 --- a/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts +++ b/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts @@ -17,7 +17,7 @@ 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 { InfraLogAnalysis } from './lib/log_analysis'; +import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from './lib/log_analysis'; import { InfraSnapshot } from './lib/snapshot'; import { InfraSourceStatus } from './lib/source_status'; import { InfraSources } from './lib/sources'; @@ -87,7 +87,8 @@ export class InfraServerPlugin { } ); const snapshot = new InfraSnapshot({ sources, framework }); - const logAnalysis = new InfraLogAnalysis({ framework }); + const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); + const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); // TODO: separate these out individually and do away with "domains" as a temporary group const domainLibs: InfraDomainLibs = { @@ -103,7 +104,8 @@ export class InfraServerPlugin { this.libs = { configuration: this.config, framework, - logAnalysis, + logEntryCategoriesAnalysis, + logEntryRateAnalysis, snapshot, sources, sourceStatus, diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts index 17494212777198..d9ca9a96ffe51e 100644 --- a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './log_entry_categories'; +export * from './log_entry_category_datasets'; export * from './log_entry_rate'; diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts new file mode 100644 index 00000000000000..7eb7de57b2f929 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { schema } from '@kbn/config-schema'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, + getLogEntryCategoriesRequestPayloadRT, + getLogEntryCategoriesSuccessReponsePayloadRT, +} from '../../../../common/http_api/log_analysis'; +import { throwErrors } from '../../../../common/runtime_types'; +import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; + +const anyObject = schema.object({}, { allowUnknowns: true }); + +export const initGetLogEntryCategoriesRoute = ({ + framework, + logEntryCategoriesAnalysis, +}: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, + validate: { + // short-circuit forced @kbn/config-schema validation so we can do io-ts validation + body: anyObject, + }, + }, + async (requestContext, request, response) => { + const { + data: { + categoryCount, + histograms, + sourceId, + timeRange: { startTime, endTime }, + datasets, + }, + } = pipe( + getLogEntryCategoriesRequestPayloadRT.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + try { + const { + data: topLogEntryCategories, + timing, + } = await logEntryCategoriesAnalysis.getTopLogEntryCategories( + requestContext, + request, + sourceId, + startTime, + endTime, + categoryCount, + datasets ?? [], + histograms.map(histogram => ({ + bucketCount: histogram.bucketCount, + endTime: histogram.timeRange.endTime, + id: histogram.id, + startTime: histogram.timeRange.startTime, + })) + ); + + return response.ok({ + body: getLogEntryCategoriesSuccessReponsePayloadRT.encode({ + data: { + categories: topLogEntryCategories, + }, + timing, + }), + }); + } catch (e) { + const { statusCode = 500, message = 'Unknown error occurred' } = e; + + if (e instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message } }); + } + + return response.customError({ + statusCode, + body: { message }, + }); + } + } + ); +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts new file mode 100644 index 00000000000000..81326330282776 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { + getLogEntryCategoryDatasetsRequestPayloadRT, + getLogEntryCategoryDatasetsSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, +} from '../../../../common/http_api/log_analysis'; +import { throwErrors } from '../../../../common/runtime_types'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; + +const anyObject = schema.object({}, { allowUnknowns: true }); + +export const initGetLogEntryCategoryDatasetsRoute = ({ + framework, + logEntryCategoriesAnalysis, +}: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, + validate: { + // short-circuit forced @kbn/config-schema validation so we can do io-ts validation + body: anyObject, + }, + }, + async (requestContext, request, response) => { + const { + data: { + sourceId, + timeRange: { startTime, endTime }, + }, + } = pipe( + getLogEntryCategoryDatasetsRequestPayloadRT.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + try { + const { + data: logEntryCategoryDatasets, + timing, + } = await logEntryCategoriesAnalysis.getLogEntryCategoryDatasets( + requestContext, + request, + sourceId, + startTime, + endTime + ); + + return response.ok({ + body: getLogEntryCategoryDatasetsSuccessReponsePayloadRT.encode({ + data: { + datasets: logEntryCategoryDatasets, + }, + timing, + }), + }); + } catch (e) { + const { statusCode = 500, message = 'Unknown error occurred' } = e; + + if (e instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message } }); + } + + return response.customError({ + statusCode, + body: { message }, + }); + } + } + ); +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts index 9778311bd8e58c..6551316fd0c645 100644 --- a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts @@ -18,11 +18,11 @@ import { GetLogEntryRateSuccessResponsePayload, } from '../../../../common/http_api/log_analysis'; import { throwErrors } from '../../../../common/runtime_types'; -import { NoLogRateResultsIndexError } from '../../../lib/log_analysis'; +import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; const anyObject = schema.object({}, { allowUnknowns: true }); -export const initGetLogEntryRateRoute = ({ framework, logAnalysis }: InfraBackendLibs) => { +export const initGetLogEntryRateRoute = ({ framework, logEntryRateAnalysis }: InfraBackendLibs) => { framework.registerRoute( { method: 'post', @@ -39,13 +39,13 @@ export const initGetLogEntryRateRoute = ({ framework, logAnalysis }: InfraBacken fold(throwErrors(Boom.badRequest), identity) ); - const logEntryRateBuckets = await logAnalysis.getLogEntryRateBuckets( + const logEntryRateBuckets = await logEntryRateAnalysis.getLogEntryRateBuckets( requestContext, + request, payload.data.sourceId, payload.data.timeRange.startTime, payload.data.timeRange.endTime, - payload.data.bucketDuration, - request + payload.data.bucketDuration ); return response.ok({ @@ -59,7 +59,7 @@ export const initGetLogEntryRateRoute = ({ framework, logAnalysis }: InfraBacken }); } catch (e) { const { statusCode = 500, message = 'Unknown error occurred' } = e; - if (e instanceof NoLogRateResultsIndexError) { + if (e instanceof NoLogAnalysisResultsIndexError) { return response.notFound({ body: { message } }); } return response.customError({ diff --git a/x-pack/legacy/plugins/infra/server/utils/elasticsearch_runtime_types.ts b/x-pack/legacy/plugins/infra/server/utils/elasticsearch_runtime_types.ts new file mode 100644 index 00000000000000..a48c65d648b25a --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/utils/elasticsearch_runtime_types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const commonSearchSuccessResponseFieldsRT = rt.type({ + _shards: rt.type({ + total: rt.number, + successful: rt.number, + skipped: rt.number, + failed: rt.number, + }), + timed_out: rt.boolean, + took: rt.number, +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 3b0c188318309d..7d1c68cfdd9769 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6075,8 +6075,6 @@ "xpack.infra.logs.highlights.goToPreviousHighlightButtonLabel": "前のハイライトにスキップ", "xpack.infra.logs.index.settingsTabTitle": "設定", "xpack.infra.logs.index.streamTabTitle": "ストリーム", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessContent": "機械学習ロボットがデータの収集を開始するまでしばらくお待ちください。", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessTitle": "成功!", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | ストリーム", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "ログエントリーを検索", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "パーセント", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3cc476937d4e72..413b9c65616ccd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6077,8 +6077,6 @@ "xpack.infra.logs.highlights.goToPreviousHighlightButtonLabel": "跳转到上一高亮条目", "xpack.infra.logs.index.settingsTabTitle": "设置", "xpack.infra.logs.index.streamTabTitle": "流式传输", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessContent": "请注意,我们的 Machine Learning 机器人若干分钟后才会开始收集数据。", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessTitle": "成功!", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | 流式传输", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "搜索日志条目", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "百分比", From 70cedb08f9c88817cc8442cb64611be742bcf16b Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 13 Jan 2020 14:29:08 -0500 Subject: [PATCH 28/62] Update alerting task_runner test snapshots (#54627) --- .../plugins/alerting/server/task_runner/task_runner.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts index 45ee13e2370d22..394c13e1bd24f6 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts @@ -428,7 +428,6 @@ describe('Task Runner', () => { "runAt": 1970-01-01T00:05:00.000Z, "state": Object { "previousStartedAt": 1970-01-01T00:00:00.000Z, - "startedAt": 1969-12-31T23:55:00.000Z, }, } `); @@ -462,7 +461,6 @@ describe('Task Runner', () => { "runAt": 1970-01-01T00:05:00.000Z, "state": Object { "previousStartedAt": 1970-01-01T00:00:00.000Z, - "startedAt": 1969-12-31T23:55:00.000Z, }, } `); @@ -495,7 +493,6 @@ describe('Task Runner', () => { "runAt": 1970-01-01T00:05:00.000Z, "state": Object { "previousStartedAt": 1970-01-01T00:00:00.000Z, - "startedAt": 1969-12-31T23:55:00.000Z, }, } `); From 6f3ff99968a2c4c43a3992a0a2fc495e7d960c78 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 13 Jan 2020 20:30:11 +0100 Subject: [PATCH 29/62] [Uptime] Monitor SSL Certificate Color version for warning (#54040) * update monitor list columns * update columns * update snaps * enhance ui * update SSL Cert to badge warning * fix i18n errors * removed unnecessary margin * update snaps * update ssl * update snaps * added test for warning state * added test for warning state * update test name * update test name Co-authored-by: Elastic Machine --- .../monitor_ssl_certificate.test.tsx.snap | 21 ----- .../monitor_ssl_certificate.test.tsx | 38 ---------- .../monitor_ssl_certificate.test.tsx.snap | 30 ++++++++ .../__test__/monitor_ssl_certificate.test.tsx | 76 +++++++++++++++++++ .../monitor_ssl_certificate.tsx | 38 ++++++---- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 7 files changed, 128 insertions(+), 79 deletions(-) delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap delete mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/monitor_ssl_certificate.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap deleted file mode 100644 index d731a168225b7b..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MonitorStatusBar component renders 1`] = ` -Array [ -
, -
-
- SSL certificate expires in 2 months -
-
, -] -`; - -exports[`MonitorStatusBar component renders null if invalid date 1`] = `null`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx deleted file mode 100644 index 03eb252aa8c097..00000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import moment from 'moment'; -import { renderWithIntl } from 'test_utils/enzyme_helpers'; -import { PingTls } from '../../../../common/graphql/types'; -import { MonitorSSLCertificate } from '../monitor_status_details/monitor_status_bar'; - -describe('MonitorStatusBar component', () => { - let monitorTls: PingTls; - - beforeEach(() => { - const dateInTwoMonths = moment() - .add(2, 'month') - .toString(); - - monitorTls = { - certificate_not_valid_after: dateInTwoMonths, - }; - }); - - it('renders', () => { - const component = renderWithIntl(); - expect(component).toMatchSnapshot(); - }); - - it('renders null if invalid date', () => { - monitorTls = { - certificate_not_valid_after: 'i am so invalid date', - }; - const component = renderWithIntl(); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/monitor_ssl_certificate.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/monitor_ssl_certificate.test.tsx.snap new file mode 100644 index 00000000000000..0cb0a7ec248df1 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/monitor_ssl_certificate.test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MonitorStatusBar component renders 1`] = ` +Array [ +
, +
+ SSL certificate expires + + + + in 2 months + + + +
, +] +`; + +exports[`MonitorStatusBar component renders null if invalid date 1`] = `null`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx new file mode 100644 index 00000000000000..2eae14301fd4d9 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import moment from 'moment'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { EuiBadge } from '@elastic/eui'; +import { renderWithIntl } from 'test_utils/enzyme_helpers'; +import { PingTls } from '../../../../../common/graphql/types'; +import { MonitorSSLCertificate } from '../monitor_status_bar'; + +describe('MonitorStatusBar component', () => { + let monitorTls: PingTls; + + beforeEach(() => { + const dateInTwoMonths = moment() + .add(2, 'month') + .toString(); + + monitorTls = { + certificate_not_valid_after: dateInTwoMonths, + }; + }); + + it('renders', () => { + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders null if invalid date', () => { + monitorTls = { + certificate_not_valid_after: 'i am so invalid date', + }; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders expiration date with a warning state if ssl expiry date is less than 30 days', () => { + const dateIn15Days = moment() + .add(15, 'day') + .toString(); + monitorTls = { + certificate_not_valid_after: dateIn15Days, + }; + const component = mountWithIntl(); + + const badgeComponent = component.find(EuiBadge); + expect(badgeComponent.props().color).toBe('warning'); + + const badgeComponentText = component.find('.euiBadge__text'); + expect(badgeComponentText.text()).toBe(moment(dateIn15Days).fromNow()); + + expect(badgeComponent.find('span.euiBadge--warning')).toBeTruthy(); + }); + + it('does not render the expiration date with a warning state if expiry date is greater than a month', () => { + const dateIn40Days = moment() + .add(40, 'day') + .toString(); + monitorTls = { + certificate_not_valid_after: dateIn40Days, + }; + const component = mountWithIntl(); + + const badgeComponent = component.find(EuiBadge); + expect(badgeComponent.props().color).toBe('default'); + + const badgeComponentText = component.find('.euiBadge__text'); + expect(badgeComponentText.text()).toBe(moment(dateIn40Days).fromNow()); + + expect(badgeComponent.find('span.euiBadge--warning')).toHaveLength(0); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx index 5e916c40e712d7..c57348c4ab4cd4 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx @@ -5,9 +5,8 @@ */ import React from 'react'; -import { get } from 'lodash'; import moment from 'moment'; -import { EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiSpacer, EuiText, EuiBadge } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -21,30 +20,37 @@ interface Props { } export const MonitorSSLCertificate = ({ tls }: Props) => { - const certificateValidity: string | undefined = get( - tls, - 'certificate_not_valid_after', - undefined - ); + const certValidityDate = new Date(tls?.certificate_not_valid_after ?? ''); - const validExpiryDate = certificateValidity && !isNaN(new Date(certificateValidity).valueOf()); + const isValidDate = !isNaN(certValidityDate.valueOf()); - return validExpiryDate && certificateValidity ? ( + const dateIn30Days = moment().add('30', 'days'); + + const isExpiringInMonth = isValidDate && dateIn30Days > moment(certValidityDate); + + return isValidDate ? ( <> + {moment(certValidityDate).fromNow()} + + ), }} /> diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 7d1c68cfdd9769..5661020ba6fa6a 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -11823,8 +11823,6 @@ "xpack.uptime.kueryBar.searchPlaceholder": "モニター ID、名前、プロトコルタイプなどを検索…", "xpack.uptime.monitorList.noItemForSelectedFiltersMessage": "選択されたフィルター条件でモニターが見つかりませんでした", "xpack.uptime.monitorList.table.description": "列にステータス、名前、URL、IP、ダウンタイム履歴、統合が入力されたモニターステータス表です。この表は現在 {length} 項目を表示しています。", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.ariaLabel": "SSL 証明書の有効期限:", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.content": "SSL 証明書の有効期限: {certificateValidity}", "xpack.uptime.notFountPage.homeLinkText": "ホームへ戻る", "xpack.uptime.overviewPageLink.disabled.ariaLabel": "無効になったページ付けボタンです。モニターリストがこれ以上ナビゲーションできないことを示しています。", "xpack.uptime.overviewPageLink.next.ariaLabel": "次の結果ページ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 413b9c65616ccd..1bcfab4240aeda 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -11912,8 +11912,6 @@ "xpack.uptime.kueryBar.searchPlaceholder": "搜索监测 ID、名称和协议类型......", "xpack.uptime.monitorList.noItemForSelectedFiltersMessage": "未找到匹配选定筛选条件的监测", "xpack.uptime.monitorList.table.description": "具有“状态”、“名称”、“URL”、“IP”、“中断历史记录”和“集成”列的“监测状态”表。该表当前显示 {length} 个项目。", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.ariaLabel": "SSL 证书过期", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.content": "SSL 证书于 {certificateValidity} 过期", "xpack.uptime.notFountPage.homeLinkText": "返回主页", "xpack.uptime.overviewPageLink.disabled.ariaLabel": "禁用的分页按钮表示在监测列表中无法进行进一步导航。", "xpack.uptime.overviewPageLink.next.ariaLabel": "下页结果", From e90ca93687057675b8fc836d77995b7e00635e22 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Mon, 13 Jan 2020 20:31:28 +0100 Subject: [PATCH 30/62] [Uptime] Most recent checks info on details page (#54340) * update API * update query * hide layer control and added loc tags * update test * remove unused comment * update API * remove capitalization * style fix * update types * added location status number on details page * useref instead of createRef * update interface * update import * removed redundant file * fix header for empty data * refactor for most recent check * remove redundant code * remone unused translation * update status bar * update styling * update snaps * added API tests * fix types * fixing integration tests and a typo * remove unused translations * update tests * fixed PR feedback * update feedback * update messaging * update snap * added timestamp in front of tags * update snaps * improve readability * PR feedbacka and snaps * PR feedbacka and snaps * update txt * snaps * fix timestamp issue in tests Co-authored-by: Elastic Machine --- .../common/runtime_types/monitor/locations.ts | 1 + .../location_status_tags.test.tsx.snap | 577 ++++++++++++++++++ .../__tests__/location_status_tags.test.tsx | 101 +++ .../functional/location_map/index.tsx | 1 + .../location_map/location_status_tags.tsx | 108 +++- .../__test__/status_by_location.test.tsx | 7 + .../elasticsearch_monitors_adapter.ts | 3 +- 7 files changed, 768 insertions(+), 30 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts index a40453b3671b79..ea3cfe677ca991 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts @@ -10,6 +10,7 @@ import { CheckGeoType, SummaryType } from '../common'; export const MonitorLocationType = t.partial({ summary: SummaryType, geo: CheckGeoType, + timestamp: t.string, }); // Typescript type for type checking diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap new file mode 100644 index 00000000000000..6228183e7c2b2b --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap @@ -0,0 +1,577 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StatusByLocation component renders when all locations are down 1`] = ` +.c3 { + display: inline-block; + margin-left: 4px; +} + +.c2 { + font-weight: 600; +} + +.c1 { + margin-bottom: 5px; +} + +.c0 { + padding: 10px; + max-height: 229px; + overflow: hidden; +} + +
+ +
+ + + +
+
+ Islamabad +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Berlin +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ +
+`; + +exports[`StatusByLocation component renders when all locations are up 1`] = ` +.c3 { + display: inline-block; + margin-left: 4px; +} + +.c2 { + font-weight: 600; +} + +.c1 { + margin-bottom: 5px; +} + +.c0 { + padding: 10px; + max-height: 229px; + overflow: hidden; +} + +
+ + +
+ + + +
+
+ Islamabad +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Berlin +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+
+`; + +exports[`StatusByLocation component renders when there are many location 1`] = ` +Array [ + .c3 { + display: inline-block; + margin-left: 4px; +} + +.c2 { + font-weight: 600; +} + +.c1 { + margin-bottom: 5px; +} + +.c0 { + padding: 10px; + max-height: 229px; + overflow: hidden; +} + +
+ +
+ + + +
+
+ Islamabad +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Berlin +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ st-paul +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Tokya +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ New York +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Toronto +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Sydney +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Paris +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ +
, + .c0 { + padding-left: 18px; +} + +
+
+
+

+ 1 Others ... +

+
+
+
, +] +`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx new file mode 100644 index 00000000000000..21e5881654533f --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx @@ -0,0 +1,101 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import moment from 'moment'; +import { renderWithIntl } from 'test_utils/enzyme_helpers'; +import { MonitorLocation } from '../../../../../common/runtime_types/monitor'; +import { LocationStatusTags } from '../'; + +describe('StatusByLocation component', () => { + let monitorLocations: MonitorLocation[]; + + const start = moment('2020-01-10T12:22:32.567Z'); + beforeAll(() => { + moment.prototype.fromNow = jest.fn((date: string) => start.from(date)); + }); + + it('renders when there are many location', () => { + monitorLocations = [ + { + summary: { up: 0, down: 1 }, + geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:28.825Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:31.586Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Tokya', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:25.771Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'New York', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:27.485Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Toronto', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:28.815Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Sydney', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.132Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Paris', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.973Z', + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when all locations are up', () => { + monitorLocations = [ + { + summary: { up: 4, down: 0 }, + geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', + }, + { + summary: { up: 4, down: 0 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-08T12:22:28.825Z', + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when all locations are down', () => { + monitorLocations = [ + { + summary: { up: 0, down: 2 }, + geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-06T12:22:32.567Z', + }, + { + summary: { up: 0, down: 2 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:28.825Z', + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx index 1f4b88b971c4ce..140d33bbeef66f 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx @@ -5,3 +5,4 @@ */ export * from './location_map'; +export * from './location_status_tags'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx index a10d8e02e68638..6563c03ad7c34f 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx @@ -7,9 +7,16 @@ import React, { useContext } from 'react'; import styled from 'styled-components'; import { EuiBadge, EuiText } from '@elastic/eui'; +import moment from 'moment'; +import { FormattedMessage } from '@kbn/i18n/react'; import { UptimeSettingsContext } from '../../../contexts'; import { MonitorLocation } from '../../../../common/runtime_types'; +const TimeStampSpan = styled.span` + display: inline-block; + margin-left: 4px; +`; + const TextStyle = styled.div` font-weight: 600; `; @@ -20,54 +27,97 @@ const BadgeItem = styled.div` const TagContainer = styled.div` padding: 10px; - max-height: 200px; + max-height: 229px; overflow: hidden; `; +const OtherLocationsDiv = styled.div` + padding-left: 18px; +`; + interface Props { locations: MonitorLocation[]; } +interface StatusTag { + label: string; + timestamp: number; +} + export const LocationStatusTags = ({ locations }: Props) => { const { colors: { gray, danger }, } = useContext(UptimeSettingsContext); - const upLocs: string[] = []; - const downLocs: string[] = []; + const upLocations: StatusTag[] = []; + const downLocations: StatusTag[] = []; locations.forEach((item: any) => { if (item.summary.down === 0) { - upLocs.push(item.geo.name); + upLocations.push({ label: item.geo.name, timestamp: new Date(item.timestamp).valueOf() }); } else { - downLocs.push(item.geo.name); + downLocations.push({ label: item.geo.name, timestamp: new Date(item.timestamp).valueOf() }); } }); + // Sort by recent timestamp + upLocations.sort((a, b) => { + return a.timestamp < b.timestamp ? 1 : b.timestamp < a.timestamp ? -1 : 0; + }); + + moment.locale('en', { + relativeTime: { + future: 'in %s', + past: '%s ago', + s: '%ds', + ss: '%ss', + m: '%dm', + mm: '%dm', + h: '%dh', + hh: '%dh', + d: '%dd', + dd: '%dd', + M: '%d Mon', + MM: '%d Mon', + y: '%d Yr', + yy: '%d Yr', + }, + }); + + const tagLabel = (item: StatusTag, ind: number, color: string) => ( + + + + {item.label} + + + + {moment(item.timestamp).fromNow()} + + + ); + return ( - - - {downLocs.map((item, ind) => ( - - - - {item} - - - - ))} - - - {upLocs.map((item, ind) => ( - - - - {item} - - - - ))} - - + <> + + {downLocations.map((item, ind) => tagLabel(item, ind, danger))} + {upLocations.map((item, ind) => tagLabel(item, ind, gray))} + + {locations.length > 7 && ( + + +

+ +

+
+
+ )} + ); }; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx index 4e515a52b8de6f..38864103564ca3 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx @@ -17,6 +17,7 @@ describe('StatusByLocation component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, { summary: { up: 4, down: 0 }, @@ -32,6 +33,7 @@ describe('StatusByLocation component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); @@ -43,6 +45,7 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); @@ -54,10 +57,12 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, { summary: { up: 0, down: 4 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); @@ -69,10 +74,12 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, { summary: { up: 4, down: 0 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts index 37a9e032cd442d..b237fd8771f581 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts @@ -334,7 +334,7 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { order: 'desc', }, }, - _source: ['monitor', 'summary', 'observer'], + _source: ['monitor', 'summary', 'observer', '@timestamp'], }, }, }, @@ -365,6 +365,7 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { const location: MonitorLocation = { summary: mostRecentLocation?.summary, geo: getGeo(mostRecentLocation?.observer?.geo), + timestamp: mostRecentLocation['@timestamp'], }; monLocs.push(location); } From 62e7edbe26d68eb0c132c8eaa58c39e01d19a6c4 Mon Sep 17 00:00:00 2001 From: robbruce Date: Mon, 13 Jan 2020 19:50:33 +0000 Subject: [PATCH 31/62] Fixes #45896 (#50229) Co-authored-by: Elastic Machine --- .../plugins/canvas/server/lib/query_es_sql.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js b/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js index 15d3dc52ee3117..f7907e2cffb261 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js +++ b/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js @@ -30,6 +30,19 @@ export const queryEsSQL = (elasticsearchClient, { count, query, filter, timezone }); const columnNames = map(columns, 'name'); const rows = res.rows.map(row => zipObject(columnNames, row)); + + if (!!res.cursor) { + elasticsearchClient('transport.request', { + path: '/_sql/close', + method: 'POST', + body: { + cursor: res.cursor, + }, + }).catch(e => { + throw new Error(`Unexpected error from Elasticsearch: ${e.message}`); + }); + } + return { type: 'datatable', columns, From 2178ee38c0a3daf4acac62b5f37db4f1147070b5 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Mon, 13 Jan 2020 13:58:59 -0600 Subject: [PATCH 32/62] uiSettings - use validation field for image field maxSize (#54522) * uiSettings - use validation field for image field maxSize --- .../server/kibana-plugin-server.basepath.get.md | 2 +- .../server/kibana-plugin-server.basepath.md | 4 ++-- .../server/kibana-plugin-server.basepath.set.md | 2 +- .../kibana-plugin-server.uisettingsparams.md | 1 + ...plugin-server.uisettingsparams.validation.md | 11 +++++++++++ src/core/server/server.api.md | 5 +++++ src/core/server/ui_settings/types.ts | 17 +++++++++++++++++ .../sections/settings/components/field/field.js | 2 +- .../settings/components/field/field.test.js | 3 +-- .../sections/settings/lib/to_editable_config.js | 13 +++++++------ x-pack/legacy/plugins/reporting/index.ts | 2 +- 11 files changed, 48 insertions(+), 14 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-server.uisettingsparams.validation.md diff --git a/docs/development/core/server/kibana-plugin-server.basepath.get.md b/docs/development/core/server/kibana-plugin-server.basepath.get.md index 6ef7022f10e624..a20bc1a4e3174c 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.get.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.get.md @@ -9,5 +9,5 @@ returns `basePath` value, specific for an incoming request. Signature: ```typescript -get: (request: KibanaRequest | LegacyRequest) => string; +get: (request: LegacyRequest | KibanaRequest) => string; ``` diff --git a/docs/development/core/server/kibana-plugin-server.basepath.md b/docs/development/core/server/kibana-plugin-server.basepath.md index 50a30f7c43fe68..63aeb7f711d978 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.md @@ -20,9 +20,9 @@ The constructor for this class is marked as internal. Third-party code should no | Property | Modifiers | Type | Description | | --- | --- | --- | --- | -| [get](./kibana-plugin-server.basepath.get.md) | | (request: KibanaRequest<unknown, unknown, unknown, any> | LegacyRequest) => string | returns basePath value, specific for an incoming request. | +| [get](./kibana-plugin-server.basepath.get.md) | | (request: LegacyRequest | KibanaRequest<unknown, unknown, unknown, any>) => string | returns basePath value, specific for an incoming request. | | [prepend](./kibana-plugin-server.basepath.prepend.md) | | (path: string) => string | Prepends path with the basePath. | | [remove](./kibana-plugin-server.basepath.remove.md) | | (path: string) => string | Removes the prepended basePath from the path. | | [serverBasePath](./kibana-plugin-server.basepath.serverbasepath.md) | | string | returns the server's basePathSee [BasePath.get](./kibana-plugin-server.basepath.get.md) for getting the basePath value for a specific request | -| [set](./kibana-plugin-server.basepath.set.md) | | (request: KibanaRequest<unknown, unknown, unknown, any> | LegacyRequest, requestSpecificBasePath: string) => void | sets basePath value, specific for an incoming request. | +| [set](./kibana-plugin-server.basepath.set.md) | | (request: LegacyRequest | KibanaRequest<unknown, unknown, unknown, any>, requestSpecificBasePath: string) => void | sets basePath value, specific for an incoming request. | diff --git a/docs/development/core/server/kibana-plugin-server.basepath.set.md b/docs/development/core/server/kibana-plugin-server.basepath.set.md index 56a7f644d34ccc..ac08baa0bb99e9 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.set.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.set.md @@ -9,5 +9,5 @@ sets `basePath` value, specific for an incoming request. Signature: ```typescript -set: (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; +set: (request: LegacyRequest | KibanaRequest, requestSpecificBasePath: string) => void; ``` diff --git a/docs/development/core/server/kibana-plugin-server.uisettingsparams.md b/docs/development/core/server/kibana-plugin-server.uisettingsparams.md index a38499e8f37dda..89eb5b10b9de52 100644 --- a/docs/development/core/server/kibana-plugin-server.uisettingsparams.md +++ b/docs/development/core/server/kibana-plugin-server.uisettingsparams.md @@ -24,5 +24,6 @@ export interface UiSettingsParams | [readonly](./kibana-plugin-server.uisettingsparams.readonly.md) | boolean | a flag indicating that value cannot be changed | | [requiresPageReload](./kibana-plugin-server.uisettingsparams.requirespagereload.md) | boolean | a flag indicating whether new value applying requires page reloading | | [type](./kibana-plugin-server.uisettingsparams.type.md) | UiSettingsType | defines a type of UI element [UiSettingsType](./kibana-plugin-server.uisettingstype.md) | +| [validation](./kibana-plugin-server.uisettingsparams.validation.md) | ImageValidation | StringValidation | | | [value](./kibana-plugin-server.uisettingsparams.value.md) | SavedObjectAttribute | default value to fall back to if a user doesn't provide any | diff --git a/docs/development/core/server/kibana-plugin-server.uisettingsparams.validation.md b/docs/development/core/server/kibana-plugin-server.uisettingsparams.validation.md new file mode 100644 index 00000000000000..f097f36e999ba8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.uisettingsparams.validation.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [UiSettingsParams](./kibana-plugin-server.uisettingsparams.md) > [validation](./kibana-plugin-server.uisettingsparams.validation.md) + +## UiSettingsParams.validation property + +Signature: + +```typescript +validation?: ImageValidation | StringValidation; +``` diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index bf7dc14c732659..65477e93e225e4 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1935,6 +1935,11 @@ export interface UiSettingsParams { readonly?: boolean; requiresPageReload?: boolean; type?: UiSettingsType; + // Warning: (ae-forgotten-export) The symbol "ImageValidation" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "StringValidation" needs to be exported by the entry point index.d.ts + // + // (undocumented) + validation?: ImageValidation | StringValidation; value?: SavedObjectAttribute; } diff --git a/src/core/server/ui_settings/types.ts b/src/core/server/ui_settings/types.ts index 5e3f0a4fbb6bd2..2ab6114e7df882 100644 --- a/src/core/server/ui_settings/types.ts +++ b/src/core/server/ui_settings/types.ts @@ -102,6 +102,23 @@ export interface UiSettingsParams { readonly?: boolean; /** defines a type of UI element {@link UiSettingsType} */ type?: UiSettingsType; + /* + * Allows defining a custom validation applicable to value change on the client. + * @deprecated + */ + validation?: ImageValidation | StringValidation; +} + +export interface StringValidation { + regexString: string; + message: string; +} + +export interface ImageValidation { + maxSize: { + length: number; + description: string; + }; } /** @internal */ diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js index 939dc8c20e4657..65d212c23a28c1 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js @@ -224,7 +224,7 @@ export class Field extends PureComponent { } const file = files[0]; - const { maxSize } = this.props.setting.options; + const { maxSize } = this.props.setting.validation; try { const base64Image = await this.getImageAsBase64(file); const isInvalid = !!(maxSize && maxSize.length && base64Image.length > maxSize.length); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js index 74bb0e25ff52eb..07ce6f84d2bb64 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js @@ -72,10 +72,9 @@ const settings = { defVal: null, isCustom: false, isOverridden: false, - options: { + validation: { maxSize: { length: 1000, - displayName: '1 kB', description: 'Description for 1 kB', }, }, diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js index 791f9e400b4075..bb561cbe042121 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js @@ -43,12 +43,13 @@ export function toEditableConfig({ def, name, value, isCustom, isOverridden }) { defVal: def.value, type: getValType(def, value), description: def.description, - validation: def.validation - ? { - regex: new RegExp(def.validation.regexString), - message: def.validation.message, - } - : undefined, + validation: + def.validation && def.validation.regexString + ? { + regex: new RegExp(def.validation.regexString), + message: def.validation.message, + } + : def.validation, options: def.options, optionLabels: def.optionLabels, requiresPageReload: !!def.requiresPageReload, diff --git a/x-pack/legacy/plugins/reporting/index.ts b/x-pack/legacy/plugins/reporting/index.ts index faa27bfb2d6eae..ef0ab37738362d 100644 --- a/x-pack/legacy/plugins/reporting/index.ts +++ b/x-pack/legacy/plugins/reporting/index.ts @@ -59,7 +59,7 @@ export const reporting = (kibana: any) => { defaultMessage: `Custom image to use in the PDF's footer`, }), type: 'image', - options: { + validation: { maxSize: { length: kbToBase64Length(200), description: '200 kB', From 054bbbbc46e3e2cb0908297e315fc3d4d22d8cd7 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Mon, 13 Jan 2020 13:36:51 -0700 Subject: [PATCH 33/62] [SIEM][Detection Engine] Increases the number or rules you can view on a single page (#54628) * Increased the number or rules you can view on a single page * messed up one line --- .../siem/public/pages/detection_engine/rules/all/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index e900058b6c53cf..d928cc09498512 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -217,7 +217,7 @@ export const AllRules = React.memo<{ pageIndex: pagination.page - 1, pageSize: pagination.perPage, totalItemCount: pagination.total, - pageSizeOptions: [5, 10, 20], + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], }} sorting={{ sort: { field: 'activate', direction: filterOptions.sortOrder } }} selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps} From 24b3ecbae0ac30a98b27eb67c3703df26345de9a Mon Sep 17 00:00:00 2001 From: Corey Robertson Date: Mon, 13 Jan 2020 15:40:05 -0500 Subject: [PATCH 34/62] [Canvas] Enable Embeddable maps (#53971) * Enables Embeddable maps in Canvas. Updates expressions as maps are interacted with * Fix type check errors * Update imports. Remove filters from initial embed expressions * Adds hide layer functionality to canvas map embeds * Fix typecheck error * Fix Type check Co-authored-by: Elastic Machine --- .../expression_types/embeddable.ts | 4 +- .../expression_types/embeddable_types.ts | 2 +- .../functions/common/index.ts | 4 + .../functions/common/map_center.ts | 50 +++++++++++++ .../functions/common/saved_map.test.ts | 12 +-- .../functions/common/saved_map.ts | 68 +++++++++++++++-- .../functions/common/time_range.ts | 44 +++++++++++ .../renderers/{ => embeddable}/embeddable.tsx | 36 +++++---- .../embeddable_input_to_expression.test.ts | 75 +++++++++++++++++++ .../embeddable_input_to_expression.ts | 50 +++++++++++++ .../canvas_plugin_src/renderers/index.js | 2 +- .../canvas/i18n/functions/dict/map_center.ts | 27 +++++++ .../canvas/i18n/functions/dict/saved_map.ts | 16 +++- .../canvas/i18n/functions/dict/time_range.ts | 24 ++++++ .../canvas/i18n/functions/function_help.ts | 4 + .../element_content/element_content.js | 11 ++- .../element_wrapper/lib/handlers.js | 12 +++ .../components/embeddable_flyout/index.tsx | 7 +- .../workpad_interactive_page/index.js | 28 +++++++ .../canvas/public/state/actions/embeddable.ts | 36 +++++++++ .../canvas/public/state/reducers/elements.js | 2 +- .../public/state/reducers/embeddable.ts | 67 +++++++++++++++++ .../public/state/reducers/embeddables.test.ts | 41 ++++++++++ .../canvas/public/state/reducers/index.js | 3 +- .../lib/build_embeddable_filters.test.ts | 4 +- .../server/lib/build_embeddable_filters.ts | 6 +- .../components/rendered_element.tsx | 2 + .../legacy/plugins/canvas/types/functions.ts | 13 ++++ .../legacy/plugins/canvas/types/renderers.ts | 4 + .../public/customize_time_range_modal.tsx | 2 +- 30 files changed, 608 insertions(+), 48 deletions(-) create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/map_center.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/time_range.ts rename x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/{ => embeddable}/embeddable.tsx (74%) create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts create mode 100644 x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts create mode 100644 x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts create mode 100644 x-pack/legacy/plugins/canvas/i18n/functions/dict/time_range.ts create mode 100644 x-pack/legacy/plugins/canvas/public/state/actions/embeddable.ts create mode 100644 x-pack/legacy/plugins/canvas/public/state/reducers/embeddable.ts create mode 100644 x-pack/legacy/plugins/canvas/public/state/reducers/embeddables.test.ts diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts index 063e69d1d21416..e728ea25f55043 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts @@ -5,11 +5,11 @@ */ import { ExpressionType } from 'src/plugins/expressions/public'; -import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { EmbeddableInput } from '../../../../../../src/plugins/embeddable/public'; import { EmbeddableTypes } from './embeddable_types'; export const EmbeddableExpressionType = 'embeddable'; -export { EmbeddableTypes }; +export { EmbeddableTypes, EmbeddableInput }; export interface EmbeddableExpression { type: typeof EmbeddableExpressionType; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts index 3669bd3e082016..8f5ad859d28ba9 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts @@ -9,7 +9,7 @@ import { MAP_SAVED_OBJECT_TYPE } from '../../../maps/common/constants'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/visualize_embeddable/constants'; import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants'; -export const EmbeddableTypes = { +export const EmbeddableTypes: { map: string; search: string; visualization: string } = { map: MAP_SAVED_OBJECT_TYPE, search: SEARCH_EMBEDDABLE_TYPE, visualization: VISUALIZE_EMBEDDABLE_TYPE, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 097aef69d4b4c5..48b50930d563e0 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -32,6 +32,7 @@ import { image } from './image'; import { joinRows } from './join_rows'; import { lt } from './lt'; import { lte } from './lte'; +import { mapCenter } from './map_center'; import { mapColumn } from './mapColumn'; import { math } from './math'; import { metric } from './metric'; @@ -57,6 +58,7 @@ import { staticColumn } from './staticColumn'; import { string } from './string'; import { table } from './table'; import { tail } from './tail'; +import { timerange } from './time_range'; import { timefilter } from './timefilter'; import { timefilterControl } from './timefilterControl'; import { switchFn } from './switch'; @@ -91,6 +93,7 @@ export const functions = [ lt, lte, joinRows, + mapCenter, mapColumn, math, metric, @@ -118,6 +121,7 @@ export const functions = [ tail, timefilter, timefilterControl, + timerange, switchFn, caseFn, ]; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/map_center.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/map_center.ts new file mode 100644 index 00000000000000..21f9e9fe3148d9 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/map_center.ts @@ -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 { ExpressionFunction } from 'src/plugins/expressions/common'; +import { getFunctionHelp } from '../../../i18n/functions'; +import { MapCenter } from '../../../types'; + +interface Args { + lat: number; + lon: number; + zoom: number; +} + +export function mapCenter(): ExpressionFunction<'mapCenter', null, Args, MapCenter> { + const { help, args: argHelp } = getFunctionHelp().mapCenter; + return { + name: 'mapCenter', + help, + type: 'mapCenter', + context: { + types: ['null'], + }, + args: { + lat: { + types: ['number'], + required: true, + help: argHelp.lat, + }, + lon: { + types: ['number'], + required: true, + help: argHelp.lon, + }, + zoom: { + types: ['number'], + required: true, + help: argHelp.zoom, + }, + }, + fn: (context, args) => { + return { + type: 'mapCenter', + ...args, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts index 25f035bbb6d8c3..5b95886faa13da 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts @@ -5,7 +5,7 @@ */ jest.mock('ui/new_platform'); import { savedMap } from './saved_map'; -import { buildEmbeddableFilters } from '../../../server/lib/build_embeddable_filters'; +import { getQueryFilters } from '../../../server/lib/build_embeddable_filters'; const filterContext = { and: [ @@ -24,20 +24,22 @@ describe('savedMap', () => { const fn = savedMap().fn; const args = { id: 'some-id', + center: null, + title: null, + timerange: null, + hideLayer: [], }; it('accepts null context', () => { const expression = fn(null, args, {}); expect(expression.input.filters).toEqual([]); - expect(expression.input.timeRange).toBeUndefined(); }); it('accepts filter context', () => { const expression = fn(filterContext, args, {}); - const embeddableFilters = buildEmbeddableFilters(filterContext.and); + const embeddableFilters = getQueryFilters(filterContext.and); - expect(expression.input.filters).toEqual(embeddableFilters.filters); - expect(expression.input.timeRange).toEqual(embeddableFilters.timeRange); + expect(expression.input.filters).toEqual(embeddableFilters); }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts index 460cb9c34efffc..b6d88c06ed06d0 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts @@ -7,8 +7,8 @@ import { ExpressionFunction } from 'src/plugins/expressions/common/types'; import { TimeRange } from 'src/plugins/data/public'; import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; -import { buildEmbeddableFilters } from '../../../server/lib/build_embeddable_filters'; -import { Filter } from '../../../types'; +import { getQueryFilters } from '../../../server/lib/build_embeddable_filters'; +import { Filter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; import { EmbeddableTypes, EmbeddableExpressionType, @@ -19,19 +19,36 @@ import { esFilters } from '../../../../../../../src/plugins/data/public'; interface Arguments { id: string; + center: MapCenter | null; + hideLayer: string[]; + title: string | null; + timerange: TimeRangeArg | null; } // Map embeddable is missing proper typings, so type is just to document what we // are expecting to pass to the embeddable -interface SavedMapInput extends EmbeddableInput { +export type SavedMapInput = EmbeddableInput & { id: string; + isLayerTOCOpen: boolean; timeRange?: TimeRange; refreshConfig: { isPaused: boolean; interval: number; }; + hideFilterActions: true; filters: esFilters.Filter[]; -} + mapCenter?: { + lat: number; + lon: number; + zoom: number; + }; + hiddenLayers?: string[]; +}; + +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; type Return = EmbeddableExpression; @@ -46,21 +63,56 @@ export function savedMap(): ExpressionFunction<'savedMap', Filter | null, Argume required: false, help: argHelp.id, }, + center: { + types: ['mapCenter'], + help: argHelp.center, + required: false, + }, + hideLayer: { + types: ['string'], + help: argHelp.hideLayer, + required: false, + multi: true, + }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + title: { + types: ['string'], + help: argHelp.title, + required: false, + }, }, type: EmbeddableExpressionType, - fn: (context, { id }) => { + fn: (context, args) => { const filters = context ? context.and : []; + const center = args.center + ? { + lat: args.center.lat, + lon: args.center.lon, + zoom: args.center.zoom, + } + : undefined; + return { type: EmbeddableExpressionType, input: { - id, - ...buildEmbeddableFilters(filters), - + id: args.id, + filters: getQueryFilters(filters), + timeRange: args.timerange || defaultTimeRange, refreshConfig: { isPaused: false, interval: 0, }, + + mapCenter: center, + hideFilterActions: true, + title: args.title ? args.title : undefined, + isLayerTOCOpen: false, + hiddenLayers: args.hideLayer || [], }, embeddableType: EmbeddableTypes.map, }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/time_range.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/time_range.ts new file mode 100644 index 00000000000000..716026279ccea2 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/time_range.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFunction } from 'src/plugins/expressions/common'; +import { getFunctionHelp } from '../../../i18n/functions'; +import { TimeRange } from '../../../types'; + +interface Args { + from: string; + to: string; +} + +export function timerange(): ExpressionFunction<'timerange', null, Args, TimeRange> { + const { help, args: argHelp } = getFunctionHelp().timerange; + return { + name: 'timerange', + help, + type: 'timerange', + context: { + types: ['null'], + }, + args: { + from: { + types: ['string'], + required: true, + help: argHelp.from, + }, + to: { + types: ['string'], + required: true, + help: argHelp.to, + }, + }, + fn: (context, args) => { + return { + type: 'timerange', + ...args, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx similarity index 74% rename from x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable.tsx rename to x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 5c7ef1a8c1799c..8642ebd901bb4d 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -10,32 +10,27 @@ import { I18nContext } from 'ui/i18n'; import { npStart } from 'ui/new_platform'; import { IEmbeddable, + EmbeddableFactory, EmbeddablePanel, EmbeddableFactoryNotFoundError, - EmbeddableInput, -} from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; -import { start } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; -import { EmbeddableExpression } from '../expression_types/embeddable'; -import { RendererStrings } from '../../i18n'; +} from '../../../../../../../src/plugins/embeddable/public'; +import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; +import { EmbeddableExpression } from '../../expression_types/embeddable'; +import { RendererStrings } from '../../../i18n'; import { SavedObjectFinderProps, SavedObjectFinderUi, -} from '../../../../../../src/plugins/kibana_react/public'; +} from '../../../../../../../src/plugins/kibana_react/public'; const { embeddable: strings } = RendererStrings; +import { embeddableInputToExpression } from './embeddable_input_to_expression'; +import { EmbeddableInput } from '../../expression_types'; +import { RendererHandlers } from '../../../types'; const embeddablesRegistry: { [key: string]: IEmbeddable; } = {}; -interface Handlers { - setFilter: (text: string) => void; - getFilter: () => string | null; - done: () => void; - onResize: (fn: () => void) => void; - onDestroy: (fn: () => void) => void; -} - const renderEmbeddable = (embeddableObject: IEmbeddable, domNode: HTMLElement) => { const SavedObjectFinder = (props: SavedObjectFinderProps) => ( ({ render: async ( domNode: HTMLElement, { input, embeddableType }: EmbeddableExpression, - handlers: Handlers + handlers: RendererHandlers ) => { if (!embeddablesRegistry[input.id]) { const factory = Array.from(start.getEmbeddableFactories()).find( embeddableFactory => embeddableFactory.type === embeddableType - ); + ) as EmbeddableFactory; if (!factory) { handlers.done(); @@ -86,8 +81,13 @@ const embeddable = () => ({ } const embeddableObject = await factory.createFromSavedObject(input.id, input); + embeddablesRegistry[input.id] = embeddableObject; + ReactDOM.unmountComponentAtNode(domNode); + const subscription = embeddableObject.getInput$().subscribe(function(updatedInput) { + handlers.onEmbeddableInputChange(embeddableInputToExpression(updatedInput, embeddableType)); + }); ReactDOM.render(renderEmbeddable(embeddableObject, domNode), domNode, () => handlers.done()); handlers.onResize(() => { @@ -97,7 +97,11 @@ const embeddable = () => ({ }); handlers.onDestroy(() => { + subscription.unsubscribe(); + handlers.onEmbeddableDestroyed(); + delete embeddablesRegistry[input.id]; + return ReactDOM.unmountComponentAtNode(domNode); }); } else { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts new file mode 100644 index 00000000000000..93d747537c34c1 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { embeddableInputToExpression } from './embeddable_input_to_expression'; +import { SavedMapInput } from '../../functions/common/saved_map'; +import { EmbeddableTypes } from '../../expression_types'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseSavedMapInput = { + id: 'embeddableId', + filters: [], + isLayerTOCOpen: false, + refreshConfig: { + isPaused: true, + interval: 0, + }, + hideFilterActions: true as true, +}; + +describe('input to expression', () => { + describe('Map Embeddable', () => { + it('converts to a savedMap expression', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedMap'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('center'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + mapCenter: { + lat: 1, + lon: 2, + zoom: 3, + }, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + const centerExpression = ast.chain[0].arguments.center[0] as Ast; + + expect(centerExpression.chain[0].function).toBe('mapCenter'); + expect(centerExpression.chain[0].arguments.lat[0]).toEqual(input.mapCenter?.lat); + expect(centerExpression.chain[0].arguments.lon[0]).toEqual(input.mapCenter?.lon); + expect(centerExpression.chain[0].arguments.zoom[0]).toEqual(input.mapCenter?.zoom); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts new file mode 100644 index 00000000000000..a3cb53acebed24 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -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 { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; +import { SavedMapInput } from '../../functions/common/saved_map'; + +/* + Take the input from an embeddable and the type of embeddable and convert it into an expression +*/ +export function embeddableInputToExpression( + input: EmbeddableInput, + embeddableType: string +): string { + const expressionParts: string[] = []; + + if (embeddableType === EmbeddableTypes.map) { + const mapInput = input as SavedMapInput; + + expressionParts.push('savedMap'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (mapInput.mapCenter) { + expressionParts.push( + `center={mapCenter lat=${mapInput.mapCenter.lat} lon=${mapInput.mapCenter.lon} zoom=${mapInput.mapCenter.zoom}}` + ); + } + + if (mapInput.timeRange) { + expressionParts.push( + `timerange={timerange from="${mapInput.timeRange.from}" to="${mapInput.timeRange.to}"}` + ); + } + + if (mapInput.hiddenLayers && mapInput.hiddenLayers.length) { + for (const layerId of mapInput.hiddenLayers) { + expressionParts.push(`hideLayer="${layerId}"`); + } + } + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js index 50fa6943fc74ae..48364be06e539c 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js @@ -7,7 +7,7 @@ import { advancedFilter } from './advanced_filter'; import { debug } from './debug'; import { dropdownFilter } from './dropdown_filter'; -import { embeddable } from './embeddable'; +import { embeddable } from './embeddable/embeddable'; import { error } from './error'; import { image } from './image'; import { markdown } from './markdown'; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts new file mode 100644 index 00000000000000..3022ad07089d25 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { mapCenter } from '../../../canvas_plugin_src/functions/common/map_center'; +import { FunctionHelp } from '../'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.mapCenterHelpText', { + defaultMessage: `Returns an object with the center coordinates and zoom level of the map`, + }), + args: { + lat: i18n.translate('xpack.canvas.functions.mapCenter.args.latHelpText', { + defaultMessage: `Latitude for the center of the map`, + }), + lon: i18n.translate('xpack.canvas.functions.savedMap.args.lonHelpText', { + defaultMessage: `Longitude for the center of the map`, + }), + zoom: i18n.translate('xpack.canvas.functions.savedMap.args.zoomHelpText', { + defaultMessage: `The zoom level of the map`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts index d01b77e1cfd511..53bcd481f185ff 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts @@ -14,6 +14,20 @@ export const help: FunctionHelp> = { defaultMessage: `Returns an embeddable for a saved map object`, }), args: { - id: 'The id of the saved map object', + id: i18n.translate('xpack.canvas.functions.savedMap.args.idHelpText', { + defaultMessage: `The ID of the Saved Map Object`, + }), + center: i18n.translate('xpack.canvas.functions.savedMap.args.centerHelpText', { + defaultMessage: `The center and zoom level the map should have`, + }), + hideLayer: i18n.translate('xpack.canvas.functions.savedMap.args.hideLayer', { + defaultMessage: `The IDs of map layers that should be hidden`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedMap.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + title: i18n.translate('xpack.canvas.functions.savedMap.args.titleHelpText', { + defaultMessage: `The title for the map`, + }), }, }; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/time_range.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/time_range.ts new file mode 100644 index 00000000000000..476a9978800df9 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/time_range.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { timerange } from '../../../canvas_plugin_src/functions/common/time_range'; +import { FunctionHelp } from '../function_help'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.timerangeHelpText', { + defaultMessage: `An object that represents a span of time`, + }), + args: { + from: i18n.translate('xpack.canvas.functions.timerange.args.fromHelpText', { + defaultMessage: `The start of the time range`, + }), + to: i18n.translate('xpack.canvas.functions.timerange.args.toHelpText', { + defaultMessage: `The end of the time range`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts index f6b3c451c6fbbc..94d7e6f43326f4 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts @@ -44,6 +44,7 @@ import { help as joinRows } from './dict/join_rows'; import { help as location } from './dict/location'; import { help as lt } from './dict/lt'; import { help as lte } from './dict/lte'; +import { help as mapCenter } from './dict/map_center'; import { help as mapColumn } from './dict/map_column'; import { help as markdown } from './dict/markdown'; import { help as math } from './dict/math'; @@ -75,6 +76,7 @@ import { help as tail } from './dict/tail'; import { help as timefilter } from './dict/timefilter'; import { help as timefilterControl } from './dict/timefilter_control'; import { help as timelion } from './dict/timelion'; +import { help as timerange } from './dict/time_range'; import { help as to } from './dict/to'; import { help as urlparam } from './dict/urlparam'; @@ -196,6 +198,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ location, lt, lte, + mapCenter, mapColumn, markdown, math, @@ -227,6 +230,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ timefilter, timefilterControl, timelion, + timerange, to, urlparam, }); diff --git a/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js b/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js index 89c0b5b21c581a..1926fb4aaa5eb8 100644 --- a/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js +++ b/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js @@ -47,7 +47,14 @@ export const ElementContent = compose( pure, ...branches )(({ renderable, renderFunction, size, handlers }) => { - const { getFilter, setFilter, done, onComplete } = handlers; + const { + getFilter, + setFilter, + done, + onComplete, + onEmbeddableInputChange, + onEmbeddableDestroyed, + } = handlers; return Style.it( renderable.css, @@ -69,7 +76,7 @@ export const ElementContent = compose( config={renderable.value} css={renderable.css} // This is an actual CSS stylesheet string, it will be scoped by RenderElement size={size} // Size is only passed for the purpose of triggering the resize event, it isn't really used otherwise - handlers={{ getFilter, setFilter, done }} + handlers={{ getFilter, setFilter, done, onEmbeddableInputChange, onEmbeddableDestroyed }} />
diff --git a/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js b/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js index ce6791f2f88b63..e93cea597901f9 100644 --- a/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js +++ b/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js @@ -6,6 +6,10 @@ import { isEqual } from 'lodash'; import { setFilter } from '../../../state/actions/elements'; +import { + updateEmbeddableExpression, + fetchEmbeddableRenderable, +} from '../../../state/actions/embeddable'; export const createHandlers = dispatch => { let isComplete = false; @@ -32,6 +36,14 @@ export const createHandlers = dispatch => { completeFn = fn; }, + onEmbeddableInputChange(embeddableExpression) { + dispatch(updateEmbeddableExpression({ elementId: element.id, embeddableExpression })); + }, + + onEmbeddableDestroyed() { + dispatch(fetchEmbeddableRenderable(element.id)); + }, + done() { // don't emit if the element is already done if (isComplete) { diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index c54c56e1561cae..565ca5fa5bbd6e 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -19,14 +19,15 @@ import { withKibana } from '../../../../../../../src/plugins/kibana_react/public const allowedEmbeddables = { [EmbeddableTypes.map]: (id: string) => { - return `filters | savedMap id="${id}" | render`; + return `savedMap id="${id}" | render`; }, - [EmbeddableTypes.visualization]: (id: string) => { + // FIX: Only currently allow Map embeddables + /* [EmbeddableTypes.visualization]: (id: string) => { return `filters | savedVisualization id="${id}" | render`; }, [EmbeddableTypes.search]: (id: string) => { return `filters | savedSearch id="${id}" | render`; - }, + },*/ }; interface StateProps { diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js index 4ee3a65172a2ed..b775524acf6393 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js @@ -73,6 +73,32 @@ function closest(s) { return null; } +// If you interact with an embeddable panel, only the header should be draggable +// This function will determine if an element is an embeddable body or not +const isEmbeddableBody = element => { + const hasClosest = typeof element.closest === 'function'; + + if (hasClosest) { + return element.closest('.embeddable') && !element.closest('.embPanel__header'); + } else { + return closest.call(element, '.embeddable') && !closest.call(element, '.embPanel__header'); + } +}; + +// Some elements in an embeddable may be portaled out of the embeddable container. +// We do not want clicks on those to trigger drags, etc, in the workpad. This function +// will check to make sure the clicked item is actually in the container +const isInWorkpad = element => { + const hasClosest = typeof element.closest === 'function'; + const workpadContainerSelector = '.canvasWorkpadContainer'; + + if (hasClosest) { + return !!element.closest(workpadContainerSelector); + } else { + return !!closest.call(element, workpadContainerSelector); + } +}; + const componentLayoutState = ({ aeroStore, setAeroStore, @@ -209,6 +235,8 @@ export const InteractivePage = compose( withProps((...props) => ({ ...props, canDragElement: element => { + return !isEmbeddableBody(element) && isInWorkpad(element); + const hasClosest = typeof element.closest === 'function'; if (hasClosest) { diff --git a/x-pack/legacy/plugins/canvas/public/state/actions/embeddable.ts b/x-pack/legacy/plugins/canvas/public/state/actions/embeddable.ts new file mode 100644 index 00000000000000..3604d7e3c2141d --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/actions/embeddable.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch } from 'redux'; +import { createAction } from 'redux-actions'; +// @ts-ignore Untyped +import { createThunk } from 'redux-thunks'; +// @ts-ignore Untyped Local +import { fetchRenderable } from './elements'; +import { State } from '../../../types'; + +export const UpdateEmbeddableExpressionActionType = 'updateEmbeddableExpression'; +export interface UpdateEmbeddableExpressionPayload { + embeddableExpression: string; + elementId: string; +} +export const updateEmbeddableExpression = createAction( + UpdateEmbeddableExpressionActionType +); + +export const fetchEmbeddableRenderable = createThunk( + 'fetchEmbeddableRenderable', + ({ dispatch, getState }: { dispatch: Dispatch; getState: () => State }, elementId: string) => { + const pageWithElement = getState().persistent.workpad.pages.find(page => { + return page.elements.find(element => element.id === elementId) !== undefined; + }); + + if (pageWithElement) { + const element = pageWithElement.elements.find(el => el.id === elementId); + dispatch(fetchRenderable(element)); + } + } +); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js b/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js index 10a5bdb5998ea1..c7e8a5c2ff2d81 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js @@ -28,7 +28,7 @@ function getNodeIndexById(page, nodeId, location) { return page[location].findIndex(node => node.id === nodeId); } -function assignNodeProperties(workpadState, pageId, nodeId, props) { +export function assignNodeProperties(workpadState, pageId, nodeId, props) { const pageIndex = getPageIndexById(workpadState, pageId); const location = getLocationFromIds(workpadState, pageId, nodeId); const nodesPath = `pages.${pageIndex}.${location}`; diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/embeddable.ts b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddable.ts new file mode 100644 index 00000000000000..9969c38cfa7672 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddable.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fromExpression, toExpression } from '@kbn/interpreter/common'; +import { handleActions } from 'redux-actions'; +import { State } from '../../../types'; + +import { + UpdateEmbeddableExpressionActionType, + UpdateEmbeddableExpressionPayload, +} from '../actions/embeddable'; + +// @ts-ignore untyped local +import { assignNodeProperties } from './elements'; + +export const embeddableReducer = handleActions< + State['persistent']['workpad'], + UpdateEmbeddableExpressionPayload +>( + { + [UpdateEmbeddableExpressionActionType]: (workpadState, { payload }) => { + if (!payload) { + return workpadState; + } + + const { elementId, embeddableExpression } = payload; + + // Find the element + const pageWithElement = workpadState.pages.find(page => { + return page.elements.find(element => element.id === elementId) !== undefined; + }); + + if (!pageWithElement) { + return workpadState; + } + + const element = pageWithElement.elements.find(elem => elem.id === elementId); + + if (!element) { + return workpadState; + } + + const existingAst = fromExpression(element.expression); + const newAst = fromExpression(embeddableExpression); + const searchForFunction = newAst.chain[0].function; + + // Find the first matching function in the existing ASt + const existingAstFunction = existingAst.chain.find(f => f.function === searchForFunction); + + if (!existingAstFunction) { + return workpadState; + } + + existingAstFunction.arguments = newAst.chain[0].arguments; + + const updatedExpression = toExpression(existingAst); + + return assignNodeProperties(workpadState, pageWithElement.id, elementId, { + expression: updatedExpression, + }); + }, + }, + {} as State['persistent']['workpad'] +); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/embeddables.test.ts b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddables.test.ts new file mode 100644 index 00000000000000..5b1192630897a2 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddables.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +jest.mock('ui/new_platform'); +import { State } from '../../../types'; +import { updateEmbeddableExpression } from '../actions/embeddable'; +import { embeddableReducer } from './embeddable'; + +const elementId = 'element-1111'; +const embeddableId = '1234'; +const mockWorkpadState = { + pages: [ + { + elements: [ + { + id: elementId, + expression: `function1 | function2 id="${embeddableId}" change="start value" remove="remove"`, + }, + ], + }, + ], +} as State['persistent']['workpad']; + +describe('embeddables reducer', () => { + it('updates the functions expression', () => { + const updatedValue = 'updated value'; + + const action = updateEmbeddableExpression({ + elementId, + embeddableExpression: `function2 id="${embeddableId}" change="${updatedValue}" add="add"`, + }); + + const newState = embeddableReducer(mockWorkpadState, action); + + expect(newState.pages[0].elements[0].expression.replace(/\s/g, '')).toBe( + `function1 | ${action.payload!.embeddableExpression}`.replace(/\s/g, '') + ); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/index.js b/x-pack/legacy/plugins/canvas/public/state/reducers/index.js index b60a0a3b32656d..cec6f9dceef6d5 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/index.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/index.js @@ -16,6 +16,7 @@ import { pagesReducer } from './pages'; import { elementsReducer } from './elements'; import { assetsReducer } from './assets'; import { historyReducer } from './history'; +import { embeddableReducer } from './embeddable'; export function getRootReducer(initialState) { return combineReducers({ @@ -25,7 +26,7 @@ export function getRootReducer(initialState) { persistent: reduceReducers( historyReducer, combineReducers({ - workpad: reduceReducers(workpadReducer, pagesReducer, elementsReducer), + workpad: reduceReducers(workpadReducer, pagesReducer, elementsReducer, embeddableReducer), schemaVersion: (state = get(initialState, 'persistent.schemaVersion')) => state, }) ), diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts index d1632fc3eef285..b422a9451293ff 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts +++ b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts @@ -23,10 +23,10 @@ const timeFilter: Filter = { }; describe('buildEmbeddableFilters', () => { - it('converts non time Canvas Filters to ES Filters ', () => { + it('converts all Canvas Filters to ES Filters ', () => { const filters = buildEmbeddableFilters([timeFilter, columnFilter, columnFilter]); - expect(filters.filters).toHaveLength(2); + expect(filters.filters).toHaveLength(3); }); it('converts time filter to time range', () => { diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts index 52fcc9813a93db..1a78a1e0570164 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts +++ b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts @@ -35,10 +35,8 @@ function getTimeRangeFromFilters(filters: Filter[]): TimeRange | undefined { : undefined; } -function getQueryFilters(filters: Filter[]): esFilters.Filter[] { - return buildBoolArray(filters.filter(filter => filter.type !== 'time')).map( - esFilters.buildQueryFilter - ); +export function getQueryFilters(filters: Filter[]): esFilters.Filter[] { + return buildBoolArray(filters).map(esFilters.buildQueryFilter); } export function buildEmbeddableFilters(filters: Filter[]): EmbeddableFilterInput { diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx b/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx index 03b3e0df8a0cf8..317a3417841b8b 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx @@ -69,6 +69,8 @@ export class RenderedElementComponent extends PureComponent { onResize: () => {}, setFilter: () => {}, getFilter: () => '', + onEmbeddableInputChange: () => {}, + onEmbeddableDestroyed: () => {}, }); } catch (e) { // eslint-disable-next-line no-console diff --git a/x-pack/legacy/plugins/canvas/types/functions.ts b/x-pack/legacy/plugins/canvas/types/functions.ts index 6510c018f1ed4a..773c9c3020a857 100644 --- a/x-pack/legacy/plugins/canvas/types/functions.ts +++ b/x-pack/legacy/plugins/canvas/types/functions.ts @@ -192,3 +192,16 @@ export interface AxisConfig { */ export const isAxisConfig = (axisConfig: any): axisConfig is AxisConfig => !!axisConfig && axisConfig.type === 'axisConfig'; + +export interface MapCenter { + type: 'mapCenter'; + lat: number; + lon: number; + zoom: number; +} + +export interface TimeRange { + type: 'timerange'; + from: string; + to: string; +} diff --git a/x-pack/legacy/plugins/canvas/types/renderers.ts b/x-pack/legacy/plugins/canvas/types/renderers.ts index 282a1c820e3462..af1710e69c2578 100644 --- a/x-pack/legacy/plugins/canvas/types/renderers.ts +++ b/x-pack/legacy/plugins/canvas/types/renderers.ts @@ -17,6 +17,10 @@ export interface RendererHandlers { getFilter: () => string; /** Sets the value of the filter property on the element object persisted on the workpad */ setFilter: (filter: string) => void; + /** Handler to invoke when the input to a function has changed internally */ + onEmbeddableInputChange: (expression: string) => void; + /** Handler to invoke when a rendered embeddable is destroyed */ + onEmbeddableDestroyed: () => void; } export interface RendererSpec { diff --git a/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx b/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx index 90393f9f4ff6f1..9880a2b811f8b8 100644 --- a/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx @@ -137,7 +137,7 @@ export class CustomizeTimeRangeModal extends Component {i18n.translate( From 51d96e52ec09acac9ed580bef84af6bfe3c4eed9 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 13 Jan 2020 15:54:58 -0500 Subject: [PATCH 35/62] Skip flaky test --- .../security_and_spaces/tests/alerting/alerts.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 551498e22d5c8c..d20450f8ec47e9 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -761,7 +761,8 @@ export default function alertTests({ getService }: FtrProviderContext) { } }); - it(`should unmute all instances when unmuting an alert`, async () => { + // Flaky: https://github.com/elastic/kibana/issues/54125 + it.skip(`should unmute all instances when unmuting an alert`, async () => { const testStart = new Date(); const reference = alertUtils.generateReference(); const response = await alertUtils.createAlwaysFiringAction({ From e9319360e21ed6d85e00b2f99c2764e9e228a0f2 Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Mon, 13 Jan 2020 21:59:45 +0100 Subject: [PATCH 36/62] [SIEM] Detection Engine Create Rule Design Review #1 (#54442) --- .../detection_engine/rules/all/columns.tsx | 19 +- .../rules/components/add_item_form/index.tsx | 23 +- .../assets/list_tree_icon.svg | 1 + .../components/description_step/helpers.tsx | 147 +++++++----- .../components/description_step/index.tsx | 48 ++-- .../rules/components/mitre/index.tsx | 16 +- .../components/optional_field_label/index.tsx | 16 ++ .../rules/components/query_bar/index.tsx | 2 +- .../components/schedule_item_form/index.tsx | 42 +++- .../rules/components/severity_badge/index.tsx | 32 +++ .../rules/components/step_about_rule/data.tsx | 17 +- .../components/step_about_rule/index.tsx | 150 ++++++------ .../components/step_about_rule/schema.tsx | 14 +- .../components/step_content_wrapper/index.tsx | 18 ++ .../components/step_define_rule/index.tsx | 227 ++++++++++-------- .../components/step_schedule_rule/index.tsx | 186 +++++++------- .../components/step_schedule_rule/schema.tsx | 6 +- .../detection_engine/rules/create/index.tsx | 47 ++-- .../detection_engine/rules/details/index.tsx | 2 +- .../pages/detection_engine/rules/types.ts | 1 + 20 files changed, 588 insertions(+), 426 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/assets/list_tree_icon.svg create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 95b9c9324894fb..636cbb8ecb0647 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -8,7 +8,6 @@ import { EuiBadge, - EuiHealth, EuiIconTip, EuiLink, EuiTextColor, @@ -17,7 +16,6 @@ import { } from '@elastic/eui'; import * as H from 'history'; import React from 'react'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { getEmptyTagValue } from '../../../../components/empty_value'; import { deleteRulesAction, @@ -32,6 +30,7 @@ import { TableData } from '../types'; import * as i18n from '../translations'; import { PreferenceFormattedDate } from '../../../../components/formatted_date'; import { RuleSwitch } from '../components/rule_switch'; +import { SeverityBadge } from '../components/severity_badge'; const getActions = (dispatch: React.Dispatch, history: H.History) => [ { @@ -92,21 +91,7 @@ export const getColumns = ( { field: 'severity', name: i18n.COLUMN_SEVERITY, - render: (value: TableData['severity']) => ( - - {value} - - ), + render: (value: TableData['severity']) => , truncateText: true, }, { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index b3cc81b5cdfcfc..0c75da7d8a6320 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -37,6 +37,25 @@ const MyEuiFormRow = styled(EuiFormRow)` } `; +export const MyAddItemButton = styled(EuiButtonEmpty)` + margin-top: 4px; + + &.euiButtonEmpty--xSmall { + font-size: 12px; + } + + .euiIcon { + width: 12px; + height: 12px; + } +`; + +MyAddItemButton.defaultProps = { + flush: 'left', + iconType: 'plusInCircle', + size: 'xs', +}; + export const AddItem = ({ addText, dataTestSubj, @@ -160,9 +179,9 @@ export const AddItem = ({ ); })} - + {addText} - + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/assets/list_tree_icon.svg b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/assets/list_tree_icon.svg new file mode 100644 index 00000000000000..527d8d445bc03e --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/assets/list_tree_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx index 09d0c1131ea10d..e8b6919165c8bf 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx @@ -9,12 +9,10 @@ import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, - EuiHealth, EuiLink, - EuiText, - EuiListGroup, + EuiButtonEmpty, + EuiSpacer, } from '@elastic/eui'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { isEmpty } from 'lodash/fp'; import React from 'react'; @@ -27,6 +25,11 @@ import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_ import { FilterLabel } from './filter_label'; import * as i18n from './translations'; import { BuildQueryBarDescription, BuildThreatsDescription, ListItems } from './types'; +import { SeverityBadge } from '../severity_badge'; +import ListTreeIcon from './assets/list_tree_icon.svg'; + +const isNotEmptyArray = (values: string[]) => + !isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0; const EuiBadgeWrap = styled(EuiBadge)` .euiBadge__text { @@ -97,10 +100,17 @@ const ThreatsEuiFlexGroup = styled(EuiFlexGroup)` } `; -const MyEuiListGroup = styled(EuiListGroup)` - padding: 0px; - .euiListGroupItem__button { - padding: 0px; +const TechniqueLinkItem = styled(EuiButtonEmpty)` + .euiIcon { + width: 8px; + height: 8px; + } +`; + +const ReferenceLinkItem = styled(EuiButtonEmpty)` + .euiIcon { + width: 12px; + height: 12px; } `; @@ -118,28 +128,31 @@ export const buildThreatsDescription = ({ const tactic = tacticsOptions.find(t => t.name === threat.tactic.name); return ( - -
- - {tactic != null ? tactic.text : ''} - -
- { - const myTechnique = techniquesOptions.find(t => t.name === technique.name); - return { - label: myTechnique != null ? myTechnique.label : '', - href: technique.reference, - target: '_blank', - }; - })} - /> -
+ + {tactic != null ? tactic.text : ''} + + + {threat.techniques.map(technique => { + const myTechnique = techniquesOptions.find(t => t.name === technique.name); + return ( + + + {myTechnique != null ? myTechnique.label : ''} + + + ); + })} +
); })} + ), }, @@ -148,12 +161,34 @@ export const buildThreatsDescription = ({ return []; }; +export const buildUnorderedListArrayDescription = ( + label: string, + field: string, + values: string[] +): ListItems[] => { + if (isNotEmptyArray(values)) { + return [ + { + title: label, + description: ( +
    + {values.map((val: string) => + isEmpty(val) ? null :
  • {val}
  • + )} +
+ ), + }, + ]; + } + return []; +}; + export const buildStringArrayDescription = ( label: string, field: string, values: string[] ): ListItems[] => { - if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + if (isNotEmptyArray(values)) { return [ { title: label, @@ -174,46 +209,34 @@ export const buildStringArrayDescription = ( return []; }; -export const buildSeverityDescription = (label: string, value: string): ListItems[] => { - return [ - { - title: label, - description: ( - - {value} - - ), - }, - ]; -}; +export const buildSeverityDescription = (label: string, value: string): ListItems[] => [ + { + title: label, + description: , + }, +]; export const buildUrlsDescription = (label: string, values: string[]): ListItems[] => { - if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + if (isNotEmptyArray(values)) { return [ { title: label, description: ( - ({ - label: val, - href: val, - iconType: 'link', - size: 'xs', - target: '_blank', - }))} - /> + + {values.map((val: string) => ( + + + {val} + + + ))} + ), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index af4f93c0fdbcd2..8cf1601e2c4b64 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiTextArea } from '@elastic/eui'; +import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty, chunk, get, pick } from 'lodash/fp'; import React, { memo, useState } from 'react'; -import styled from 'styled-components'; import { IIndexPattern, @@ -26,6 +25,7 @@ import { buildSeverityDescription, buildStringArrayDescription, buildThreatsDescription, + buildUnorderedListArrayDescription, buildUrlsDescription, } from './helpers'; @@ -36,15 +36,6 @@ interface StepRuleDescriptionProps { schema: FormSchema; } -const EuiFlexItemWidth = styled(EuiFlexItem)<{ direction: string }>` - ${props => (props.direction === 'row' ? 'width : 50%;' : 'width: 100%;')}; -`; - -const MyEuiTextArea = styled(EuiTextArea)` - max-width: 100%; - height: 80px; -`; - const StepRuleDescriptionComponent: React.FC = ({ data, direction = 'row', @@ -62,13 +53,24 @@ const StepRuleDescriptionComponent: React.FC = ({ ], [] ); + + if (direction === 'row') { + return ( + + {chunk(Math.ceil(listItems.length / 2), listItems).map((chunkListItems, index) => ( + + + + ))} + + ); + } + return ( - - {chunk(Math.ceil(listItems.length / 2), listItems).map((chunkListItems, index) => ( - - - - ))} + + + + ); }; @@ -123,18 +125,28 @@ const getDescriptionItem = ( return [ { title: label, - description: , + description: get(field, value), }, ]; } else if (field === 'references') { const urls: string[] = get(field, value); return buildUrlsDescription(label, urls); + } else if (field === 'falsePositives') { + const values: string[] = get(field, value); + return buildUnorderedListArrayDescription(label, field, values); } else if (Array.isArray(get(field, value))) { const values: string[] = get(field, value); return buildStringArrayDescription(label, field, values); } else if (field === 'severity') { const val: string = get(field, value); return buildSeverityDescription(label, val); + } else if (field === 'riskScore') { + return [ + { + title: label, + description: get(field, value), + }, + ]; } else if (field === 'timeline') { const timeline = get(field, value) as FieldValueTimeline; return [ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index 2c19e99e901140..f9a22c37cfdf00 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -5,7 +5,6 @@ */ import { - EuiButtonEmpty, EuiButtonIcon, EuiFormRow, EuiSuperSelect, @@ -24,6 +23,7 @@ import * as Rulei18n from '../../translations'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; import { threatsDefault } from '../step_about_rule/default_value'; import { IMitreEnterpriseAttack } from '../../types'; +import { MyAddItemButton } from '../add_item_form'; import { isMitreAttackInvalid } from './helpers'; import * as i18n from './translations'; @@ -134,13 +134,19 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI const getSelectTechniques = (item: IMitreEnterpriseAttack, index: number, disabled: boolean) => { const invalid = isMitreAttackInvalid(item.tactic.name, item.techniques); + const options = techniquesOptions.filter(t => t.tactics.includes(kebabCase(item.tactic.name))); + const selectedOptions = item.techniques.map(technic => ({ + ...technic, + label: `${technic.name} (${technic.id})`, // API doesn't allow for label field + })); + return ( t.tactics.includes(kebabCase(item.tactic.name)))} - selectedOptions={item.techniques} + options={options} + selectedOptions={selectedOptions} onChange={updateTechniques.bind(null, index)} isDisabled={disabled || item.tactic.name === 'none'} fullWidth={true} @@ -202,9 +208,9 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI {values.length - 1 !== index && }
))} - + {i18n.ADD_MITRE_ATTACK} - + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.tsx new file mode 100644 index 00000000000000..0dab87b0a3b744 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.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 { EuiText } from '@elastic/eui'; +import React from 'react'; + +import * as RuleI18n from '../../translations'; + +export const OptionalFieldLabel = ( + + {RuleI18n.OPTIONAL_FIELD} + +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx index 3e39beb6e61b78..46a7a13ec03f16 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx @@ -51,7 +51,7 @@ interface QueryBarDefineRuleProps { const StyledEuiFormRow = styled(EuiFormRow)` .kbnTypeahead__items { - max-height: 14vh !important; + max-height: 45vh !important; } .globalQueryBar { padding: 4px 0px 0px 0px; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx index 8097c27cddfe8b..fa4bea319f8590 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFieldNumber, + EuiFormRow, + EuiSelect, + EuiFormControlLayout, +} from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; @@ -26,10 +33,28 @@ const timeTypeOptions = [ { value: 'h', text: I18n.HOURS }, ]; +// move optional label to the end of input +const StyledLabelAppend = styled(EuiFlexItem)` + &.euiFlexItem.euiFlexItem--flexGrowZero { + margin-left: 31px; + } +`; + const StyledEuiFormRow = styled(EuiFormRow)` + max-width: none; + .euiFormControlLayout { max-width: 200px !important; } + + .euiFormControlLayout__childrenWrapper > *:first-child { + box-shadow: none; + height: 38px; + } + + .euiFormControlLayout:not(:first-child) { + border-left: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; + } `; const MyEuiSelect = styled(EuiSelect)` @@ -89,9 +114,9 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu {field.label} - + {field.labelAppend} - + ), [field.label, field.labelAppend] @@ -107,7 +132,7 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu data-test-subj={dataTestSubj} describedByIds={idAria ? [idAria] : undefined} > - } - fullWidth - min={0} - onChange={onChangeTimeVal} - value={timeVal} - {...rest} - /> + > + + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.tsx new file mode 100644 index 00000000000000..09c02dfca56f96 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { upperFirst } from 'lodash/fp'; +import React from 'react'; +import { EuiHealth } from '@elastic/eui'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +interface Props { + value: string; +} + +const SeverityBadgeComponent: React.FC = ({ value }) => ( + + {upperFirst(value)} + +); + +export const SeverityBadge = React.memo(SeverityBadgeComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx index 9fb64189ebd1af..269d2d4509508d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import styled from 'styled-components'; import { EuiHealth } from '@elastic/eui'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; @@ -16,22 +17,30 @@ interface SeverityOptionItem { inputDisplay: React.ReactElement; } +const StyledEuiHealth = styled(EuiHealth)` + line-height: inherit; +`; + export const severityOptions: SeverityOptionItem[] = [ { value: 'low', - inputDisplay: {I18n.LOW}, + inputDisplay: {I18n.LOW}, }, { value: 'medium', - inputDisplay: {I18n.MEDIUM} , + inputDisplay: ( + {I18n.MEDIUM} + ), }, { value: 'high', - inputDisplay: {I18n.HIGH} , + inputDisplay: {I18n.HIGH}, }, { value: 'critical', - inputDisplay: {I18n.CRITICAL} , + inputDisplay: ( + {I18n.CRITICAL} + ), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index 8956776dcd3b21..0e03a11776fb7e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -6,7 +6,7 @@ import { EuiButton, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEqual, get } from 'lodash/fp'; -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import { RuleStepProps, RuleStep, AboutStepRule } from '../../types'; @@ -22,6 +22,7 @@ import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; import { PickTimeline } from '../pick_timeline'; +import { StepContentWrapper } from '../step_content_wrapper'; const CommonUseField = getUseField({ component: Field }); @@ -33,64 +34,67 @@ const TagContainer = styled.div` margin-top: 16px; `; -export const StepAboutRule = memo( - ({ - defaultValues, - descriptionDirection = 'row', - isReadOnlyView, - isUpdateView = false, - isLoading, - setForm, - setStepData, - }) => { - const [myStepData, setMyStepData] = useState(stepAboutDefaultValue); +const StepAboutRuleComponent: FC = ({ + addPadding = false, + defaultValues, + descriptionDirection = 'row', + isReadOnlyView, + isUpdateView = false, + isLoading, + setForm, + setStepData, +}) => { + const [myStepData, setMyStepData] = useState(stepAboutDefaultValue); - const { form } = useForm({ - defaultValue: myStepData, - options: { stripEmptyFields: false }, - schema, - }); + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.aboutRule, null, false); - const { isValid, data } = await form.submit(); - if (isValid) { - setStepData(RuleStep.aboutRule, data, isValid); - setMyStepData({ ...data, isNew: false } as AboutStepRule); - } + const onSubmit = useCallback(async () => { + if (setStepData) { + setStepData(RuleStep.aboutRule, null, false); + const { isValid, data } = await form.submit(); + if (isValid) { + setStepData(RuleStep.aboutRule, data, isValid); + setMyStepData({ ...data, isNew: false } as AboutStepRule); } - }, [form]); + } + }, [form]); - useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - if (!isReadOnlyView) { - Object.keys(schema).forEach(key => { - const val = get(key, myDefaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); - } + useEffect(() => { + const { isNew, ...initDefaultValue } = myStepData; + if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { + const myDefaultValues = { + ...defaultValues, + isNew: false, + }; + setMyStepData(myDefaultValues); + if (!isReadOnlyView) { + Object.keys(schema).forEach(key => { + const val = get(key, myDefaultValues); + if (val != null) { + form.setFieldValue(key, val); + } + }); } - }, [defaultValues]); + } + }, [defaultValues]); - useEffect(() => { - if (setForm != null) { - setForm(RuleStep.aboutRule, form); - } - }, [form]); + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.aboutRule, form); + } + }, [form]); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView && myStepData != null ? ( + - ) : ( - <> + + ) : ( + <> +
( }} - {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - - )} - - ); - } -); +
+ {!isUpdateView && ( + <> + + + + + {RuleI18n.CONTINUE} + + + + + )} + + ); +}; + +export const StepAboutRule = memo(StepAboutRuleComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 008a1b48610d69..3de0e7605f3d97 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -4,11 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; -import * as RuleI18n from '../../translations'; import { IMitreEnterpriseAttack } from '../../types'; import { FIELD_TYPES, @@ -18,6 +15,7 @@ import { ERROR_CODE, } from '../shared_imports'; import { isMitreAttackInvalid } from '../mitre/helpers'; +import { OptionalFieldLabel } from '../optional_field_label'; import { isUrlInvalid } from './helpers'; import * as I18n from './translations'; @@ -108,7 +106,7 @@ export const schema: FormSchema = { defaultMessage: 'Reference URLs', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, validations: [ { validator: ( @@ -136,10 +134,10 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldFalsePositiveLabel', { - defaultMessage: 'False positives examples', + defaultMessage: 'False positive examples', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, }, threats: { label: i18n.translate( @@ -148,7 +146,7 @@ export const schema: FormSchema = { defaultMessage: 'MITRE ATT&CK\\u2122', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, validations: [ { validator: ( @@ -184,6 +182,6 @@ export const schema: FormSchema = { 'Type one or more custom identifying tags for this rule. Press enter after each tag to begin a new one.', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx new file mode 100644 index 00000000000000..b04a321dab05bc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import styled from 'styled-components'; + +const StyledDiv = styled.div<{ addPadding: boolean }>` + padding-left: ${({ addPadding }) => addPadding && '53px'}; /* to align with the step title */ +`; + +StyledDiv.defaultProps = { + addPadding: false, +}; + +export const StepContentWrapper = React.memo(StyledDiv); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index ecd2ce442238fa..6bdef4a69af1e6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -12,7 +12,8 @@ import { EuiButton, } from '@elastic/eui'; import { isEmpty, isEqual, get } from 'lodash/fp'; -import React, { memo, useCallback, useState, useEffect } from 'react'; +import React, { FC, memo, useCallback, useState, useEffect } from 'react'; +import styled from 'styled-components'; import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; @@ -22,6 +23,7 @@ import * as RuleI18n from '../../translations'; import { DefineStepRule, RuleStep, RuleStepProps } from '../../types'; import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; +import { StepContentWrapper } from '../step_content_wrapper'; import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; @@ -42,6 +44,20 @@ const stepDefineDefaultValue = { }, }; +const MyLabelButton = styled(EuiButtonEmpty)` + height: 18px; + font-size: 12px; + + .euiIcon { + width: 14px; + height: 14px; + } +`; + +MyLabelButton.defaultProps = { + flush: 'right', +}; + const getStepDefaultValue = ( indicesConfig: string[], defaultValues: DefineStepRule | null @@ -59,106 +75,104 @@ const getStepDefaultValue = ( } }; -export const StepDefineRule = memo( - ({ - defaultValues, - descriptionDirection = 'row', - isReadOnlyView, - isLoading, - isUpdateView = false, - resizeParentContainer, - setForm, - setStepData, - }) => { - const [openTimelineSearch, setOpenTimelineSearch] = useState(false); - const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); - const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); - const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( - defaultValues != null ? defaultValues.index : indicesConfig ?? [] - ); - const [ - { - browserFields, - indexPatterns: indexPatternQueryBar, - isLoading: indexPatternLoadingQueryBar, - }, - ] = useFetchIndexPatterns(mylocalIndicesConfig); - const [myStepData, setMyStepData] = useState( - getStepDefaultValue(indicesConfig, null) - ); - - const { form } = useForm({ - defaultValue: myStepData, - options: { stripEmptyFields: false }, - schema, - }); - - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.defineRule, null, false); - const { isValid, data } = await form.submit(); - if (isValid && setStepData) { - setStepData(RuleStep.defineRule, data, isValid); - setMyStepData({ ...data, isNew: false } as DefineStepRule); - } +const StepDefineRuleComponent: FC = ({ + addPadding = false, + defaultValues, + descriptionDirection = 'row', + isReadOnlyView, + isLoading, + isUpdateView = false, + setForm, + setStepData, +}) => { + const [openTimelineSearch, setOpenTimelineSearch] = useState(false); + const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); + const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); + const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( + defaultValues != null ? defaultValues.index : indicesConfig ?? [] + ); + const [ + { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, + ] = useFetchIndexPatterns(mylocalIndicesConfig); + const [myStepData, setMyStepData] = useState( + getStepDefaultValue(indicesConfig, null) + ); + + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); + + const onSubmit = useCallback(async () => { + if (setStepData) { + setStepData(RuleStep.defineRule, null, false); + const { isValid, data } = await form.submit(); + if (isValid && setStepData) { + setStepData(RuleStep.defineRule, data, isValid); + setMyStepData({ ...data, isNew: false } as DefineStepRule); } - }, [form]); - - useEffect(() => { - if (indicesConfig != null && defaultValues != null) { - const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); - if (!isEqual(myDefaultValues, myStepData)) { - setMyStepData(myDefaultValues); - setLocalUseIndicesConfig(isEqual(myDefaultValues.index, indicesConfig)); - if (!isReadOnlyView) { - Object.keys(schema).forEach(key => { - const val = get(key, myDefaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); - } + } + }, [form]); + + useEffect(() => { + if (indicesConfig != null && defaultValues != null) { + const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); + if (!isEqual(myDefaultValues, myStepData)) { + setMyStepData(myDefaultValues); + setLocalUseIndicesConfig(isEqual(myDefaultValues.index, indicesConfig)); + if (!isReadOnlyView) { + Object.keys(schema).forEach(key => { + const val = get(key, myDefaultValues); + if (val != null) { + form.setFieldValue(key, val); + } + }); } } - }, [defaultValues, indicesConfig]); + } + }, [defaultValues, indicesConfig]); - useEffect(() => { - if (setForm != null) { - setForm(RuleStep.defineRule, form); - } - }, [form]); + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.defineRule, form); + } + }, [form]); - const handleResetIndices = useCallback(() => { - const indexField = form.getFields().index; - indexField.setValue(indicesConfig); - }, [form, indicesConfig]); + const handleResetIndices = useCallback(() => { + const indexField = form.getFields().index; + indexField.setValue(indicesConfig); + }, [form, indicesConfig]); - const handleOpenTimelineSearch = useCallback(() => { - setOpenTimelineSearch(true); - }, []); + const handleOpenTimelineSearch = useCallback(() => { + setOpenTimelineSearch(true); + }, []); - const handleCloseTimelineSearch = useCallback(() => { - setOpenTimelineSearch(false); - }, []); + const handleCloseTimelineSearch = useCallback(() => { + setOpenTimelineSearch(false); + }, []); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView && myStepData != null ? ( + - ) : ( - <> + + ) : ( + <> +
- {i18n.RESET_DEFAULT_INDEX} - + + {i18n.RESET_DEFAULT_INDEX} + ) : null, }} componentProps={{ @@ -176,9 +190,9 @@ export const StepDefineRule = memo( config={{ ...schema.queryBar, labelAppend: ( - - {i18n.IMPORT_TIMELINE_QUERY} - + + {i18n.IMPORT_TIMELINE_QUERY} + ), }} component={QueryBarDefineRule} @@ -192,7 +206,6 @@ export const StepDefineRule = memo( dataTestSubj: 'detectionEngineStepDefineRuleQueryBar', openTimelineSearch, onCloseTimelineSearch: handleCloseTimelineSearch, - resizeParentContainer, }} /> @@ -212,24 +225,26 @@ export const StepDefineRule = memo( }} - {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - - )} - - ); - } -); +
+ {!isUpdateView && ( + <> + + + + + {RuleI18n.CONTINUE} + + + + + )} + + ); +}; + +export const StepDefineRule = memo(StepDefineRuleComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index 35b8ca6650bf6d..b99201abe87777 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -6,12 +6,13 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import { isEqual, get } from 'lodash/fp'; -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import { RuleStep, RuleStepProps, ScheduleStepRule } from '../../types'; import { StepRuleDescription } from '../description_step'; import { ScheduleItem } from '../schedule_item_form'; import { Form, UseField, useForm } from '../shared_imports'; +import { StepContentWrapper } from '../step_content_wrapper'; import { schema } from './schema'; import * as I18n from './translations'; @@ -26,67 +27,70 @@ const stepScheduleDefaultValue = { from: '0m', }; -export const StepScheduleRule = memo( - ({ - defaultValues, - descriptionDirection = 'row', - isReadOnlyView, - isLoading, - isUpdateView = false, - setStepData, - setForm, - }) => { - const [myStepData, setMyStepData] = useState(stepScheduleDefaultValue); +const StepScheduleRuleComponent: FC = ({ + addPadding = false, + defaultValues, + descriptionDirection = 'row', + isReadOnlyView, + isLoading, + isUpdateView = false, + setStepData, + setForm, +}) => { + const [myStepData, setMyStepData] = useState(stepScheduleDefaultValue); - const { form } = useForm({ - defaultValue: myStepData, - options: { stripEmptyFields: false }, - schema, - }); + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); - const onSubmit = useCallback( - async (enabled: boolean) => { - if (setStepData) { - setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await form.submit(); - if (newIsValid) { - setStepData(RuleStep.scheduleRule, { ...data, enabled }, newIsValid); - setMyStepData({ ...data, isNew: false } as ScheduleStepRule); - } - } - }, - [form] - ); - - useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - if (!isReadOnlyView) { - Object.keys(schema).forEach(key => { - const val = get(key, myDefaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); + const onSubmit = useCallback( + async (enabled: boolean) => { + if (setStepData) { + setStepData(RuleStep.scheduleRule, null, false); + const { isValid: newIsValid, data } = await form.submit(); + if (newIsValid) { + setStepData(RuleStep.scheduleRule, { ...data, enabled }, newIsValid); + setMyStepData({ ...data, isNew: false } as ScheduleStepRule); } } - }, [defaultValues]); + }, + [form] + ); - useEffect(() => { - if (setForm != null) { - setForm(RuleStep.scheduleRule, form); + useEffect(() => { + const { isNew, ...initDefaultValue } = myStepData; + if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { + const myDefaultValues = { + ...defaultValues, + isNew: false, + }; + setMyStepData(myDefaultValues); + if (!isReadOnlyView) { + Object.keys(schema).forEach(key => { + const val = get(key, myDefaultValues); + if (val != null) { + form.setFieldValue(key, val); + } + }); } - }, [form]); + } + }, [defaultValues]); - return isReadOnlyView && myStepData != null ? ( + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.scheduleRule, form); + } + }, [form]); + + return isReadOnlyView && myStepData != null ? ( + - ) : ( - <> + + ) : ( + <> +
( }} /> +
+ + {!isUpdateView && ( + <> + + + + + {I18n.COMPLETE_WITHOUT_ACTIVATING} + + + + + {I18n.COMPLETE_WITH_ACTIVATING} + + + + + )} + + ); +}; - {!isUpdateView && ( - <> - - - - - {I18n.COMPLETE_WITHOUT_ACTIVATING} - - - - - {I18n.COMPLETE_WITH_ACTIVATING} - - - - - )} - - ); - } -); +export const StepScheduleRule = memo(StepScheduleRuleComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx index 31e56265dec424..4da17b88b9ad0f 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiText } from '@elastic/eui'; -import React from 'react'; import { i18n } from '@kbn/i18n'; -import * as RuleI18n from '../../translations'; +import { OptionalFieldLabel } from '../optional_field_label'; import { FormSchema } from '../shared_imports'; export const schema: FormSchema = { @@ -33,7 +31,7 @@ export const schema: FormSchema = { defaultMessage: 'Additional look-back', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, helpText: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepScheduleRule.fieldAdditionalLookBackHelpText', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index 9a0f41bbd8c517..e5656f5b081fb0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -27,26 +27,17 @@ import * as i18n from './translations'; const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.scheduleRule]; -const ResizeEuiPanel = styled(EuiPanel)<{ - height?: number; +const MyEuiPanel = styled(EuiPanel)<{ + zIndex?: number; }>` + position: relative; + z-index: ${props => props.zIndex}; /* ugly fix to allow searchBar to overflow the EuiPanel */ + .euiAccordion__iconWrapper { display: none; } .euiAccordion__childWrapper { - height: ${props => (props.height !== -1 ? `${props.height}px !important` : 'auto')}; - } - .euiAccordion__button { - cursor: default !important; - &:hover { - text-decoration: none !important; - } - } -`; - -const MyEuiPanel = styled(EuiPanel)` - .euiAccordion__iconWrapper { - display: none; + overflow: visible; } .euiAccordion__button { cursor: default !important; @@ -64,7 +55,6 @@ export const CreateRuleComponent = React.memo(() => { canUserCRUD, hasManageApiKey, } = useUserInfo(); - const [heightAccordion, setHeightAccordion] = useState(-1); const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); @@ -239,7 +229,7 @@ export const CreateRuleComponent = React.memo(() => { isLoading={isLoading || loading} title={i18n.PAGE_TITLE} /> - + { ) } > - + setHeightAccordion(height)} + descriptionDirection="row" /> - - - + + + { ) } > - + { /> - - + + { ) } > - + ( {aboutRuleData != null && ( void; isReadOnlyView: boolean; From b65710d33d1ddd3121ea2a31ab6798b4d4ca0dce Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Mon, 13 Jan 2020 13:25:14 -0800 Subject: [PATCH 37/62] Service Map Data API at Runtime (#54027) * [APM] Runtime service maps * Make nodes interactive * Don't use smaller range query on initial request * Address feedback from Ron * Get all services separately * Get single service as well * Query both transactions/spans for initial request * Optimize 'top' query for service maps * Use agent.name from scripted metric * adds basic loading overlay * filter out service map node self reference edges from being rendered * Make service map initial load time range configurable with `xpack.apm.serviceMapInitialTimeRange` default to last 1 hour in milliseconds * ensure destination.address is not missing in the composite agg when fetching sample trace ids * wip: added incremental data fetch & progress bar * implement progressive loading design while blocking service map interaction during loading * adds filter that destination.address exists before fetching sample trace ids * reduce pairs of connections to 1 bi-directional connection with arrows on both ends of the edge * Optimize query; add update button * Allow user interaction after 5s, auto update in that time, otherwise show toast for user to update the map with button * Correctly reduce nodes/connections * - remove non-interactive state while loading - use cytoscape element definition types * - readability improvements to the ServiceMap component - only show the update map button toast after last request loads * addresses feedback for changes to the Cytoscape component * Add span.type/span.subtype do external nodes * PR feedback Co-authored-by: Dario Gieselaar --- .../elasticsearch_fieldnames.test.ts.snap | 6 + .../apm/common/elasticsearch_fieldnames.ts | 2 + .../legacy/plugins/apm/common/service_map.ts | 23 ++ x-pack/legacy/plugins/apm/index.ts | 3 +- .../components/app/ServiceMap/Cytoscape.tsx | 1 + .../app/ServiceMap/LoadingOverlay.tsx | 66 +++++ .../app/ServiceMap/cytoscapeOptions.ts | 23 +- .../app/ServiceMap/get_cytoscape_elements.ts | 158 ++++++++++ .../components/app/ServiceMap/index.tsx | 181 +++++++++-- .../apm/server/lib/helpers/es_client.ts | 49 ++- .../server/lib/service_map/get_service_map.ts | 129 ++++++++ .../get_service_map_from_trace_ids.ts | 280 ++++++++++++++++++ .../lib/service_map/get_trace_sample_ids.ts | 177 +++++++++++ .../apm/server/routes/create_apm_api.ts | 8 +- .../plugins/apm/server/routes/service_map.ts | 34 +++ .../plugins/apm/server/routes/services.ts | 15 - .../apm/typings/elasticsearch/aggregations.ts | 47 +++ .../apm/typings/elasticsearch/index.ts | 1 + x-pack/plugins/apm/server/index.ts | 2 + 19 files changed, 1142 insertions(+), 63 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/common/service_map.ts create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts create mode 100644 x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map.ts create mode 100644 x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts create mode 100644 x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts create mode 100644 x-pack/legacy/plugins/apm/server/routes/service_map.ts diff --git a/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index e345ca3552e5ae..8f87b3473b2e48 100644 --- a/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -4,6 +4,8 @@ exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; +exports[`Error DESTINATION_ADDRESS 1`] = `undefined`; + exports[`Error ERROR_CULPRIT 1`] = `"handleOopsie"`; exports[`Error ERROR_EXC_HANDLED 1`] = `undefined`; @@ -112,6 +114,8 @@ exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; +exports[`Span DESTINATION_ADDRESS 1`] = `undefined`; + exports[`Span ERROR_CULPRIT 1`] = `undefined`; exports[`Span ERROR_EXC_HANDLED 1`] = `undefined`; @@ -220,6 +224,8 @@ exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; +exports[`Transaction DESTINATION_ADDRESS 1`] = `undefined`; + exports[`Transaction ERROR_CULPRIT 1`] = `undefined`; exports[`Transaction ERROR_EXC_HANDLED 1`] = `undefined`; diff --git a/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts index 0d7ff3114e73fb..ce2db4964a4120 100644 --- a/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts @@ -14,6 +14,8 @@ export const HTTP_REQUEST_METHOD = 'http.request.method'; export const USER_ID = 'user.id'; export const USER_AGENT_NAME = 'user_agent.name'; +export const DESTINATION_ADDRESS = 'destination.address'; + export const OBSERVER_VERSION_MAJOR = 'observer.version_major'; export const OBSERVER_LISTENING = 'observer.listening'; export const PROCESSOR_EVENT = 'processor.event'; diff --git a/x-pack/legacy/plugins/apm/common/service_map.ts b/x-pack/legacy/plugins/apm/common/service_map.ts new file mode 100644 index 00000000000000..fbaa489c450399 --- /dev/null +++ b/x-pack/legacy/plugins/apm/common/service_map.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 ServiceConnectionNode { + 'service.name': string; + 'service.environment': string | null; + 'agent.name': string; +} +export interface ExternalConnectionNode { + 'destination.address': string; + 'span.type': string; + 'span.subtype': string; +} + +export type ConnectionNode = ServiceConnectionNode | ExternalConnectionNode; + +export interface Connection { + source: ConnectionNode; + destination: ConnectionNode; +} diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index cf2cbd25072151..0934cb0019f44c 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -71,7 +71,8 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(false) + serviceMapEnabled: Joi.boolean().default(false), + serviceMapInitialTimeRange: Joi.number().default(60 * 1000 * 60) // last 1 hour }).default(); }, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 238158c5bf224f..bc020815cc9cbb 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -73,6 +73,7 @@ export function Cytoscape({ cy.on('data', event => { // Add the "primary" class to the node if its id matches the serviceName. if (cy.nodes().length > 0 && serviceName) { + cy.nodes().removeClass('primary'); cy.getElementById(serviceName).addClass('primary'); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx new file mode 100644 index 00000000000000..efafdbcecd41cd --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import React from 'react'; +import { EuiProgress, EuiText, EuiSpacer } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; + +const Container = styled.div` + position: relative; +`; + +const Overlay = styled.div` + position: absolute; + top: 0; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + padding: ${theme.gutterTypes.gutterMedium}; +`; + +const ProgressBarContainer = styled.div` + width: 50%; + max-width: 600px; +`; + +interface Props { + children: React.ReactNode; + isLoading: boolean; + percentageLoaded: number; +} + +export const LoadingOverlay = ({ + children, + isLoading, + percentageLoaded +}: Props) => ( + + {isLoading && ( + + + + + + + {i18n.translate('xpack.apm.loadingServiceMap', { + defaultMessage: + 'Loading service map... This might take a short while.' + })} + + + )} + {children} + +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 03ae9d0c287e53..d4e792ccf761bc 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -8,17 +8,13 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { icons, defaultIcon } from './icons'; const layout = { - animate: true, - animationEasing: theme.euiAnimSlightBounce as cytoscape.Css.TransitionTimingFunction, - animationDuration: parseInt(theme.euiAnimSpeedFast, 10), name: 'dagre', nodeDimensionsIncludeLabels: true, - rankDir: 'LR', - spacingFactor: 2 + rankDir: 'LR' }; function isDatabaseOrExternal(agentName: string) { - return agentName === 'database' || agentName === 'external'; + return !agentName; } const style: cytoscape.Stylesheet[] = [ @@ -47,7 +43,7 @@ const style: cytoscape.Stylesheet[] = [ 'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif', 'font-size': theme.euiFontSizeXS, height: theme.avatarSizing.l.size, - label: 'data(id)', + label: 'data(label)', 'min-zoomed-font-size': theme.euiSizeL, 'overlay-opacity': 0, shape: (el: cytoscape.NodeSingular) => @@ -76,7 +72,18 @@ const style: cytoscape.Stylesheet[] = [ // // @ts-ignore 'target-distance-from-node': theme.paddingSizes.xs, - width: 2 + width: 1, + 'source-arrow-shape': 'none' + } + }, + { + selector: 'edge[bidirectional]', + style: { + 'source-arrow-shape': 'triangle', + 'target-arrow-shape': 'triangle', + // @ts-ignore + 'source-distance-from-node': theme.paddingSizes.xs, + 'target-distance-from-node': theme.paddingSizes.xs } } ]; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts new file mode 100644 index 00000000000000..c9caa27af41c50 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts @@ -0,0 +1,158 @@ +/* + * 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 { ValuesType } from 'utility-types'; +import { sortBy, isEqual } from 'lodash'; +import { Connection, ConnectionNode } from '../../../../common/service_map'; +import { ServiceMapAPIResponse } from '../../../../server/lib/service_map/get_service_map'; +import { getAPMHref } from '../../shared/Links/apm/APMLink'; + +function getConnectionNodeId(node: ConnectionNode): string { + if ('destination.address' in node) { + // use a prefix to distinguish exernal destination ids from services + return `>${node['destination.address']}`; + } + return node['service.name']; +} + +function getConnectionId(connection: Connection) { + return `${getConnectionNodeId(connection.source)}~${getConnectionNodeId( + connection.destination + )}`; +} +export function getCytoscapeElements( + responses: ServiceMapAPIResponse[], + search: string +) { + const discoveredServices = responses.flatMap( + response => response.discoveredServices + ); + + const serviceNodes = responses + .flatMap(response => response.services) + .map(service => ({ + ...service, + id: service['service.name'] + })); + + // maps destination.address to service.name if possible + function getConnectionNode(node: ConnectionNode) { + let mappedNode: ConnectionNode | undefined; + + if ('destination.address' in node) { + mappedNode = discoveredServices.find(map => isEqual(map.from, node))?.to; + } + + if (!mappedNode) { + mappedNode = node; + } + + return { + ...mappedNode, + id: getConnectionNodeId(mappedNode) + }; + } + + // build connections with mapped nodes + const connections = responses + .flatMap(response => response.connections) + .map(connection => { + const source = getConnectionNode(connection.source); + const destination = getConnectionNode(connection.destination); + + return { + source, + destination, + id: getConnectionId({ source, destination }) + }; + }) + .filter(connection => connection.source.id !== connection.destination.id); + + const nodes = connections + .flatMap(connection => [connection.source, connection.destination]) + .concat(serviceNodes); + + type ConnectionWithId = ValuesType; + type ConnectionNodeWithId = ValuesType; + + const connectionsById = connections.reduce((connectionMap, connection) => { + return { + ...connectionMap, + [connection.id]: connection + }; + }, {} as Record); + + const nodesById = nodes.reduce((nodeMap, node) => { + return { + ...nodeMap, + [node.id]: node + }; + }, {} as Record); + + const cyNodes = (Object.values(nodesById) as ConnectionNodeWithId[]).map( + node => { + let data = {}; + + if ('service.name' in node) { + data = { + href: getAPMHref( + `/services/${node['service.name']}/service-map`, + search + ), + agentName: node['agent.name'] || node['agent.name'] + }; + } + + return { + group: 'nodes' as const, + data: { + id: node.id, + label: + 'service.name' in node + ? node['service.name'] + : node['destination.address'], + ...data + } + }; + } + ); + + // instead of adding connections in two directions, + // we add a `bidirectional` flag to use in styling + const dedupedConnections = (sortBy( + Object.values(connectionsById), + // make sure that order is stable + 'id' + ) as ConnectionWithId[]).reduce< + Array + >((prev, connection) => { + const reversedConnection = prev.find( + c => + c.destination.id === connection.source.id && + c.source.id === connection.destination.id + ); + + if (reversedConnection) { + reversedConnection.bidirectional = true; + return prev; + } + + return prev.concat(connection); + }, []); + + const cyEdges = dedupedConnections.map(connection => { + return { + group: 'edges' as const, + data: { + id: connection.id, + source: connection.source.id, + target: connection.destination.id, + bidirectional: connection.bidirectional ? true : undefined + } + }; + }, []); + + return [...cyNodes, ...cyEdges]; +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index cc09975a344b5e..d3cc2b14e2c685 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -5,13 +5,30 @@ */ import theme from '@elastic/eui/dist/eui_theme_light.json'; -import React from 'react'; -import { useFetcher } from '../../../hooks/useFetcher'; +import React, { + useMemo, + useEffect, + useState, + useRef, + useCallback +} from 'react'; +import { find, isEqual } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { EuiButton } from '@elastic/eui'; +import { ElementDefinition } from 'cytoscape'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { ServiceMapAPIResponse } from '../../../../server/lib/service_map/get_service_map'; import { useLicense } from '../../../hooks/useLicense'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { Controls } from './Controls'; import { Cytoscape } from './Cytoscape'; import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; +import { useCallApmApi } from '../../../hooks/useCallApmApi'; +import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity'; +import { useLocation } from '../../../hooks/useLocation'; +import { LoadingOverlay } from './LoadingOverlay'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { getCytoscapeElements } from './get_cytoscape_elements'; interface ServiceMapProps { serviceName?: string; @@ -37,37 +54,159 @@ ${theme.euiColorLightShade}`, margin: `-${theme.gutterTypes.gutterLarge}` }; +const MAX_REQUESTS = 5; + export function ServiceMap({ serviceName }: ServiceMapProps) { - const { - urlParams: { start, end } - } = useUrlParams(); + const callApmApi = useCallApmApi(); + const license = useLicense(); + const { search } = useLocation(); + const { urlParams, uiFilters } = useUrlParams(); + const { notifications } = useApmPluginContext().core; + const params = useDeepObjectIdentity({ + start: urlParams.start, + end: urlParams.end, + environment: urlParams.environment, + serviceName, + uiFilters: { + ...uiFilters, + environment: undefined + } + }); + + const renderedElements = useRef([]); + const openToast = useRef(null); + + const [responses, setResponses] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [percentageLoaded, setPercentageLoaded] = useState(0); + const [, _setUnusedState] = useState(false); + + const elements = useMemo(() => getCytoscapeElements(responses, search), [ + responses, + search + ]); + + const forceUpdate = useCallback(() => _setUnusedState(value => !value), []); + + const getNext = useCallback( + async (input: { reset?: boolean; after?: string | undefined }) => { + const { start, end, uiFilters: strippedUiFilters, ...query } = params; + + if (input.reset) { + renderedElements.current = []; + setResponses([]); + } - const { data } = useFetcher( - callApmApi => { if (start && end) { - return callApmApi({ - pathname: '/api/apm/service-map', - params: { query: { start, end } } - }); + setIsLoading(true); + try { + const data = await callApmApi({ + pathname: '/api/apm/service-map', + params: { + query: { + ...query, + start, + end, + uiFilters: JSON.stringify(strippedUiFilters), + after: input.after + } + } + }); + setResponses(resp => resp.concat(data)); + setIsLoading(false); + + const shouldGetNext = + responses.length + 1 < MAX_REQUESTS && data.after; + + if (shouldGetNext) { + setPercentageLoaded(value => value + 30); // increase loading bar 30% + await getNext({ after: data.after }); + } + } catch (error) { + setIsLoading(false); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.apm.errorServiceMapData', { + defaultMessage: `Error loading service connections` + }) + }); + } } }, - [start, end] + [callApmApi, params, responses.length, notifications.toasts] ); - const elements = Array.isArray(data) ? data : []; - const license = useLicense(); + useEffect(() => { + const loadServiceMaps = async () => { + setPercentageLoaded(5); + await getNext({ reset: true }); + setPercentageLoaded(100); + }; + + loadServiceMaps(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params]); + + useEffect(() => { + if (renderedElements.current.length === 0) { + renderedElements.current = elements; + return; + } + + const newElements = elements.filter(element => { + return !find(renderedElements.current, el => isEqual(el, element)); + }); + + const updateMap = () => { + renderedElements.current = elements; + if (openToast.current) { + notifications.toasts.remove(openToast.current); + } + forceUpdate(); + }; + + if (newElements.length > 0 && percentageLoaded === 100) { + openToast.current = notifications.toasts.add({ + title: i18n.translate('xpack.apm.newServiceMapData', { + defaultMessage: `Newly discovered connections are available.` + }), + onClose: () => { + openToast.current = null; + }, + toastLifeTimeMs: 24 * 60 * 60 * 1000, + text: toMountPoint( + + {i18n.translate('xpack.apm.updateServiceMap', { + defaultMessage: 'Update map' + })} + + ) + }).id; + } + + return () => { + if (openToast.current) { + notifications.toasts.remove(openToast.current); + } + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elements, percentageLoaded]); + const isValidPlatinumLicense = license?.isActive && (license?.type === 'platinum' || license?.type === 'trial'); return isValidPlatinumLicense ? ( - - - + + + + + ) : ( ); diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts index aeeb39733b5db3..737eeac95516e9 100644 --- a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts @@ -11,7 +11,7 @@ import { IndicesDeleteParams, IndicesCreateParams } from 'elasticsearch'; -import { merge } from 'lodash'; +import { merge, uniqueId } from 'lodash'; import { cloneDeep, isString } from 'lodash'; import { KibanaRequest } from 'src/core/server'; import { OBSERVER_VERSION_MAJOR } from '../../../common/elasticsearch_fieldnames'; @@ -127,6 +127,23 @@ export function getESClient( ? callAsInternalUser : callAsCurrentUser; + const debug = context.params.query._debug; + + function withTime( + fn: (log: typeof console.log) => Promise + ): Promise { + const log = console.log.bind(console, uniqueId()); + if (!debug) { + return fn(log); + } + const time = process.hrtime(); + return fn(log).then(data => { + const now = process.hrtime(time); + log(`took: ${Math.round(now[0] * 1000 + now[1] / 1e6)}ms`); + return data; + }); + } + return { search: async < TDocument = unknown, @@ -141,27 +158,29 @@ export function getESClient( apmOptions ); - if (context.params.query._debug) { - console.log(`--DEBUG ES QUERY--`); - console.log( - `${request.url.pathname} ${JSON.stringify(context.params.query)}` - ); - console.log(`GET ${nextParams.index}/_search`); - console.log(JSON.stringify(nextParams.body, null, 2)); - } + return withTime(log => { + if (context.params.query._debug) { + log(`--DEBUG ES QUERY--`); + log( + `${request.url.pathname} ${JSON.stringify(context.params.query)}` + ); + log(`GET ${nextParams.index}/_search`); + log(JSON.stringify(nextParams.body, null, 2)); + } - return (callMethod('search', nextParams) as unknown) as Promise< - ESSearchResponse - >; + return (callMethod('search', nextParams) as unknown) as Promise< + ESSearchResponse + >; + }); }, index: (params: APMIndexDocumentParams) => { - return callMethod('index', params); + return withTime(() => callMethod('index', params)); }, delete: (params: IndicesDeleteParams) => { - return callMethod('delete', params); + return withTime(() => callMethod('delete', params)); }, indicesCreate: (params: IndicesCreateParams) => { - return callMethod('indices.create', params); + return withTime(() => callMethod('indices.create', params)); } }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map.ts new file mode 100644 index 00000000000000..04e2a43a4b8f1d --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PromiseReturnType } from '../../../typings/common'; +import { + Setup, + SetupTimeRange, + SetupUIFilters +} from '../helpers/setup_request'; +import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; +import { getTraceSampleIds } from './get_trace_sample_ids'; +import { getServicesProjection } from '../../../common/projections/services'; +import { mergeProjection } from '../../../common/projections/util/merge_projection'; +import { + SERVICE_AGENT_NAME, + SERVICE_NAME +} from '../../../common/elasticsearch_fieldnames'; + +export interface IEnvOptions { + setup: Setup & SetupTimeRange & SetupUIFilters; + serviceName?: string; + environment?: string; + after?: string; +} + +async function getConnectionData({ + setup, + serviceName, + environment, + after +}: IEnvOptions) { + const { traceIds, after: nextAfter } = await getTraceSampleIds({ + setup, + serviceName, + environment, + after + }); + + const serviceMapData = traceIds.length + ? await getServiceMapFromTraceIds({ + setup, + serviceName, + environment, + traceIds + }) + : { connections: [], discoveredServices: [] }; + + return { + after: nextAfter, + ...serviceMapData + }; +} + +async function getServicesData(options: IEnvOptions) { + // only return services on the first request for the global service map + if (options.after) { + return []; + } + + const { setup } = options; + + const projection = getServicesProjection({ setup }); + + const { filter } = projection.body.query.bool; + + const params = mergeProjection(projection, { + body: { + size: 0, + query: { + bool: { + ...projection.body.query.bool, + filter: options.serviceName + ? filter.concat({ + term: { + [SERVICE_NAME]: options.serviceName + } + }) + : filter + } + }, + aggs: { + services: { + terms: { + field: projection.body.aggs.services.terms.field, + size: 500 + }, + aggs: { + agent_name: { + terms: { + field: SERVICE_AGENT_NAME + } + } + } + } + } + } + }); + + const { client } = setup; + + const response = await client.search(params); + + return ( + response.aggregations?.services.buckets.map(bucket => { + return { + 'service.name': bucket.key as string, + 'agent.name': + (bucket.agent_name.buckets[0]?.key as string | undefined) || '', + 'service.environment': options.environment || null + }; + }) || [] + ); +} + +export type ServiceMapAPIResponse = PromiseReturnType; +export async function getServiceMap(options: IEnvOptions) { + const [connectionData, servicesData] = await Promise.all([ + getConnectionData(options), + getServicesData(options) + ]); + + return { + ...connectionData, + services: servicesData + }; +} diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts new file mode 100644 index 00000000000000..ea9af12ac7f9a4 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts @@ -0,0 +1,280 @@ +/* + * 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 { uniq, find } from 'lodash'; +import { Setup } from '../helpers/setup_request'; +import { + TRACE_ID, + PROCESSOR_EVENT +} from '../../../common/elasticsearch_fieldnames'; +import { + Connection, + ServiceConnectionNode, + ConnectionNode, + ExternalConnectionNode +} from '../../../common/service_map'; + +export async function getServiceMapFromTraceIds({ + setup, + traceIds, + serviceName, + environment +}: { + setup: Setup; + traceIds: string[]; + serviceName?: string; + environment?: string; +}) { + const { indices, client } = setup; + + const serviceMapParams = { + index: [ + indices['apm_oss.spanIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { + terms: { + [PROCESSOR_EVENT]: ['span', 'transaction'] + } + }, + { + terms: { + [TRACE_ID]: traceIds + } + } + ] + } + }, + aggs: { + service_map: { + scripted_metric: { + init_script: { + lang: 'painless', + source: `state.eventsById = new HashMap(); + + String[] fieldsToCopy = new String[] { + 'parent.id', + 'service.name', + 'service.environment', + 'destination.address', + 'trace.id', + 'processor.event', + 'span.type', + 'span.subtype', + 'agent.name' + }; + state.fieldsToCopy = fieldsToCopy;` + }, + map_script: { + lang: 'painless', + source: `def id; + if (!doc['span.id'].empty) { + id = doc['span.id'].value; + } else { + id = doc['transaction.id'].value; + } + + def copy = new HashMap(); + copy.id = id; + + for(key in state.fieldsToCopy) { + if (!doc[key].empty) { + copy[key] = doc[key].value; + } + } + + state.eventsById[id] = copy` + }, + combine_script: { + lang: 'painless', + source: `return state.eventsById;` + }, + reduce_script: { + lang: 'painless', + source: ` + def getDestination ( def event ) { + def destination = new HashMap(); + destination['destination.address'] = event['destination.address']; + destination['span.type'] = event['span.type']; + destination['span.subtype'] = event['span.subtype']; + return destination; + } + + def processAndReturnEvent(def context, def eventId) { + if (context.processedEvents[eventId] != null) { + return context.processedEvents[eventId]; + } + + def event = context.eventsById[eventId]; + + if (event == null) { + return null; + } + + def service = new HashMap(); + service['service.name'] = event['service.name']; + service['service.environment'] = event['service.environment']; + service['agent.name'] = event['agent.name']; + + def basePath = new ArrayList(); + + def parentId = event['parent.id']; + def parent; + + if (parentId != null && parentId != event['id']) { + parent = processAndReturnEvent(context, parentId); + if (parent != null) { + /* copy the path from the parent */ + basePath.addAll(parent.path); + /* flag parent path for removal, as it has children */ + context.locationsToRemove.add(parent.path); + + /* if the parent has 'destination.address' set, and the service is different, + we've discovered a service */ + + if (parent['destination.address'] != null + && parent['destination.address'] != "" + && (parent['span.type'] == 'external' + || parent['span.type'] == 'messaging') + && (parent['service.name'] != event['service.name'] + || parent['service.environment'] != event['service.environment'] + ) + ) { + def parentDestination = getDestination(parent); + context.externalToServiceMap.put(parentDestination, service); + } + } + } + + def lastLocation = basePath.size() > 0 ? basePath[basePath.size() - 1] : null; + + def currentLocation = service; + + /* only add the current location to the path if it's different from the last one*/ + if (lastLocation == null || !lastLocation.equals(currentLocation)) { + basePath.add(currentLocation); + } + + /* if there is an outgoing span, create a new path */ + if (event['span.type'] == 'external' || event['span.type'] == 'messaging') { + def outgoingLocation = getDestination(event); + def outgoingPath = new ArrayList(basePath); + outgoingPath.add(outgoingLocation); + context.paths.add(outgoingPath); + } + + event.path = basePath; + + context.processedEvents[eventId] = event; + return event; + } + + def context = new HashMap(); + + context.processedEvents = new HashMap(); + context.eventsById = new HashMap(); + + context.paths = new HashSet(); + context.externalToServiceMap = new HashMap(); + context.locationsToRemove = new HashSet(); + + for (state in states) { + context.eventsById.putAll(state); + } + + for (entry in context.eventsById.entrySet()) { + processAndReturnEvent(context, entry.getKey()); + } + + def paths = new HashSet(); + + for(foundPath in context.paths) { + if (!context.locationsToRemove.contains(foundPath)) { + paths.add(foundPath); + } + } + + def response = new HashMap(); + response.paths = paths; + + def discoveredServices = new HashSet(); + + for(entry in context.externalToServiceMap.entrySet()) { + def map = new HashMap(); + map.from = entry.getKey(); + map.to = entry.getValue(); + discoveredServices.add(map); + } + response.discoveredServices = discoveredServices; + + return response;` + } + } + } + } + } + }; + + const serviceMapResponse = await client.search(serviceMapParams); + + const scriptResponse = serviceMapResponse.aggregations?.service_map.value as { + paths: ConnectionNode[][]; + discoveredServices: Array<{ + from: ExternalConnectionNode; + to: ServiceConnectionNode; + }>; + }; + + let paths = scriptResponse.paths; + + if (serviceName || environment) { + paths = paths.filter(path => { + return path.some(node => { + let matches = true; + if (serviceName) { + matches = + matches && + 'service.name' in node && + node['service.name'] === serviceName; + } + if (environment) { + matches = + matches && + 'service.environment' in node && + node['service.environment'] === environment; + } + return matches; + }); + }); + } + + const connections = uniq( + paths.flatMap(path => { + return path.reduce((conns, location, index) => { + const prev = path[index - 1]; + if (prev) { + return conns.concat({ + source: prev, + destination: location + }); + } + return conns; + }, [] as Connection[]); + }, [] as Connection[]), + (value, index, array) => { + return find(array, value); + } + ); + + return { + connections, + discoveredServices: scriptResponse.discoveredServices + }; +} diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts new file mode 100644 index 00000000000000..acf113b4266081 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { uniq, take, sortBy } from 'lodash'; +import { + Setup, + SetupUIFilters, + SetupTimeRange +} from '../helpers/setup_request'; +import { rangeFilter } from '../helpers/range_filter'; +import { ESFilter } from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + SERVICE_ENVIRONMENT, + SPAN_TYPE, + SPAN_SUBTYPE, + DESTINATION_ADDRESS, + TRACE_ID +} from '../../../common/elasticsearch_fieldnames'; + +const MAX_TRACES_TO_INSPECT = 1000; + +export async function getTraceSampleIds({ + after, + serviceName, + environment, + setup +}: { + after?: string; + serviceName?: string; + environment?: string; + setup: Setup & SetupTimeRange & SetupUIFilters; +}) { + const isTop = !after; + + const { start, end, client, indices, config } = setup; + + const rangeEnd = end; + const rangeStart = isTop + ? rangeEnd - config['xpack.apm.serviceMapInitialTimeRange'] + : start; + + const rangeQuery = { range: rangeFilter(rangeStart, rangeEnd) }; + + const query = { + bool: { + filter: [ + { + term: { + [PROCESSOR_EVENT]: 'span' + } + }, + { + exists: { + field: DESTINATION_ADDRESS + } + }, + rangeQuery + ] as ESFilter[] + } + } as { bool: { filter: ESFilter[]; must_not?: ESFilter[] | ESFilter } }; + + if (serviceName) { + query.bool.filter.push({ term: { [SERVICE_NAME]: serviceName } }); + } + + if (environment) { + query.bool.filter.push({ term: { [SERVICE_ENVIRONMENT]: environment } }); + } + + const afterObj = + after && after !== 'top' + ? { after: JSON.parse(Buffer.from(after, 'base64').toString()) } + : {}; + + const params = { + index: [indices['apm_oss.spanIndices']], + body: { + size: 0, + query, + aggs: { + connections: { + composite: { + size: 1000, + ...afterObj, + sources: [ + { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, + { + [SERVICE_ENVIRONMENT]: { + terms: { field: SERVICE_ENVIRONMENT, missing_bucket: true } + } + }, + { + [SPAN_TYPE]: { + terms: { field: SPAN_TYPE, missing_bucket: true } + } + }, + { + [SPAN_SUBTYPE]: { + terms: { field: SPAN_SUBTYPE, missing_bucket: true } + } + }, + { + [DESTINATION_ADDRESS]: { + terms: { field: DESTINATION_ADDRESS } + } + } + ] + }, + aggs: { + sample: { + sampler: { + shard_size: 30 + }, + aggs: { + trace_ids: { + terms: { + field: TRACE_ID, + execution_hint: 'map' as const, + // remove bias towards large traces by sorting on trace.id + // which will be random-esque + order: { + _key: 'desc' as const + } + } + } + } + } + } + } + } + } + }; + + const tracesSampleResponse = await client.search< + { trace: { id: string } }, + typeof params + >(params); + + let nextAfter: string | undefined; + + const receivedAfterKey = + tracesSampleResponse.aggregations?.connections.after_key; + + if (!after) { + nextAfter = 'top'; + } else if (receivedAfterKey) { + nextAfter = Buffer.from(JSON.stringify(receivedAfterKey)).toString( + 'base64' + ); + } + + // make sure at least one trace per composite/connection bucket + // is queried + const traceIdsWithPriority = + tracesSampleResponse.aggregations?.connections.buckets.flatMap(bucket => + bucket.sample.trace_ids.buckets.map((sampleDocBucket, index) => ({ + traceId: sampleDocBucket.key as string, + priority: index + })) + ) || []; + + const traceIds = take( + uniq( + sortBy(traceIdsWithPriority, 'priority').map(({ traceId }) => traceId) + ), + MAX_TRACES_TO_INSPECT + ); + + return { + after: nextAfter, + traceIds + }; +} diff --git a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts index e98842151da847..a9a8241da39d17 100644 --- a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts @@ -58,7 +58,7 @@ import { uiFiltersEnvironmentsRoute } from './ui_filters'; import { createApi } from './create_api'; -import { serviceMapRoute } from './services'; +import { serviceMapRoute } from './service_map'; const createApmApi = () => { const api = createApi() @@ -118,10 +118,12 @@ const createApmApi = () => { .add(transactionsLocalFiltersRoute) .add(serviceNodesLocalFiltersRoute) .add(uiFiltersEnvironmentsRoute) - .add(serviceMapRoute) // Transaction - .add(transactionByTraceIdRoute); + .add(transactionByTraceIdRoute) + + // Service map + .add(serviceMapRoute); return api; }; diff --git a/x-pack/legacy/plugins/apm/server/routes/service_map.ts b/x-pack/legacy/plugins/apm/server/routes/service_map.ts new file mode 100644 index 00000000000000..94b176147f7a1d --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/routes/service_map.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 * as t from 'io-ts'; +import Boom from 'boom'; +import { setupRequest } from '../lib/helpers/setup_request'; +import { createRoute } from './create_route'; +import { uiFiltersRt, rangeRt } from './default_api_types'; +import { getServiceMap } from '../lib/service_map/get_service_map'; + +export const serviceMapRoute = createRoute(() => ({ + path: '/api/apm/service-map', + params: { + query: t.intersection([ + t.partial({ environment: t.string, serviceName: t.string }), + uiFiltersRt, + rangeRt, + t.partial({ after: t.string }) + ]) + }, + handler: async ({ context, request }) => { + if (!context.config['xpack.apm.serviceMapEnabled']) { + throw Boom.notFound(); + } + const setup = await setupRequest(context, request); + const { + query: { serviceName, environment, after } + } = context.params; + return getServiceMap({ setup, serviceName, environment, after }); + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/services.ts b/x-pack/legacy/plugins/apm/server/routes/services.ts index 78cb092b85db66..18777183ea1de3 100644 --- a/x-pack/legacy/plugins/apm/server/routes/services.ts +++ b/x-pack/legacy/plugins/apm/server/routes/services.ts @@ -5,7 +5,6 @@ */ import * as t from 'io-ts'; -import Boom from 'boom'; import { AgentName } from '../../typings/es_schemas/ui/fields/Agent'; import { createApmTelementry, @@ -18,7 +17,6 @@ import { getServiceTransactionTypes } from '../lib/services/get_service_transact import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; -import { getServiceMap } from '../lib/services/map'; import { getServiceAnnotations } from '../lib/services/annotations'; export const servicesRoute = createRoute(() => ({ @@ -87,19 +85,6 @@ export const serviceNodeMetadataRoute = createRoute(() => ({ } })); -export const serviceMapRoute = createRoute(() => ({ - path: '/api/apm/service-map', - params: { - query: rangeRt - }, - handler: async ({ context }) => { - if (context.config['xpack.apm.serviceMapEnabled']) { - return getServiceMap(); - } - return new Boom('Not found', { statusCode: 404 }); - } -})); - export const serviceAnnotationsRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/annotations', params: { diff --git a/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts index 74a9436d7a4bc6..6d3620f11a87b2 100644 --- a/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts @@ -36,6 +36,19 @@ interface MetricsAggregationResponsePart { value: number | null; } +type GetCompositeKeys< + TAggregationOptionsMap extends AggregationOptionsMap +> = TAggregationOptionsMap extends { + composite: { sources: Array }; +} + ? keyof Source + : never; + +type CompositeOptionsSource = Record< + string, + { terms: { field: string; missing_bucket?: boolean } } | undefined +>; + export interface AggregationOptionsByType { terms: { field: string; @@ -97,6 +110,22 @@ export interface AggregationOptionsByType { buckets_path: BucketsPath; script?: Script; }; + composite: { + size?: number; + sources: CompositeOptionsSource[]; + after?: Record; + }; + diversified_sampler: { + shard_size?: number; + max_docs_per_value?: number; + } & ({ script: Script } | { field: string }); // TODO use MetricsAggregationOptions if possible + scripted_metric: { + params?: Record; + init_script?: Script; + map_script: Script; + combine_script: Script; + reduce_script: Script; + }; } type AggregationType = keyof AggregationOptionsByType; @@ -229,6 +258,24 @@ interface AggregationResponsePart< value: number | null; } | undefined; + composite: { + after_key: Record, number>; + buckets: Array< + { + key: Record, number>; + doc_count: number; + } & BucketSubAggregationResponse< + TAggregationOptionsMap['aggs'], + TDocument + > + >; + }; + diversified_sampler: { + doc_count: number; + } & AggregationResponseMap; + scripted_metric: { + value: unknown; + }; } // Type for debugging purposes. If you see an error in AggregationResponseMap diff --git a/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts b/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts index eff39838bd957e..064b684cf9aa6b 100644 --- a/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts +++ b/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts @@ -56,6 +56,7 @@ export interface ESFilter { | string | string[] | number + | boolean | Record | ESFilter[]; }; diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index b0e10d245e0b95..e301d157d2c7c7 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -16,6 +16,7 @@ export const config = { }, schema: schema.object({ serviceMapEnabled: schema.boolean({ defaultValue: false }), + serviceMapInitialTimeRange: schema.number({ defaultValue: 60 * 1000 * 60 }), // last 1 hour autocreateApmIndexPattern: schema.boolean({ defaultValue: true }), ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -37,6 +38,7 @@ export function mergeConfigs(apmOssConfig: APMOSSConfig, apmConfig: APMXPackConf 'apm_oss.onboardingIndices': apmOssConfig.onboardingIndices, 'apm_oss.indexPattern': apmOssConfig.indexPattern, 'xpack.apm.serviceMapEnabled': apmConfig.serviceMapEnabled, + 'xpack.apm.serviceMapInitialTimeRange': apmConfig.serviceMapInitialTimeRange, 'xpack.apm.ui.enabled': apmConfig.ui.enabled, 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, From e9e44ec8518b220c937e598e036be393c625c1d1 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 13 Jan 2020 16:34:56 -0500 Subject: [PATCH 38/62] [Maps] add text halo color and width style properties (#53827) * [Maps] add text halo color and width style properties * fix jest test * update for new editor UI * add removed styling * get halo size from label size * fix label border size with dynamic label size * clean up * fix jest test * fix jest test Co-authored-by: Elastic Machine --- .../components/get_vector_style_label.js | 8 +++ .../vector_style_label_border_size_editor.js | 65 +++++++++++++++++++ .../vector/components/vector_style_editor.js | 22 +++++++ .../properties/dynamic_color_property.js | 5 ++ .../properties/dynamic_color_property.test.js | 7 +- .../properties/dynamic_size_property.js | 18 ++--- .../properties/dynamic_style_property.js | 9 ++- .../properties/label_border_size_property.js | 50 ++++++++++++++ .../properties/static_color_property.js | 4 ++ .../layers/styles/vector/vector_style.js | 14 ++++ .../layers/styles/vector/vector_style.test.js | 11 ++++ .../styles/vector/vector_style_defaults.js | 42 ++++++++++-- 12 files changed, 239 insertions(+), 16 deletions(-) create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js create mode 100644 x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js index 325fc28f92051b..16cfd34c95ab36 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js @@ -42,6 +42,14 @@ export function getVectorStyleLabel(styleName) { return i18n.translate('xpack.maps.styles.vector.labelSizeLabel', { defaultMessage: 'Label size', }); + case VECTOR_STYLES.LABEL_BORDER_COLOR: + return i18n.translate('xpack.maps.styles.vector.labelBorderColorLabel', { + defaultMessage: 'Label border color', + }); + case VECTOR_STYLES.LABEL_BORDER_SIZE: + return i18n.translate('xpack.maps.styles.vector.labelBorderWidthLabel', { + defaultMessage: 'Label border width', + }); default: return styleName; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js new file mode 100644 index 00000000000000..7d06e8b530011b --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js @@ -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 React from 'react'; + +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { LABEL_BORDER_SIZES, VECTOR_STYLES } from '../../vector_style_defaults'; +import { getVectorStyleLabel } from '../get_vector_style_label'; +import { i18n } from '@kbn/i18n'; + +const options = [ + { + value: LABEL_BORDER_SIZES.NONE, + text: i18n.translate('xpack.maps.styles.labelBorderSize.noneLabel', { + defaultMessage: 'None', + }), + }, + { + value: LABEL_BORDER_SIZES.SMALL, + text: i18n.translate('xpack.maps.styles.labelBorderSize.smallLabel', { + defaultMessage: 'Small', + }), + }, + { + value: LABEL_BORDER_SIZES.MEDIUM, + text: i18n.translate('xpack.maps.styles.labelBorderSize.mediumLabel', { + defaultMessage: 'Medium', + }), + }, + { + value: LABEL_BORDER_SIZES.LARGE, + text: i18n.translate('xpack.maps.styles.labelBorderSize.largeLabel', { + defaultMessage: 'Large', + }), + }, +]; + +export function VectorStyleLabelBorderSizeEditor({ handlePropertyChange, styleProperty }) { + function onChange(e) { + const styleDescriptor = { + options: { size: e.target.value }, + }; + handlePropertyChange(styleProperty.getStyleName(), styleDescriptor); + } + + return ( + + + + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js index dffe513644db84..bd22b4b9cc5cee 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js @@ -12,6 +12,7 @@ import { VectorStyleColorEditor } from './color/vector_style_color_editor'; import { VectorStyleSizeEditor } from './size/vector_style_size_editor'; import { VectorStyleSymbolEditor } from './vector_style_symbol_editor'; import { VectorStyleLabelEditor } from './label/vector_style_label_editor'; +import { VectorStyleLabelBorderSizeEditor } from './label/vector_style_label_border_size_editor'; import { VectorStyle } from '../vector_style'; import { OrientationEditor } from './orientation/orientation_editor'; import { @@ -248,6 +249,27 @@ export class VectorStyleEditor extends Component { } /> + + + + + + ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js index 57e4d09f3abecd..804a0f8975d3eb 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js @@ -55,6 +55,11 @@ export class DynamicColorProperty extends DynamicStyleProperty { mbMap.setPaintProperty(mbLayerId, 'text-opacity', alpha); } + syncLabelBorderColorWithMb(mbLayerId, mbMap) { + const color = this._getMbColor(); + mbMap.setPaintProperty(mbLayerId, 'text-halo-color', color); + } + isCustomColorRamp() { return this._options.useCustomColorRamp; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js index 0affeefde13135..21c24e837b412a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -// eslint-disable-next-line no-unused-vars +jest.mock('../components/vector_style_editor', () => ({ + VectorStyleEditor: () => { + return
mockVectorStyleEditor
; + }, +})); + import React from 'react'; import { shallow } from 'enzyme'; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js index f2e56722268148..5a4da1a80c9187 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js @@ -5,7 +5,7 @@ */ import { DynamicStyleProperty } from './dynamic_style_property'; -import { getComputedFieldName } from '../style_util'; + import { HALF_LARGE_MAKI_ICON_SIZE, LARGE_MAKI_ICON_SIZE, @@ -63,7 +63,7 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } syncHaloWidthWithMb(mbLayerId, mbMap) { - const haloWidth = this._getMbSize(); + const haloWidth = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'icon-halo-width', haloWidth); } @@ -76,7 +76,7 @@ export class DynamicSizeProperty extends DynamicStyleProperty { mbMap.setLayoutProperty(symbolLayerId, 'icon-image', `${symbolId}-${iconPixels}`); const halfIconPixels = iconPixels / 2; - const targetName = getComputedFieldName(VECTOR_STYLES.ICON_SIZE, this._options.field.name); + const targetName = this.getComputedFieldName(); // Using property state instead of feature-state because layout properties do not support feature-state mbMap.setLayoutProperty(symbolLayerId, 'icon-size', [ 'interpolate', @@ -94,29 +94,29 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } syncCircleStrokeWidthWithMb(mbLayerId, mbMap) { - const lineWidth = this._getMbSize(); + const lineWidth = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'circle-stroke-width', lineWidth); } syncCircleRadiusWithMb(mbLayerId, mbMap) { - const circleRadius = this._getMbSize(); + const circleRadius = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'circle-radius', circleRadius); } syncLineWidthWithMb(mbLayerId, mbMap) { - const lineWidth = this._getMbSize(); + const lineWidth = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'line-width', lineWidth); } syncLabelSizeWithMb(mbLayerId, mbMap) { - const lineWidth = this._getMbSize(); + const lineWidth = this.getMbSizeExpression(); mbMap.setLayoutProperty(mbLayerId, 'text-size', lineWidth); } - _getMbSize() { + getMbSizeExpression() { if (this._isSizeDynamicConfigComplete(this._options)) { return this._getMbDataDrivenSize({ - targetName: getComputedFieldName(this._styleName, this._options.field.name), + targetName: this.getComputedFieldName(), minSize: this._options.minSize, maxSize: this._options.maxSize, }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index cb5858fa47b3ef..97ab7cb78015bc 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { AbstractStyleProperty } from './style_property'; import { DEFAULT_SIGMA } from '../vector_style_defaults'; import { STYLE_TYPE } from '../../../../../common/constants'; -import { scaleValue } from '../style_util'; +import { scaleValue, getComputedFieldName } from '../style_util'; import React from 'react'; import { OrdinalLegend } from './components/ordinal_legend'; import { CategoricalLegend } from './components/categorical_legend'; @@ -31,6 +31,13 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return this._field; } + getComputedFieldName() { + if (!this.isComplete()) { + return null; + } + return getComputedFieldName(this._styleName, this.getField().getName()); + } + isDynamic() { return true; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js new file mode 100644 index 00000000000000..e08c2875c310eb --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js @@ -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 _ from 'lodash'; +import { AbstractStyleProperty } from './style_property'; +import { DEFAULT_LABEL_SIZE, LABEL_BORDER_SIZES } from '../vector_style_defaults'; + +const SMALL_SIZE = 1 / 16; +const MEDIUM_SIZE = 1 / 8; +const LARGE_SIZE = 1 / 5; // halo of 1/4 is just a square. Use smaller ratio to preserve contour on letters + +function getWidthRatio(size) { + switch (size) { + case LABEL_BORDER_SIZES.LARGE: + return LARGE_SIZE; + case LABEL_BORDER_SIZES.MEDIUM: + return MEDIUM_SIZE; + default: + return SMALL_SIZE; + } +} + +export class LabelBorderSizeProperty extends AbstractStyleProperty { + constructor(options, styleName, labelSizeProperty) { + super(options, styleName); + this._labelSizeProperty = labelSizeProperty; + } + + syncLabelBorderSizeWithMb(mbLayerId, mbMap) { + const widthRatio = getWidthRatio(this.getOptions().size); + + if (this.getOptions().size === LABEL_BORDER_SIZES.NONE) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', 0); + } else if (this._labelSizeProperty.isDynamic() && this._labelSizeProperty.isComplete()) { + const labelSizeExpression = this._labelSizeProperty.getMbSizeExpression(); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', [ + 'max', + ['*', labelSizeExpression, widthRatio], + 1, + ]); + } else { + const labelSize = _.get(this._labelSizeProperty.getOptions(), 'size', DEFAULT_LABEL_SIZE); + const labelBorderSize = Math.max(labelSize * widthRatio, 1); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', labelBorderSize); + } + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js index 658eb6a164556f..ebe2a322711fc3 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js @@ -39,4 +39,8 @@ export class StaticColorProperty extends StaticStyleProperty { mbMap.setPaintProperty(mbLayerId, 'text-color', this._options.color); mbMap.setPaintProperty(mbLayerId, 'text-opacity', alpha); } + + syncLabelBorderColorWithMb(mbLayerId, mbMap) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-color', this._options.color); + } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js index d1efcbb72d1a7e..30d1c5726ba481 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js @@ -38,6 +38,7 @@ import { StaticOrientationProperty } from './properties/static_orientation_prope import { DynamicOrientationProperty } from './properties/dynamic_orientation_property'; import { StaticTextProperty } from './properties/static_text_property'; import { DynamicTextProperty } from './properties/dynamic_text_property'; +import { LabelBorderSizeProperty } from './properties/label_border_size_property'; import { extractColorFromStyleProperty } from './components/legend/extract_color_from_style_property'; const POINTS = [GEO_JSON_TYPE.POINT, GEO_JSON_TYPE.MULTI_POINT]; @@ -100,6 +101,15 @@ export class VectorStyle extends AbstractStyle { this._descriptor.properties[VECTOR_STYLES.LABEL_COLOR], VECTOR_STYLES.LABEL_COLOR ); + this._labelBorderColorStyleProperty = this._makeColorProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_BORDER_COLOR], + VECTOR_STYLES.LABEL_BORDER_COLOR + ); + this._labelBorderSizeStyleProperty = new LabelBorderSizeProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_BORDER_SIZE].options, + VECTOR_STYLES.LABEL_BORDER_SIZE, + this._labelSizeStyleProperty + ); } _getAllStyleProperties() { @@ -112,6 +122,8 @@ export class VectorStyle extends AbstractStyle { this._labelStyleProperty, this._labelSizeStyleProperty, this._labelColorStyleProperty, + this._labelBorderColorStyleProperty, + this._labelBorderSizeStyleProperty, ]; } @@ -537,6 +549,8 @@ export class VectorStyle extends AbstractStyle { this._labelStyleProperty.syncTextFieldWithMb(textLayerId, mbMap); this._labelColorStyleProperty.syncLabelColorWithMb(textLayerId, mbMap, alpha); this._labelSizeStyleProperty.syncLabelSizeWithMb(textLayerId, mbMap); + this._labelBorderSizeStyleProperty.syncLabelBorderSizeWithMb(textLayerId, mbMap); + this._labelBorderColorStyleProperty.syncLabelBorderColorWithMb(textLayerId, mbMap); } setMBSymbolPropertiesForPoints({ mbMap, symbolLayerId, alpha }) { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js index 3d2911720c3120..c250d83720580c 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js @@ -102,6 +102,17 @@ describe('getDescriptorWithMissingStylePropsRemoved', () => { }, type: 'STATIC', }, + labelBorderColor: { + options: { + color: '#FFFFFF', + }, + type: 'STATIC', + }, + labelBorderSize: { + options: { + size: 'SMALL', + }, + }, labelColor: { options: { color: '#000000', diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js index 4bae90c3165f20..3631613e7907cd 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js @@ -16,6 +16,14 @@ export const MAX_SIZE = 64; export const DEFAULT_MIN_SIZE = 4; export const DEFAULT_MAX_SIZE = 32; export const DEFAULT_SIGMA = 3; +export const DEFAULT_LABEL_SIZE = 14; + +export const LABEL_BORDER_SIZES = { + NONE: 'NONE', + SMALL: 'SMALL', + MEDIUM: 'MEDIUM', + LARGE: 'LARGE', +}; export const VECTOR_STYLES = { SYMBOL: 'symbol', @@ -27,6 +35,8 @@ export const VECTOR_STYLES = { LABEL_TEXT: 'labelText', LABEL_COLOR: 'labelColor', LABEL_SIZE: 'labelSize', + LABEL_BORDER_COLOR: 'labelBorderColor', + LABEL_BORDER_SIZE: 'labelBorderSize', }; export const LINE_STYLES = [VECTOR_STYLES.LINE_COLOR, VECTOR_STYLES.LINE_WIDTH]; @@ -45,6 +55,11 @@ export function getDefaultProperties(mapColors = []) { symbolId: DEFAULT_ICON, }, }, + [VECTOR_STYLES.LABEL_BORDER_SIZE]: { + options: { + size: LABEL_BORDER_SIZES.SMALL, + }, + }, }; } @@ -103,7 +118,13 @@ export function getDefaultStaticProperties(mapColors = []) { [VECTOR_STYLES.LABEL_SIZE]: { type: VectorStyle.STYLE_TYPE.STATIC, options: { - size: 14, + size: DEFAULT_LABEL_SIZE, + }, + }, + [VECTOR_STYLES.LABEL_BORDER_COLOR]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + color: isDarkMode ? '#000000' : '#FFFFFF', }, }, }; @@ -158,7 +179,7 @@ export function getDefaultDynamicProperties() { }, }, [VECTOR_STYLES.ICON_ORIENTATION]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { field: undefined, fieldMetaOptions: { @@ -168,13 +189,13 @@ export function getDefaultDynamicProperties() { }, }, [VECTOR_STYLES.LABEL_TEXT]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { field: undefined, }, }, [VECTOR_STYLES.LABEL_COLOR]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { color: COLOR_GRADIENTS[0].value, field: undefined, @@ -185,7 +206,7 @@ export function getDefaultDynamicProperties() { }, }, [VECTOR_STYLES.LABEL_SIZE]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { minSize: DEFAULT_MIN_SIZE, maxSize: DEFAULT_MAX_SIZE, @@ -196,5 +217,16 @@ export function getDefaultDynamicProperties() { }, }, }, + [VECTOR_STYLES.LABEL_BORDER_COLOR]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + color: COLOR_GRADIENTS[0].value, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, }; } From aa16a9d455c5cc91cd7c36bcc839373ae65e0905 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Mon, 13 Jan 2020 23:54:41 +0200 Subject: [PATCH 39/62] Support "Deprecated" label in advanced settings (#54539) * Support deprecating label in advanced settings mark courier:batchSearches as deprecated * jest update * Add deprecation to UiSettingsParams type Translate click aria label Use docLinks service * Rename doc link * Remove url option from DeprecationSettings * Simplify code * Updated docs * Revert "Updated docs" This reverts commit c9512ced1f24c315d9bf088ea7d73bdb5e49c0f3. * snapshots * docs --- ...gin-server.uisettingsparams.deprecation.md | 13 +++++++ .../kibana-plugin-server.uisettingsparams.md | 1 + .../public/doc_links/doc_links_service.ts | 3 ++ src/core/server/server.api.md | 2 ++ src/core/server/ui_settings/types.ts | 11 ++++++ .../advanced_settings.test.js.snap | 30 ++++++++++++++++ .../settings/components/field/field.js | 34 +++++++++++++++++++ .../settings/lib/to_editable_config.js | 1 + .../kibana/ui_setting_defaults.js | 6 ++++ .../language_switcher.test.tsx.snap | 6 ++++ .../query_string_input.test.tsx.snap | 18 ++++++++++ 11 files changed, 125 insertions(+) create mode 100644 docs/development/core/server/kibana-plugin-server.uisettingsparams.deprecation.md diff --git a/docs/development/core/server/kibana-plugin-server.uisettingsparams.deprecation.md b/docs/development/core/server/kibana-plugin-server.uisettingsparams.deprecation.md new file mode 100644 index 00000000000000..7ad26b85bf81c8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.uisettingsparams.deprecation.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [UiSettingsParams](./kibana-plugin-server.uisettingsparams.md) > [deprecation](./kibana-plugin-server.uisettingsparams.deprecation.md) + +## UiSettingsParams.deprecation property + +optional deprecation information. Used to generate a deprecation warning. + +Signature: + +```typescript +deprecation?: DeprecationSettings; +``` diff --git a/docs/development/core/server/kibana-plugin-server.uisettingsparams.md b/docs/development/core/server/kibana-plugin-server.uisettingsparams.md index 89eb5b10b9de52..fc2f8038f973fa 100644 --- a/docs/development/core/server/kibana-plugin-server.uisettingsparams.md +++ b/docs/development/core/server/kibana-plugin-server.uisettingsparams.md @@ -17,6 +17,7 @@ export interface UiSettingsParams | Property | Type | Description | | --- | --- | --- | | [category](./kibana-plugin-server.uisettingsparams.category.md) | string[] | used to group the configured setting in the UI | +| [deprecation](./kibana-plugin-server.uisettingsparams.deprecation.md) | DeprecationSettings | optional deprecation information. Used to generate a deprecation warning. | | [description](./kibana-plugin-server.uisettingsparams.description.md) | string | description provided to a user in UI | | [name](./kibana-plugin-server.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-server.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 44dc76bfe6e320..36b220f16f3950 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -115,6 +115,9 @@ export class DocLinksService { date: { dateMath: `${ELASTICSEARCH_DOCS}common-options.html#date-math`, }, + management: { + kibanaSearchSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-search-settings`, + }, }, }); } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 65477e93e225e4..7f3a9605710127 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1928,6 +1928,8 @@ export type SharedGlobalConfig = RecursiveReadonly_2<{ // @public export interface UiSettingsParams { category?: string[]; + // Warning: (ae-forgotten-export) The symbol "DeprecationSettings" needs to be exported by the entry point index.d.ts + deprecation?: DeprecationSettings; description?: string; name?: string; optionLabels?: Record; diff --git a/src/core/server/ui_settings/types.ts b/src/core/server/ui_settings/types.ts index 2ab6114e7df882..14eb71a22cefcc 100644 --- a/src/core/server/ui_settings/types.ts +++ b/src/core/server/ui_settings/types.ts @@ -73,6 +73,15 @@ export interface UserProvidedValues { isOverridden?: boolean; } +/** + * UiSettings deprecation field options. + * @public + * */ +export interface DeprecationSettings { + message: string; + docLinksKey: string; +} + /** * UI element type to represent the settings. * @public @@ -102,6 +111,8 @@ export interface UiSettingsParams { readonly?: boolean; /** defines a type of UI element {@link UiSettingsType} */ type?: UiSettingsType; + /** optional deprecation information. Used to generate a deprecation warning. */ + deprecation?: DeprecationSettings; /* * Allows defining a custom validation applicable to value change on the client. * @deprecated diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap index 10d165d0d69c46..eef8f3fc93d90f 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap @@ -60,6 +60,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": Array [ "default_value", ], + "deprecation": undefined, "description": "Description for Test array setting", "displayName": "Test array setting", "isCustom": undefined, @@ -79,6 +80,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "elasticsearch", ], "defVal": true, + "deprecation": undefined, "description": "Description for Test boolean setting", "displayName": "Test boolean setting", "isCustom": undefined, @@ -100,6 +102,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test custom string setting", "displayName": "Test custom string setting", "isCustom": undefined, @@ -119,6 +122,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test image setting", "displayName": "Test image setting", "isCustom": undefined, @@ -140,6 +144,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": "{ \\"foo\\": \\"bar\\" }", + "deprecation": undefined, "description": "Description for overridden json", "displayName": "An overridden json", "isCustom": undefined, @@ -159,6 +164,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 1234, + "deprecation": undefined, "description": "Description for overridden number", "displayName": "An overridden number", "isCustom": undefined, @@ -178,6 +184,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for overridden select setting", "displayName": "Test overridden select setting", "isCustom": undefined, @@ -201,6 +208,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "foo", + "deprecation": undefined, "description": "Description for overridden string", "displayName": "An overridden string", "isCustom": undefined, @@ -220,6 +228,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "{\\"foo\\": \\"bar\\"}", + "deprecation": undefined, "description": "Description for Test json setting", "displayName": "Test json setting", "isCustom": undefined, @@ -239,6 +248,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "", + "deprecation": undefined, "description": "Description for Test markdown setting", "displayName": "Test markdown setting", "isCustom": undefined, @@ -258,6 +268,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 5, + "deprecation": undefined, "description": "Description for Test number setting", "displayName": "Test number setting", "isCustom": undefined, @@ -277,6 +288,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for Test select setting", "displayName": "Test select setting", "isCustom": undefined, @@ -300,6 +312,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -345,6 +358,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": Array [ "default_value", ], + "deprecation": undefined, "description": "Description for Test array setting", "displayName": "Test array setting", "isCustom": undefined, @@ -364,6 +378,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "elasticsearch", ], "defVal": true, + "deprecation": undefined, "description": "Description for Test boolean setting", "displayName": "Test boolean setting", "isCustom": undefined, @@ -385,6 +400,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test custom string setting", "displayName": "Test custom string setting", "isCustom": undefined, @@ -404,6 +420,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test image setting", "displayName": "Test image setting", "isCustom": undefined, @@ -425,6 +442,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": "{ \\"foo\\": \\"bar\\" }", + "deprecation": undefined, "description": "Description for overridden json", "displayName": "An overridden json", "isCustom": undefined, @@ -444,6 +462,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 1234, + "deprecation": undefined, "description": "Description for overridden number", "displayName": "An overridden number", "isCustom": undefined, @@ -463,6 +482,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for overridden select setting", "displayName": "Test overridden select setting", "isCustom": undefined, @@ -486,6 +506,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "foo", + "deprecation": undefined, "description": "Description for overridden string", "displayName": "An overridden string", "isCustom": undefined, @@ -505,6 +526,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "{\\"foo\\": \\"bar\\"}", + "deprecation": undefined, "description": "Description for Test json setting", "displayName": "Test json setting", "isCustom": undefined, @@ -524,6 +546,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "", + "deprecation": undefined, "description": "Description for Test markdown setting", "displayName": "Test markdown setting", "isCustom": undefined, @@ -543,6 +566,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 5, + "deprecation": undefined, "description": "Description for Test number setting", "displayName": "Test number setting", "isCustom": undefined, @@ -562,6 +586,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for Test select setting", "displayName": "Test select setting", "isCustom": undefined, @@ -585,6 +610,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -705,6 +731,7 @@ exports[`AdvancedSettings should render read-only when saving is disabled 1`] = "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -748,6 +775,7 @@ exports[`AdvancedSettings should render read-only when saving is disabled 1`] = "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -886,6 +914,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -929,6 +958,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js index 65d212c23a28c1..a2f201cf757f52 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js @@ -19,12 +19,14 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; +import { npStart } from 'ui/new_platform'; import 'brace/theme/textmate'; import 'brace/mode/markdown'; import { toastNotifications } from 'ui/notify'; import { + EuiBadge, EuiButton, EuiButtonEmpty, EuiCode, @@ -41,6 +43,7 @@ import { EuiImage, EuiLink, EuiSpacer, + EuiToolTip, EuiText, EuiSelect, EuiSwitch, @@ -565,6 +568,36 @@ export class Field extends PureComponent { renderDescription(setting) { let description; + let deprecation; + + if (setting.deprecation) { + const { links } = npStart.core.docLinks; + + deprecation = ( + <> + + { + window.open(links.management[setting.deprecation.docLinksKey], '_blank'); + }} + onClickAriaLabel={i18n.translate( + 'kbn.management.settings.field.deprecationClickAreaLabel', + { + defaultMessage: 'Click to view deprecation documentation for {settingName}.', + values: { + settingName: setting.name, + }, + } + )} + > + Deprecated + + + + + ); + } if (React.isValidElement(setting.description)) { description = setting.description; @@ -582,6 +615,7 @@ export class Field extends PureComponent { return ( + {deprecation} {description} {this.renderDefaultValue(setting)} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js index bb561cbe042121..6efb89cfba2b25 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js @@ -43,6 +43,7 @@ export function toEditableConfig({ def, name, value, isCustom, isOverridden }) { defVal: def.value, type: getValType(def, value), description: def.description, + deprecation: def.deprecation, validation: def.validation && def.validation.regexString ? { diff --git a/src/legacy/core_plugins/kibana/ui_setting_defaults.js b/src/legacy/core_plugins/kibana/ui_setting_defaults.js index 196d9662f8b15c..dc8fee4a849c50 100644 --- a/src/legacy/core_plugins/kibana/ui_setting_defaults.js +++ b/src/legacy/core_plugins/kibana/ui_setting_defaults.js @@ -458,6 +458,12 @@ export function getUiSettingDefaults() { away or update the query. When enabled, dashboard panels will load together when all of the data is loaded, and searches will not terminate.`, }), + deprecation: { + message: i18n.translate('kbn.advancedSettings.courier.batchSearchesTextDeprecation', { + defaultMessage: 'This setting is deprecated and will be removed in Kibana 8.0.', + }), + docLinksKey: 'kibanaSearchSettings', + }, category: ['search'], }, 'search:includeFrozen': { diff --git a/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap b/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap index 7ab7d7653eb5e3..4ec29ca409b80b 100644 --- a/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap +++ b/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap @@ -170,6 +170,9 @@ exports[`LanguageSwitcher should toggle off if language is lucene 1`] = ` "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -460,6 +463,9 @@ exports[`LanguageSwitcher should toggle on if language is kuery 1`] = ` "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, diff --git a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap index 6f5f9b39561876..15e74e98920e29 100644 --- a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap +++ b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap @@ -276,6 +276,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -896,6 +899,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -1504,6 +1510,9 @@ exports[`QueryStringInput Should pass the query language to the language switche "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -2121,6 +2130,9 @@ exports[`QueryStringInput Should pass the query language to the language switche "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -2729,6 +2741,9 @@ exports[`QueryStringInput Should render the given query 1`] = ` "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -3346,6 +3361,9 @@ exports[`QueryStringInput Should render the given query 1`] = ` "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, From 6f54c06695e99d3c94c0096d2c2ca3a9a92ac127 Mon Sep 17 00:00:00 2001 From: Garrett Spong Date: Mon, 13 Jan 2020 15:25:24 -0700 Subject: [PATCH 40/62] [SIEM] Use bulk actions API when updating or deleting rules (#54521) ## Summary This PR updates the `All Rules Table` actions to use the new bulk API introduced in https://github.com/elastic/kibana/pull/53543. More robust error reporting has also been added to let the user know exactly which operation has failed. Note that individual `update`/`delete` requests now also go through the bulk API as this simplifies the implementation and error handling. Additional features: * Adds toast error when failing to activate, deactivate or delete a rule (related https://github.com/elastic/kibana/issues/54515) * Extracted commonly used toast utility for better re-use * Removes ability to delete `immutable` rules ##### Activate/Deactivate Before: ![bulk_activate_before](https://user-images.githubusercontent.com/2946766/72196245-0ea50300-33d4-11ea-8d49-5ebdb63db1a1.gif) (Ignore failed requests from test env -- request count is important here) ##### Activate/Deactivate After: ![bulk_activate_after](https://user-images.githubusercontent.com/2946766/72196361-c0443400-33d4-11ea-9a42-11f66c64e925.gif) ##### Delete Before: ![bulk_delete_before](https://user-images.githubusercontent.com/2946766/72196249-149ae400-33d4-11ea-80fc-b2f7fb83245f.gif) (Ignore failed requests from test env -- request count is important here) ##### Delete After: ![bulk_delete_after](https://user-images.githubusercontent.com/2946766/72196366-c803d880-33d4-11ea-90d8-f1917b18035f.gif) ### Checklist Use ~~strikethroughs~~ to remove checklist items you don't feel are applicable to this PR. - [x] This was checked for cross-browser compatibility, [including a check against IE11](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] ~[Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~ - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios - [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~ ### For maintainers - [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ - [ ] ~This includes a feature addition or change that requires a release note and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ --- .../components/embeddables/embedded_map.tsx | 6 +- .../embeddables/embedded_map_helpers.test.tsx | 27 +-- .../embeddables/embedded_map_helpers.tsx | 26 --- .../public/components/toasters/index.test.tsx | 33 +++- .../siem/public/components/toasters/index.tsx | 26 +++ .../containers/detection_engine/rules/api.ts | 39 ++--- .../detection_engine/rules/types.ts | 12 ++ .../rules/all/__mocks__/mock.ts | 154 ++++++++++++++++++ .../detection_engine/rules/all/actions.tsx | 82 ++++++++-- .../rules/all/batch_actions.tsx | 17 +- .../detection_engine/rules/all/columns.tsx | 18 +- .../rules/all/helpers.test.tsx | 61 +++++++ .../detection_engine/rules/all/helpers.ts | 28 +++- .../detection_engine/rules/all/index.tsx | 45 +++-- .../rules/components/rule_switch/index.tsx | 4 +- .../detection_engine/rules/translations.ts | 41 +++++ .../pages/detection_engine/rules/types.ts | 1 + 17 files changed, 500 insertions(+), 120 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx index b39d43cc01b425..771e220a2a0b3e 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx @@ -15,10 +15,10 @@ import { DEFAULT_INDEX_KEY } from '../../../common/constants'; import { getIndexPatternTitleIdMapping } from '../../hooks/api/helpers'; import { useIndexPatterns } from '../../hooks/use_index_patterns'; import { Loader } from '../loader'; -import { useStateToaster } from '../toasters'; +import { displayErrorToast, useStateToaster } from '../toasters'; import { Embeddable } from './embeddable'; import { EmbeddableHeader } from './embeddable_header'; -import { createEmbeddable, displayErrorToast } from './embedded_map_helpers'; +import { createEmbeddable } from './embedded_map_helpers'; import { IndexPatternsMissingPrompt } from './index_patterns_missing_prompt'; import { MapToolTip } from './map_tool_tip/map_tool_tip'; import * as i18n from './translations'; @@ -134,7 +134,7 @@ export const EmbeddedMapComponent = ({ } } catch (e) { if (isSubscribed) { - displayErrorToast(i18n.ERROR_CREATING_EMBEDDABLE, e.message, dispatchToaster); + displayErrorToast(i18n.ERROR_CREATING_EMBEDDABLE, [e.message], dispatchToaster); setIsError(true); } } diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx index 4e5fcee4398279..a83e8377deeb68 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx @@ -4,19 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createEmbeddable, displayErrorToast } from './embedded_map_helpers'; +import { createEmbeddable } from './embedded_map_helpers'; import { createUiNewPlatformMock } from 'ui/new_platform/__mocks__/helpers'; import { createPortalNode } from 'react-reverse-portal'; jest.mock('ui/new_platform'); -jest.mock('uuid', () => { - return { - v1: jest.fn(() => '27261ae0-0bbb-11ea-b0ea-db767b07ea47'), - v4: jest.fn(() => '9e1f72a9-7c73-4b7f-a562-09940f7daf4a'), - }; -}); - const { npStart } = createUiNewPlatformMock(); npStart.plugins.embeddable.getEmbeddableFactory = jest.fn().mockImplementation(() => ({ createFromState: () => ({ @@ -25,24 +18,6 @@ npStart.plugins.embeddable.getEmbeddableFactory = jest.fn().mockImplementation(( })); describe('embedded_map_helpers', () => { - describe('displayErrorToast', () => { - test('dispatches toast with correct title and message', () => { - const mockToast = { - toast: { - color: 'danger', - errors: ['message'], - iconType: 'alert', - id: '9e1f72a9-7c73-4b7f-a562-09940f7daf4a', - title: 'Title', - }, - type: 'addToaster', - }; - const dispatchToasterMock = jest.fn(); - displayErrorToast('Title', 'message', dispatchToasterMock); - expect(dispatchToasterMock.mock.calls[0][0]).toEqual(mockToast); - }); - }); - describe('createEmbeddable', () => { test('attaches refresh action', async () => { const setQueryMock = jest.fn(); diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx index b9a9df9824eee4..838e74cc5624c8 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx @@ -7,7 +7,6 @@ import uuid from 'uuid'; import React from 'react'; import { OutPortal, PortalNode } from 'react-reverse-portal'; -import { ActionToaster, AppToast } from '../toasters'; import { ViewMode } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; import { IndexPatternMapping, @@ -22,31 +21,6 @@ import { MAP_SAVED_OBJECT_TYPE } from '../../../../maps/common/constants'; import * as i18n from './translations'; import { Query, esFilters } from '../../../../../../../src/plugins/data/public'; -/** - * Displays an error toast for the provided title and message - * - * @param errorTitle Title of error to display in toaster and modal - * @param errorMessage Message to display in error modal when clicked - * @param dispatchToaster provided by useStateToaster() - */ -export const displayErrorToast = ( - errorTitle: string, - errorMessage: string, - dispatchToaster: React.Dispatch -) => { - const toast: AppToast = { - id: uuid.v4(), - title: errorTitle, - color: 'danger', - iconType: 'alert', - errors: [errorMessage], - }; - dispatchToaster({ - type: 'addToaster', - toast, - }); -}; - /** * Creates MapEmbeddable with provided initial configuration * diff --git a/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx index 5ef5a5ab31d4bd..9338eb9f0fabd7 100644 --- a/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx @@ -8,7 +8,20 @@ import { cloneDeep, set } from 'lodash/fp'; import { mount } from 'enzyme'; import React, { useEffect } from 'react'; -import { AppToast, useStateToaster, ManageGlobalToaster, GlobalToaster } from '.'; +import { + AppToast, + useStateToaster, + ManageGlobalToaster, + GlobalToaster, + displayErrorToast, +} from '.'; + +jest.mock('uuid', () => { + return { + v1: jest.fn(() => '27261ae0-0bbb-11ea-b0ea-db767b07ea47'), + v4: jest.fn(() => '9e1f72a9-7c73-4b7f-a562-09940f7daf4a'), + }; +}); const mockToast: AppToast = { color: 'danger', @@ -270,4 +283,22 @@ describe('Toaster', () => { expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test'); }); }); + + describe('displayErrorToast', () => { + test('dispatches toast with correct title and message', () => { + const mockErrorToast = { + toast: { + color: 'danger', + errors: ['message'], + iconType: 'alert', + id: '9e1f72a9-7c73-4b7f-a562-09940f7daf4a', + title: 'Title', + }, + type: 'addToaster', + }; + const dispatchToasterMock = jest.fn(); + displayErrorToast('Title', ['message'], dispatchToasterMock); + expect(dispatchToasterMock.mock.calls[0][0]).toEqual(mockErrorToast); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx b/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx index 27d59d429913cc..7098e618aeb55a 100644 --- a/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx @@ -8,6 +8,7 @@ import { EuiGlobalToastList, EuiGlobalToastListToast as Toast, EuiButton } from import { noop } from 'lodash/fp'; import React, { createContext, Dispatch, useReducer, useContext, useState } from 'react'; import styled from 'styled-components'; +import uuid from 'uuid'; import { ModalAllErrors } from './modal_all_errors'; import * as i18n from './translations'; @@ -122,3 +123,28 @@ const ErrorToastContainer = styled.div` `; ErrorToastContainer.displayName = 'ErrorToastContainer'; + +/** + * Displays an error toast for the provided title and message + * + * @param errorTitle Title of error to display in toaster and modal + * @param errorMessages Message to display in error modal when clicked + * @param dispatchToaster provided by useStateToaster() + */ +export const displayErrorToast = ( + errorTitle: string, + errorMessages: string[], + dispatchToaster: React.Dispatch +) => { + const toast: AppToast = { + id: uuid.v4(), + title: errorTitle, + color: 'danger', + iconType: 'alert', + errors: errorMessages, + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index b69a8de29e0474..8f8b66ae35a3b5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -16,6 +16,7 @@ import { Rule, FetchRuleProps, BasicFetchProps, + RuleError, } from './types'; import { throwIfNotOk } from '../../../hooks/api/api'; import { @@ -122,50 +123,50 @@ export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise => { - const requests = ids.map(id => - fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}`, { + const response = await fetch( + `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + { method: 'PUT', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'kbn-xsrf': 'true', }, - body: JSON.stringify({ id, enabled }), - }) + body: JSON.stringify(ids.map(id => ({ id, enabled }))), + } ); - const responses = await Promise.all(requests); - await responses.map(response => throwIfNotOk(response)); - return Promise.all( - responses.map>(response => response.json()) - ); + await throwIfNotOk(response); + return response.json(); }; /** * Deletes provided Rule ID's * * @param ids array of Rule ID's (not rule_id) to delete + * + * @throws An error if response is not OK */ -export const deleteRules = async ({ ids }: DeleteRulesProps): Promise => { - // TODO: Don't delete if immutable! - const requests = ids.map(id => - fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}?id=${id}`, { +export const deleteRules = async ({ ids }: DeleteRulesProps): Promise> => { + const response = await fetch( + `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_bulk_delete`, + { method: 'DELETE', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'kbn-xsrf': 'true', }, - }) + body: JSON.stringify(ids.map(id => ({ id }))), + } ); - const responses = await Promise.all(requests); - await responses.map(response => throwIfNotOk(response)); - return Promise.all( - responses.map>(response => response.json()) - ); + await throwIfNotOk(response); + return response.json(); }; /** diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index a329d96d444aac..147b04567f6c7c 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -78,9 +78,11 @@ export const RuleSchema = t.intersection([ updated_by: t.string, }), t.partial({ + output_index: t.string, saved_id: t.string, timeline_id: t.string, timeline_title: t.string, + version: t.number, }), ]); @@ -89,6 +91,16 @@ export const RulesSchema = t.array(RuleSchema); export type Rule = t.TypeOf; export type Rules = t.TypeOf; +export interface RuleError { + rule_id: string; + error: { status_code: number; message: string }; +} + +export interface RuleResponseBuckets { + rules: Rule[]; + errors: RuleError[]; +} + export interface PaginationOptions { page: number; perPage: number; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts new file mode 100644 index 00000000000000..3762cb0a4ba075 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -0,0 +1,154 @@ +/* + * 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 { Rule, RuleError } from '../../../../../containers/detection_engine/rules'; +import { TableData } from '../../types'; + +export const mockRule = (id: string): Rule => ({ + created_at: '2020-01-10T21:11:45.839Z', + updated_at: '2020-01-10T21:11:45.839Z', + created_by: 'elastic', + description: '24/7', + enabled: true, + false_positives: [], + filters: [], + from: 'now-300s', + id, + immutable: false, + index: ['auditbeat-*'], + interval: '5m', + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + language: 'kuery', + output_index: '.siem-signals-default', + max_signals: 100, + risk_score: 21, + name: 'Home Grown!', + query: '', + references: [], + saved_id: "Garrett's IP", + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Untitled timeline', + meta: { from: '0m' }, + severity: 'low', + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'saved_query', + threats: [], + version: 1, +}); + +export const mockRuleError = (id: string): RuleError => ({ + rule_id: id, + error: { status_code: 404, message: `id: "${id}" not found` }, +}); + +export const mockRules: Rule[] = [ + mockRule('abe6c564-050d-45a5-aaf0-386c37dd1f61'), + mockRule('63f06f34-c181-4b2d-af35-f2ace572a1ee'), +]; +export const mockTableData: TableData[] = [ + { + activate: true, + id: 'abe6c564-050d-45a5-aaf0-386c37dd1f61', + immutable: false, + isLoading: false, + lastCompletedRun: undefined, + lastResponse: { type: '—' }, + method: 'saved_query', + rule: { + href: '#/detection-engine/rules/id/abe6c564-050d-45a5-aaf0-386c37dd1f61', + name: 'Home Grown!', + status: 'Status Placeholder', + }, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + severity: 'low', + sourceRule: { + created_at: '2020-01-10T21:11:45.839Z', + created_by: 'elastic', + description: '24/7', + enabled: true, + false_positives: [], + filters: [], + from: 'now-300s', + id: 'abe6c564-050d-45a5-aaf0-386c37dd1f61', + immutable: false, + index: ['auditbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { from: '0m' }, + name: 'Home Grown!', + output_index: '.siem-signals-default', + query: '', + references: [], + risk_score: 21, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + saved_id: "Garrett's IP", + severity: 'low', + tags: [], + threats: [], + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Untitled timeline', + to: 'now', + type: 'saved_query', + updated_at: '2020-01-10T21:11:45.839Z', + updated_by: 'elastic', + version: 1, + }, + tags: [], + }, + { + activate: true, + id: '63f06f34-c181-4b2d-af35-f2ace572a1ee', + immutable: false, + isLoading: false, + lastCompletedRun: undefined, + lastResponse: { type: '—' }, + method: 'saved_query', + rule: { + href: '#/detection-engine/rules/id/63f06f34-c181-4b2d-af35-f2ace572a1ee', + name: 'Home Grown!', + status: 'Status Placeholder', + }, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + severity: 'low', + sourceRule: { + created_at: '2020-01-10T21:11:45.839Z', + created_by: 'elastic', + description: '24/7', + enabled: true, + false_positives: [], + filters: [], + from: 'now-300s', + id: '63f06f34-c181-4b2d-af35-f2ace572a1ee', + immutable: false, + index: ['auditbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { from: '0m' }, + name: 'Home Grown!', + output_index: '.siem-signals-default', + query: '', + references: [], + risk_score: 21, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + saved_id: "Garrett's IP", + severity: 'low', + tags: [], + threats: [], + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Untitled timeline', + to: 'now', + type: 'saved_query', + updated_at: '2020-01-10T21:11:45.839Z', + updated_by: 'elastic', + version: 1, + }, + tags: [], + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx index 469745262d9446..24e3cfde1e4481 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx @@ -5,7 +5,7 @@ */ import * as H from 'history'; -import React from 'react'; +import React, { Dispatch } from 'react'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine'; import { @@ -16,40 +16,92 @@ import { } from '../../../../containers/detection_engine/rules'; import { Action } from './reducer'; +import { ActionToaster, displayErrorToast } from '../../../../components/toasters'; + +import * as i18n from '../translations'; +import { bucketRulesResponse } from './helpers'; + export const editRuleAction = (rule: Rule, history: H.History) => { history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${rule.id}/edit`); }; export const runRuleAction = () => {}; -export const duplicateRuleAction = async (rule: Rule, dispatch: React.Dispatch) => { - dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: true }); - const duplicatedRule = await duplicateRules({ rules: [rule] }); - dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: false }); - dispatch({ type: 'updateRules', rules: duplicatedRule, appendRuleId: rule.id }); +export const duplicateRuleAction = async ( + rule: Rule, + dispatch: React.Dispatch, + dispatchToaster: Dispatch +) => { + try { + dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: true }); + const duplicatedRule = await duplicateRules({ rules: [rule] }); + dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: false }); + dispatch({ type: 'updateRules', rules: duplicatedRule, appendRuleId: rule.id }); + } catch (e) { + displayErrorToast(i18n.DUPLICATE_RULE_ERROR, [e.message], dispatchToaster); + } }; export const exportRulesAction = async (rules: Rule[], dispatch: React.Dispatch) => { dispatch({ type: 'setExportPayload', exportPayload: rules }); }; -export const deleteRulesAction = async (ids: string[], dispatch: React.Dispatch) => { - dispatch({ type: 'updateLoading', ids, isLoading: true }); - const deletedRules = await deleteRules({ ids }); - dispatch({ type: 'deleteRules', rules: deletedRules }); +export const deleteRulesAction = async ( + ids: string[], + dispatch: React.Dispatch, + dispatchToaster: Dispatch +) => { + try { + dispatch({ type: 'updateLoading', ids, isLoading: true }); + + const response = await deleteRules({ ids }); + const { rules, errors } = bucketRulesResponse(response); + + dispatch({ type: 'deleteRules', rules }); + + if (errors.length > 0) { + displayErrorToast( + i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ids.length), + errors.map(e => e.error.message), + dispatchToaster + ); + } + } catch (e) { + displayErrorToast( + i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ids.length), + [e.message], + dispatchToaster + ); + } }; export const enableRulesAction = async ( ids: string[], enabled: boolean, - dispatch: React.Dispatch + dispatch: React.Dispatch, + dispatchToaster: Dispatch ) => { + const errorTitle = enabled + ? i18n.BATCH_ACTION_ACTIVATE_SELECTED_ERROR(ids.length) + : i18n.BATCH_ACTION_DEACTIVATE_SELECTED_ERROR(ids.length); + try { dispatch({ type: 'updateLoading', ids, isLoading: true }); - const updatedRules = await enableRules({ ids, enabled }); - dispatch({ type: 'updateRules', rules: updatedRules }); - } catch { - // TODO Add error toast support to actions (and @throw jsdoc to api calls) + + const response = await enableRules({ ids, enabled }); + const { rules, errors } = bucketRulesResponse(response); + + dispatch({ type: 'updateRules', rules }); + + if (errors.length > 0) { + displayErrorToast( + errorTitle, + errors.map(e => e.error.message), + dispatchToaster + ); + } + } catch (e) { + displayErrorToast(errorTitle, [e.message], dispatchToaster); dispatch({ type: 'updateLoading', ids, isLoading: false }); } }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx index 72d38454ad9bc3..3356ef101677d0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx @@ -5,20 +5,23 @@ */ import { EuiContextMenuItem } from '@elastic/eui'; -import React from 'react'; +import React, { Dispatch } from 'react'; import * as i18n from '../translations'; import { TableData } from '../types'; import { Action } from './reducer'; import { deleteRulesAction, enableRulesAction, exportRulesAction } from './actions'; +import { ActionToaster } from '../../../../components/toasters'; export const getBatchItems = ( selectedState: TableData[], - dispatch: React.Dispatch, + dispatch: Dispatch, + dispatchToaster: Dispatch, closePopover: () => void ) => { const containsEnabled = selectedState.some(v => v.activate); const containsDisabled = selectedState.some(v => !v.activate); const containsLoading = selectedState.some(v => v.isLoading); + const containsImmutable = selectedState.some(v => v.immutable); return [ { closePopover(); const deactivatedIds = selectedState.filter(s => !s.activate).map(s => s.id); - await enableRulesAction(deactivatedIds, true, dispatch); + await enableRulesAction(deactivatedIds, true, dispatch, dispatchToaster); }} > {i18n.BATCH_ACTION_ACTIVATE_SELECTED} @@ -40,7 +43,7 @@ export const getBatchItems = ( onClick={async () => { closePopover(); const activatedIds = selectedState.filter(s => s.activate).map(s => s.id); - await enableRulesAction(activatedIds, false, dispatch); + await enableRulesAction(activatedIds, false, dispatch, dispatchToaster); }} > {i18n.BATCH_ACTION_DEACTIVATE_SELECTED} @@ -72,12 +75,14 @@ export const getBatchItems = ( { closePopover(); await deleteRulesAction( selectedState.map(({ sourceRule: { id } }) => id), - dispatch + dispatch, + dispatchToaster ); }} > diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 636cbb8ecb0647..0c1804f26ecdd8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -15,7 +15,7 @@ import { EuiTableActionsColumnType, } from '@elastic/eui'; import * as H from 'history'; -import React from 'react'; +import React, { Dispatch } from 'react'; import { getEmptyTagValue } from '../../../../components/empty_value'; import { deleteRulesAction, @@ -31,8 +31,13 @@ import * as i18n from '../translations'; import { PreferenceFormattedDate } from '../../../../components/formatted_date'; import { RuleSwitch } from '../components/rule_switch'; import { SeverityBadge } from '../components/severity_badge'; +import { ActionToaster } from '../../../../components/toasters'; -const getActions = (dispatch: React.Dispatch, history: H.History) => [ +const getActions = ( + dispatch: React.Dispatch, + dispatchToaster: Dispatch, + history: H.History +) => [ { description: i18n.EDIT_RULE_SETTINGS, icon: 'visControls', @@ -51,7 +56,8 @@ const getActions = (dispatch: React.Dispatch, history: H.History) => [ description: i18n.DUPLICATE_RULE, icon: 'copy', name: i18n.DUPLICATE_RULE, - onClick: (rowItem: TableData) => duplicateRuleAction(rowItem.sourceRule, dispatch), + onClick: (rowItem: TableData) => + duplicateRuleAction(rowItem.sourceRule, dispatch, dispatchToaster), }, { description: i18n.EXPORT_RULE, @@ -63,7 +69,8 @@ const getActions = (dispatch: React.Dispatch, history: H.History) => [ description: i18n.DELETE_RULE, icon: 'trash', name: i18n.DELETE_RULE, - onClick: (rowItem: TableData) => deleteRulesAction([rowItem.id], dispatch), + onClick: (rowItem: TableData) => deleteRulesAction([rowItem.id], dispatch, dispatchToaster), + enabled: (rowItem: TableData) => !rowItem.immutable, }, ]; @@ -72,6 +79,7 @@ type RulesColumns = EuiBasicTableColumn | EuiTableActionsColumnType, + dispatchToaster: Dispatch, history: H.History, hasNoPermissions: boolean ): RulesColumns[] => { @@ -164,7 +172,7 @@ export const getColumns = ( ]; const actions: RulesColumns[] = [ { - actions: getActions(dispatch, history), + actions: getActions(dispatch, dispatchToaster, history), width: '40px', } as EuiTableActionsColumnType, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx new file mode 100644 index 00000000000000..e925161444e424 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx @@ -0,0 +1,61 @@ +/* + * 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 { bucketRulesResponse, formatRules } from './helpers'; +import { mockRule, mockRuleError, mockRules, mockTableData } from './__mocks__/mock'; +import uuid from 'uuid'; +import { Rule, RuleError } from '../../../../containers/detection_engine/rules'; + +describe('AllRulesTable Helpers', () => { + const mockRule1: Readonly = mockRule(uuid.v4()); + const mockRule2: Readonly = mockRule(uuid.v4()); + const mockRuleError1: Readonly = mockRuleError(uuid.v4()); + const mockRuleError2: Readonly = mockRuleError(uuid.v4()); + + describe('formatRules', () => { + test('formats rules with no selection', () => { + const formattedRules = formatRules(mockRules); + expect(formattedRules).toEqual(mockTableData); + }); + + test('formats rules with selection', () => { + const mockTableDataWithSelected = [...mockTableData]; + mockTableDataWithSelected[0].isLoading = true; + const formattedRules = formatRules(mockRules, [mockRules[0].id]); + expect(formattedRules).toEqual(mockTableDataWithSelected); + }); + }); + + describe('bucketRulesResponse', () => { + test('buckets empty response', () => { + const bucketedResponse = bucketRulesResponse([]); + expect(bucketedResponse).toEqual({ rules: [], errors: [] }); + }); + + test('buckets all error response', () => { + const bucketedResponse = bucketRulesResponse([mockRuleError1, mockRuleError2]); + expect(bucketedResponse).toEqual({ rules: [], errors: [mockRuleError1, mockRuleError2] }); + }); + + test('buckets all success response', () => { + const bucketedResponse = bucketRulesResponse([mockRule1, mockRule2]); + expect(bucketedResponse).toEqual({ rules: [mockRule1, mockRule2], errors: [] }); + }); + + test('buckets mixed success/error response', () => { + const bucketedResponse = bucketRulesResponse([ + mockRule1, + mockRuleError1, + mockRule2, + mockRuleError2, + ]); + expect(bucketedResponse).toEqual({ + rules: [mockRule1, mockRule2], + errors: [mockRuleError1, mockRuleError2], + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts index f5d39553142429..b18938920082d7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts @@ -4,13 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Rule } from '../../../../containers/detection_engine/rules'; +import { + Rule, + RuleError, + RuleResponseBuckets, +} from '../../../../containers/detection_engine/rules'; import { TableData } from '../types'; import { getEmptyValue } from '../../../../components/empty_value'; +/** + * Formats rules into the correct format for the AllRulesTable + * + * @param rules as returned from the Rules API + * @param selectedIds ids of the currently selected rules + */ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] => rules.map(rule => ({ id: rule.id, + immutable: rule.immutable, rule_id: rule.rule_id, rule: { href: `#/detection-engine/rules/id/${encodeURIComponent(rule.id)}`, @@ -28,3 +39,18 @@ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] sourceRule: rule, isLoading: selectedIds?.includes(rule.id) ?? false, })); + +/** + * Separates rules/errors from bulk rules API response (create/update/delete) + * + * @param response Array from bulk rules API + */ +export const bucketRulesResponse = (response: Array) => + response.reduce( + (acc, cv): RuleResponseBuckets => { + return 'error' in cv + ? { rules: [...acc.rules], errors: [...acc.errors, cv] } + : { rules: [...acc.rules, cv], errors: [...acc.errors] }; + }, + { rules: [], errors: [] } + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index d928cc09498512..202be75f09e699 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -84,11 +84,35 @@ export const AllRules = React.memo<{ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( - + ), - [selectedItems, dispatch] + [selectedItems, dispatch, dispatchToaster] + ); + + const tableOnChangeCallback = useCallback( + ({ page, sort }: EuiBasicTableOnChange) => { + dispatch({ + type: 'updatePagination', + pagination: { ...pagination, page: page.index + 1, perPage: page.size }, + }); + dispatch({ + type: 'updateFilterOptions', + filterOptions: { + ...filterOptions, + sortField: 'enabled', // Only enabled is supported for sorting currently + sortOrder: sort?.direction ?? 'desc', + }, + }); + }, + [dispatch, filterOptions, pagination] ); + const columns = useMemo(() => { + return getColumns(dispatch, dispatchToaster, history, hasNoPermissions); + }, [dispatch, dispatchToaster, history]); + useEffect(() => { dispatch({ type: 'loading', isLoading: isLoadingRules }); @@ -195,24 +219,11 @@ export const AllRules = React.memo<{ { - dispatch({ - type: 'updatePagination', - pagination: { ...pagination, page: page.index + 1, perPage: page.size }, - }); - dispatch({ - type: 'updateFilterOptions', - filterOptions: { - ...filterOptions, - sortField: 'enabled', // Only enabled is supported for sorting currently - sortOrder: sort!.direction, - }, - }); - }} + onChange={tableOnChangeCallback} pagination={{ pageIndex: pagination.page - 1, pageSize: pagination.perPage, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx index 09be3df7d6929c..9cb0323ed8987a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx @@ -18,6 +18,7 @@ import React, { useCallback, useState, useEffect } from 'react'; import { enableRules } from '../../../../../containers/detection_engine/rules'; import { enableRulesAction } from '../../all/actions'; import { Action } from '../../all/reducer'; +import { useStateToaster } from '../../../../../components/toasters'; const StaticSwitch = styled(EuiSwitch)` .euiSwitch__thumb, @@ -50,12 +51,13 @@ export const RuleSwitchComponent = ({ }: RuleSwitchProps) => { const [myIsLoading, setMyIsLoading] = useState(false); const [myEnabled, setMyEnabled] = useState(enabled ?? false); + const [, dispatchToaster] = useStateToaster(); const onRuleStateChange = useCallback( async (event: EuiSwitchEvent) => { setMyIsLoading(true); if (dispatch != null) { - await enableRulesAction([id], event.target.checked!, dispatch); + await enableRulesAction([id], event.target.checked!, dispatch, dispatchToaster); } else { try { const updatedRules = await enableRules({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts index 8d4407b9f73e84..d55e08e9ecd738 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/translations.ts @@ -50,6 +50,15 @@ export const BATCH_ACTION_ACTIVATE_SELECTED = i18n.translate( } ); +export const BATCH_ACTION_ACTIVATE_SELECTED_ERROR = (totalRules: number) => + i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.activateSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error activating {totalRules, plural, =1 {rule} other {rules}}…', + } + ); + export const BATCH_ACTION_DEACTIVATE_SELECTED = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.batchActions.deactivateSelectedTitle', { @@ -57,6 +66,15 @@ export const BATCH_ACTION_DEACTIVATE_SELECTED = i18n.translate( } ); +export const BATCH_ACTION_DEACTIVATE_SELECTED_ERROR = (totalRules: number) => + i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.deactivateSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error deactivating {totalRules, plural, =1 {rule} other {rules}}…', + } + ); + export const BATCH_ACTION_EXPORT_SELECTED = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.batchActions.exportSelectedTitle', { @@ -78,6 +96,22 @@ export const BATCH_ACTION_DELETE_SELECTED = i18n.translate( } ); +export const BATCH_ACTION_DELETE_SELECTED_IMMUTABLE = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle', + { + defaultMessage: 'Selection contains immutable rules which cannot be deleted', + } +); + +export const BATCH_ACTION_DELETE_SELECTED_ERROR = (totalRules: number) => + i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error deleting {totalRules, plural, =1 {rule} other {rules}}…', + } + ); + export const EXPORT_FILENAME = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.exportFilenameTitle', { @@ -143,6 +177,13 @@ export const DUPLICATE_RULE = i18n.translate( } ); +export const DUPLICATE_RULE_ERROR = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription', + { + defaultMessage: 'Error duplicating rule…', + } +); + export const EXPORT_RULE = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.actions.exportRuleDescription', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index 13b328e9061c9d..3da294fc9b8454 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -25,6 +25,7 @@ export interface EuiBasicTableOnChange { export interface TableData { id: string; + immutable: boolean; rule_id: string; rule: { href: string; From 5e6071162dbbe14ec9a476da29a5b1e3c50c577c Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 13 Jan 2020 15:37:33 -0700 Subject: [PATCH 41/62] [dev/build/sass] build stylesheets for disabled plugins too (#54654) --- src/dev/sass/build_sass.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/dev/sass/build_sass.js b/src/dev/sass/build_sass.js index 14f03a7a116a6f..1ff7c700d03861 100644 --- a/src/dev/sass/build_sass.js +++ b/src/dev/sass/build_sass.js @@ -19,6 +19,7 @@ import { resolve } from 'path'; +import * as Rx from 'rxjs'; import { toArray } from 'rxjs/operators'; import { createFailError } from '@kbn/dev-utils'; @@ -61,9 +62,11 @@ export async function buildSass({ log, kibanaDir, watch }) { const scanDirs = [resolve(kibanaDir, 'src/legacy/core_plugins')]; const paths = [resolve(kibanaDir, 'x-pack')]; - const { spec$ } = findPluginSpecs({ plugins: { scanDirs, paths } }); - const enabledPlugins = await spec$.pipe(toArray()).toPromise(); - const uiExports = collectUiExports(enabledPlugins); + const { spec$, disabledSpec$ } = findPluginSpecs({ plugins: { scanDirs, paths } }); + const allPlugins = await Rx.merge(spec$, disabledSpec$) + .pipe(toArray()) + .toPromise(); + const uiExports = collectUiExports(allPlugins); const { styleSheetPaths } = uiExports; log.info('%s %d styleSheetPaths', watch ? 'watching' : 'found', styleSheetPaths.length); From 8259445350434f6a53f9bcde5b0551ae37d47fa8 Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Mon, 13 Jan 2020 16:16:20 -0800 Subject: [PATCH 42/62] Create UI for alerting and actions plugin (#48959) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactored reducers type definitions * Fixed dependancy objects * Fixed action add * Fixed logging app icon * Added action types params fields * Added fields for check and re-notify alert * Add tags to alert list * Adjusted threshold expression with validation, added visualization * Move delete button to the left and hide when no selection * Rename action list title column to name * fixed request * Removed watcher labels * Design cleanup * Added expression default values * Added visualization for index threshold alert * Rename Actions tab to Connectors * Rename "create action" to "create connector" * Remove actions column name * Add count per action type * Hide checkboxes when user can't delete * Add title to home, rename Alerting UI breadcrumb (remove UI part) * Added correct binding for interval and throttle * Added tags support for create Alert UI * Added server error display in UI on save alert * Added connectors for action forms * Update button styles * Switch inputs to compressed forms * Fixed some fields for add alert form * Fixed updating action by index * Fixed filter for index/fields api requests * Remove the test alert type that was in the init function * Fixed action type icon on add connector form and did small refactoring on action forms; added action validation * Rename alerting UI plugin to triggers and actions UI (or something else) #50305 * Implemented action connector edit UI * Add bulk actions to alerts list * Update home title spacing * Fixed editing secrets action property * Changing behaviour of bulk actions and disable buttons during request * Refactored plugin definition with appdependency interface * Moved add dependencies to the separate file * Enable visualization if only hasExpressionErrors passed * Fixed add action twice on click card * Fix actions column in alert list * Fixed action canSave capability * Renamed Actions to ActionConnectors in appropriate UI files * Renamed alertTypeParams to params in UI code * Add filter for tags * Cleanup previous commit * Fix alert type filter * Refactored edit form to use ActionTableItem * Renamed ActionTableItem to ActionConnectorTableItem * Fixed missing button key error for alerts list filter * Renamed translation labels for connectors * Enable UI plugin by default * Rename buildin to builtin * Fix some type checks * Add API tests * Split API file into smaller files * Rename plugin id * Remove dependency on actions plugin (should be optional dep in NP) * Fix some translation ids * Revert "Rename plugin id" This reverts commit f6daeb3d5ea8c281fa3ce7393015ce5684cbd25a. * Rename method for loading connectors * Added functional tests base * Fix functional test type filter * Add test alert type for now * Initial connectors functional tests * Rename description to name * Use unique connector names to allow re-running tests * Assert on more things * Update alert/action menu items. Flyout width. Add index.scss file * Added action connector list unit tests * Add bulk delete functional test * Move tests to SSL functional environment * Fix tests * Added unit tests for actionTypeRegistry and alertTypeRegistry * Fixed update connector with only properties * Added some functional tests for alerts with TODOs * connectors list page cleanup * empty state cleanup * Added connector edit flyout unit test * Fix functional tests * text cleanup * zindex fix for index threshold trigger * Expand the functional tests, add assertions * Fixed edit connector from the Name column, and removed pencil button * Remove tags filter, use search bar instead * Finalize functional tests * Support filtering alerts by action type * Rename plugin name for translations * Rename default breadcrumb title to alerts and actions * Added unit tests for connectors empty prompt, fixed api tests * Added unit test for select action type menu for create connector; Fixed update selected connector for edit form * Added unit test for edit connector flyout * Added alerts list unit tests * Added connector form unit tests * Added connector reducer unit tests * Fixed some failing unit tests * Fixed alerts list unit tests * Set alert tab default if it is available * Added doc_title and get_time_units unit tests * Added some test fixes * Fixed index threshold expression to display only index and fields * Added email building action unit tests * Added unit tests for builtin action types * Remove test alert type * Move create alert UI behind feature flag 'createAlertUiEnabled' * Fix functional tests * Update codeowners * Update codeowners for tests * Revert watcher changes * Fix type check failure * Fix unit test failures * Fixed typecheck failures * Fixed language check errors * Did some text/type fixes * Fixed typecheck * Fixed unit tests warning * Fix failing functional tests * Fix registry tests to have cleaner diff when it fails * Make DEFAULT_SECTION a Section type * Remove unused constructor * Make app dependency error string same line * Remove unused error pages * Set interface to alerts context * Fix action_connector_form.tsx label * Fix label in connector_add_flyout.tsx * Fix label in alert_add.tsx * Move alert_types to builtin_alert_types * Move some threshold constants into threshold folder * Move api.ts within threshold folder * Removed duplication logic from action type and alert type registry list * Fixed email action type test and adjusted validation to support arrays ony * Added missing connector fields for email action type * Fixed building action types issues due to comments * Refactored with more new platform structure; fixed some comments from review * Capitalize Actions in 'Alerts and Actions' labels * Skip flaky tests * Fix failing functional test * Fixed failing unit tests, added new deps * Fixed type checks * Fixed language check failing * Fix broken functional tests * Refactored actionConnectors and alerting context * Removed doc title service * added get time options type definitions * removed obsolete code * Made generic registry type for actionTypes and alert types * Fixed some enum types * fixed type check CI * Convert EuiSearchBar to normal text field * Fix typo * Fix conditional rendering * Fix bug where selection doesn't reset * Fix broken functional test, wait for ENTER key to search alerts * Make app section hide from menu when user doesn't have access * Fixed connector name validation (error due to renaming from description) * Removed obsolete useEffect * Removed unused ShareRouter * Fixed key validation error * Mobed wrongly wrapped objects * Removed useEffect from connectors form * Replaced error forms with eui controls props * Added delete confirmation dialog for connectors list * Fixed build errors * Fixed failing test * Skip flaky tests * Added null check for app context - render components tree only if it isn't null * Fixed type check eror * Did changes on the UX and text/labels commnets * Fixed failing tests * Fixed error handling * Refactored Webhook form http headers due to the mockup * Fixed build * Fix labels issue * Fix spacing and form row alignment * Fixed failing type check * put ownfocus on popover in actions list * fix spacing and flex * fix color on conectors list * clean up webhook headers form * fix logic check for headers * Made changes due to review comments * Fixed delete connector test * Fixed all flaky test for delete connectors 53956 * Fixed type check due to NP changes * Disable plugin by default * Added configuration props for functional tests to enable triggers and actions ui * removed timeout from test * added enable triggers and actions to functional/config.js * fix the build * Changed ci group and disabled plugin * changed config setting to root * Changed disable approach * Experiment with index managment * Set back configuration settings for triggers and actions * Enable plugins * Set index management to disabled to see the failing issue * Revert experimental back for index_managment * Fixed type check Co-authored-by: Mike Côté Co-authored-by: dave.snider@gmail.com Co-authored-by: DeFazio Co-authored-by: Elastic Machine Co-authored-by: Peter Schretlen --- .github/CODEOWNERS | 3 + x-pack/.i18nrc.json | 1 + x-pack/index.js | 2 + .../server/builtin_action_types/email.test.ts | 2 +- .../server/builtin_action_types/email.ts | 4 +- .../builtin_action_types/es_index.test.ts | 2 +- .../server/builtin_action_types/es_index.ts | 4 +- .../builtin_action_types/pagerduty.test.ts | 2 +- .../server/builtin_action_types/pagerduty.ts | 4 +- .../builtin_action_types/server_log.test.ts | 4 +- .../server/builtin_action_types/server_log.ts | 2 +- .../server/builtin_action_types/slack.test.ts | 2 +- .../server/builtin_action_types/slack.ts | 4 +- .../builtin_action_types/webhook.test.ts | 2 +- .../server/builtin_action_types/webhook.ts | 4 +- x-pack/legacy/plugins/siem/server/plugin.ts | 21 +- .../plugins/triggers_actions_ui/index.ts | 43 + .../triggers_actions_ui/np_ready/kibana.json | 6 + .../application/action_type_registry.mock.ts | 21 + .../application/alert_type_registry.mock.ts | 21 + .../np_ready/public/application/app.tsx | 64 ++ .../public/application/app_context.tsx | 30 + .../np_ready/public/application/boot.tsx | 34 + .../builtin_action_types/email.test.tsx | 228 ++++ .../components/builtin_action_types/email.tsx | 545 +++++++++ .../builtin_action_types/es_index.test.tsx | 140 +++ .../builtin_action_types/es_index.tsx | 124 ++ .../components/builtin_action_types/index.ts | 27 + .../builtin_action_types/pagerduty.test.tsx | 179 +++ .../builtin_action_types/pagerduty.tsx | 361 ++++++ .../builtin_action_types/server_log.test.tsx | 130 +++ .../builtin_action_types/server_log.tsx | 116 ++ .../builtin_action_types/slack.test.tsx | 152 +++ .../components/builtin_action_types/slack.tsx | 175 +++ .../builtin_action_types/webhook.test.tsx | 174 +++ .../builtin_action_types/webhook.tsx | 501 +++++++++ .../components/builtin_alert_types/index.ts | 17 + .../threshold/constants/aggregation_types.ts | 17 + .../threshold/constants/comparators.ts | 13 + .../threshold/constants/index.ts | 8 + .../threshold/expression.tsx | 1000 +++++++++++++++++ .../builtin_alert_types/threshold/lib/api.ts | 79 ++ .../builtin_alert_types/threshold/types.ts | 25 + .../threshold/visualization.tsx | 303 +++++ .../components/delete_connectors_modal.tsx | 91 ++ .../components/section_loading.tsx | 23 + .../application/constants/action_groups.ts | 11 + .../public/application/constants/index.ts | 21 + .../public/application/constants/plugin.ts | 14 + .../application/constants/time_units.ts | 12 + .../context/actions_connectors_context.tsx | 39 + .../application/context/alerts_context.tsx | 32 + .../np_ready/public/application/home.tsx | 126 +++ .../lib/action_connector_api.test.ts | 135 +++ .../application/lib/action_connector_api.ts | 85 ++ .../public/application/lib/alert_api.test.ts | 406 +++++++ .../public/application/lib/alert_api.ts | 126 +++ .../public/application/lib/breadcrumb.test.ts | 31 + .../public/application/lib/breadcrumb.ts | 35 + .../public/application/lib/capabilities.ts | 53 + .../public/application/lib/doc_title.test.ts | 14 + .../public/application/lib/doc_title.ts | 28 + .../application/lib/get_time_options.test.ts | 36 + .../application/lib/get_time_options.ts | 37 + .../application/lib/get_time_unit_label.ts | 33 + .../action_connector_form.test.tsx | 113 ++ .../action_connector_form.tsx | 270 +++++ .../action_type_menu.test.tsx | 91 ++ .../action_type_menu.tsx | 83 ++ .../connector_add_flyout.test.tsx | 100 ++ .../connector_add_flyout.tsx | 104 ++ .../connector_edit_flyout.test.tsx | 100 ++ .../connector_edit_flyout.tsx | 68 ++ .../connector_reducer.test.ts | 91 ++ .../connector_reducer.ts | 78 ++ .../sections/action_connector_form/index.ts | 8 + .../components/_index.scss | 1 + .../components/actions_connectors_list.scss | 3 + .../actions_connectors_list.test.tsx | 362 ++++++ .../components/actions_connectors_list.tsx | 399 +++++++ .../sections/alert_add/alert_add.tsx | 803 +++++++++++++ .../sections/alert_add/alert_reducer.ts | 121 ++ .../application/sections/alert_add/index.ts | 7 + .../components/action_type_filter.tsx | 72 ++ .../components/alerts_list.test.tsx | 453 ++++++++ .../alerts_list/components/alerts_list.tsx | 330 ++++++ .../components/bulk_action_popover.tsx | 253 +++++ .../components/collapsed_item_actions.tsx | 140 +++ .../alerts_list/components/type_filter.tsx | 74 ++ .../public/application/type_registry.test.ts | 117 ++ .../public/application/type_registry.ts | 56 + .../np_ready/public/index.ts | 15 + .../np_ready/public/plugin.ts | 107 ++ .../np_ready/public/types.ts | 120 ++ .../public/hacks/register.ts | 25 + .../triggers_actions_ui/public/index.scss | 5 + .../triggers_actions_ui/public/legacy.ts | 98 ++ .../public/manage_angular_lifecycle.ts | 28 + x-pack/scripts/functional_tests.js | 1 + x-pack/test/functional/services/index.ts | 1 + .../apps/triggers_actions_ui/alerts.ts | 344 ++++++ .../apps/triggers_actions_ui/connectors.ts | 202 ++++ .../apps/triggers_actions_ui/home_page.ts | 60 + .../apps/triggers_actions_ui/index.ts | 16 + x-pack/test/functional_with_es_ssl/config.ts | 59 + .../fixtures/plugins/alerts/index.ts | 24 + .../fixtures/plugins/alerts/package.json | 7 + .../ftr_provider_context.d.ts | 12 + .../page_objects/index.ts | 13 + .../page_objects/triggers_actions_ui_page.ts | 97 ++ .../functional_with_es_ssl/services/index.ts | 11 + 111 files changed, 11511 insertions(+), 15 deletions(-) create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/index.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/kibana.json create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/action_type_registry.mock.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/alert_type_registry.mock.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app_context.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/boot.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/index.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/index.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/aggregation_types.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/comparators.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/index.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/lib/api.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/types.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/delete_connectors_modal.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/section_loading.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/plugin.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/time_units.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/actions_connectors_context.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/home.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.test.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/capabilities.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.test.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.test.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_unit_label.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.test.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/index.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/_index.scss create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/index.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/action_type_filter.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/bulk_action_popover.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/type_filter.tsx create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.test.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/index.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/plugin.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/public/hacks/register.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/public/index.scss create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/public/legacy.ts create mode 100644 x-pack/legacy/plugins/triggers_actions_ui/public/manage_angular_lifecycle.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts create mode 100644 x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts create mode 100644 x-pack/test/functional_with_es_ssl/config.ts create mode 100644 x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts create mode 100644 x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json create mode 100644 x-pack/test/functional_with_es_ssl/ftr_provider_context.d.ts create mode 100644 x-pack/test/functional_with_es_ssl/page_objects/index.ts create mode 100644 x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts create mode 100644 x-pack/test/functional_with_es_ssl/services/index.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9a4f2b71da1ffb..acfb7307f49c47 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -132,6 +132,9 @@ /x-pack/test/alerting_api_integration @elastic/kibana-alerting-services /x-pack/test/plugin_api_integration/plugins/task_manager @elastic/kibana-alerting-services /x-pack/test/plugin_api_integration/test_suites/task_manager @elastic/kibana-alerting-services +/x-pack/legacy/plugins/triggers_actions_ui/ @elastic/kibana-alerting-services +/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/ @elastic/kibana-alerting-services +/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/ @elastic/kibana-alerting-services # Design **/*.scss @elastic/kibana-design diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 7e86d2f1dc4357..71e3bdd6c8c846 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -4,6 +4,7 @@ "xpack.actions": "legacy/plugins/actions", "xpack.advancedUiActions": "plugins/advanced_ui_actions", "xpack.alerting": "legacy/plugins/alerting", + "xpack.triggersActionsUI": "legacy/plugins/triggers_actions_ui", "xpack.apm": "legacy/plugins/apm", "xpack.beatsManagement": "legacy/plugins/beats_management", "xpack.canvas": "legacy/plugins/canvas", diff --git a/x-pack/index.js b/x-pack/index.js index 56547f89b1e906..83a7b5540334f6 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -42,6 +42,7 @@ import { transform } from './legacy/plugins/transform'; import { actions } from './legacy/plugins/actions'; import { alerting } from './legacy/plugins/alerting'; import { lens } from './legacy/plugins/lens'; +import { triggersActionsUI } from './legacy/plugins/triggers_actions_ui'; module.exports = function(kibana) { return [ @@ -83,5 +84,6 @@ module.exports = function(kibana) { snapshotRestore(kibana), actions(kibana), alerting(kibana), + triggersActionsUI(kibana), ]; }; diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts index 4aaecc8e9d7df4..74263c603c11e9 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts @@ -49,7 +49,7 @@ beforeEach(() => { describe('actionTypeRegistry.get() works', () => { test('action type static data is as expected', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('email'); + expect(actionType.name).toEqual('Email'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts index dd2bd328ce53fe..94d7852e76fad7 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts @@ -118,7 +118,9 @@ export function getActionType(params: GetActionTypeParams): ActionType { const { logger, configurationUtilities } = params; return { id: '.email', - name: 'email', + name: i18n.translate('xpack.actions.builtin.emailTitle', { + defaultMessage: 'Email', + }), validate: { config: schema.object(ConfigSchemaProps, { validate: curry(validateConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts index 1da8b06e1587a9..dbac84ef681f19 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { describe('actionTypeRegistry.get() works', () => { test('action type static data is as expected', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('index'); + expect(actionType.name).toEqual('Index'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts index 0e9fe0483ee1ea..ddf33ba63f71af 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts @@ -38,7 +38,9 @@ const ParamsSchema = schema.object({ export function getActionType({ logger }: { logger: Logger }): ActionType { return { id: '.index', - name: 'index', + name: i18n.translate('xpack.actions.builtin.esIndexTitle', { + defaultMessage: 'Index', + }), validate: { config: ConfigSchema, params: ParamsSchema, diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts index cb3548524ebbb6..f60fdf7fef95e1 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -38,7 +38,7 @@ beforeAll(() => { describe('get()', () => { test('should return correct action type', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('pagerduty'); + expect(actionType.name).toEqual('PagerDuty'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts index 250c169278c571..b26621702cf5be 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -96,7 +96,9 @@ export function getActionType({ }): ActionType { return { id: '.pagerduty', - name: 'pagerduty', + name: i18n.translate('xpack.actions.builtin.pagerdutyTitle', { + defaultMessage: 'PagerDuty', + }), validate: { config: schema.object(configSchemaProps, { validate: curry(valdiateActionTypeConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts index c59ddf97017fdc..8f28b9e8f51254 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -25,7 +25,7 @@ beforeAll(() => { describe('get()', () => { test('returns action type', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('server-log'); + expect(actionType.name).toEqual('Server log'); }); }); @@ -98,6 +98,6 @@ describe('execute()', () => { config: {}, secrets: {}, }); - expect(mockedLogger.info).toHaveBeenCalledWith('server-log: message text here'); + expect(mockedLogger.info).toHaveBeenCalledWith('Server log: message text here'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts index 0edf409e4d46c6..34b8602eeba368 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts @@ -12,7 +12,7 @@ import { Logger } from '../../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; import { withoutControlCharacters } from './lib/string_utils'; -const ACTION_NAME = 'server-log'; +const ACTION_NAME = 'Server log'; // params definition diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts index a2b0db8bdb70f0..aebc9c49935997 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts @@ -29,7 +29,7 @@ beforeAll(() => { describe('action registeration', () => { test('returns action type', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('slack'); + expect(actionType.name).toEqual('Slack'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts index 92611d6f162ffb..b8989e59a22579 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts @@ -49,7 +49,9 @@ export function getActionType({ }): ActionType { return { id: '.slack', - name: 'slack', + name: i18n.translate('xpack.actions.builtin.slackTitle', { + defaultMessage: 'Slack', + }), validate: { secrets: schema.object(secretsSchemaProps, { validate: curry(valdiateActionTypeConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts index 64dd3a485f8e21..b95fef97ac7b9a 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -25,7 +25,7 @@ beforeAll(() => { describe('actionType', () => { test('exposes the action as `webhook` on its Id and Name', () => { expect(actionType.id).toEqual('.webhook'); - expect(actionType.name).toEqual('webhook'); + expect(actionType.name).toEqual('Webhook'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts index 06fe2fb0e591c0..fa88d3c72c1631 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts @@ -56,7 +56,9 @@ export function getActionType({ }): ActionType { return { id: '.webhook', - name: 'webhook', + name: i18n.translate('xpack.actions.builtin.webhookTitle', { + defaultMessage: 'Webhook', + }), validate: { config: schema.object(configSchemaProps, { validate: curry(valdiateActionTypeConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index 90ae79ef19d5ba..9d1983cf1d4daa 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -60,7 +60,16 @@ export class Plugin { ], read: ['config'], }, - ui: ['show', 'crud'], + ui: [ + 'show', + 'crud', + 'alerting:show', + 'actions:show', + 'alerting:save', + 'actions:save', + 'alerting:delete', + 'actions:delete', + ], }, read: { api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], @@ -73,7 +82,15 @@ export class Plugin { timelineSavedObjectType, ], }, - ui: ['show'], + ui: [ + 'show', + 'alerting:show', + 'actions:show', + 'alerting:save', + 'actions:save', + 'alerting:delete', + 'actions:delete', + ], }, }, }); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/index.ts new file mode 100644 index 00000000000000..c6ac3649a14775 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Legacy } from 'kibana'; +import { Root } from 'joi'; +import { resolve } from 'path'; + +export function triggersActionsUI(kibana: any) { + return new kibana.Plugin({ + id: 'triggers_actions_ui', + configPrefix: 'xpack.triggers_actions_ui', + isEnabled(config: Legacy.KibanaConfig) { + return ( + config.get('xpack.triggers_actions_ui.enabled') && + (config.get('xpack.actions.enabled') || config.get('xpack.alerting.enabled')) + ); + }, + publicDir: resolve(__dirname, 'public'), + require: ['kibana'], + config(Joi: Root) { + return Joi.object() + .keys({ + enabled: Joi.boolean().default(false), + createAlertUiEnabled: Joi.boolean().default(false), + }) + .default(); + }, + uiExports: { + home: ['plugins/triggers_actions_ui/hacks/register'], + managementSections: ['plugins/triggers_actions_ui/legacy'], + styleSheetPaths: resolve(__dirname, 'public/index.scss'), + injectDefaultVars(server: Legacy.Server) { + const serverConfig = server.config(); + return { + createAlertUiEnabled: serverConfig.get('xpack.triggers_actions_ui.createAlertUiEnabled'), + }; + }, + }, + }); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/kibana.json b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/kibana.json new file mode 100644 index 00000000000000..3fd7389aef494e --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "triggers_actions_ui", + "version": "kibana", + "server": false, + "ui": true + } diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/action_type_registry.mock.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/action_type_registry.mock.ts new file mode 100644 index 00000000000000..8ebfd7f933cd34 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/action_type_registry.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionTypeRegistryContract } from '../types'; + +const createActionTypeRegistryMock = () => { + const mocked: jest.Mocked = { + has: jest.fn(x => true), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }; + return mocked; +}; + +export const actionTypeRegistryMock = { + create: createActionTypeRegistryMock, +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/alert_type_registry.mock.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/alert_type_registry.mock.ts new file mode 100644 index 00000000000000..89eca7563a4e1c --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/alert_type_registry.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertTypeRegistryContract } from '../types'; + +const createAlertTypeRegistryMock = () => { + const mocked: jest.Mocked = { + has: jest.fn(), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }; + return mocked; +}; + +export const alertTypeRegistryMock = { + create: createAlertTypeRegistryMock, +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx new file mode 100644 index 00000000000000..3ad6b5b7c697de --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { Switch, Route, Redirect, HashRouter } from 'react-router-dom'; +import { + ChromeStart, + DocLinksStart, + ToastsSetup, + HttpSetup, + IUiSettingsClient, +} from 'kibana/public'; +import { BASE_PATH, Section } from './constants'; +import { TriggersActionsUIHome } from './home'; +import { AppContextProvider, useAppDependencies } from './app_context'; +import { hasShowAlertsCapability } from './lib/capabilities'; +import { LegacyDependencies, ActionTypeModel, AlertTypeModel } from '../types'; +import { TypeRegistry } from './type_registry'; + +export interface AppDeps { + chrome: ChromeStart; + docLinks: DocLinksStart; + toastNotifications: ToastsSetup; + injectedMetadata: any; + http: HttpSetup; + uiSettings: IUiSettingsClient; + legacy: LegacyDependencies; + actionTypeRegistry: TypeRegistry; + alertTypeRegistry: TypeRegistry; +} + +export const App = (appDeps: AppDeps) => { + const sections: Section[] = ['alerts', 'connectors']; + + const sectionsRegex = sections.join('|'); + + return ( + + + + + + ); +}; + +export const AppWithoutRouter = ({ sectionsRegex }: any) => { + const { + legacy: { capabilities }, + } = useAppDependencies(); + const canShowAlerts = hasShowAlertsCapability(capabilities.get()); + const DEFAULT_SECTION: Section = canShowAlerts ? 'alerts' : 'connectors'; + return ( + + + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app_context.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app_context.tsx new file mode 100644 index 00000000000000..bf2e0c7274e7b6 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app_context.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, useContext } from 'react'; +import { AppDeps } from './app'; + +const AppContext = createContext(null); + +export const AppContextProvider = ({ + children, + appDeps, +}: { + appDeps: AppDeps | null; + children: React.ReactNode; +}) => { + return appDeps ? {children} : null; +}; + +export const useAppDependencies = (): AppDeps => { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error( + 'The app dependencies Context has not been set. Use the "setAppDependencies()" method when bootstrapping the app.' + ); + } + return ctx; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/boot.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/boot.tsx new file mode 100644 index 00000000000000..a37bedbfbdda88 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/boot.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { SavedObjectsClientContract } from 'src/core/public'; + +import { App, AppDeps } from './app'; +import { setSavedObjectsClient } from '../application/components/builtin_alert_types/threshold/lib/api'; +import { LegacyDependencies } from '../types'; + +interface BootDeps extends AppDeps { + element: HTMLElement; + savedObjects: SavedObjectsClientContract; + I18nContext: any; + legacy: LegacyDependencies; +} + +export const boot = (bootDeps: BootDeps) => { + const { I18nContext, element, legacy, savedObjects, ...appDeps } = bootDeps; + + setSavedObjectsClient(savedObjects); + + render( + + + , + element + ); + return () => unmountComponentAtNode(element); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx new file mode 100644 index 00000000000000..5c924982c3536a --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx @@ -0,0 +1,228 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.email'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('email'); + }); +}); + +describe('connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: '2323', + host: 'localhost', + test: 'test', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + service: [], + port: [], + host: [], + user: [], + password: [], + }, + }); + + delete actionConnector.config.test; + actionConnector.config.host = 'elastic.co'; + actionConnector.config.port = 8080; + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + service: [], + port: [], + host: [], + user: [], + password: [], + }, + }); + delete actionConnector.config.host; + delete actionConnector.config.port; + actionConnector.config.service = 'testService'; + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + service: [], + port: [], + host: [], + user: [], + password: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + service: ['Service is required.'], + port: ['Port is required.'], + host: ['Host is required.'], + user: [], + password: [], + }, + }); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + to: [], + cc: ['test1@test.com'], + message: 'message {test}', + subject: 'test', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + to: [], + cc: [], + bcc: [], + message: [], + subject: [], + }, + }); + }); + + test('action params validation fails when action params is not valid', () => { + const actionParams = { + to: ['test@test.com'], + subject: 'test', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + to: [], + cc: [], + bcc: [], + message: ['Message is required.'], + subject: [], + }, + }); + }); +}); + +describe('EmailActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + expect(actionTypeModel.actionConnectorFields).not.toBeNull(); + if (!actionTypeModel.actionConnectorFields) { + return; + } + const ConnectorFields = actionTypeModel.actionConnectorFields; + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + }, + } as ActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="emailFromInput"]') + .first() + .prop('value') + ).toBe('test@test.com'); + expect(wrapper.find('[data-test-subj="emailHostInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailPortInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailUserInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailPasswordInput"]').length > 0).toBeTruthy(); + }); +}); + +describe('EmailParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + to: ['test@test.com'], + subject: 'test', + message: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="toEmailAddressInput"]') + .first() + .prop('selectedOptions') + ).toStrictEqual([{ label: 'test@test.com' }]); + expect(wrapper.find('[data-test-subj="ccEmailAddressInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="bccEmailAddressInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailSubjectInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailMessageInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx new file mode 100644 index 00000000000000..a6750ccf96debc --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx @@ -0,0 +1,545 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { + EuiFieldText, + EuiFlexItem, + EuiFlexGroup, + EuiFieldNumber, + EuiFieldPassword, + EuiComboBox, + EuiTextArea, + EuiSwitch, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + ActionTypeModel, + ActionConnectorFieldsProps, + ActionConnector, + ValidationResult, + ActionParamsProps, +} from '../../../types'; + +export function getActionType(): ActionTypeModel { + const mailformat = /^[^@\s]+@[^@\s]+$/; + return { + id: '.email', + iconClass: 'email', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText', + { + defaultMessage: 'Send email from your server.', + } + ), + validateConnector: (action: ActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + from: new Array(), + service: new Array(), + port: new Array(), + host: new Array(), + user: new Array(), + password: new Array(), + }; + validationResult.errors = errors; + if (!action.config.from) { + errors.from.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText', + { + defaultMessage: 'Sender is required.', + } + ) + ); + } + if (action.config.from && !action.config.from.trim().match(mailformat)) { + errors.from.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText', + { + defaultMessage: 'Sender is not a valid email address.', + } + ) + ); + } + if (!action.config.port && !action.config.service) { + errors.port.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', + { + defaultMessage: 'Port is required.', + } + ) + ); + } + if (!action.config.service && (!action.config.port || !action.config.host)) { + errors.service.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServiceText', + { + defaultMessage: 'Service is required.', + } + ) + ); + } + if (!action.config.host && !action.config.service) { + errors.host.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', + { + defaultMessage: 'Host is required.', + } + ) + ); + } + if (!action.secrets.user) { + errors.user.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', + { + defaultMessage: 'Username is required.', + } + ) + ); + } + if (!action.secrets.password) { + errors.password.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + { + defaultMessage: 'Password is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + to: new Array(), + cc: new Array(), + bcc: new Array(), + message: new Array(), + subject: new Array(), + }; + validationResult.errors = errors; + if ( + (!(actionParams.to instanceof Array) || actionParams.to.length === 0) && + (!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) && + (!(actionParams.bcc instanceof Array) || actionParams.bcc.length === 0) + ) { + const errorText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', + { + defaultMessage: 'No [to], [cc], or [bcc] entries. At least one entry is required.', + } + ); + errors.to.push(errorText); + errors.cc.push(errorText); + errors.bcc.push(errorText); + } + if (!actionParams.message) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + if (!actionParams.subject) { + errors.subject.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', + { + defaultMessage: 'Subject is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: EmailActionConnectorFields, + actionParamsFields: EmailParamsFields, + }; +} + +const EmailActionConnectorFields: React.FunctionComponent = ({ + action, + editActionConfig, + editActionSecrets, + errors, +}) => { + const { from, host, port, secure } = action.config; + const { user, password } = action.secrets; + + return ( + + + + 0 && from !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel', + { + defaultMessage: 'Sender', + } + )} + > + 0 && from !== undefined} + name="from" + value={from || ''} + data-test-subj="emailFromInput" + onChange={e => { + editActionConfig('from', e.target.value); + }} + onBlur={() => { + if (!from) { + editActionConfig('from', ''); + } + }} + /> + + + + + + 0 && host !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel', + { + defaultMessage: 'Host', + } + )} + > + 0 && host !== undefined} + name="host" + value={host || ''} + data-test-subj="emailHostInput" + onChange={e => { + editActionConfig('host', e.target.value); + }} + onBlur={() => { + if (!host) { + editActionConfig('host', ''); + } + }} + /> + + + + + + 0 && port !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel', + { + defaultMessage: 'Port', + } + )} + > + 0 && port !== undefined} + fullWidth + name="port" + value={port || ''} + data-test-subj="emailPortInput" + onChange={e => { + editActionConfig('port', parseInt(e.target.value, 10)); + }} + onBlur={() => { + if (!port) { + editActionConfig('port', ''); + } + }} + /> + + + + + + { + editActionConfig('secure', e.target.checked); + }} + /> + + + + + + + + + 0 && user !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', + { + defaultMessage: 'Username', + } + )} + > + 0 && user !== undefined} + name="user" + value={user || ''} + data-test-subj="emailUserInput" + onChange={e => { + editActionSecrets('user', e.target.value); + }} + onBlur={() => { + if (!user) { + editActionSecrets('user', ''); + } + }} + /> + + + + 0 && password !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', + { + defaultMessage: 'Password', + } + )} + > + 0 && password !== undefined} + name="password" + value={password || ''} + data-test-subj="emailPasswordInput" + onChange={e => { + editActionSecrets('password', e.target.value); + }} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + /> + + + + + ); +}; + +const EmailParamsFields: React.FunctionComponent = ({ + action, + editAction, + index, + errors, + hasErrors, +}) => { + const { to, cc, bcc, subject, message } = action; + const toOptions = to ? to.map((label: string) => ({ label })) : []; + const ccOptions = cc ? cc.map((label: string) => ({ label })) : []; + const bccOptions = bcc ? bcc.map((label: string) => ({ label })) : []; + + return ( + + + { + const newOptions = [...toOptions, { label: searchValue }]; + editAction( + 'to', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'to', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!to) { + editAction('to', [], index); + } + }} + /> + + + { + const newOptions = [...ccOptions, { label: searchValue }]; + editAction( + 'cc', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'cc', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!cc) { + editAction('cc', [], index); + } + }} + /> + + + { + const newOptions = [...bccOptions, { label: searchValue }]; + editAction( + 'bcc', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'bcc', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!bcc) { + editAction('bcc', [], index); + } + }} + /> + + + { + editAction('subject', e.target.value, index); + }} + /> + + + { + editAction('message', e.target.value, index); + }} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx new file mode 100644 index 00000000000000..b6a7c4d82aca44 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.index'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type .index is registered', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('indexOpen'); + }); +}); + +describe('index connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test_es_index', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: {}, + }); + + delete actionConnector.config.index; + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: {}, + }); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + index: 'test', + refresh: false, + executionTimeField: '1', + documents: ['test'], + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: {}, + }); + + const emptyActionParams = {}; + + expect(actionTypeModel.validateParams(emptyActionParams)).toEqual({ + errors: {}, + }); + }); +}); + +describe('IndexActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + expect(actionTypeModel.actionConnectorFields).not.toBeNull(); + if (!actionTypeModel.actionConnectorFields) { + return; + } + const ConnectorFields = actionTypeModel.actionConnectorFields; + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test', + }, + } as ActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="indexInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="indexInput"]') + .first() + .prop('value') + ).toBe('test'); + }); +}); + +describe('IndexParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + index: 'test_index', + refresh: false, + documents: ['test'], + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="indexInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="indexInput"]') + .first() + .prop('value') + ).toBe('test_index'); + expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx new file mode 100644 index 00000000000000..aa15195cdc2867 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiFieldText, EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + ActionTypeModel, + ActionConnectorFieldsProps, + ValidationResult, + ActionParamsProps, +} from '../../../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.index', + iconClass: 'indexOpen', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.selectMessageText', + { + defaultMessage: 'Index data into Elasticsearch.', + } + ), + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + actionConnectorFields: IndexActionConnectorFields, + actionParamsFields: IndexParamsFields, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + }; +} + +const IndexActionConnectorFields: React.FunctionComponent = ({ + action, + editActionConfig, +}) => { + const { index } = action.config; + return ( + + ) => { + editActionConfig('index', e.target.value); + }} + onBlur={() => { + if (!index) { + editActionConfig('index', ''); + } + }} + /> + + ); +}; + +const IndexParamsFields: React.FunctionComponent = ({ + action, + index, + editAction, + errors, + hasErrors, +}) => { + const { refresh } = action; + return ( + + + ) => { + editAction('index', e.target.value, index); + }} + onBlur={() => { + if (!action.index) { + editAction('index', '', index); + } + }} + /> + + { + editAction('refresh', e.target.checked, index); + }} + label={ + + } + /> + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/index.ts new file mode 100644 index 00000000000000..6ffd9b2c9ffde8 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getActionType as getServerLogActionType } from './server_log'; +import { getActionType as getSlackActionType } from './slack'; +import { getActionType as getEmailActionType } from './email'; +import { getActionType as getIndexActionType } from './es_index'; +import { getActionType as getPagerDutyActionType } from './pagerduty'; +import { getActionType as getWebhookActionType } from './webhook'; +import { TypeRegistry } from '../../type_registry'; +import { ActionTypeModel } from '../../../types'; + +export function registerBuiltInActionTypes({ + actionTypeRegistry, +}: { + actionTypeRegistry: TypeRegistry; +}) { + actionTypeRegistry.register(getServerLogActionType()); + actionTypeRegistry.register(getSlackActionType()); + actionTypeRegistry.register(getEmailActionType()); + actionTypeRegistry.register(getIndexActionType()); + actionTypeRegistry.register(getPagerDutyActionType()); + actionTypeRegistry.register(getWebhookActionType()); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx new file mode 100644 index 00000000000000..582315c95812a4 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx @@ -0,0 +1,179 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.pagerduty'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('apps'); + }); +}); + +describe('pagerduty connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + routingKey: 'test', + }, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: [], + }, + }); + + delete actionConnector.config.apiUrl; + actionConnector.secrets.routingKey = 'test1'; + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: ['A routing key is required.'], + }, + }); + }); +}); + +describe('pagerduty action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + eventAction: 'trigger', + dedupKey: 'test', + summary: '2323', + source: 'source', + severity: 'critical', + timestamp: '234654564654', + component: 'test', + group: 'group', + class: 'test class', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: {}, + }); + }); +}); + +describe('PagerDutyActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + expect(actionTypeModel.actionConnectorFields).not.toBeNull(); + if (!actionTypeModel.actionConnectorFields) { + return; + } + const ConnectorFields = actionTypeModel.actionConnectorFields; + const actionConnector = { + secrets: { + routingKey: 'test', + }, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as ActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="pagerdutyApiUrlInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="pagerdutyApiUrlInput"]') + .first() + .prop('value') + ).toBe('http:\\test'); + expect(wrapper.find('[data-test-subj="pagerdutyRoutingKeyInput"]').length > 0).toBeTruthy(); + }); +}); + +describe('PagerDutyParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + eventAction: 'trigger', + dedupKey: 'test', + summary: '2323', + source: 'source', + severity: 'critical', + timestamp: '234654564654', + component: 'test', + group: 'group', + class: 'test class', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="severitySelect"]') + .first() + .prop('value') + ).toStrictEqual('critical'); + expect(wrapper.find('[data-test-subj="eventActionSelect"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="dedupKeyInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="timestampInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="componentInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="groupInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="sourceInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="pagerdutyDescriptionInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx new file mode 100644 index 00000000000000..69c7ec166df60e --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx @@ -0,0 +1,361 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiLink, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + ActionTypeModel, + ActionConnectorFieldsProps, + ActionConnector, + ValidationResult, + ActionParamsProps, +} from '../../../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.pagerduty', + iconClass: 'apps', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.selectMessageText', + { + defaultMessage: 'Send an event in PagerDuty.', + } + ), + validateConnector: (action: ActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + routingKey: new Array(), + }; + validationResult.errors = errors; + if (!action.secrets.routingKey) { + errors.routingKey.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', + { + defaultMessage: 'A routing key is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: PagerDutyActionConnectorFields, + actionParamsFields: PagerDutyParamsFields, + }; +} + +const PagerDutyActionConnectorFields: React.FunctionComponent = ({ + errors, + action, + editActionConfig, + editActionSecrets, +}) => { + const { apiUrl } = action.config; + const { routingKey } = action.secrets; + return ( + + + ) => { + editActionConfig('apiUrl', e.target.value); + }} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + /> + + + + + } + error={errors.routingKey} + isInvalid={errors.routingKey.length > 0 && routingKey !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel', + { + defaultMessage: 'Routing key', + } + )} + > + 0 && routingKey !== undefined} + name="routingKey" + value={routingKey || ''} + data-test-subj="pagerdutyRoutingKeyInput" + onChange={(e: React.ChangeEvent) => { + editActionSecrets('routingKey', e.target.value); + }} + onBlur={() => { + if (!routingKey) { + editActionSecrets('routingKey', ''); + } + }} + /> + + + ); +}; + +const PagerDutyParamsFields: React.FunctionComponent = ({ + action, + editAction, + index, + errors, + hasErrors, +}) => { + const { eventAction, dedupKey, summary, source, severity, timestamp, component, group } = action; + const severityOptions = [ + { value: 'critical', text: 'Critical' }, + { value: 'info', text: 'Info' }, + { value: 'warning', text: 'Warning' }, + { value: 'error', text: 'Error' }, + ]; + const eventActionOptions = [ + { value: 'trigger', text: 'Trigger' }, + { value: 'resolve', text: 'Resolve' }, + { value: 'acknowledge', text: 'Acknowledge' }, + ]; + return ( + + + + + { + editAction('severity', e.target.value, index); + }} + /> + + + + + { + editAction('eventAction', e.target.value, index); + }} + /> + + + + + + + ) => { + editAction('dedupKey', e.target.value, index); + }} + onBlur={() => { + if (!index) { + editAction('dedupKey', '', index); + } + }} + /> + + + + + ) => { + editAction('timestamp', e.target.value, index); + }} + onBlur={() => { + if (!index) { + editAction('timestamp', '', index); + } + }} + /> + + + + + ) => { + editAction('component', e.target.value, index); + }} + onBlur={() => { + if (!index) { + editAction('component', '', index); + } + }} + /> + + + ) => { + editAction('group', e.target.value, index); + }} + onBlur={() => { + if (!index) { + editAction('group', '', index); + } + }} + /> + + + ) => { + editAction('source', e.target.value, index); + }} + onBlur={() => { + if (!index) { + editAction('source', '', index); + } + }} + /> + + + ) => { + editAction('summary', e.target.value, index); + }} + onBlur={() => { + if (!summary) { + editAction('summary', '', index); + } + }} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx new file mode 100644 index 00000000000000..b79be4eef523bc --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.server-log'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logsApp'); + }); +}); + +describe('server-log connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.server-log', + name: 'server-log', + config: {}, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: {}, + }); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + message: 'test message', + level: 'trace', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { message: [] }, + }); + }); +}); + +describe('ServerLogParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + message: 'test message', + level: 'trace', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="loggingLevelSelect"]') + .first() + .prop('value') + ).toStrictEqual('trace'); + expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); + }); + + test('level param field is rendered with default value if not selected', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + message: 'test message', + level: 'info', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="loggingLevelSelect"]') + .first() + .prop('value') + ).toStrictEqual('info'); + expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); + }); + + test('params validation fails when message is not valid', () => { + const actionParams = { + message: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + message: ['Message is required.'], + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx new file mode 100644 index 00000000000000..885061aa819243 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSelect, EuiTextArea, EuiFormRow } from '@elastic/eui'; +import { ActionTypeModel, ValidationResult, ActionParamsProps } from '../../../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.server-log', + iconClass: 'logsApp', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText', + { + defaultMessage: 'Add a message to a Kibana log.', + } + ), + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.message || actionParams.message.length === 0) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServerLogMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: ServerLogParamsFields, + }; +} + +export const ServerLogParamsFields: React.FunctionComponent = ({ + action, + editAction, + index, + errors, + hasErrors, +}) => { + const { message, level } = action; + const levelOptions = [ + { value: 'trace', text: 'Trace' }, + { value: 'debug', text: 'Debug' }, + { value: 'info', text: 'Info' }, + { value: 'warn', text: 'Warning' }, + { value: 'error', text: 'Error' }, + { value: 'fatal', text: 'Fatal' }, + ]; + + // Set default value 'info' for level param + editAction('level', 'info', index); + + return ( + + + { + editAction('level', e.target.value, index); + }} + /> + + + { + editAction('message', e.target.value, index); + }} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx new file mode 100644 index 00000000000000..36beea4d2f2be2 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx @@ -0,0 +1,152 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.slack'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logoSlack'); + }); +}); + +describe('slack connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + webhookUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: ['Webhook URL is required.'], + }, + }); + }); +}); + +describe('slack action params validation', () => { + test('if action params validation succeeds when action params is valid', () => { + const actionParams = { + message: 'message {test}', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { message: [] }, + }); + }); +}); + +describe('SlackActionFields renders', () => { + test('all connector fields is rendered', () => { + expect(actionTypeModel.actionConnectorFields).not.toBeNull(); + if (!actionTypeModel.actionConnectorFields) { + return; + } + const ConnectorFields = actionTypeModel.actionConnectorFields; + const actionConnector = { + secrets: { + webhookUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as ActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + /> + ); + expect(wrapper.find('[data-test-subj="slackWebhookUrlInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="slackWebhookUrlInput"]') + .first() + .prop('value') + ).toBe('http:\\test'); + }); +}); + +describe('SlackParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + message: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="slackMessageTextarea"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="slackMessageTextarea"]') + .first() + .prop('value') + ).toStrictEqual('test message'); + }); + + test('params validation fails when message is not valid', () => { + const actionParams = { + message: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + message: ['Message is required.'], + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx new file mode 100644 index 00000000000000..0ae51725169bf1 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { + EuiFieldText, + EuiTextArea, + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiFormRow, + EuiLink, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + ActionTypeModel, + ActionConnectorFieldsProps, + ActionConnector, + ValidationResult, + ActionParamsProps, +} from '../../../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.slack', + iconClass: 'logoSlack', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.selectMessageText', + { + defaultMessage: 'Send a message to a Slack channel or user.', + } + ), + validateConnector: (action: ActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + webhookUrl: new Array(), + }; + validationResult.errors = errors; + if (!action.secrets.webhookUrl) { + errors.webhookUrl.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.message || actionParams.message.length === 0) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: SlackActionFields, + actionParamsFields: SlackParamsFields, + }; +} + +const SlackActionFields: React.FunctionComponent = ({ + action, + editActionSecrets, + errors, +}) => { + const { webhookUrl } = action.secrets; + + return ( + + + + + } + error={errors.webhookUrl} + isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel', + { + defaultMessage: 'Webhook URL', + } + )} + > + 0 && webhookUrl !== undefined} + name="webhookUrl" + placeholder="URL like https://hooks.slack.com/services" + value={webhookUrl || ''} + data-test-subj="slackWebhookUrlInput" + onChange={e => { + editActionSecrets('webhookUrl', e.target.value); + }} + onBlur={() => { + if (!webhookUrl) { + editActionSecrets('webhookUrl', ''); + } + }} + /> + + + ); +}; + +const SlackParamsFields: React.FunctionComponent = ({ + action, + editAction, + index, + errors, + hasErrors, +}) => { + const { message } = action; + + return ( + + + + window.alert('Button clicked')} + iconType="indexOpen" + aria-label="Add variable" + /> + + + + { + editAction('message', e.target.value, index); + }} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx new file mode 100644 index 00000000000000..cd342f2e199693 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx @@ -0,0 +1,174 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.webhook'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logoWebhook'); + }); +}); + +describe('webhook connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.webhook', + name: 'webhook', + config: { + method: 'PUT', + url: 'http:\\test', + headers: ['content-type: text'], + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + url: [], + method: [], + user: [], + password: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: { + user: 'user', + }, + id: 'test', + actionTypeId: '.webhook', + name: 'webhook', + config: { + method: 'PUT', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + url: ['URL is required.'], + method: [], + user: [], + password: ['Password is required.'], + }, + }); + }); +}); + +describe('webhook action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + body: 'message {test}', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { body: [] }, + }); + }); +}); + +describe('WebhookActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + expect(actionTypeModel.actionConnectorFields).not.toBeNull(); + if (!actionTypeModel.actionConnectorFields) { + return; + } + const ConnectorFields = actionTypeModel.actionConnectorFields; + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.webhook', + name: 'webhook', + config: { + method: 'PUT', + url: 'http:\\test', + headers: ['content-type: text'], + }, + } as ActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + /> + ); + expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy(); + wrapper + .find('[data-test-subj="webhookViewHeadersSwitch"]') + .first() + .simulate('click'); + expect(wrapper.find('[data-test-subj="webhookMethodSelect"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookUrlText"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookUserInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookPasswordInput"]').length > 0).toBeTruthy(); + }); +}); + +describe('WebhookParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + body: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="webhookBodyEditor"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="webhookBodyEditor"]') + .first() + .prop('value') + ).toStrictEqual('test message'); + }); + + test('params validation fails when body is not valid', () => { + const actionParams = { + body: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + body: ['Body is required.'], + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx new file mode 100644 index 00000000000000..70a9a6f3d75b3a --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx @@ -0,0 +1,501 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiFieldPassword, + EuiFieldText, + EuiFormRow, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiButtonIcon, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiTitle, + EuiCodeEditor, + EuiSwitch, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + ActionTypeModel, + ActionConnectorFieldsProps, + ActionConnector, + ValidationResult, + ActionParamsProps, +} from '../../../types'; + +const HTTP_VERBS = ['post', 'put']; + +export function getActionType(): ActionTypeModel { + return { + id: '.webhook', + iconClass: 'logoWebhook', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.selectMessageText', + { + defaultMessage: 'Send a request to a web service.', + } + ), + validateConnector: (action: ActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + url: new Array(), + method: new Array(), + user: new Array(), + password: new Array(), + }; + validationResult.errors = errors; + if (!action.config.url) { + errors.url.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', + { + defaultMessage: 'URL is required.', + } + ) + ); + } + if (!action.config.method) { + errors.method.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', + { + defaultMessage: 'Method is required.', + } + ) + ); + } + if (!action.secrets.user) { + errors.user.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHostText', + { + defaultMessage: 'Username is required.', + } + ) + ); + } + if (!action.secrets.password) { + errors.password.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', + { + defaultMessage: 'Password is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + body: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.body || actionParams.body.length === 0) { + errors.body.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', + { + defaultMessage: 'Body is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: WebhookActionConnectorFields, + actionParamsFields: WebhookParamsFields, + }; +} + +const WebhookActionConnectorFields: React.FunctionComponent = ({ + action, + editActionConfig, + editActionSecrets, + errors, +}) => { + const [httpHeaderKey, setHttpHeaderKey] = useState(''); + const [httpHeaderValue, setHttpHeaderValue] = useState(''); + const [hasHeaders, setHasHeaders] = useState(false); + + const { user, password } = action.secrets; + const { method, url, headers } = action.config; + + editActionConfig('method', 'post'); // set method to POST by default + + const headerErrors = { + keyHeader: new Array(), + valueHeader: new Array(), + }; + if (!httpHeaderKey && httpHeaderValue) { + headerErrors.keyHeader.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHeaderKeyText', + { + defaultMessage: 'Header key is required.', + } + ) + ); + } + if (httpHeaderKey && !httpHeaderValue) { + headerErrors.valueHeader.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHeaderValueText', + { + defaultMessage: 'Header value is required.', + } + ) + ); + } + const hasHeaderErrors = headerErrors.keyHeader.length > 0 || headerErrors.valueHeader.length > 0; + + function addHeader() { + if (headers && !!Object.keys(headers).find(key => key === httpHeaderKey)) { + return; + } + const updatedHeaders = headers + ? { ...headers, [httpHeaderKey]: httpHeaderValue } + : { [httpHeaderKey]: httpHeaderValue }; + editActionConfig('headers', updatedHeaders); + setHttpHeaderKey(''); + setHttpHeaderValue(''); + } + + function viewHeaders() { + setHasHeaders(!hasHeaders); + if (!hasHeaders) { + editActionConfig('headers', {}); + } + } + + function removeHeader(keyToRemove: string) { + const updatedHeaders = Object.keys(headers) + .filter(key => key !== keyToRemove) + .reduce((headerToRemove: Record, key: string) => { + headerToRemove[key] = headers[key]; + return headerToRemove; + }, {}); + editActionConfig('headers', updatedHeaders); + } + + let headerControl; + if (hasHeaders) { + headerControl = ( + + +
+ +
+
+ + + + + { + setHttpHeaderKey(e.target.value); + }} + /> + + + + + { + setHttpHeaderValue(e.target.value); + }} + /> + + + + + addHeader()} + > + + + + + +
+ ); + } + + const headersList = Object.keys(headers || {}).map((key: string) => { + return ( + + + removeHeader(key)} + /> + + + + {key} + {headers[key]} + + + + ); + }); + + return ( + + + + + ({ text: verb.toUpperCase(), value: verb }))} + onChange={e => { + editActionConfig('method', e.target.value); + }} + /> + + + + 0 && url !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.urlTextFieldLabel', + { + defaultMessage: 'URL', + } + )} + > + 0 && url !== undefined} + fullWidth + value={url || ''} + data-test-subj="webhookUrlText" + onChange={e => { + editActionConfig('url', e.target.value); + }} + onBlur={() => { + if (!url) { + editActionConfig('url', ''); + } + }} + /> + + + + + + 0 && user !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.userTextFieldLabel', + { + defaultMessage: 'Username', + } + )} + > + 0 && user !== undefined} + name="user" + value={user || ''} + data-test-subj="webhookUserInput" + onChange={e => { + editActionSecrets('user', e.target.value); + }} + onBlur={() => { + if (!user) { + editActionSecrets('user', ''); + } + }} + /> + + + + 0 && password !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.passwordTextFieldLabel', + { + defaultMessage: 'Password', + } + )} + > + 0 && password !== undefined} + value={password || ''} + data-test-subj="webhookPasswordInput" + onChange={e => { + editActionSecrets('password', e.target.value); + }} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + /> + + + + + + viewHeaders()} + /> + + +
+ {hasHeaders && Object.keys(headers || {}).length > 0 ? ( + + + +
+ +
+
+ + {headersList} +
+ ) : null} + + {headerControl} + +
+
+ ); +}; + +const WebhookParamsFields: React.FunctionComponent = ({ + action, + editAction, + index, + errors, + hasErrors, +}) => { + const { body } = action; + + return ( + + + { + editAction('body', json, index); + }} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/index.ts new file mode 100644 index 00000000000000..6c5d440e47888f --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/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 { getActionType as getThresholdAlertType } from './threshold/expression'; +import { TypeRegistry } from '../../type_registry'; +import { AlertTypeModel } from '../../../types'; + +export function registerBuiltInAlertTypes({ + alertTypeRegistry, +}: { + alertTypeRegistry: TypeRegistry; +}) { + alertTypeRegistry.register(getThresholdAlertType()); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/aggregation_types.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/aggregation_types.ts new file mode 100644 index 00000000000000..68c2818502b2c2 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/aggregation_types.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. + */ + +export const AGGREGATION_TYPES: { [key: string]: string } = { + COUNT: 'count', + + AVERAGE: 'avg', + + SUM: 'sum', + + MIN: 'min', + + MAX: 'max', +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/comparators.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/comparators.ts new file mode 100644 index 00000000000000..21b350c0f8ce41 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/comparators.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const COMPARATORS: { [key: string]: string } = { + GREATER_THAN: '>', + GREATER_THAN_OR_EQUALS: '>=', + BETWEEN: 'between', + LESS_THAN: '<', + LESS_THAN_OR_EQUALS: '<=', +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/index.ts new file mode 100644 index 00000000000000..f88ee5ee23f901 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { COMPARATORS } from './comparators'; +export { AGGREGATION_TYPES } from './aggregation_types'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx new file mode 100644 index 00000000000000..907a61677b2631 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -0,0 +1,1000 @@ +/* + * 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, { useState, Fragment, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiExpression, + EuiPopover, + EuiPopoverTitle, + EuiSelect, + EuiSpacer, + EuiComboBox, + EuiFieldNumber, + EuiComboBoxOptionProps, + EuiText, + EuiFormRow, + EuiCallOut, +} from '@elastic/eui'; +import { AlertTypeModel, Alert, ValidationResult } from '../../../../types'; +import { Comparator, AggregationType, GroupByType } from './types'; +import { AGGREGATION_TYPES, COMPARATORS } from './constants'; +import { + getMatchingIndicesForThresholdAlertType, + getThresholdAlertTypeFields, + loadIndexPatterns, +} from './lib/api'; +import { useAppDependencies } from '../../../app_context'; +import { getTimeOptions, getTimeFieldOptions } from '../../../lib/get_time_options'; +import { getTimeUnitLabel } from '../../../lib/get_time_unit_label'; +import { ThresholdVisualization } from './visualization'; + +const DEFAULT_VALUES = { + AGGREGATION_TYPE: 'count', + TERM_SIZE: 5, + THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, + TIME_WINDOW_SIZE: 5, + TIME_WINDOW_UNIT: 'm', + TRIGGER_INTERVAL_SIZE: 1, + TRIGGER_INTERVAL_UNIT: 'm', + THRESHOLD: [1000, 5000], + GROUP_BY: 'all', +}; + +const expressionFieldsWithValidation = [ + 'index', + 'timeField', + 'aggField', + 'termSize', + 'termField', + 'threshold0', + 'threshold1', + 'timeWindowSize', +]; + +const validateAlertType = (alert: Alert): ValidationResult => { + const { + index, + timeField, + aggType, + aggField, + groupBy, + termSize, + termField, + threshold, + timeWindowSize, + } = alert.params; + const validationResult = { errors: {} }; + const errors = { + aggField: new Array(), + termSize: new Array(), + termField: new Array(), + timeWindowSize: new Array(), + threshold0: new Array(), + threshold1: new Array(), + index: new Array(), + timeField: new Array(), + }; + validationResult.errors = errors; + if (!index) { + errors.index.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredIndexText', { + defaultMessage: 'Index is required.', + }) + ); + } + if (!timeField) { + errors.timeField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTimeFieldText', { + defaultMessage: 'Time field is required.', + }) + ); + } + if (aggType && aggregationTypes[aggType].fieldRequired && !aggField) { + errors.aggField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredAggFieldText', { + defaultMessage: 'Aggregation field is required.', + }) + ); + } + if (!termSize) { + errors.termSize.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTermSizedText', { + defaultMessage: 'Term size is required.', + }) + ); + } + if (groupBy && groupByTypes[groupBy].sizeRequired && !termField) { + errors.termField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredtTermFieldText', { + defaultMessage: 'Term field is required.', + }) + ); + } + if (!timeWindowSize) { + errors.timeWindowSize.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTimeWindowSizeText', { + defaultMessage: 'Time window size is required.', + }) + ); + } + if (threshold && threshold.length > 0 && !threshold[0]) { + errors.threshold0.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold0Text', { + defaultMessage: 'Threshold0, is required.', + }) + ); + } + if (threshold && threshold.length > 1 && !threshold[1]) { + errors.threshold1.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold1Text', { + defaultMessage: 'Threshold1 is required.', + }) + ); + } + return validationResult; +}; + +export function getActionType(): AlertTypeModel { + return { + id: 'threshold', + name: 'Index Threshold', + iconClass: 'alert', + alertParamsExpression: IndexThresholdAlertTypeExpression, + validate: validateAlertType, + }; +} + +export const aggregationTypes: { [key: string]: AggregationType } = { + count: { + text: 'count()', + fieldRequired: false, + value: AGGREGATION_TYPES.COUNT, + validNormalizedTypes: [], + }, + avg: { + text: 'average()', + fieldRequired: true, + validNormalizedTypes: ['number'], + value: AGGREGATION_TYPES.AVERAGE, + }, + sum: { + text: 'sum()', + fieldRequired: true, + validNormalizedTypes: ['number'], + value: AGGREGATION_TYPES.SUM, + }, + min: { + text: 'min()', + fieldRequired: true, + validNormalizedTypes: ['number', 'date'], + value: AGGREGATION_TYPES.MIN, + }, + max: { + text: 'max()', + fieldRequired: true, + validNormalizedTypes: ['number', 'date'], + value: AGGREGATION_TYPES.MAX, + }, +}; + +export const comparators: { [key: string]: Comparator } = { + [COMPARATORS.GREATER_THAN]: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.comparators.isAboveLabel', + { + defaultMessage: 'Is above', + } + ), + value: COMPARATORS.GREATER_THAN, + requiredValues: 1, + }, + [COMPARATORS.GREATER_THAN_OR_EQUALS]: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.comparators.isAboveOrEqualsLabel', + { + defaultMessage: 'Is above or equals', + } + ), + value: COMPARATORS.GREATER_THAN_OR_EQUALS, + requiredValues: 1, + }, + [COMPARATORS.LESS_THAN]: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.comparators.isBelowLabel', + { + defaultMessage: 'Is below', + } + ), + value: COMPARATORS.LESS_THAN, + requiredValues: 1, + }, + [COMPARATORS.LESS_THAN_OR_EQUALS]: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.comparators.isBelowOrEqualsLabel', + { + defaultMessage: 'Is below or equals', + } + ), + value: COMPARATORS.LESS_THAN_OR_EQUALS, + requiredValues: 1, + }, + [COMPARATORS.BETWEEN]: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.comparators.isBetweenLabel', + { + defaultMessage: 'Is between', + } + ), + value: COMPARATORS.BETWEEN, + requiredValues: 2, + }, +}; + +export const groupByTypes: { [key: string]: GroupByType } = { + all: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.groupByLabel.allDocumentsLabel', + { + defaultMessage: 'all documents', + } + ), + sizeRequired: false, + value: 'all', + validNormalizedTypes: [], + }, + top: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.groupByLabel.topLabel', + { + defaultMessage: 'top', + } + ), + sizeRequired: true, + value: 'top', + validNormalizedTypes: ['number', 'date', 'keyword'], + }, +}; + +interface Props { + alert: Alert; + setAlertParams: (property: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; + errors: { [key: string]: string[] }; + hasErrors?: boolean; +} + +export const IndexThresholdAlertTypeExpression: React.FunctionComponent = ({ + alert, + setAlertParams, + setAlertProperty, + errors, + hasErrors, +}) => { + const { http } = useAppDependencies(); + + const { + index, + timeField, + aggType, + aggField, + groupBy, + termSize, + termField, + thresholdComparator, + threshold, + timeWindowSize, + timeWindowUnit, + } = alert.params; + + const firstFieldOption = { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.timeFieldOptionLabel', + { + defaultMessage: 'Select a field', + } + ), + value: '', + }; + + const [aggTypePopoverOpen, setAggTypePopoverOpen] = useState(false); + const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); + const [indexPatterns, setIndexPatterns] = useState([]); + const [esFields, setEsFields] = useState>([]); + const [indexOptions, setIndexOptions] = useState([]); + const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); + const [isIndiciesLoading, setIsIndiciesLoading] = useState(false); + const [alertThresholdPopoverOpen, setAlertThresholdPopoverOpen] = useState(false); + const [alertDurationPopoverOpen, setAlertDurationPopoverOpen] = useState(false); + const [aggFieldPopoverOpen, setAggFieldPopoverOpen] = useState(false); + const [groupByPopoverOpen, setGroupByPopoverOpen] = useState(false); + + const andThresholdText = i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.andLabel', + { + defaultMessage: 'AND', + } + ); + + const hasExpressionErrors = !!Object.keys(errors).find( + errorKey => expressionFieldsWithValidation.includes(errorKey) && errors[errorKey].length >= 1 + ); + + const getIndexPatterns = async () => { + const indexPatternObjects = await loadIndexPatterns(); + const titles = indexPatternObjects.map((indexPattern: any) => indexPattern.attributes.title); + setIndexPatterns(titles); + }; + + const expressionErrorMessage = i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.fixErrorInExpressionBelowValidationMessage', + { + defaultMessage: 'Expression contains errors.', + } + ); + + const setDefaultExpressionValues = () => { + setAlertProperty('params', { + aggType: DEFAULT_VALUES.AGGREGATION_TYPE, + termSize: DEFAULT_VALUES.TERM_SIZE, + thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, + timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, + triggerIntervalUnit: DEFAULT_VALUES.TRIGGER_INTERVAL_UNIT, + groupBy: DEFAULT_VALUES.GROUP_BY, + threshold: DEFAULT_VALUES.THRESHOLD, + }); + }; + + const getFields = async (indexes: string[]) => { + return await getThresholdAlertTypeFields({ indexes, http }); + }; + + useEffect(() => { + getIndexPatterns(); + }, []); + + useEffect(() => { + setDefaultExpressionValues(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + interface IOption { + label: string; + options: Array<{ value: string; label: string }>; + } + + const getIndexOptions = async (pattern: string, indexPatternsParam: string[]) => { + const options: IOption[] = []; + + if (!pattern) { + return options; + } + + const matchingIndices = (await getMatchingIndicesForThresholdAlertType({ + pattern, + http, + })) as string[]; + const matchingIndexPatterns = indexPatternsParam.filter(anIndexPattern => { + return anIndexPattern.includes(pattern); + }) as string[]; + + if (matchingIndices.length || matchingIndexPatterns.length) { + const matchingOptions = _.uniq([...matchingIndices, ...matchingIndexPatterns]); + + options.push({ + label: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.indicesAndIndexPatternsLabel', + { + defaultMessage: 'Based on your indices and index patterns', + } + ), + options: matchingOptions.map(match => { + return { + label: match, + value: match, + }; + }), + }); + } + + options.push({ + label: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.threshold.chooseLabel', { + defaultMessage: 'Choose…', + }), + options: [ + { + value: pattern, + label: pattern, + }, + ], + }); + + return options; + }; + + const indexPopover = ( + + + + + + } + isInvalid={hasErrors && index !== undefined} + error={errors.index} + helpText={ + + } + > + { + return { + label: anIndex, + value: anIndex, + }; + })} + onChange={async (selected: EuiComboBoxOptionProps[]) => { + setAlertParams( + 'index', + selected.map(aSelected => aSelected.value) + ); + const indices = selected.map(s => s.value as string); + + // reset time field and expression fields if indices are deleted + if (indices.length === 0) { + setTimeFieldOptions([firstFieldOption]); + setAlertParams('timeFields', []); + + setDefaultExpressionValues(); + return; + } + const currentEsFields = await getFields(indices); + const timeFields = getTimeFieldOptions(currentEsFields as any); + + setEsFields(currentEsFields); + setAlertParams('timeFields', timeFields); + setTimeFieldOptions([firstFieldOption, ...timeFields]); + }} + onSearchChange={async search => { + setIsIndiciesLoading(true); + setIndexOptions(await getIndexOptions(search, indexPatterns)); + setIsIndiciesLoading(false); + }} + onBlur={() => { + if (!index) { + setAlertParams('index', []); + } + }} + /> + + + + + } + isInvalid={hasErrors && timeField !== undefined} + error={errors.timeField} + > + { + setAlertParams('timeField', e.target.value); + }} + onBlur={() => { + if (timeField === undefined) { + setAlertParams('timeField', ''); + } + }} + /> + + + + + + ); + + return ( + + {hasExpressionErrors ? ( + + + + + + ) : null} + + + { + setIndexPopoverOpen(true); + }} + color={index ? 'secondary' : 'danger'} + /> + } + isOpen={indexPopoverOpen} + closePopover={() => { + setIndexPopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + zIndex={8000} + > +
+ + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.indexButtonLabel', + { + defaultMessage: 'index', + } + )} + + {indexPopover} +
+
+
+ + { + setAggTypePopoverOpen(true); + }} + /> + } + isOpen={aggTypePopoverOpen} + closePopover={() => { + setAggTypePopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + > +
+ + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.whenButtonLabel', + { + defaultMessage: 'when', + } + )} + + { + setAlertParams('aggType', e.target.value); + setAggTypePopoverOpen(false); + }} + options={Object.values(aggregationTypes).map(({ text, value }) => { + return { + text, + value, + }; + })} + /> +
+
+
+ {aggType && aggregationTypes[aggType].fieldRequired ? ( + + { + setAggFieldPopoverOpen(true); + }} + color={aggField ? 'secondary' : 'danger'} + /> + } + isOpen={aggFieldPopoverOpen} + closePopover={() => { + setAggFieldPopoverOpen(false); + }} + anchorPosition="downLeft" + > +
+ + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.ofButtonLabel', + { + defaultMessage: 'of', + } + )} + + + + + { + if ( + aggregationTypes[aggType].validNormalizedTypes.includes( + field.normalizedType + ) + ) { + esFieldOptions.push({ + label: field.name, + }); + } + return esFieldOptions; + }, [])} + selectedOptions={aggField ? [{ label: aggField }] : []} + onChange={selectedOptions => { + setAlertParams( + 'aggField', + selectedOptions.length === 1 ? selectedOptions[0].label : undefined + ); + setAggFieldPopoverOpen(false); + }} + /> + + + +
+
+
+ ) : null} + + { + setGroupByPopoverOpen(true); + }} + color={groupBy === 'all' || (termSize && termField) ? 'secondary' : 'danger'} + /> + } + isOpen={groupByPopoverOpen} + closePopover={() => { + setGroupByPopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + > +
+ + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.overButtonLabel', + { + defaultMessage: 'over', + } + )} + + + + { + setAlertParams('termSize', null); + setAlertParams('termField', null); + setAlertParams('groupBy', e.target.value); + }} + options={Object.values(groupByTypes).map(({ text, value }) => { + return { + text, + value, + }; + })} + /> + + + {groupByTypes[groupBy || DEFAULT_VALUES.GROUP_BY].sizeRequired ? ( + + + + { + const { value } = e.target; + const termSizeVal = value !== '' ? parseFloat(value) : value; + setAlertParams('termSize', termSizeVal); + }} + min={1} + /> + + + + + { + setAlertParams('termField', e.target.value); + }} + options={esFields.reduce( + (options: any, field: any) => { + if ( + groupByTypes[ + groupBy || DEFAULT_VALUES.GROUP_BY + ].validNormalizedTypes.includes(field.normalizedType) + ) { + options.push({ + text: field.name, + value: field.name, + }); + } + return options; + }, + [firstFieldOption] + )} + /> + + + + ) : null} + +
+
+
+ + { + setAlertThresholdPopoverOpen(true); + }} + color={ + (errors.threshold0 && errors.threshold0.length) || + (errors.threshold1 && errors.threshold1.length) + ? 'danger' + : 'secondary' + } + /> + } + isOpen={alertThresholdPopoverOpen} + closePopover={() => { + setAlertThresholdPopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + > +
+ + {comparators[thresholdComparator || DEFAULT_VALUES.THRESHOLD_COMPARATOR].text} + + + + { + setAlertParams('thresholdComparator', e.target.value); + }} + options={Object.values(comparators).map(({ text, value }) => { + return { text, value }; + })} + /> + + {Array.from( + Array( + comparators[thresholdComparator || DEFAULT_VALUES.THRESHOLD_COMPARATOR] + .requiredValues + ) + ).map((_notUsed, i) => { + return ( + + {i > 0 ? ( + + {andThresholdText} + {hasErrors && } + + ) : null} + + + { + const { value } = e.target; + const thresholdVal = value !== '' ? parseFloat(value) : value; + const newThreshold = [...threshold]; + newThreshold[i] = thresholdVal; + setAlertParams('threshold', newThreshold); + }} + /> + + + + ); + })} + +
+
+
+ + { + setAlertDurationPopoverOpen(true); + }} + color={timeWindowSize ? 'secondary' : 'danger'} + /> + } + isOpen={alertDurationPopoverOpen} + closePopover={() => { + setAlertDurationPopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + > +
+ + + + + + + { + const { value } = e.target; + const timeWindowSizeVal = value !== '' ? parseInt(value, 10) : value; + setAlertParams('timeWindowSize', timeWindowSizeVal); + }} + /> + + + + { + setAlertParams('timeWindowUnit', e.target.value); + }} + options={getTimeOptions(timeWindowSize)} + /> + + +
+
+
+
+ {hasExpressionErrors ? null : ( + + + + )} +
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/lib/api.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/lib/api.ts new file mode 100644 index 00000000000000..956007049a8216 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/lib/api.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 { HttpSetup } from 'kibana/public'; + +const WATCHER_API_ROOT = '/api/watcher'; + +// TODO: replace watcher api with the proper from alerts + +export async function getMatchingIndicesForThresholdAlertType({ + pattern, + http, +}: { + pattern: string; + http: HttpSetup; +}): Promise> { + if (!pattern.startsWith('*')) { + pattern = `*${pattern}`; + } + if (!pattern.endsWith('*')) { + pattern = `${pattern}*`; + } + const { indices } = await http.post(`${WATCHER_API_ROOT}/indices`, { + body: JSON.stringify({ pattern }), + }); + return indices; +} + +export async function getThresholdAlertTypeFields({ + indexes, + http, +}: { + indexes: string[]; + http: HttpSetup; +}): Promise> { + const { fields } = await http.post(`${WATCHER_API_ROOT}/fields`, { + body: JSON.stringify({ indexes }), + }); + return fields; +} + +let savedObjectsClient: any; + +export const setSavedObjectsClient = (aSavedObjectsClient: any) => { + savedObjectsClient = aSavedObjectsClient; +}; + +export const getSavedObjectsClient = () => { + return savedObjectsClient; +}; + +export const loadIndexPatterns = async () => { + const { savedObjects } = await getSavedObjectsClient().find({ + type: 'index-pattern', + fields: ['title'], + perPage: 10000, + }); + return savedObjects; +}; + +export async function getThresholdAlertVisualizationData({ + model, + visualizeOptions, + http, +}: { + model: any; + visualizeOptions: any; + http: HttpSetup; +}): Promise> { + const { visualizeData } = await http.post(`${WATCHER_API_ROOT}/watch/visualize`, { + body: JSON.stringify({ + watch: model, + options: visualizeOptions, + }), + }); + return visualizeData; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/types.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/types.ts new file mode 100644 index 00000000000000..fd2a401fe59f3b --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Comparator { + text: string; + value: string; + requiredValues: number; +} + +export interface AggregationType { + text: string; + fieldRequired: boolean; + value: string; + validNormalizedTypes: string[]; +} + +export interface GroupByType { + text: string; + sizeRequired: boolean; + value: string; + validNormalizedTypes: string[]; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx new file mode 100644 index 00000000000000..8433c585ef3e5b --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx @@ -0,0 +1,303 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useEffect, useState } from 'react'; +import { IUiSettingsClient } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { + AnnotationDomainTypes, + Axis, + getAxisId, + getSpecId, + Chart, + LineAnnotation, + LineSeries, + Position, + ScaleType, + Settings, +} from '@elastic/charts'; +import { TimeBuckets } from 'ui/time_buckets'; +import dateMath from '@elastic/datemath'; +import moment from 'moment-timezone'; +import { EuiCallOut, EuiLoadingChart, EuiSpacer, EuiEmptyPrompt, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { npStart } from 'ui/new_platform'; +import { getThresholdAlertVisualizationData } from './lib/api'; +import { comparators, aggregationTypes } from './expression'; +import { useAppDependencies } from '../../../app_context'; +import { Alert } from '../../../../types'; + +const customTheme = () => { + return { + lineSeriesStyle: { + line: { + strokeWidth: 3, + }, + point: { + visible: false, + }, + }, + }; +}; + +const getTimezone = (uiSettings: IUiSettingsClient) => { + const config = uiSettings; + const DATE_FORMAT_CONFIG_KEY = 'dateFormat:tz'; + const isCustomTimezone = !config.isDefault(DATE_FORMAT_CONFIG_KEY); + if (isCustomTimezone) { + return config.get(DATE_FORMAT_CONFIG_KEY); + } + + const detectedTimezone = moment.tz.guess(); + if (detectedTimezone) { + return detectedTimezone; + } + // default to UTC if we can't figure out the timezone + const tzOffset = moment().format('Z'); + return tzOffset; +}; + +const getDomain = (alertParams: any) => { + const VISUALIZE_TIME_WINDOW_MULTIPLIER = 5; + const fromExpression = `now-${alertParams.timeWindowSize * VISUALIZE_TIME_WINDOW_MULTIPLIER}${ + alertParams.timeWindowUnit + }`; + const toExpression = 'now'; + const fromMoment = dateMath.parse(fromExpression); + const toMoment = dateMath.parse(toExpression); + const visualizeTimeWindowFrom = fromMoment ? fromMoment.valueOf() : 0; + const visualizeTimeWindowTo = toMoment ? toMoment.valueOf() : 0; + return { + min: visualizeTimeWindowFrom, + max: visualizeTimeWindowTo, + }; +}; + +const getThreshold = (alertParams: any) => { + return alertParams.threshold.slice( + 0, + comparators[alertParams.thresholdComparator].requiredValues + ); +}; + +const getTimeBuckets = (alertParams: any) => { + const domain = getDomain(alertParams); + const timeBuckets = new TimeBuckets(); + timeBuckets.setBounds(domain); + return timeBuckets; +}; + +interface Props { + alert: Alert; +} + +export const ThresholdVisualization: React.FunctionComponent = ({ alert }) => { + const { http, uiSettings, toastNotifications } = useAppDependencies(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + const [visualizationData, setVisualizationData] = useState>([]); + + const chartsTheme = npStart.plugins.eui_utils.useChartsTheme(); + const { + index, + timeField, + triggerIntervalSize, + triggerIntervalUnit, + aggType, + aggField, + termSize, + termField, + thresholdComparator, + timeWindowSize, + timeWindowUnit, + groupBy, + threshold, + } = alert.params; + + const domain = getDomain(alert.params); + const timeBuckets = new TimeBuckets(); + timeBuckets.setBounds(domain); + const interval = timeBuckets.getInterval().expression; + const visualizeOptions = { + rangeFrom: domain.min, + rangeTo: domain.max, + interval, + timezone: getTimezone(uiSettings), + }; + + // Fetching visualization data is independent of alert actions + const alertWithoutActions = { ...alert.params, actions: [], type: 'threshold' }; + + useEffect(() => { + (async () => { + try { + setIsLoading(true); + setVisualizationData( + await getThresholdAlertVisualizationData({ + model: alertWithoutActions, + visualizeOptions, + http, + }) + ); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.unableToLoadVisualizationMessage', + { defaultMessage: 'Unable to load visualization' } + ), + }); + setError(e); + } finally { + setIsLoading(false); + } + })(); + /* eslint-disable react-hooks/exhaustive-deps */ + }, [ + index, + timeField, + triggerIntervalSize, + triggerIntervalUnit, + aggType, + aggField, + termSize, + termField, + thresholdComparator, + timeWindowSize, + timeWindowUnit, + groupBy, + threshold, + ]); + /* eslint-enable react-hooks/exhaustive-deps */ + + if (isLoading) { + return ( + } + body={ + + + + } + /> + ); + } + + if (error) { + return ( + + + + } + color="danger" + iconType="alert" + > + {error} + + + + ); + } + + if (visualizationData) { + const alertVisualizationDataKeys = Object.keys(visualizationData); + const timezone = getTimezone(uiSettings); + const actualThreshold = getThreshold(alert.params); + let maxY = actualThreshold[actualThreshold.length - 1]; + + (Object.values(visualizationData) as number[][][]).forEach(data => { + data.forEach(([, y]) => { + if (y > maxY) { + maxY = y; + } + }); + }); + const dateFormatter = (d: number) => { + return moment(d) + .tz(timezone) + .format(getTimeBuckets(alert.params).getScaledDateFormat()); + }; + const aggLabel = aggregationTypes[aggType].text; + return ( +
+ + {alertVisualizationDataKeys.length ? ( + + + + + {alertVisualizationDataKeys.map((key: string) => { + return ( + + ); + })} + {actualThreshold.map((_value: any, i: number) => { + const specId = i === 0 ? 'threshold' : `threshold${i}`; + return ( + + ); + })} + + ) : ( + + } + color="warning" + > + + + )} + +
+ ); + } + + return null; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/delete_connectors_modal.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/delete_connectors_modal.tsx new file mode 100644 index 00000000000000..b7d1a4ffe29664 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/delete_connectors_modal.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useAppDependencies } from '../app_context'; +import { deleteActions } from '../lib/action_connector_api'; + +export const DeleteConnectorsModal = ({ + connectorsToDelete, + callback, +}: { + connectorsToDelete: string[]; + callback: (deleted?: string[]) => void; +}) => { + const { http, toastNotifications } = useAppDependencies(); + const numConnectorsToDelete = connectorsToDelete.length; + if (!numConnectorsToDelete) { + return null; + } + const confirmModalText = i18n.translate( + 'xpack.triggersActionsUI.deleteSelectedConnectorsConfirmModal.descriptionText', + { + defaultMessage: + "You can't recover {numConnectorsToDelete, plural, one {a deleted connector} other {deleted connectors}}.", + values: { numConnectorsToDelete }, + } + ); + const confirmButtonText = i18n.translate( + 'xpack.triggersActionsUI.deleteSelectedConnectorsConfirmModal.deleteButtonLabel', + { + defaultMessage: + 'Delete {numConnectorsToDelete, plural, one {connector} other {# connectors}} ', + values: { numConnectorsToDelete }, + } + ); + const cancelButtonText = i18n.translate( + 'xpack.triggersActionsUI.deleteSelectedConnectorsConfirmModal.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + ); + return ( + + callback()} + onConfirm={async () => { + const { successes, errors } = await deleteActions({ ids: connectorsToDelete, http }); + const numSuccesses = successes.length; + const numErrors = errors.length; + callback(successes); + if (numSuccesses > 0) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.connectorsList.deleteSelectedConnectorsSuccessNotification.descriptionText', + { + defaultMessage: + 'Deleted {numSuccesses, number} {numSuccesses, plural, one {connector} other {connectors}}', + values: { numSuccesses }, + } + ) + ); + } + + if (numErrors > 0) { + toastNotifications.addDanger( + i18n.translate( + 'xpack.triggersActionsUI.sections.connectorsList.deleteSelectedConnectorsErrorNotification.descriptionText', + { + defaultMessage: + 'Failed to delete {numErrors, number} {numErrors, plural, one {connector} other {connectors}}', + values: { numErrors }, + } + ) + ); + } + }} + cancelButtonText={cancelButtonText} + confirmButtonText={confirmButtonText} + > + {confirmModalText} + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/section_loading.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/section_loading.tsx new file mode 100644 index 00000000000000..4c6273682a0e4c --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/section_loading.tsx @@ -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. + */ + +import React from 'react'; + +import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui'; + +interface Props { + children: React.ReactNode; +} + +export const SectionLoading: React.FunctionComponent = ({ children }) => { + return ( + } + body={{children}} + data-test-subj="sectionLoading" + /> + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts new file mode 100644 index 00000000000000..83a03010d55ad6 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum ACTION_GROUPS { + ALERT = 'alert', + WARNING = 'warning', + UNACKNOWLEDGED = 'unacknowledged', +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts new file mode 100644 index 00000000000000..a8364ffe21019d --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const BASE_PATH = '/management/kibana/triggersActions'; +export const BASE_ACTION_API_PATH = '/api/action'; +export const BASE_ALERT_API_PATH = '/api/alert'; + +export type Section = 'connectors' | 'alerts'; + +export const routeToHome = `${BASE_PATH}`; +export const routeToConnectors = `${BASE_PATH}/connectors`; +export const routeToAlerts = `${BASE_PATH}/alerts`; + +export { TIME_UNITS } from './time_units'; +export enum SORT_ORDERS { + ASCENDING = 'asc', + DESCENDING = 'desc', +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/plugin.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/plugin.ts new file mode 100644 index 00000000000000..63ba7df2556de3 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/plugin.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const PLUGIN = { + ID: 'triggers_actions_ui', + getI18nName: (i18n: any): string => { + return i18n.translate('xpack.triggersActionsUI.appName', { + defaultMessage: 'Alerts and Actions', + }); + }, +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/time_units.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/time_units.ts new file mode 100644 index 00000000000000..2a4f8fbd421ed6 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/time_units.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum TIME_UNITS { + SECOND = 's', + MINUTE = 'm', + HOUR = 'h', + DAY = 'd', +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/actions_connectors_context.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/actions_connectors_context.tsx new file mode 100644 index 00000000000000..11786950d0f263 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/actions_connectors_context.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, useContext } from 'react'; +import { ActionType } from '../../types'; + +export interface ActionsConnectorsContextValue { + addFlyoutVisible: boolean; + editFlyoutVisible: boolean; + setEditFlyoutVisibility: React.Dispatch>; + setAddFlyoutVisibility: React.Dispatch>; + actionTypesIndex: Record | undefined; + reloadConnectors: () => Promise; +} + +const ActionsConnectorsContext = createContext(null as any); + +export const ActionsConnectorsContextProvider = ({ + children, + value, +}: { + value: ActionsConnectorsContextValue; + children: React.ReactNode; +}) => { + return ( + {children} + ); +}; + +export const useActionsConnectorsContext = () => { + const ctx = useContext(ActionsConnectorsContext); + if (!ctx) { + throw new Error('ActionsConnectorsContext has not been set.'); + } + return ctx; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx new file mode 100644 index 00000000000000..06be1bb7c58516 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, createContext } from 'react'; + +export interface AlertsContextValue { + alertFlyoutVisible: boolean; + setAlertFlyoutVisibility: React.Dispatch>; +} + +const AlertsContext = createContext(null as any); + +export const AlertsContextProvider = ({ + children, + value, +}: { + value: AlertsContextValue; + children: React.ReactNode; +}) => { + return {children}; +}; + +export const useAlertsContext = () => { + const ctx = useContext(AlertsContext); + if (!ctx) { + throw new Error('ActionsConnectorsContext has not been set.'); + } + return ctx; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/home.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/home.tsx new file mode 100644 index 00000000000000..3312f1a103b29a --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/home.tsx @@ -0,0 +1,126 @@ +/* + * 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, { useEffect } from 'react'; +import { Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiSpacer, + EuiTab, + EuiTabs, + EuiTitle, +} from '@elastic/eui'; + +import { BASE_PATH, Section, routeToConnectors, routeToAlerts } from './constants'; +import { getCurrentBreadcrumb } from './lib/breadcrumb'; +import { getCurrentDocTitle } from './lib/doc_title'; +import { useAppDependencies } from './app_context'; +import { hasShowActionsCapability, hasShowAlertsCapability } from './lib/capabilities'; + +import { ActionsConnectorsList } from './sections/actions_connectors_list/components/actions_connectors_list'; +import { AlertsList } from './sections/alerts_list/components/alerts_list'; + +interface MatchParams { + section: Section; +} + +export const TriggersActionsUIHome: React.FunctionComponent> = ({ + match: { + params: { section }, + }, + history, +}) => { + const { + chrome, + legacy: { MANAGEMENT_BREADCRUMB, capabilities }, + } = useAppDependencies(); + + const canShowActions = hasShowActionsCapability(capabilities.get()); + const canShowAlerts = hasShowAlertsCapability(capabilities.get()); + const tabs: Array<{ + id: Section; + name: React.ReactNode; + }> = []; + + if (canShowAlerts) { + tabs.push({ + id: 'alerts', + name: ( + + ), + }); + } + + if (canShowActions) { + tabs.push({ + id: 'connectors', + name: ( + + ), + }); + } + + const onSectionChange = (newSection: Section) => { + history.push(`${BASE_PATH}/${newSection}`); + }; + + // Set breadcrumb and page title + useEffect(() => { + chrome.setBreadcrumbs([MANAGEMENT_BREADCRUMB, getCurrentBreadcrumb(section || 'home')]); + chrome.docTitle.change(getCurrentDocTitle(section || 'home')); + }, [section, chrome, MANAGEMENT_BREADCRUMB]); + + return ( + + + + + +

+ +

+
+
+
+ + + {tabs.map(tab => ( + onSectionChange(tab.id)} + isSelected={tab.id === section} + key={tab.id} + data-test-subj={`${tab.id}Tab`} + > + {tab.name} + + ))} + + + + + + {canShowActions && ( + + )} + {canShowAlerts && } + +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts new file mode 100644 index 00000000000000..bc2949917edea7 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts @@ -0,0 +1,135 @@ +/* + * 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 { ActionConnector, ActionConnectorWithoutId, ActionType } from '../../types'; +import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { + createActionConnector, + deleteActions, + loadActionTypes, + loadAllActions, + updateActionConnector, +} from './action_connector_api'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('loadActionTypes', () => { + test('should call get types API', async () => { + const resolvedValue: ActionType[] = [ + { + id: 'test', + name: 'Test', + }, + ]; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadActionTypes({ http }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/action/types", + ] + `); + }); +}); + +describe('loadAllActions', () => { + test('should call find actions API', async () => { + const resolvedValue = { + page: 1, + perPage: 10000, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAllActions({ http }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/action/_find", + Object { + "query": Object { + "per_page": 10000, + }, + }, + ] + `); + }); +}); + +describe('createActionConnector', () => { + test('should call create action API', async () => { + const connector: ActionConnectorWithoutId = { + actionTypeId: 'test', + name: 'My test', + config: {}, + secrets: {}, + }; + const resolvedValue: ActionConnector = { ...connector, id: '123' }; + http.post.mockResolvedValueOnce(resolvedValue); + + const result = await createActionConnector({ http, connector }); + expect(result).toEqual(resolvedValue); + expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/action", + Object { + "body": "{\\"actionTypeId\\":\\"test\\",\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{}}", + }, + ] + `); + }); +}); + +describe('updateActionConnector', () => { + test('should call the update API', async () => { + const id = '123'; + const connector: ActionConnectorWithoutId = { + actionTypeId: 'test', + name: 'My test', + config: {}, + secrets: {}, + }; + const resolvedValue = { ...connector, id }; + http.put.mockResolvedValueOnce(resolvedValue); + + const result = await updateActionConnector({ http, connector, id }); + expect(result).toEqual(resolvedValue); + expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/action/123", + Object { + "body": "{\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{}}", + }, + ] + `); + }); +}); + +describe('deleteActions', () => { + test('should call delete API per action', async () => { + const ids = ['1', '2', '3']; + + const result = await deleteActions({ ids, http }); + expect(result).toEqual({ errors: [], successes: [undefined, undefined, undefined] }); + expect(http.delete.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/action/1", + ], + Array [ + "/api/action/2", + ], + Array [ + "/api/action/3", + ], + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.ts new file mode 100644 index 00000000000000..5b2b59603d281d --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.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 { HttpSetup } from 'kibana/public'; +import { BASE_ACTION_API_PATH } from '../constants'; +import { ActionConnector, ActionConnectorWithoutId, ActionType } from '../../types'; + +// We are assuming there won't be many actions. This is why we will load +// all the actions in advance and assume the total count to not go over 100 or so. +// We'll set this max setting assuming it's never reached. +const MAX_ACTIONS_RETURNED = 10000; + +export async function loadActionTypes({ http }: { http: HttpSetup }): Promise { + return await http.get(`${BASE_ACTION_API_PATH}/types`); +} + +export async function loadAllActions({ + http, +}: { + http: HttpSetup; +}): Promise<{ + page: number; + perPage: number; + total: number; + data: ActionConnector[]; +}> { + return await http.get(`${BASE_ACTION_API_PATH}/_find`, { + query: { + per_page: MAX_ACTIONS_RETURNED, + }, + }); +} + +export async function createActionConnector({ + http, + connector, +}: { + http: HttpSetup; + connector: Omit; +}): Promise { + return await http.post(`${BASE_ACTION_API_PATH}`, { + body: JSON.stringify(connector), + }); +} + +export async function updateActionConnector({ + http, + connector, + id, +}: { + http: HttpSetup; + connector: Pick; + id: string; +}): Promise { + return await http.put(`${BASE_ACTION_API_PATH}/${id}`, { + body: JSON.stringify({ + name: connector.name, + config: connector.config, + secrets: connector.secrets, + }), + }); +} + +export async function deleteActions({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise<{ successes: string[]; errors: string[] }> { + const successes: string[] = []; + const errors: string[] = []; + await Promise.all(ids.map(id => http.delete(`${BASE_ACTION_API_PATH}/${id}`))).then( + function(fulfilled) { + successes.push(...fulfilled); + }, + function(rejected) { + errors.push(...rejected); + } + ); + return { successes, errors }; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts new file mode 100644 index 00000000000000..858c90258247ec --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts @@ -0,0 +1,406 @@ +/* + * 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 { Alert, AlertType } from '../../types'; +import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { + createAlert, + deleteAlerts, + disableAlerts, + enableAlerts, + loadAlerts, + loadAlertTypes, + muteAlerts, + unmuteAlerts, + updateAlert, +} from './alert_api'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('loadAlertTypes', () => { + test('should call get alert types API', async () => { + const resolvedValue: AlertType[] = [ + { + id: 'test', + name: 'Test', + }, + ]; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlertTypes({ http }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/types", + ] + `); + }); +}); + +describe('loadAlerts', () => { + test('should call find API with base parameters', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ http, page: { index: 0, size: 10 } }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + }, + }, + ] + `); + }); + + test('should call find API with searchText', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ http, searchText: 'apples', page: { index: 0, size: 10 } }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": "apples", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); + + test('should call find API with actionTypesFilter', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + searchText: 'foo', + page: { index: 0, size: 10 }, + }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": "foo", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); + + test('should call find API with typesFilter', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + typesFilter: ['foo', 'bar'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + }, + }, + ] + `); + }); + + test('should call find API with actionTypesFilter and typesFilter', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + searchText: 'baz', + typesFilter: ['foo', 'bar'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": "baz", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); + + test('should call find API with searchText and tagsFilter and typesFilter', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + searchText: 'apples, foo, baz', + typesFilter: ['foo', 'bar'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": "apples, foo, baz", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); +}); + +describe('deleteAlerts', () => { + test('should call delete API for each alert', async () => { + const ids = ['1', '2', '3']; + const result = await deleteAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.delete.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1", + ], + Array [ + "/api/alert/2", + ], + Array [ + "/api/alert/3", + ], + ] + `); + }); +}); + +describe('createAlert', () => { + test('should call create alert API', async () => { + const alertToCreate = { + name: 'test', + tags: ['foo'], + enabled: true, + alertTypeId: 'test', + interval: '1m', + actions: [], + params: {}, + throttle: null, + }; + const resolvedValue: Alert = { + ...alertToCreate, + id: '123', + createdBy: null, + updatedBy: null, + muteAll: false, + mutedInstanceIds: [], + }; + http.post.mockResolvedValueOnce(resolvedValue); + + const result = await createAlert({ http, alert: alertToCreate }); + expect(result).toEqual(resolvedValue); + expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert", + Object { + "body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"enabled\\":true,\\"alertTypeId\\":\\"test\\",\\"interval\\":\\"1m\\",\\"actions\\":[],\\"params\\":{},\\"throttle\\":null}", + }, + ] + `); + }); +}); + +describe('updateAlert', () => { + test('should call alert update API', async () => { + const alertToUpdate = { + throttle: '1m', + name: 'test', + tags: ['foo'], + interval: '1m', + params: {}, + actions: [], + }; + const resolvedValue: Alert = { + ...alertToUpdate, + id: '123', + enabled: true, + alertTypeId: 'test', + createdBy: null, + updatedBy: null, + muteAll: false, + mutedInstanceIds: [], + }; + http.put.mockResolvedValueOnce(resolvedValue); + + const result = await updateAlert({ http, id: '123', alert: alertToUpdate }); + expect(result).toEqual(resolvedValue); + expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/123", + Object { + "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"interval\\":\\"1m\\",\\"params\\":{},\\"actions\\":[]}", + }, + ] + `); + }); +}); + +describe('enableAlerts', () => { + test('should call enable alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await enableAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_enable", + ], + Array [ + "/api/alert/2/_enable", + ], + Array [ + "/api/alert/3/_enable", + ], + ] + `); + }); +}); + +describe('disableAlerts', () => { + test('should call disable alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await disableAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_disable", + ], + Array [ + "/api/alert/2/_disable", + ], + Array [ + "/api/alert/3/_disable", + ], + ] + `); + }); +}); + +describe('muteAlerts', () => { + test('should call mute alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await muteAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_mute_all", + ], + Array [ + "/api/alert/2/_mute_all", + ], + Array [ + "/api/alert/3/_mute_all", + ], + ] + `); + }); +}); + +describe('unmuteAlerts', () => { + test('should call unmute alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await unmuteAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_unmute_all", + ], + Array [ + "/api/alert/2/_unmute_all", + ], + Array [ + "/api/alert/3/_unmute_all", + ], + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts new file mode 100644 index 00000000000000..9867acbd7a622a --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts @@ -0,0 +1,126 @@ +/* + * 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 { HttpSetup } from 'kibana/public'; +import { BASE_ALERT_API_PATH } from '../constants'; +import { Alert, AlertType, AlertWithoutId } from '../../types'; + +export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { + return await http.get(`${BASE_ALERT_API_PATH}/types`); +} + +export async function loadAlerts({ + http, + page, + searchText, + typesFilter, + actionTypesFilter, +}: { + http: HttpSetup; + page: { index: number; size: number }; + searchText?: string; + typesFilter?: string[]; + actionTypesFilter?: string[]; +}): Promise<{ + page: number; + perPage: number; + total: number; + data: Alert[]; +}> { + const filters = []; + if (typesFilter && typesFilter.length) { + filters.push(`alert.attributes.alertTypeId:(${typesFilter.join(' or ')})`); + } + if (actionTypesFilter && actionTypesFilter.length) { + filters.push( + [ + '(', + actionTypesFilter.map(id => `alert.attributes.actions:{ actionTypeId:${id} }`).join(' OR '), + ')', + ].join('') + ); + } + return await http.get(`${BASE_ALERT_API_PATH}/_find`, { + query: { + page: page.index + 1, + per_page: page.size, + search_fields: searchText ? JSON.stringify(['name', 'tags']) : undefined, + search: searchText, + filter: filters.length ? filters.join(' and ') : undefined, + default_search_operator: 'AND', + }, + }); +} + +export async function deleteAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map(id => http.delete(`${BASE_ALERT_API_PATH}/${id}`))); +} + +export async function createAlert({ + http, + alert, +}: { + http: HttpSetup; + alert: Omit; +}): Promise { + return await http.post(`${BASE_ALERT_API_PATH}`, { + body: JSON.stringify(alert), + }); +} + +export async function updateAlert({ + http, + alert, + id, +}: { + http: HttpSetup; + alert: Pick; + id: string; +}): Promise { + return await http.put(`${BASE_ALERT_API_PATH}/${id}`, { + body: JSON.stringify(alert), + }); +} + +export async function enableAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_enable`))); +} + +export async function disableAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_disable`))); +} + +export async function muteAlerts({ ids, http }: { ids: string[]; http: HttpSetup }): Promise { + await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_mute_all`))); +} + +export async function unmuteAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_unmute_all`))); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.test.ts new file mode 100644 index 00000000000000..b75e014640d722 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getCurrentBreadcrumb } from './breadcrumb'; +import { i18n } from '@kbn/i18n'; +import { routeToConnectors, routeToAlerts, routeToHome } from '../constants'; + +describe('getCurrentBreadcrumb', () => { + test('if change calls return proper breadcrumb title ', async () => { + expect(getCurrentBreadcrumb('connectors')).toMatchObject({ + text: i18n.translate('xpack.triggersActionsUI.connectors.breadcrumbTitle', { + defaultMessage: 'Connectors', + }), + href: `#${routeToConnectors}`, + }); + expect(getCurrentBreadcrumb('alerts')).toMatchObject({ + text: i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', { + defaultMessage: 'Alerts', + }), + href: `#${routeToAlerts}`, + }); + expect(getCurrentBreadcrumb('home')).toMatchObject({ + text: i18n.translate('xpack.triggersActionsUI.home.breadcrumbTitle', { + defaultMessage: 'Alerts and Actions', + }), + href: `#${routeToHome}`, + }); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.ts new file mode 100644 index 00000000000000..f833ae9eb39ac0 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { routeToHome, routeToConnectors, routeToAlerts } from '../constants'; + +export const getCurrentBreadcrumb = (type: string): { text: string; href: string } => { + // Home and sections + switch (type) { + case 'connectors': + return { + text: i18n.translate('xpack.triggersActionsUI.connectors.breadcrumbTitle', { + defaultMessage: 'Connectors', + }), + href: `#${routeToConnectors}`, + }; + case 'alerts': + return { + text: i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', { + defaultMessage: 'Alerts', + }), + href: `#${routeToAlerts}`, + }; + default: + return { + text: i18n.translate('xpack.triggersActionsUI.home.breadcrumbTitle', { + defaultMessage: 'Alerts and Actions', + }), + href: `#${routeToHome}`, + }; + } +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/capabilities.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/capabilities.ts new file mode 100644 index 00000000000000..e5693e31c2d663 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/capabilities.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * NOTE: Applications that want to show the alerting UIs will need to add + * check against their features here until we have a better solution. This + * will possibly go away with https://github.com/elastic/kibana/issues/52300. + */ + +export function hasShowAlertsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['alerting:show']) { + return true; + } + return false; +} + +export function hasShowActionsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['actions:show']) { + return true; + } + return false; +} + +export function hasSaveAlertsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['alerting:save']) { + return true; + } + return false; +} + +export function hasSaveActionsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['actions:save']) { + return true; + } + return false; +} + +export function hasDeleteAlertsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['alerting:delete']) { + return true; + } + return false; +} + +export function hasDeleteActionsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['actions:delete']) { + return true; + } + return false; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.test.ts new file mode 100644 index 00000000000000..f351adf79eb2c1 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.test.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getCurrentDocTitle } from './doc_title'; + +describe('getCurrentDocTitle', () => { + test('if change calls return the proper doc title ', async () => { + expect(getCurrentDocTitle('home') === 'Alerts and Actions').toBeTruthy(); + expect(getCurrentDocTitle('connectors') === 'Connectors').toBeTruthy(); + expect(getCurrentDocTitle('alerts') === 'Alerts').toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.ts new file mode 100644 index 00000000000000..15bd6bc77b1328 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; + +export const getCurrentDocTitle = (page: string): string => { + let updatedTitle: string; + + switch (page) { + case 'connectors': + updatedTitle = i18n.translate('xpack.triggersActionsUI.connectors.breadcrumbTitle', { + defaultMessage: 'Connectors', + }); + break; + case 'alerts': + updatedTitle = i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', { + defaultMessage: 'Alerts', + }); + break; + default: + updatedTitle = i18n.translate('xpack.triggersActionsUI.home.breadcrumbTitle', { + defaultMessage: 'Alerts and Actions', + }); + } + return updatedTitle; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.test.ts new file mode 100644 index 00000000000000..3ed7eea026db48 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getTimeOptions, getTimeFieldOptions } from './get_time_options'; + +describe('get_time_options', () => { + test('if getTimeOptions return single unit time options', () => { + const timeUnitValue = getTimeOptions('1'); + expect(timeUnitValue).toMatchObject([ + { text: 'second', value: 's' }, + { text: 'minute', value: 'm' }, + { text: 'hour', value: 'h' }, + { text: 'day', value: 'd' }, + ]); + }); + + test('if getTimeOptions return multiple unit time options', () => { + const timeUnitValue = getTimeOptions('10'); + expect(timeUnitValue).toMatchObject([ + { text: 'seconds', value: 's' }, + { text: 'minutes', value: 'm' }, + { text: 'hours', value: 'h' }, + { text: 'days', value: 'd' }, + ]); + }); + + test('if getTimeFieldOptions return only date type fields', () => { + const timeOnlyTypeFields = getTimeFieldOptions([ + { type: 'date', name: 'order_date' }, + { type: 'number', name: 'sum' }, + ]); + expect(timeOnlyTypeFields).toMatchObject([{ text: 'order_date', value: 'order_date' }]); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.ts new file mode 100644 index 00000000000000..d24f20a4fc289d --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getTimeUnitLabel } from './get_time_unit_label'; +import { TIME_UNITS } from '../constants'; + +export const getTimeOptions = (unitSize: string) => + Object.entries(TIME_UNITS).map(([_key, value]) => { + return { + text: getTimeUnitLabel(value, unitSize), + value, + }; + }); + +interface TimeFieldOptions { + text: string; + value: string; +} + +export const getTimeFieldOptions = ( + fields: Array<{ type: string; name: string }> +): TimeFieldOptions[] => { + const options: TimeFieldOptions[] = []; + + fields.forEach((field: { type: string; name: string }) => { + if (field.type === 'date') { + options.push({ + text: field.name, + value: field.name, + }); + } + }); + return options; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_unit_label.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_unit_label.ts new file mode 100644 index 00000000000000..a6218554153287 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_unit_label.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 { i18n } from '@kbn/i18n'; +import { TIME_UNITS } from '../constants'; + +export function getTimeUnitLabel(timeUnit = TIME_UNITS.SECOND, timeValue = '0') { + switch (timeUnit) { + case TIME_UNITS.SECOND: + return i18n.translate('xpack.triggersActionsUI.timeUnits.secondLabel', { + defaultMessage: '{timeValue, plural, one {second} other {seconds}}', + values: { timeValue }, + }); + case TIME_UNITS.MINUTE: + return i18n.translate('xpack.triggersActionsUI.timeUnits.minuteLabel', { + defaultMessage: '{timeValue, plural, one {minute} other {minutes}}', + values: { timeValue }, + }); + case TIME_UNITS.HOUR: + return i18n.translate('xpack.triggersActionsUI.timeUnits.hourLabel', { + defaultMessage: '{timeValue, plural, one {hour} other {hours}}', + values: { timeValue }, + }); + case TIME_UNITS.DAY: + return i18n.translate('xpack.triggersActionsUI.timeUnits.dayLabel', { + defaultMessage: '{timeValue, plural, one {day} other {days}}', + values: { timeValue }, + }); + } +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx new file mode 100644 index 00000000000000..c129ce73c7176e --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult, ActionConnector } from '../../../types'; +import { ActionConnectorForm } from './action_connector_form'; +import { AppContextProvider } from '../../app_context'; +const actionTypeRegistry = actionTypeRegistryMock.create(); + +describe('action_connector_form', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + actions: { + delete: true, + save: true, + show: true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + + const actionType = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValue(actionType); + actionTypeRegistry.has.mockReturnValue(true); + + const initialConnector = { + actionTypeId: actionType.id, + config: {}, + secrets: {}, + } as ActionConnector; + + await act(async () => { + wrapper = mountWithIntl( + + {}, + editFlyoutVisible: false, + setEditFlyoutVisibility: () => {}, + actionTypesIndex: { + 'my-action-type': { id: 'my-action-type', name: 'my-action-type-name' }, + }, + reloadConnectors: () => { + return new Promise(() => {}); + }, + }} + > + {}} + /> + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders action_connector_form', () => { + const connectorNameField = wrapper.find('[data-test-subj="nameInput"]'); + expect(connectorNameField.exists()).toBeTruthy(); + expect(connectorNameField.first().prop('value')).toBe(''); + }); +}); + +async function waitForRender(wrapper: ReactWrapper) { + await Promise.resolve(); + await Promise.resolve(); + wrapper.update(); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx new file mode 100644 index 00000000000000..682c1fbb54b67e --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx @@ -0,0 +1,270 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useState, useReducer } from 'react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiCallOut, + EuiLink, + EuiText, + EuiSpacer, + EuiButtonEmpty, + EuiFlyoutFooter, + EuiFieldText, + EuiFlyoutBody, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { createActionConnector, updateActionConnector } from '../../lib/action_connector_api'; +import { useAppDependencies } from '../../app_context'; +import { connectorReducer } from './connector_reducer'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { ActionConnector, IErrorObject } from '../../../types'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; + +interface ActionConnectorProps { + initialConnector: ActionConnector; + actionTypeName: string; + setFlyoutVisibility: React.Dispatch>; +} + +export const ActionConnectorForm = ({ + initialConnector, + actionTypeName, + setFlyoutVisibility, +}: ActionConnectorProps) => { + const { + http, + toastNotifications, + legacy: { capabilities }, + actionTypeRegistry, + } = useAppDependencies(); + + const { reloadConnectors } = useActionsConnectorsContext(); + const canSave = hasSaveActionsCapability(capabilities.get()); + + // hooks + const [{ connector }, dispatch] = useReducer(connectorReducer, { connector: initialConnector }); + + const setActionProperty = (key: string, value: any) => { + dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); + }; + + const setActionConfigProperty = (key: string, value: any) => { + dispatch({ command: { type: 'setConfigProperty' }, payload: { key, value } }); + }; + + const setActionSecretsProperty = (key: string, value: any) => { + dispatch({ command: { type: 'setSecretsProperty' }, payload: { key, value } }); + }; + + const [isSaving, setIsSaving] = useState(false); + const [serverError, setServerError] = useState<{ + body: { message: string; error: string }; + } | null>(null); + + const actionTypeRegistered = actionTypeRegistry.get(initialConnector.actionTypeId); + if (!actionTypeRegistered) return null; + + function validateBaseProperties(actionObject: ActionConnector) { + const validationResult = { errors: {} }; + const errors = { + name: new Array(), + }; + validationResult.errors = errors; + if (!actionObject.name) { + errors.name.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.error.requiredNameText', + { + defaultMessage: 'Name is required.', + } + ) + ); + } + return validationResult; + } + + const FieldsComponent = actionTypeRegistered.actionConnectorFields; + const errors = { + ...actionTypeRegistered.validateConnector(connector).errors, + ...validateBaseProperties(connector).errors, + } as IErrorObject; + const hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1); + + async function onActionConnectorSave(): Promise { + let message: string; + let savedConnector: ActionConnector | undefined; + let error; + if (connector.id === undefined) { + await createActionConnector({ http, connector }) + .then(res => { + savedConnector = res; + }) + .catch(errorRes => { + error = errorRes; + }); + + message = 'Created'; + } else { + await updateActionConnector({ http, connector, id: connector.id }) + .then(res => { + savedConnector = res; + }) + .catch(errorRes => { + error = errorRes; + }); + message = 'Updated'; + } + if (error) { + return { + error, + }; + } + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.updateSuccessNotificationText', + { + defaultMessage: "{message} '{connectorName}'", + values: { + message, + connectorName: savedConnector ? savedConnector.name : '', + }, + } + ) + ); + return savedConnector; + } + + return ( + + + + + } + isInvalid={errors.name.length > 0 && connector.name !== undefined} + error={errors.name} + > + 0 && connector.name !== undefined} + name="name" + placeholder="Untitled" + data-test-subj="nameInput" + value={connector.name || ''} + onChange={e => { + setActionProperty('name', e.target.value); + }} + onBlur={() => { + if (!connector.name) { + setActionProperty('name', ''); + } + }} + /> + + + {FieldsComponent !== null ? ( + + {initialConnector.actionTypeId === null ? ( + + + +

+ + + + ), + }} + /> +

+
+
+ +
+ ) : null} +
+ ) : null} +
+
+ + + + setFlyoutVisibility(false)}> + {i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + )} + + + {canSave ? ( + + { + setIsSaving(true); + const savedAction = await onActionConnectorSave(); + setIsSaving(false); + if (savedAction && savedAction.error) { + return setServerError(savedAction.error); + } + setFlyoutVisibility(false); + reloadConnectors(); + }} + > + + + + ) : null} + + +
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx new file mode 100644 index 00000000000000..a9e2afb0617207 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ActionTypeMenu } from './action_type_menu'; +import { ValidationResult } from '../../../types'; +import { AppContextProvider } from '../../app_context'; +const actionTypeRegistry = actionTypeRegistryMock.create(); + +describe('connector_add_flyout', () => { + let deps: any; + + beforeAll(async () => { + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + actions: { + delete: true, + save: true, + show: true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + }); + + it('renders action type menu with proper EuiCards for registered action types', () => { + const onActionTypeChange = jest.fn(); + const actionType = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValueOnce(actionType); + + const wrapper = mountWithIntl( + + {}, + editFlyoutVisible: false, + setEditFlyoutVisibility: state => {}, + actionTypesIndex: { + 'first-action-type': { id: 'first-action-type', name: 'first' }, + 'second-action-type': { id: 'second-action-type', name: 'second' }, + }, + reloadConnectors: () => { + return new Promise(() => {}); + }, + }} + > + + + + ); + + expect(wrapper.find('[data-test-subj="first-action-type-card"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="second-action-type-card"]').exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx new file mode 100644 index 00000000000000..19373fda79b9e7 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiCard, + EuiIcon, + EuiFlexGrid, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionType } from '../../../types'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { useAppDependencies } from '../../app_context'; + +interface Props { + onActionTypeChange: (actionType: ActionType) => void; +} + +export const ActionTypeMenu = ({ onActionTypeChange }: Props) => { + const { actionTypeRegistry } = useAppDependencies(); + const { actionTypesIndex, setAddFlyoutVisibility } = useActionsConnectorsContext(); + if (!actionTypesIndex) { + return null; + } + + const actionTypes = Object.entries(actionTypesIndex) + .filter(([index]) => actionTypeRegistry.has(index)) + .map(([index, actionType]) => { + const actionTypeModel = actionTypeRegistry.get(index); + return { + iconClass: actionTypeModel ? actionTypeModel.iconClass : '', + selectMessage: actionTypeModel ? actionTypeModel.selectMessage : '', + actionType, + name: actionType.name, + typeName: index.replace('.', ''), + }; + }); + + const cardNodes = actionTypes + .sort((a, b) => a.name.localeCompare(b.name)) + .map((item, index): any => { + return ( + + } + title={item.name} + description={item.selectMessage} + onClick={() => onActionTypeChange(item.actionType)} + /> + + ); + }); + + return ( + + + {cardNodes} + + + + + setAddFlyoutVisibility(false)}> + {i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + )} + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx new file mode 100644 index 00000000000000..5095cc140f9c98 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -0,0 +1,100 @@ +/* + * 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 * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { ConnectorAddFlyout } from './connector_add_flyout'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult } from '../../../types'; +import { AppContextProvider } from '../../app_context'; +const actionTypeRegistry = actionTypeRegistryMock.create(); + +describe('connector_add_flyout', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + actions: { + delete: true, + save: true, + show: true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + {}, + editFlyoutVisible: false, + setEditFlyoutVisibility: state => {}, + actionTypesIndex: { 'my-action-type': { id: 'my-action-type', name: 'test' } }, + reloadConnectors: () => { + return new Promise(() => {}); + }, + }} + > + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders action type menu on flyout open', () => { + const actionType = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValueOnce(actionType); + actionTypeRegistry.has.mockReturnValue(true); + + expect(wrapper.find('ActionTypeMenu')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); + }); +}); + +async function waitForRender(wrapper: ReactWrapper) { + await Promise.resolve(); + await Promise.resolve(); + wrapper.update(); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx new file mode 100644 index 00000000000000..a3ec7ab4b3ab96 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -0,0 +1,104 @@ +/* + * 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, { useCallback, useState, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiFlyoutHeader, + EuiFlyout, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiText, +} from '@elastic/eui'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { ActionTypeMenu } from './action_type_menu'; +import { ActionConnectorForm } from './action_connector_form'; +import { ActionType, ActionConnector } from '../../../types'; +import { useAppDependencies } from '../../app_context'; + +export const ConnectorAddFlyout = () => { + const { actionTypeRegistry } = useAppDependencies(); + const { addFlyoutVisible, setAddFlyoutVisibility } = useActionsConnectorsContext(); + const [actionType, setActionType] = useState(undefined); + const closeFlyout = useCallback(() => { + setAddFlyoutVisibility(false); + setActionType(undefined); + }, [setAddFlyoutVisibility, setActionType]); + + if (!addFlyoutVisible) { + return null; + } + + function onActionTypeChange(newActionType: ActionType) { + setActionType(newActionType); + } + + let currentForm; + let actionTypeModel; + if (!actionType) { + currentForm = ; + } else { + actionTypeModel = actionTypeRegistry.get(actionType.id); + const initialConnector = { + actionTypeId: actionType.id, + config: {}, + secrets: {}, + } as ActionConnector; + + currentForm = ( + + ); + } + + return ( + + + + {actionTypeModel && actionTypeModel.iconClass ? ( + + + + ) : null} + + {actionTypeModel && actionType ? ( + + +

+ +

+
+ + {actionTypeModel.selectMessage} + +
+ ) : ( + +

+ +

+
+ )} +
+
+
+ {currentForm} +
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx new file mode 100644 index 00000000000000..d01539d7232fae --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -0,0 +1,100 @@ +/* + * 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 * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult } from '../../../types'; +import { ConnectorEditFlyout } from './connector_edit_flyout'; +import { AppContextProvider } from '../../app_context'; +const actionTypeRegistry = actionTypeRegistryMock.create(); +let deps: any; + +describe('connector_edit_flyout', () => { + beforeAll(async () => { + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + actions: { + delete: true, + save: true, + show: true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + }); + + test('if input connector render correct in the edit form', () => { + const connector = { + secrets: {}, + id: 'test', + actionTypeId: 'test-action-type-id', + actionType: 'test-action-type-name', + name: 'action-connector', + referencedByCount: 0, + config: {}, + }; + + const actionType = { + id: 'test-action-type-id', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValue(actionType); + actionTypeRegistry.has.mockReturnValue(true); + + const wrapper = mountWithIntl( + + {}, + editFlyoutVisible: true, + setEditFlyoutVisibility: state => {}, + actionTypesIndex: { + 'test-action-type-id': { id: 'test-action-type-id', name: 'test' }, + }, + reloadConnectors: () => { + return new Promise(() => {}); + }, + }} + > + + + + ); + + const connectorNameField = wrapper.find('[data-test-subj="nameInput"]'); + expect(connectorNameField.exists()).toBeTruthy(); + expect(connectorNameField.first().prop('value')).toBe('action-connector'); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx new file mode 100644 index 00000000000000..408989609d2ec5 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiFlyoutHeader, + EuiFlyout, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, +} from '@elastic/eui'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { ActionConnectorForm } from './action_connector_form'; +import { useAppDependencies } from '../../app_context'; +import { ActionConnectorTableItem } from '../../../types'; + +export interface ConnectorEditProps { + connector: ActionConnectorTableItem; +} + +export const ConnectorEditFlyout = ({ connector }: ConnectorEditProps) => { + const { actionTypeRegistry } = useAppDependencies(); + const { editFlyoutVisible, setEditFlyoutVisibility } = useActionsConnectorsContext(); + const closeFlyout = useCallback(() => setEditFlyoutVisibility(false), [setEditFlyoutVisibility]); + + if (!editFlyoutVisible) { + return null; + } + + const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); + + return ( + + + + {actionTypeModel ? ( + + + + ) : null} + + +

+ +

+
+
+
+
+ +
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.test.ts new file mode 100644 index 00000000000000..df7e5d8fe9a78d --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { connectorReducer } from './connector_reducer'; +import { ActionConnector } from '../../../types'; + +describe('connector reducer', () => { + let initialConnector: ActionConnector; + beforeAll(() => { + initialConnector = { + secrets: {}, + id: 'test', + actionTypeId: 'test-action-type-id', + name: 'action-connector', + referencedByCount: 0, + config: {}, + }; + }); + + test('if property name was changed', () => { + const updatedConnector = connectorReducer( + { connector: initialConnector }, + { + command: { type: 'setProperty' }, + payload: { + key: 'name', + value: 'new name', + }, + } + ); + expect(updatedConnector.connector.name).toBe('new name'); + }); + + test('if config property was added and updated', () => { + const updatedConnector = connectorReducer( + { connector: initialConnector }, + { + command: { type: 'setConfigProperty' }, + payload: { + key: 'testConfig', + value: 'new test config property', + }, + } + ); + expect(updatedConnector.connector.config.testConfig).toBe('new test config property'); + + const updatedConnectorUpdatedProperty = connectorReducer( + { connector: updatedConnector.connector }, + { + command: { type: 'setConfigProperty' }, + payload: { + key: 'testConfig', + value: 'test config property updated', + }, + } + ); + expect(updatedConnectorUpdatedProperty.connector.config.testConfig).toBe( + 'test config property updated' + ); + }); + + test('if secrets property was added', () => { + const updatedConnector = connectorReducer( + { connector: initialConnector }, + { + command: { type: 'setSecretsProperty' }, + payload: { + key: 'testSecret', + value: 'new test secret property', + }, + } + ); + expect(updatedConnector.connector.secrets.testSecret).toBe('new test secret property'); + + const updatedConnectorUpdatedProperty = connectorReducer( + { connector: updatedConnector.connector }, + { + command: { type: 'setSecretsProperty' }, + payload: { + key: 'testSecret', + value: 'test secret property updated', + }, + } + ); + expect(updatedConnectorUpdatedProperty.connector.secrets.testSecret).toBe( + 'test secret property updated' + ); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.ts new file mode 100644 index 00000000000000..4a2610f965735d --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.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 { isEqual } from 'lodash'; + +interface CommandType { + type: 'setProperty' | 'setConfigProperty' | 'setSecretsProperty'; +} + +export interface ActionState { + connector: any; +} + +export interface ReducerAction { + command: CommandType; + payload: { + key: string; + value: any; + }; +} + +export const connectorReducer = (state: ActionState, action: ReducerAction) => { + const { command, payload } = action; + const { connector } = state; + + switch (command.type) { + case 'setProperty': { + const { key, value } = payload; + if (isEqual(connector[key], value)) { + return state; + } else { + return { + ...state, + connector: { + ...connector, + [key]: value, + }, + }; + } + } + case 'setConfigProperty': { + const { key, value } = payload; + if (isEqual(connector.config[key], value)) { + return state; + } else { + return { + ...state, + connector: { + ...connector, + config: { + ...connector.config, + [key]: value, + }, + }, + }; + } + } + case 'setSecretsProperty': { + const { key, value } = payload; + if (isEqual(connector.secrets[key], value)) { + return state; + } else { + return { + ...state, + connector: { + ...connector, + secrets: { + ...connector.secrets, + [key]: value, + }, + }, + }; + } + } + } +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/index.ts new file mode 100644 index 00000000000000..aac7a514948d14 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ConnectorAddFlyout } from './connector_add_flyout'; +export { ConnectorEditFlyout } from './connector_edit_flyout'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/_index.scss b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/_index.scss new file mode 100644 index 00000000000000..98c6c2a307a748 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/_index.scss @@ -0,0 +1 @@ +@import 'actions_connectors_list'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss new file mode 100644 index 00000000000000..7a824aaeaa8d8b --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss @@ -0,0 +1,3 @@ +.actConnectorsList__logo + .actConnectorsList__logo { + margin-left: $euiSize; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx new file mode 100644 index 00000000000000..511deb8cf3b0d7 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -0,0 +1,362 @@ +/* + * 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 * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ActionsConnectorsList } from './actions_connectors_list'; +import { coreMock } from '../../../../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; +import { AppContextProvider } from '../../../app_context'; +jest.mock('../../../lib/action_connector_api', () => ({ + loadAllActions: jest.fn(), + loadActionTypes: jest.fn(), +})); + +const actionTypeRegistry = actionTypeRegistryMock.create(); + +describe('actions_connectors_list component empty', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const { loadAllActions, loadActionTypes } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAllActions.mockResolvedValueOnce({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + loadActionTypes.mockResolvedValueOnce([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'actions:show': true, + 'actions:save': true, + 'actions:delete': true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + actionTypeRegistry.has.mockReturnValue(true); + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders empty prompt', () => { + expect(wrapper.find('EuiEmptyPrompt')).toHaveLength(1); + expect( + wrapper.find('[data-test-subj="createFirstActionButton"]').find('EuiButton') + ).toHaveLength(1); + }); + + test('if click create button should render ConnectorAddFlyout', () => { + wrapper + .find('[data-test-subj="createFirstActionButton"]') + .first() + .simulate('click'); + expect(wrapper.find('ConnectorAddFlyout')).toHaveLength(1); + }); +}); + +describe('actions_connectors_list component with items', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const { loadAllActions, loadActionTypes } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAllActions.mockResolvedValueOnce({ + page: 1, + perPage: 10000, + total: 2, + data: [ + { + id: '1', + actionTypeId: 'test', + description: 'My test', + referencedByCount: 1, + config: {}, + }, + { + id: '2', + actionTypeId: 'test2', + description: 'My test 2', + referencedByCount: 1, + config: {}, + }, + ], + }); + loadActionTypes.mockResolvedValueOnce([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'actions:show': true, + 'actions:save': true, + 'actions:delete': true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: { + get() { + return null; + }, + } as any, + alertTypeRegistry: {} as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + + expect(loadAllActions).toHaveBeenCalled(); + }); + + it('renders table of connectors', () => { + expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1); + expect(wrapper.find('EuiTableRow')).toHaveLength(2); + }); + + test('if select item for edit should render ConnectorEditFlyout', () => { + wrapper + .find('[data-test-subj="edit1"]') + .first() + .simulate('click'); + expect(wrapper.find('ConnectorEditFlyout')).toHaveLength(1); + }); +}); + +describe('actions_connectors_list component empty with show only capability', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const { loadAllActions, loadActionTypes } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAllActions.mockResolvedValueOnce({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + loadActionTypes.mockResolvedValueOnce([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'actions:show': true, + 'actions:save': false, + 'actions:delete': false, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: { + get() { + return null; + }, + } as any, + alertTypeRegistry: {} as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders no permissions to create connector', () => { + expect(wrapper.find('[defaultMessage="No permissions to create connector"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="createActionButton"]')).toHaveLength(0); + }); +}); + +describe('actions_connectors_list with show only capability', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const { loadAllActions, loadActionTypes } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAllActions.mockResolvedValueOnce({ + page: 1, + perPage: 10000, + total: 2, + data: [ + { + id: '1', + actionTypeId: 'test', + description: 'My test', + referencedByCount: 1, + config: {}, + }, + { + id: '2', + actionTypeId: 'test2', + description: 'My test 2', + referencedByCount: 1, + config: {}, + }, + ], + }); + loadActionTypes.mockResolvedValueOnce([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'actions:show': true, + 'actions:save': false, + 'actions:delete': false, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: { + get() { + return null; + }, + } as any, + alertTypeRegistry: {} as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders table of connectors with delete button disabled', () => { + expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1); + expect(wrapper.find('EuiTableRow')).toHaveLength(2); + wrapper.find('EuiTableRow').forEach(elem => { + const deleteButton = elem.find('[data-test-subj="deleteConnector"]').first(); + expect(deleteButton).toBeTruthy(); + expect(deleteButton.prop('isDisabled')).toBeTruthy(); + }); + }); +}); + +async function waitForRender(wrapper: ReactWrapper) { + await Promise.resolve(); + await Promise.resolve(); + wrapper.update(); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx new file mode 100644 index 00000000000000..1990ffefdf84e2 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -0,0 +1,399 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useState, useEffect } from 'react'; +import { + EuiBadge, + EuiInMemoryTable, + EuiSpacer, + EuiButton, + EuiIcon, + EuiEmptyPrompt, + EuiTitle, + EuiLink, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context'; +import { useAppDependencies } from '../../../app_context'; +import { loadAllActions, loadActionTypes } from '../../../lib/action_connector_api'; +import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types'; +import { ConnectorAddFlyout, ConnectorEditFlyout } from '../../action_connector_form'; +import { hasDeleteActionsCapability, hasSaveActionsCapability } from '../../../lib/capabilities'; +import { DeleteConnectorsModal } from '../../../components/delete_connectors_modal'; + +export const ActionsConnectorsList: React.FunctionComponent = () => { + const { + http, + toastNotifications, + legacy: { capabilities }, + } = useAppDependencies(); + const canDelete = hasDeleteActionsCapability(capabilities.get()); + const canSave = hasSaveActionsCapability(capabilities.get()); + + const [actionTypesIndex, setActionTypesIndex] = useState(undefined); + const [actions, setActions] = useState([]); + const [data, setData] = useState([]); + const [selectedItems, setSelectedItems] = useState([]); + const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); + const [isLoadingActions, setIsLoadingActions] = useState(false); + const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); + const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); + const [actionTypesList, setActionTypesList] = useState>( + [] + ); + const [editedConnectorItem, setEditedConnectorItem] = useState< + ActionConnectorTableItem | undefined + >(undefined); + const [connectorsToDelete, setConnectorsToDelete] = useState([]); + + useEffect(() => { + loadActions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + (async () => { + try { + setIsLoadingActionTypes(true); + const actionTypes = await loadActionTypes({ http }); + const index: ActionTypeIndex = {}; + for (const actionTypeItem of actionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setActionTypesIndex(index); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } finally { + setIsLoadingActionTypes(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // Avoid flickering before action types load + if (typeof actionTypesIndex === 'undefined') { + return; + } + // Update the data for the table + const updatedData = actions.map(action => { + return { + ...action, + actionType: actionTypesIndex[action.actionTypeId] + ? actionTypesIndex[action.actionTypeId].name + : action.actionTypeId, + }; + }); + setData(updatedData); + // Update the action types list for the filter + const actionTypes = Object.values(actionTypesIndex) + .map(actionType => ({ + value: actionType.id, + name: `${actionType.name} (${getActionsCountByActionType(actions, actionType.id)})`, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + setActionTypesList(actionTypes); + }, [actions, actionTypesIndex]); + + async function loadActions() { + setIsLoadingActions(true); + try { + const actionsResponse = await loadAllActions({ http }); + setActions(actionsResponse.data); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionsMessage', + { + defaultMessage: 'Unable to load actions', + } + ), + }); + } finally { + setIsLoadingActions(false); + } + } + + async function editItem(connectorTableItem: ActionConnectorTableItem) { + setEditedConnectorItem(connectorTableItem); + setEditFlyoutVisibility(true); + } + + const actionsTableColumns = [ + { + field: 'name', + 'data-test-subj': 'connectorsTableCell-name', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.nameTitle', + { + defaultMessage: 'Name', + } + ), + sortable: false, + truncateText: true, + render: (value: string, item: ActionConnectorTableItem) => { + return ( + editItem(item)} key={item.id}> + {value} + + ); + }, + }, + { + field: 'actionType', + 'data-test-subj': 'connectorsTableCell-actionType', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actionTypeTitle', + { + defaultMessage: 'Type', + } + ), + sortable: false, + truncateText: true, + }, + { + field: 'referencedByCount', + 'data-test-subj': 'connectorsTableCell-referencedByCount', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.referencedByCountTitle', + { defaultMessage: 'Actions' } + ), + sortable: false, + truncateText: true, + render: (value: number, item: ActionConnectorTableItem) => { + return ( + + {value} + + ); + }, + }, + { + field: '', + name: '', + actions: [ + { + enabled: () => canDelete, + 'data-test-subj': 'deleteConnector', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionName', + { defaultMessage: 'Delete' } + ), + description: canDelete + ? i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDescription', + { defaultMessage: 'Delete this action' } + ) + : i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDisabledDescription', + { defaultMessage: 'Unable to delete actions' } + ), + type: 'icon', + icon: 'trash', + color: 'danger', + onClick: (item: ActionConnectorTableItem) => setConnectorsToDelete([item.id]), + }, + ], + }, + ]; + + const table = ( + ({ + 'data-test-subj': 'connectors-row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + data-test-subj="actionsTable" + pagination={true} + selection={ + canDelete + ? { + onSelectionChange(updatedSelectedItemsList: ActionConnectorTableItem[]) { + setSelectedItems(updatedSelectedItemsList); + }, + } + : undefined + } + search={{ + filters: [ + { + type: 'field_value_selection', + field: 'actionTypeId', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.filters.actionTypeIdName', + { defaultMessage: 'Type' } + ), + multiSelect: 'or', + options: actionTypesList, + }, + ], + toolsLeft: + selectedItems.length === 0 || !canDelete + ? [] + : [ + { + setConnectorsToDelete(selectedItems.map((selected: any) => selected.id)); + }} + title={ + canDelete + ? undefined + : i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteDisabledTitle', + { defaultMessage: 'Unable to delete actions' } + ) + } + > + + , + ], + toolsRight: [ + setAddFlyoutVisibility(true)} + > + + , + ], + }} + /> + ); + + const emptyPrompt = ( + + + + + + +

+ +

+
+ + } + body={ +

+ +

+ } + actions={ + setAddFlyoutVisibility(true)} + > + + + } + /> + ); + + const noPermissionPrompt = ( +

+ +

+ ); + + return ( +
+ { + if (deleted) { + if (selectedItems.length === 0 || selectedItems.length === deleted.length) { + const updatedActions = actions.filter( + action => action.id && !connectorsToDelete.includes(action.id) + ); + setActions(updatedActions); + setSelectedItems([]); + } else { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.failedToDeleteActionsMessage', + { defaultMessage: 'Failed to delete action(s)' } + ), + }); + // Refresh the actions from the server, some actions may have beend deleted + loadActions(); + } + } + setConnectorsToDelete([]); + }} + connectorsToDelete={connectorsToDelete} + /> + + {/* Render the view based on if there's data or if they can save */} + {data.length !== 0 && table} + {data.length === 0 && canSave && emptyPrompt} + {data.length === 0 && !canSave && noPermissionPrompt} + + + {editedConnectorItem ? : null} + +
+ ); +}; + +function getActionsCountByActionType(actions: ActionConnector[], actionTypeId: string) { + return actions.filter(action => action.actionTypeId === actionTypeId).length; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx new file mode 100644 index 00000000000000..9380392112c8e9 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx @@ -0,0 +1,803 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useState, useCallback, useReducer, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTitle, + EuiForm, + EuiSpacer, + EuiButtonEmpty, + EuiFlyoutFooter, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyout, + EuiFieldText, + EuiFlexGrid, + EuiFormRow, + EuiComboBox, + EuiKeyPadMenuItem, + EuiTabs, + EuiTab, + EuiLink, + EuiFieldNumber, + EuiSelect, + EuiIconTip, + EuiPortal, + EuiAccordion, + EuiButtonIcon, +} from '@elastic/eui'; +import { useAppDependencies } from '../../app_context'; +import { createAlert } from '../../lib/alert_api'; +import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api'; +import { useAlertsContext } from '../../context/alerts_context'; +import { alertReducer } from './alert_reducer'; +import { + AlertTypeModel, + Alert, + IErrorObject, + ActionTypeModel, + AlertAction, + ActionTypeIndex, + ActionConnector, +} from '../../../types'; +import { ACTION_GROUPS } from '../../constants/action_groups'; +import { getTimeOptions } from '../../lib/get_time_options'; +import { SectionLoading } from '../../components/section_loading'; + +interface Props { + refreshList: () => Promise; +} + +function validateBaseProperties(alertObject: Alert) { + const validationResult = { errors: {} }; + const errors = { + name: new Array(), + interval: new Array(), + alertTypeId: new Array(), + actionConnectors: new Array(), + }; + validationResult.errors = errors; + if (!alertObject.name) { + errors.name.push( + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredNameText', { + defaultMessage: 'Name is required.', + }) + ); + } + if (!alertObject.interval) { + errors.interval.push( + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredIntervalText', { + defaultMessage: 'Check interval is required.', + }) + ); + } + if (!alertObject.alertTypeId) { + errors.alertTypeId.push( + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredAlertTypeIdText', { + defaultMessage: 'Alert trigger is required.', + }) + ); + } + return validationResult; +} + +export const AlertAdd = ({ refreshList }: Props) => { + const { http, toastNotifications, alertTypeRegistry, actionTypeRegistry } = useAppDependencies(); + const initialAlert = { + params: {}, + alertTypeId: null, + interval: '1m', + actions: [], + tags: [], + }; + + const { alertFlyoutVisible, setAlertFlyoutVisibility } = useAlertsContext(); + // hooks + const [alertType, setAlertType] = useState(undefined); + const [{ alert }, dispatch] = useReducer(alertReducer, { alert: initialAlert }); + const [isSaving, setIsSaving] = useState(false); + const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); + const [selectedTabId, setSelectedTabId] = useState('alert'); + const [actionTypesIndex, setActionTypesIndex] = useState(undefined); + const [alertInterval, setAlertInterval] = useState(null); + const [alertIntervalUnit, setAlertIntervalUnit] = useState('m'); + const [alertThrottle, setAlertThrottle] = useState(null); + const [alertThrottleUnit, setAlertThrottleUnit] = useState(''); + const [serverError, setServerError] = useState<{ + body: { message: string; error: string }; + } | null>(null); + const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState(true); + const [connectors, setConnectors] = useState([]); + + useEffect(() => { + (async () => { + try { + setIsLoadingActionTypes(true); + const actionTypes = await loadActionTypes({ http }); + const index: ActionTypeIndex = {}; + for (const actionTypeItem of actionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setActionTypesIndex(index); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } finally { + setIsLoadingActionTypes(false); + } + })(); + }, [toastNotifications, http]); + + useEffect(() => { + dispatch({ + command: { type: 'setAlert' }, + payload: { + key: 'alert', + value: { + params: {}, + alertTypeId: null, + interval: '1m', + actions: [], + tags: [], + }, + }, + }); + }, [alertFlyoutVisible]); + + useEffect(() => { + loadConnectors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alertFlyoutVisible]); + + const setAlertProperty = (key: string, value: any) => { + dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); + }; + + const setAlertParams = (key: string, value: any) => { + dispatch({ command: { type: 'setAlertParams' }, payload: { key, value } }); + }; + + const setActionParamsProperty = (key: string, value: any, index: number) => { + dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); + }; + + const setActionProperty = (key: string, value: any, index: number) => { + dispatch({ command: { type: 'setAlertActionProperty' }, payload: { key, value, index } }); + }; + + const closeFlyout = useCallback(() => { + setAlertFlyoutVisibility(false); + setAlertType(undefined); + setIsAddActionPanelOpen(true); + setSelectedTabId('alert'); + setServerError(null); + }, [setAlertFlyoutVisibility]); + + if (!alertFlyoutVisible) { + return null; + } + + const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; + + async function loadConnectors() { + try { + const actionsResponse = await loadAllActions({ http }); + setConnectors(actionsResponse.data); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.unableToLoadActionsMessage', + { + defaultMessage: 'Unable to load connectors', + } + ), + }); + } + } + + const AlertParamsExpressionComponent = alertType ? alertType.alertParamsExpression : null; + + const errors = { + ...(alertType ? alertType.validate(alert).errors : []), + ...validateBaseProperties(alert).errors, + } as IErrorObject; + const hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1); + + const actionErrors = alert.actions.reduce((acc: any, alertAction: AlertAction) => { + const actionTypeConnectors = connectors.find(field => field.id === alertAction.id); + if (!actionTypeConnectors) { + return []; + } + const actionType = actionTypeRegistry.get(actionTypeConnectors.actionTypeId); + if (!actionType) { + return []; + } + const actionValidationErrors = actionType.validateParams(alertAction.params); + acc[alertAction.id] = actionValidationErrors; + return acc; + }, {}); + + const hasActionErrors = !!Object.keys(actionErrors).find(actionError => { + return !!Object.keys(actionErrors[actionError]).find((actionErrorKey: string) => { + return actionErrors[actionError][actionErrorKey].length >= 1; + }); + }); + + const tabs = [ + { + id: ACTION_GROUPS.ALERT, + name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.alertTabText', { + defaultMessage: 'Alert', + }), + }, + { + id: ACTION_GROUPS.WARNING, + name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.warningTabText', { + defaultMessage: 'Warning', + }), + }, + { + id: ACTION_GROUPS.UNACKNOWLEDGED, + name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.unacknowledgedTabText', { + defaultMessage: 'If unacknowledged', + }), + disabled: false, + }, + ]; + + async function onSaveAlert(): Promise { + try { + const newAlert = await createAlert({ http, alert }); + toastNotifications.addSuccess( + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText', { + defaultMessage: "Saved '{alertName}'", + values: { + alertName: newAlert.id, + }, + }) + ); + return newAlert; + } catch (error) { + return { + error, + }; + } + } + + function addActionType(actionTypeModel: ActionTypeModel) { + setIsAddActionPanelOpen(false); + const actionTypeConnectors = connectors.filter( + field => field.actionTypeId === actionTypeModel.id + ); + if (actionTypeConnectors.length > 0) { + alert.actions.push({ id: actionTypeConnectors[0].id, group: selectedTabId, params: {} }); + } + } + + const alertTypeNodes = alertTypeRegistry.list().map(function(item, index) { + return ( + { + setAlertProperty('alertTypeId', item.id); + setAlertType(item); + }} + > + + + ); + }); + + const actionTypeNodes = actionTypeRegistry.list().map(function(item, index) { + return ( + addActionType(item)} + > + + + ); + }); + + const alertTabs = tabs.map(function(tab, index): any { + return ( + { + setSelectedTabId(tab.id); + if (!alert.actions.find((action: AlertAction) => action.group === tab.id)) { + setIsAddActionPanelOpen(true); + } else { + setIsAddActionPanelOpen(false); + } + }} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + key={index} + > + {tab.name} + + ); + }); + + const alertTypeDetails = ( + + + + +
+ +
+
+
+ + { + setAlertProperty('alertTypeId', null); + setAlertType(undefined); + }} + > + + + +
+ {AlertParamsExpressionComponent ? ( + + ) : null} +
+ ); + + const getSelectedOptions = (actionItemId: string) => { + const val = connectors.find(connector => connector.id === actionItemId); + if (!val) { + return []; + } + return [ + { + label: val.name, + value: val.name, + id: actionItemId, + }, + ]; + }; + + const actionsListForGroup = ( + + {alert.actions.map((actionItem: AlertAction, index: number) => { + const actionConnector = connectors.find(field => field.id === actionItem.id); + if (!actionConnector) { + return null; + } + const optionsList = connectors + .filter(field => field.actionTypeId === actionConnector.actionTypeId) + .map(({ name, id }) => ({ + label: name, + key: id, + id, + })); + const actionTypeRegisterd = actionTypeRegistry.get(actionConnector.actionTypeId); + if (actionTypeRegisterd === null || actionItem.group !== selectedTabId) return null; + const ParamsFieldsComponent = actionTypeRegisterd.actionParamsFields; + const actionParamsErrors = + Object.keys(actionErrors).length > 0 ? actionErrors[actionItem.id] : []; + const hasActionParamsErrors = !!Object.keys(actionParamsErrors).find( + errorKey => actionParamsErrors[errorKey].length >= 1 + ); + return ( + + + + + + +
+ +
+
+
+ + } + extraAction={ + { + const updatedActions = alert.actions.filter( + (item: AlertAction) => item.id !== actionItem.id + ); + setAlertProperty('actions', updatedActions); + }} + /> + } + paddingSize="l" + > + + } + // errorKey="name" + // isShowingErrors={hasErrors} + // errors={errors} + > + { + setActionProperty('id', selectedOptions[0].id, index); + }} + isClearable={false} + /> + + + {ParamsFieldsComponent ? ( + + ) : null} +
+ ); + })} + + {!isAddActionPanelOpen ? ( + setIsAddActionPanelOpen(true)} + > + + + ) : null} +
+ ); + + let alertTypeArea; + if (alertType) { + alertTypeArea = {alertTypeDetails}; + } else { + alertTypeArea = ( + + +
+ +
+
+ + + {alertTypeNodes} + +
+ ); + } + + const labelForAlertChecked = ( + <> + {' '} + + + ); + + const labelForAlertRenotify = ( + <> + {' '} + + + ); + + return ( + + + + +

+ +

+
+
+ + + + + + } + isInvalid={hasErrors && alert.name !== undefined} + error={errors.name} + > + { + setAlertProperty('name', e.target.value); + }} + onBlur={() => { + if (!alert.name) { + setAlertProperty('name', ''); + } + }} + /> + + + + + { + const newOptions = [...tagsOptions, { label: searchValue }]; + setAlertProperty( + 'tags', + newOptions.map(newOption => newOption.label) + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + setAlertProperty( + 'tags', + selectedOptions.map(selectedOption => selectedOption.label) + ); + }} + onBlur={() => { + if (!alert.tags) { + setAlertProperty('tags', []); + } + }} + /> + + + + + + + + + + { + const interval = + e.target.value !== '' ? parseInt(e.target.value, 10) : null; + setAlertInterval(interval); + setAlertProperty('interval', `${e.target.value}${alertIntervalUnit}`); + }} + /> + + + { + setAlertIntervalUnit(e.target.value); + setAlertProperty('interval', `${alertInterval}${e.target.value}`); + }} + /> + + + + + + + + + { + const throttle = + e.target.value !== '' ? parseInt(e.target.value, 10) : null; + setAlertThrottle(throttle); + setAlertProperty('throttle', `${e.target.value}${alertThrottleUnit}`); + }} + /> + + + { + setAlertThrottleUnit(e.target.value); + setAlertProperty('throttle', `${alertThrottle}${e.target.value}`); + }} + /> + + + + + + + {alertTabs} + + {alertTypeArea} + + {actionsListForGroup} + {isAddActionPanelOpen ? ( + + +
+ +
+
+ + + {isLoadingActionTypes ? ( + + + + ) : ( + actionTypeNodes + )} + +
+ ) : null} +
+
+ + + + + {i18n.translate('xpack.triggersActionsUI.sections.alertAdd.cancelButtonLabel', { + defaultMessage: 'Cancel', + })} + + + + { + setIsSaving(true); + const savedAlert = await onSaveAlert(); + setIsSaving(false); + if (savedAlert && savedAlert.error) { + return setServerError(savedAlert.error); + } + closeFlyout(); + refreshList(); + }} + > + + + + + +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts new file mode 100644 index 00000000000000..9c2260f0178be8 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isEqual } from 'lodash'; + +interface CommandType { + type: + | 'setAlert' + | 'setProperty' + | 'setAlertParams' + | 'setAlertActionParams' + | 'setAlertActionProperty'; +} + +export interface AlertState { + alert: any; +} + +export interface AlertReducerAction { + command: CommandType; + payload: { + key: string; + value: {}; + index?: number; + }; +} + +export const alertReducer = (state: any, action: AlertReducerAction) => { + const { command, payload } = action; + const { alert } = state; + + switch (command.type) { + case 'setAlert': { + const { key, value } = payload; + if (key === 'alert') { + return { + ...state, + alert: value, + }; + } else { + return state; + } + } + case 'setProperty': { + const { key, value } = payload; + if (isEqual(alert[key], value)) { + return state; + } else { + return { + ...state, + alert: { + ...alert, + [key]: value, + }, + }; + } + } + case 'setAlertParams': { + const { key, value } = payload; + if (isEqual(alert.params[key], value)) { + return state; + } else { + return { + ...state, + alert: { + ...alert, + params: { + ...alert.params, + [key]: value, + }, + }, + }; + } + } + case 'setAlertActionParams': { + const { key, value, index } = payload; + if (index === undefined || isEqual(alert.actions[index][key], value)) { + return state; + } else { + const oldAction = alert.actions.splice(index, 1)[0]; + const updatedAction = { + ...oldAction, + params: { + ...oldAction.params, + [key]: value, + }, + }; + alert.actions.splice(index, 0, updatedAction); + return { + ...state, + alert: { + ...alert, + actions: [...alert.actions], + }, + }; + } + } + case 'setAlertActionProperty': { + const { key, value, index } = payload; + if (index === undefined || isEqual(alert.actions[index][key], value)) { + return state; + } else { + const oldAction = alert.actions.splice(index, 1)[0]; + const updatedAction = { + ...oldAction, + [key]: value, + }; + alert.actions.splice(index, 0, updatedAction); + return { + ...state, + alert: { + ...alert, + actions: [...alert.actions], + }, + }; + } + } + } +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/index.ts new file mode 100644 index 00000000000000..f88a8bb1c49d01 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/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 { AlertAdd } from './alert_add'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/action_type_filter.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/action_type_filter.tsx new file mode 100644 index 00000000000000..7a25a241b0162c --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/action_type_filter.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFilterGroup, EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { ActionType } from '../../../../types'; + +interface ActionTypeFilterProps { + actionTypes: ActionType[]; + onChange?: (selectedActionTypeIds: string[]) => void; +} + +export const ActionTypeFilter: React.FunctionComponent = ({ + actionTypes, + onChange, +}: ActionTypeFilterProps) => { + const [selectedValues, setSelectedValues] = useState([]); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + useEffect(() => { + if (onChange) { + onChange(selectedValues); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedValues]); + + return ( + + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + > + + + } + > +
+ {actionTypes.map(item => ( + { + const isPreviouslyChecked = selectedValues.includes(item.id); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter(val => val !== item.id)); + } else { + setSelectedValues(selectedValues.concat(item.id)); + } + }} + checked={selectedValues.includes(item.id) ? 'on' : undefined} + > + {item.name} + + ))} +
+
+
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx new file mode 100644 index 00000000000000..8f8aef5a16bd5f --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -0,0 +1,453 @@ +/* + * 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 * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; +import { alertTypeRegistryMock } from '../../../alert_type_registry.mock'; +import { AlertsList } from './alerts_list'; +import { ValidationResult } from '../../../../types'; +import { AppContextProvider } from '../../../app_context'; +jest.mock('../../../lib/action_connector_api', () => ({ + loadActionTypes: jest.fn(), + loadAllActions: jest.fn(), +})); +jest.mock('../../../lib/alert_api', () => ({ + loadAlerts: jest.fn(), + loadAlertTypes: jest.fn(), +})); + +const actionTypeRegistry = actionTypeRegistryMock.create(); +const alertTypeRegistry = alertTypeRegistryMock.create(); + +const alertType = { + id: 'test_alert_type', + name: 'some alert type', + iconClass: 'test', + validate: (): ValidationResult => { + return { errors: {} }; + }, + alertParamsExpression: () => null, +}; +alertTypeRegistry.list.mockReturnValue([alertType]); +actionTypeRegistry.list.mockReturnValue([]); + +describe('alerts_list component empty', () => { + let wrapper: ReactWrapper; + + beforeEach(async () => { + const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); + const { loadActionTypes, loadAllActions } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAlerts.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + loadActionTypes.mockResolvedValue([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAllActions.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: { + getInjectedVar(name: string) { + if (name === 'createAlertUiEnabled') { + return true; + } + }, + } as any, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'alerting:show': true, + 'alerting:save': true, + 'alerting:delete': true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders empty list', () => { + expect(wrapper.find('[data-test-subj="createAlertButton"]').find('EuiButton')).toHaveLength(1); + }); + + test('if click create button should render AlertAdd', () => { + wrapper + .find('[data-test-subj="createAlertButton"]') + .first() + .simulate('click'); + expect(wrapper.find('AlertAdd')).toHaveLength(1); + }); +}); + +describe('alerts_list component with items', () => { + let wrapper: ReactWrapper; + + beforeEach(async () => { + const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); + const { loadActionTypes, loadAllActions } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAlerts.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 2, + data: [ + { + id: '1', + name: 'test alert', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + interval: '5d', + actions: [], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + }, + { + id: '2', + name: 'test alert 2', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + interval: '5d', + actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + }, + ], + }); + loadActionTypes.mockResolvedValue([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAllActions.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: { + getInjectedVar(name: string) { + if (name === 'createAlertUiEnabled') { + return true; + } + }, + } as any, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'alerting:show': true, + 'alerting:save': true, + 'alerting:delete': true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + + expect(loadAlerts).toHaveBeenCalled(); + expect(loadActionTypes).toHaveBeenCalled(); + }); + + it('renders table of connectors', () => { + expect(wrapper.find('EuiBasicTable')).toHaveLength(1); + expect(wrapper.find('EuiTableRow')).toHaveLength(2); + }); +}); + +describe('alerts_list component empty with show only capability', () => { + let wrapper: ReactWrapper; + + beforeEach(async () => { + const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); + const { loadActionTypes, loadAllActions } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAlerts.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + loadActionTypes.mockResolvedValue([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAllActions.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: { + getInjectedVar(name: string) { + if (name === 'createAlertUiEnabled') { + return true; + } + }, + } as any, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'alerting:show': true, + 'alerting:save': false, + 'alerting:delete': false, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: { + get() { + return null; + }, + } as any, + alertTypeRegistry: {} as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('not renders create alert button', () => { + expect(wrapper.find('[data-test-subj="createAlertButton"]')).toHaveLength(0); + }); +}); + +describe('alerts_list with show only capability', () => { + let wrapper: ReactWrapper; + + beforeEach(async () => { + const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); + const { loadActionTypes, loadAllActions } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAlerts.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 2, + data: [ + { + id: '1', + name: 'test alert', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + interval: '5d', + actions: [], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + }, + { + id: '2', + name: 'test alert 2', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + interval: '5d', + actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + }, + ], + }); + loadActionTypes.mockResolvedValue([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAllActions.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: { + getInjectedVar(name: string) { + if (name === 'createAlertUiEnabled') { + return true; + } + }, + } as any, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'alerting:show': true, + 'alerting:save': false, + 'alerting:delete': false, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders table of alerts with delete button disabled', () => { + expect(wrapper.find('EuiBasicTable')).toHaveLength(1); + expect(wrapper.find('EuiTableRow')).toHaveLength(2); + // TODO: check delete button + }); +}); + +async function waitForRender(wrapper: ReactWrapper) { + await Promise.resolve(); + await Promise.resolve(); + wrapper.update(); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx new file mode 100644 index 00000000000000..64f06521c0f9d9 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx @@ -0,0 +1,330 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import React, { Fragment, useEffect, useState } from 'react'; +import { + EuiBasicTable, + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, +} from '@elastic/eui'; + +import { AlertsContextProvider } from '../../../context/alerts_context'; +import { useAppDependencies } from '../../../app_context'; +import { ActionType, Alert, AlertTableItem, AlertTypeIndex, Pagination } from '../../../../types'; +import { AlertAdd } from '../../alert_add'; +import { BulkActionPopover } from './bulk_action_popover'; +import { CollapsedItemActions } from './collapsed_item_actions'; +import { TypeFilter } from './type_filter'; +import { ActionTypeFilter } from './action_type_filter'; +import { loadAlerts, loadAlertTypes } from '../../../lib/alert_api'; +import { loadActionTypes } from '../../../lib/action_connector_api'; +import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities'; + +const ENTER_KEY = 13; + +export const AlertsList: React.FunctionComponent = () => { + const { + http, + injectedMetadata, + toastNotifications, + legacy: { capabilities }, + } = useAppDependencies(); + const canDelete = hasDeleteAlertsCapability(capabilities.get()); + const canSave = hasSaveAlertsCapability(capabilities.get()); + const createAlertUiEnabled = injectedMetadata.getInjectedVar('createAlertUiEnabled'); + + const [actionTypes, setActionTypes] = useState([]); + const [alertTypesIndex, setAlertTypesIndex] = useState(undefined); + const [alerts, setAlerts] = useState([]); + const [data, setData] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [isLoadingAlertTypes, setIsLoadingAlertTypes] = useState(false); + const [isLoadingAlerts, setIsLoadingAlerts] = useState(false); + const [isPerformingAction, setIsPerformingAction] = useState(false); + const [totalItemCount, setTotalItemCount] = useState(0); + const [page, setPage] = useState({ index: 0, size: 10 }); + const [searchText, setSearchText] = useState(); + const [inputText, setInputText] = useState(); + const [typesFilter, setTypesFilter] = useState([]); + const [actionTypesFilter, setActionTypesFilter] = useState([]); + const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); + + useEffect(() => { + loadAlertsData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, searchText, typesFilter, actionTypesFilter]); + + useEffect(() => { + (async () => { + try { + setIsLoadingAlertTypes(true); + const alertTypes = await loadAlertTypes({ http }); + const index: AlertTypeIndex = {}; + for (const alertType of alertTypes) { + index[alertType.id] = alertType; + } + setAlertTypesIndex(index); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertTypesMessage', + { defaultMessage: 'Unable to load alert types' } + ), + }); + } finally { + setIsLoadingAlertTypes(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + (async () => { + try { + const result = await loadActionTypes({ http }); + setActionTypes(result); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // Avoid flickering before alert types load + if (typeof alertTypesIndex === 'undefined') { + return; + } + const updatedData = alerts.map(alert => ({ + ...alert, + tagsText: alert.tags.join(', '), + alertType: alertTypesIndex[alert.alertTypeId] + ? alertTypesIndex[alert.alertTypeId].name + : alert.alertTypeId, + })); + setData(updatedData); + }, [alerts, alertTypesIndex]); + + async function loadAlertsData() { + setIsLoadingAlerts(true); + try { + const alertsResponse = await loadAlerts({ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + }); + setAlerts(alertsResponse.data); + setTotalItemCount(alertsResponse.total); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsMessage', + { + defaultMessage: 'Unable to load alerts', + } + ), + }); + } finally { + setIsLoadingAlerts(false); + } + } + + const alertsTableColumns = [ + { + field: 'name', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.nameTitle', + { defaultMessage: 'Name' } + ), + sortable: false, + truncateText: true, + 'data-test-subj': 'alertsTableCell-name', + }, + { + field: 'tagsText', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.tagsText', + { defaultMessage: 'Tags' } + ), + sortable: false, + 'data-test-subj': 'alertsTableCell-tagsText', + }, + { + field: 'alertType', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.alertTypeTitle', + { defaultMessage: 'Type' } + ), + sortable: false, + truncateText: true, + 'data-test-subj': 'alertsTableCell-alertType', + }, + { + field: 'schedule.interval', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.intervalTitle', + { defaultMessage: 'Runs every' } + ), + sortable: false, + truncateText: false, + 'data-test-subj': 'alertsTableCell-interval', + }, + { + name: '', + width: '40px', + render(item: AlertTableItem) { + return ( + loadAlertsData()} /> + ); + }, + }, + ]; + + const toolsRight = [ + setTypesFilter(types)} + options={Object.values(alertTypesIndex || {}) + .map(alertType => ({ + value: alertType.id, + name: alertType.name, + })) + .sort((a, b) => a.name.localeCompare(b.name))} + />, + setActionTypesFilter(ids)} + />, + ]; + + if (canSave && createAlertUiEnabled) { + toolsRight.push( + setAlertFlyoutVisibility(true)} + > + + + ); + } + + return ( +
+ + + + + {selectedIds.length > 0 && canDelete && ( + + setIsPerformingAction(true)} + onActionPerformed={() => { + loadAlertsData(); + setIsPerformingAction(false); + }} + /> + + )} + + } + onChange={e => setInputText(e.target.value)} + onKeyUp={e => { + if (e.keyCode === ENTER_KEY) { + setSearchText(inputText); + } + }} + placeholder={i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.searchPlaceholderTitle', + { defaultMessage: 'Search...' } + )} + /> + + + + {toolsRight.map((tool, index: number) => ( + + {tool} + + ))} + + + + + {/* Large to remain consistent with ActionsList table spacing */} + + + ({ + 'data-test-subj': 'alert-row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + data-test-subj="alertsList" + pagination={{ + pageIndex: page.index, + pageSize: page.size, + totalItemCount, + }} + selection={ + canDelete + ? { + onSelectionChange(updatedSelectedItemsList: AlertTableItem[]) { + setSelectedIds(updatedSelectedItemsList.map(item => item.id)); + }, + } + : undefined + } + onChange={({ page: changedPage }: { page: Pagination }) => { + setPage(changedPage); + }} + /> + + + +
+ ); +}; + +function pickFromData(data: AlertTableItem[], ids: string[]): AlertTableItem[] { + const result: AlertTableItem[] = []; + for (const id of ids) { + const match = data.find(item => item.id === id); + if (match) { + result.push(match); + } + } + return result; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/bulk_action_popover.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/bulk_action_popover.tsx new file mode 100644 index 00000000000000..59ec52ac83a6c5 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/bulk_action_popover.tsx @@ -0,0 +1,253 @@ +/* + * 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 } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiButtonEmpty, EuiFormRow, EuiPopover } from '@elastic/eui'; + +import { AlertTableItem } from '../../../../types'; +import { useAppDependencies } from '../../../app_context'; +import { + deleteAlerts, + disableAlerts, + enableAlerts, + muteAlerts, + unmuteAlerts, +} from '../../../lib/alert_api'; + +export interface ComponentOpts { + selectedItems: AlertTableItem[]; + onPerformingAction: () => void; + onActionPerformed: () => void; +} + +export const BulkActionPopover: React.FunctionComponent = ({ + selectedItems, + onPerformingAction, + onActionPerformed, +}: ComponentOpts) => { + const { http, toastNotifications } = useAppDependencies(); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isMutingAlerts, setIsMutingAlerts] = useState(false); + const [isUnmutingAlerts, setIsUnmutingAlerts] = useState(false); + const [isEnablingAlerts, setIsEnablingAlerts] = useState(false); + const [isDisablingAlerts, setIsDisablingAlerts] = useState(false); + const [isDeletingAlerts, setIsDeletingAlerts] = useState(false); + + const allAlertsMuted = selectedItems.every(isAlertMuted); + const allAlertsDisabled = selectedItems.every(isAlertDisabled); + const isPerformingAction = + isMutingAlerts || isUnmutingAlerts || isEnablingAlerts || isDisablingAlerts || isDeletingAlerts; + + async function onmMuteAllClick() { + onPerformingAction(); + setIsMutingAlerts(true); + const ids = selectedItems.filter(item => !isAlertMuted(item)).map(item => item.id); + try { + await muteAlerts({ http, ids }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToMuteAlertsMessage', + { + defaultMessage: 'Failed to mute alert(s)', + } + ), + }); + } finally { + setIsMutingAlerts(false); + onActionPerformed(); + } + } + + async function onUnmuteAllClick() { + onPerformingAction(); + setIsUnmutingAlerts(true); + const ids = selectedItems.filter(isAlertMuted).map(item => item.id); + try { + await unmuteAlerts({ http, ids }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToUnmuteAlertsMessage', + { + defaultMessage: 'Failed to unmute alert(s)', + } + ), + }); + } finally { + setIsUnmutingAlerts(false); + onActionPerformed(); + } + } + + async function onEnableAllClick() { + onPerformingAction(); + setIsEnablingAlerts(true); + const ids = selectedItems.filter(isAlertDisabled).map(item => item.id); + try { + await enableAlerts({ http, ids }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToEnableAlertsMessage', + { + defaultMessage: 'Failed to enable alert(s)', + } + ), + }); + } finally { + setIsEnablingAlerts(false); + onActionPerformed(); + } + } + + async function onDisableAllClick() { + onPerformingAction(); + setIsDisablingAlerts(true); + const ids = selectedItems.filter(item => !isAlertDisabled(item)).map(item => item.id); + try { + await disableAlerts({ http, ids }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToDisableAlertsMessage', + { + defaultMessage: 'Failed to disable alert(s)', + } + ), + }); + } finally { + setIsDisablingAlerts(false); + onActionPerformed(); + } + } + + async function deleteSelectedItems() { + onPerformingAction(); + setIsDeletingAlerts(true); + const ids = selectedItems.map(item => item.id); + try { + await deleteAlerts({ http, ids }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToDeleteAlertsMessage', + { + defaultMessage: 'Failed to delete alert(s)', + } + ), + }); + } finally { + setIsDeletingAlerts(false); + onActionPerformed(); + } + } + + return ( + setIsPopoverOpen(false)} + data-test-subj="bulkAction" + button={ + setIsPopoverOpen(!isPopoverOpen)} + > + + + } + > + {!allAlertsMuted && ( + + + + + + )} + {allAlertsMuted && ( + + + + + + )} + {allAlertsDisabled && ( + + + + + + )} + {!allAlertsDisabled && ( + + + + + + )} + + + + + + + ); +}; + +function isAlertDisabled(alert: AlertTableItem) { + return alert.enabled === false; +} + +function isAlertMuted(alert: AlertTableItem) { + return alert.muteAll === true; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx new file mode 100644 index 00000000000000..f063ab4f7cde3c --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFormRow, + EuiPopover, + EuiPopoverFooter, + EuiSwitch, +} from '@elastic/eui'; + +import { AlertTableItem } from '../../../../types'; +import { useAppDependencies } from '../../../app_context'; +import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities'; +import { + deleteAlerts, + disableAlerts, + enableAlerts, + muteAlerts, + unmuteAlerts, +} from '../../../lib/alert_api'; + +export interface ComponentOpts { + item: AlertTableItem; + onAlertChanged: () => void; +} + +export const CollapsedItemActions: React.FunctionComponent = ({ + item, + onAlertChanged, +}: ComponentOpts) => { + const { + http, + legacy: { capabilities }, + } = useAppDependencies(); + + const canDelete = hasDeleteAlertsCapability(capabilities.get()); + const canSave = hasSaveAlertsCapability(capabilities.get()); + + const [isEnabled, setIsEnabled] = useState(item.enabled); + const [isMuted, setIsMuted] = useState(item.muteAll); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const button = ( + setIsPopoverOpen(!isPopoverOpen)} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.popoverButtonTitle', + { defaultMessage: 'Actions' } + )} + /> + ); + + return ( + setIsPopoverOpen(false)} + ownFocus + data-test-subj="collapsedItemActions" + > + + { + if (isEnabled) { + setIsEnabled(false); + await disableAlerts({ http, ids: [item.id] }); + } else { + setIsEnabled(true); + await enableAlerts({ http, ids: [item.id] }); + } + onAlertChanged(); + }} + label={ + + } + /> + + + { + if (isMuted) { + setIsMuted(false); + await unmuteAlerts({ http, ids: [item.id] }); + } else { + setIsMuted(true); + await muteAlerts({ http, ids: [item.id] }); + } + onAlertChanged(); + }} + label={ + + } + /> + + + + { + await deleteAlerts({ http, ids: [item.id] }); + onAlertChanged(); + }} + > + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/type_filter.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/type_filter.tsx new file mode 100644 index 00000000000000..f9cf7a6efd4616 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/type_filter.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFilterGroup, EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; + +interface TypeFilterProps { + options: Array<{ + value: string; + name: string; + }>; + onChange?: (selectedTags: string[]) => void; +} + +export const TypeFilter: React.FunctionComponent = ({ + options, + onChange, +}: TypeFilterProps) => { + const [selectedValues, setSelectedValues] = useState([]); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + useEffect(() => { + if (onChange) { + onChange(selectedValues); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedValues]); + + return ( + + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + > + + + } + > +
+ {options.map((item, index) => ( + { + const isPreviouslyChecked = selectedValues.includes(item.value); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter(val => val !== item.value)); + } else { + setSelectedValues(selectedValues.concat(item.value)); + } + }} + checked={selectedValues.includes(item.value) ? 'on' : undefined} + > + {item.name} + + ))} +
+
+
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.test.ts new file mode 100644 index 00000000000000..efe58aedb8353c --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeRegistry } from './type_registry'; +import { ValidationResult, AlertTypeModel, ActionTypeModel } from '../types'; + +export const ExpressionComponent: React.FunctionComponent = () => { + return null; +}; + +const getTestAlertType = (id?: string, name?: string, iconClass?: string) => { + return { + id: id || 'test-alet-type', + name: name || 'Test alert type', + iconClass: iconClass || 'icon', + validate: (): ValidationResult => { + return { errors: {} }; + }, + alertParamsExpression: ExpressionComponent, + }; +}; + +const getTestActionType = (id?: string, iconClass?: string, selectedMessage?: string) => { + return { + id: id || 'my-action-type', + iconClass: iconClass || 'test', + selectMessage: selectedMessage || 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; +}; + +beforeEach(() => jest.resetAllMocks()); + +describe('register()', () => { + test('able to register alert types', () => { + const alertTypeRegistry = new TypeRegistry(); + alertTypeRegistry.register(getTestAlertType()); + expect(alertTypeRegistry.has('test-alet-type')).toEqual(true); + }); + + test('throws error if alert type already registered', () => { + const alertTypeRegistry = new TypeRegistry(); + alertTypeRegistry.register(getTestAlertType('my-test-alert-type-1')); + expect(() => + alertTypeRegistry.register(getTestAlertType('my-test-alert-type-1')) + ).toThrowErrorMatchingInlineSnapshot( + `"Object type \\"my-test-alert-type-1\\" is already registered."` + ); + }); +}); + +describe('get()', () => { + test('returns action type', () => { + const actionTypeRegistry = new TypeRegistry(); + actionTypeRegistry.register(getTestActionType('my-action-type-snapshot')); + const actionType = actionTypeRegistry.get('my-action-type-snapshot'); + expect(actionType).toMatchInlineSnapshot(` + Object { + "actionConnectorFields": null, + "actionParamsFields": null, + "iconClass": "test", + "id": "my-action-type-snapshot", + "selectMessage": "test", + "validateConnector": [Function], + "validateParams": [Function], + } + `); + }); + + test(`return null when action type doesn't exist`, () => { + const actionTypeRegistry = new TypeRegistry(); + expect(actionTypeRegistry.get('not-exist-action-type')).toBeNull(); + }); +}); + +describe('list()', () => { + test('returns list of action types', () => { + const actionTypeRegistry = new TypeRegistry(); + actionTypeRegistry.register(getTestActionType()); + const actionTypes = actionTypeRegistry.list(); + expect(actionTypes).toEqual([ + { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + actionConnectorFields: null, + actionParamsFields: null, + validateConnector: actionTypes[0].validateConnector, + validateParams: actionTypes[0].validateParams, + }, + ]); + }); +}); + +describe('has()', () => { + test('returns false for unregistered alert types', () => { + const alertTypeRegistry = new TypeRegistry(); + expect(alertTypeRegistry.has('my-alert-type')).toEqual(false); + }); + + test('returns true after registering an alert type', () => { + const alertTypeRegistry = new TypeRegistry(); + alertTypeRegistry.register(getTestAlertType()); + expect(alertTypeRegistry.has('test-alet-type')); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.ts new file mode 100644 index 00000000000000..3390d8910a45fa --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +interface BaseObjectType { + id: string; +} + +export class TypeRegistry { + private readonly objectTypes: Map = new Map(); + + /** + * Returns if the object type registry has the given type registered + */ + public has(id: string) { + return this.objectTypes.has(id); + } + + /** + * Registers an object type to the type registry + */ + public register(objectType: T) { + if (this.has(objectType.id)) { + throw new Error( + i18n.translate( + 'xpack.triggersActionsUI.typeRegistry.register.duplicateObjectTypeErrorMessage', + { + defaultMessage: 'Object type "{id}" is already registered.', + values: { + id: objectType.id, + }, + } + ) + ); + } + this.objectTypes.set(objectType.id, objectType); + } + + /** + * Returns an object type, null if not registered + */ + public get(id: string): T | null { + if (!this.has(id)) { + return null; + } + return this.objectTypes.get(id)!; + } + + public list() { + return Array.from(this.objectTypes).map(([id, objectType]) => objectType); + } +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/index.ts new file mode 100644 index 00000000000000..7eed516019dd01 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { Plugin } from './plugin'; + +export function plugin(ctx: PluginInitializerContext) { + return new Plugin(ctx); +} + +export { Plugin }; +export * from './plugin'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/plugin.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/plugin.ts new file mode 100644 index 00000000000000..0b0f8a4ee67907 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/plugin.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 { + CoreSetup, + CoreStart, + Plugin as CorePlugin, + PluginInitializerContext, +} from 'src/core/public'; + +import { i18n } from '@kbn/i18n'; +import { registerBuiltInActionTypes } from './application/components/builtin_action_types'; +import { registerBuiltInAlertTypes } from './application/components/builtin_alert_types'; +import { hasShowActionsCapability, hasShowAlertsCapability } from './application/lib/capabilities'; +import { PLUGIN } from './application/constants/plugin'; +import { LegacyDependencies, ActionTypeModel, AlertTypeModel } from './types'; +import { TypeRegistry } from './application/type_registry'; + +export type Setup = void; +export type Start = void; + +interface LegacyPlugins { + __LEGACY: LegacyDependencies; +} + +export class Plugin implements CorePlugin { + private actionTypeRegistry: TypeRegistry; + private alertTypeRegistry: TypeRegistry; + + constructor(initializerContext: PluginInitializerContext) { + const actionTypeRegistry = new TypeRegistry(); + this.actionTypeRegistry = actionTypeRegistry; + + const alertTypeRegistry = new TypeRegistry(); + this.alertTypeRegistry = alertTypeRegistry; + } + + public setup( + { application, notifications, http, uiSettings, injectedMetadata }: CoreSetup, + { __LEGACY }: LegacyPlugins + ): Setup { + const canShowActions = hasShowActionsCapability(__LEGACY.capabilities.get()); + const canShowAlerts = hasShowAlertsCapability(__LEGACY.capabilities.get()); + + if (!canShowActions && !canShowAlerts) { + return; + } + registerBuiltInActionTypes({ + actionTypeRegistry: this.actionTypeRegistry, + }); + + registerBuiltInAlertTypes({ + alertTypeRegistry: this.alertTypeRegistry, + }); + application.register({ + id: PLUGIN.ID, + title: PLUGIN.getI18nName(i18n), + mount: async ( + { + core: { + docLinks, + chrome, + // Waiting for types to be updated. + // @ts-ignore + savedObjects, + i18n: { Context: I18nContext }, + }, + }, + { element } + ) => { + const { boot } = await import('./application/boot'); + return boot({ + element, + toastNotifications: notifications.toasts, + injectedMetadata, + http, + uiSettings, + docLinks, + chrome, + savedObjects: savedObjects.client, + I18nContext, + legacy: { + ...__LEGACY, + }, + actionTypeRegistry: this.actionTypeRegistry, + alertTypeRegistry: this.alertTypeRegistry, + }); + }, + }); + } + + public start(core: CoreStart, { __LEGACY }: LegacyPlugins) { + const { capabilities } = __LEGACY; + const canShowActions = hasShowActionsCapability(capabilities.get()); + const canShowAlerts = hasShowAlertsCapability(capabilities.get()); + + // Don't register routes when user doesn't have access to the application + if (!canShowActions && !canShowAlerts) { + return; + } + } + + public stop() {} +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts new file mode 100644 index 00000000000000..4cf28d3bbd06fc --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { capabilities } from 'ui/capabilities'; +import { TypeRegistry } from './application/type_registry'; + +export type ActionTypeIndex = Record; +export type AlertTypeIndex = Record; +export type ActionTypeRegistryContract = PublicMethodsOf>; +export type AlertTypeRegistryContract = PublicMethodsOf>; + +export interface ActionConnectorFieldsProps { + action: ActionConnector; + editActionConfig: (property: string, value: any) => void; + editActionSecrets: (property: string, value: any) => void; + errors: { [key: string]: string[] }; + hasErrors?: boolean; +} + +export interface ActionParamsProps { + action: any; + index: number; + editAction: (property: string, value: any, index: number) => void; + errors: { [key: string]: string[] }; + hasErrors?: boolean; +} + +export interface Pagination { + index: number; + size: number; +} + +export interface ActionTypeModel { + id: string; + iconClass: string; + selectMessage: string; + validateConnector: (action: ActionConnector) => ValidationResult; + validateParams: (actionParams: any) => ValidationResult; + actionConnectorFields: React.FunctionComponent | null; + actionParamsFields: React.FunctionComponent | null; +} + +export interface ValidationResult { + errors: Record; +} + +export interface ActionType { + id: string; + name: string; +} + +export interface ActionConnector { + secrets: Record; + id: string; + actionTypeId: string; + name: string; + referencedByCount?: number; + config: Record; +} + +export type ActionConnectorWithoutId = Omit; + +export interface ActionConnectorTableItem extends ActionConnector { + actionType: ActionType['name']; +} + +export interface AlertType { + id: string; + name: string; +} + +export interface AlertAction { + group: string; + id: string; + params: Record; +} + +export interface Alert { + id: string; + name: string; + tags: string[]; + enabled: boolean; + alertTypeId: string; + interval: string; + actions: AlertAction[]; + params: Record; + scheduledTaskId?: string; + createdBy: string | null; + updatedBy: string | null; + apiKeyOwner?: string; + throttle: string | null; + muteAll: boolean; + mutedInstanceIds: string[]; +} + +export type AlertWithoutId = Omit; + +export interface AlertTableItem extends Alert { + alertType: AlertType['name']; + tagsText: string; +} + +export interface AlertTypeModel { + id: string; + name: string; + iconClass: string; + validate: (alert: Alert) => ValidationResult; + alertParamsExpression: React.FunctionComponent; +} + +export interface IErrorObject { + [key: string]: string[]; +} + +export interface LegacyDependencies { + MANAGEMENT_BREADCRUMB: { text: string; href?: string }; + capabilities: typeof capabilities; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/public/hacks/register.ts b/x-pack/legacy/plugins/triggers_actions_ui/public/hacks/register.ts new file mode 100644 index 00000000000000..7991604fcc6674 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/public/hacks/register.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { + FeatureCatalogueRegistryProvider, + FeatureCatalogueCategory, +} from 'ui/registry/feature_catalogue'; + +FeatureCatalogueRegistryProvider.register(() => { + return { + id: 'triggersActions', + title: 'Alerts and Actions', // This is a product name so we don't translate it. + description: i18n.translate('xpack.triggersActionsUI.triggersActionsDescription', { + defaultMessage: 'Data by creating, managing, and monitoring triggers and actions.', + }), + icon: 'triggersActionsApp', + path: '/app/kibana#/management/kibana/triggersActions', + showOnHomePage: true, + category: FeatureCatalogueCategory.ADMIN, + }; +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss b/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss new file mode 100644 index 00000000000000..6faad81630b2be --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss @@ -0,0 +1,5 @@ +// Imported EUI +@import 'src/legacy/ui/public/styles/_styling_constants'; + +// Styling within the app +@import '../np_ready/public/application/sections/actions_connectors_list/components/index'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/public/legacy.ts b/x-pack/legacy/plugins/triggers_actions_ui/public/legacy.ts new file mode 100644 index 00000000000000..bae91040812671 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/public/legacy.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, App, AppUnmount } from 'src/core/public'; +import { capabilities } from 'ui/capabilities'; +import { i18n } from '@kbn/i18n'; + +/* Legacy UI imports */ +import { npSetup, npStart } from 'ui/new_platform'; +import routes from 'ui/routes'; +import { management, MANAGEMENT_BREADCRUMB } from 'ui/management'; +// @ts-ignore +import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; +/* Legacy UI imports */ + +import { plugin } from '../np_ready/public'; +import { manageAngularLifecycle } from './manage_angular_lifecycle'; +import { BASE_PATH } from '../np_ready/public/application/constants'; +import { + hasShowActionsCapability, + hasShowAlertsCapability, +} from '../np_ready/public/application/lib/capabilities'; + +const REACT_ROOT_ID = 'triggersActionsRoot'; +const canShowActions = hasShowActionsCapability(capabilities.get()); +const canShowAlerts = hasShowAlertsCapability(capabilities.get()); + +const template = ` +
+
`; + +let elem: HTMLElement; +let mountApp: () => AppUnmount | Promise; +let unmountApp: AppUnmount | Promise; +routes.when(`${BASE_PATH}:section?/:subsection?/:view?/:id?`, { + template, + controller: (() => { + return ($route: any, $scope: any) => { + const shimCore: CoreSetup = { + ...npSetup.core, + application: { + ...npSetup.core.application, + register(app: App): void { + mountApp = () => + app.mount(npStart as any, { + element: elem, + appBasePath: BASE_PATH, + onAppLeave: () => undefined, + }); + }, + }, + }; + + // clean up previously rendered React app if one exists + // this happens because of React Router redirects + if (elem) { + ((unmountApp as unknown) as AppUnmount)(); + } + + $scope.$$postDigest(() => { + elem = document.getElementById(REACT_ROOT_ID)!; + const instance = plugin({} as any); + instance.setup(shimCore, { + ...(npSetup.plugins as typeof npSetup.plugins), + __LEGACY: { + MANAGEMENT_BREADCRUMB, + capabilities, + }, + }); + + instance.start(npStart.core, { + ...(npSetup.plugins as typeof npSetup.plugins), + __LEGACY: { + MANAGEMENT_BREADCRUMB, + capabilities, + }, + }); + + (mountApp() as Promise).then(fn => (unmountApp = fn)); + + manageAngularLifecycle($scope, $route, elem); + }); + }; + })(), +}); + +if (canShowActions || canShowAlerts) { + management.getSection('kibana').register('triggersActions', { + display: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', { + defaultMessage: 'Alerts and Actions', + }), + order: 7, + url: `#${BASE_PATH}`, + }); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/public/manage_angular_lifecycle.ts b/x-pack/legacy/plugins/triggers_actions_ui/public/manage_angular_lifecycle.ts new file mode 100644 index 00000000000000..efd40eaf83daad --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/public/manage_angular_lifecycle.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { unmountComponentAtNode } from 'react-dom'; + +export const manageAngularLifecycle = ($scope: any, $route: any, elem: HTMLElement) => { + const lastRoute = $route.current; + + const deregister = $scope.$on('$locationChangeSuccess', () => { + const currentRoute = $route.current; + if (lastRoute.$$route.template === currentRoute.$$route.template) { + $route.current = lastRoute; + } + }); + + $scope.$on('$destroy', () => { + if (deregister) { + deregister(); + } + + if (elem) { + unmountComponentAtNode(elem); + } + }); +}; diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index bda5b51623d058..11ee038cf39f05 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -9,6 +9,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting/configs/chromium_api.js'), require.resolve('../test/reporting/configs/chromium_functional.js'), require.resolve('../test/reporting/configs/generate_api.js'), + require.resolve('../test/functional_with_es_ssl/config.ts'), require.resolve('../test/functional/config_security_basic.js'), require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index 5ab8933a4804a7..84d5a792ae6cae 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -57,6 +57,7 @@ export const services = { ...kibanaFunctionalServices, ...commonServices, + supertest: kibanaApiIntegrationServices.supertest, esSupertest: kibanaApiIntegrationServices.esSupertest, monitoringNoData: MonitoringNoDataProvider, monitoringClusterList: MonitoringClusterListProvider, diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts new file mode 100644 index 00000000000000..4fdfe0d32ace3d --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -0,0 +1,344 @@ +/* + * 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 uuid from 'uuid'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +function generateUniqueKey() { + return uuid.v4().replace(/-/g, ''); +} + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const supertest = getService('supertest'); + const retry = getService('retry'); + + async function createAlert() { + const { body: createdAlert } = await supertest + .post(`/api/alert`) + .set('kbn-xsrf', 'foo') + .send({ + enabled: true, + name: generateUniqueKey(), + tags: ['foo', 'bar'], + alertTypeId: 'test.noop', + consumer: 'test', + schedule: { interval: '1m' }, + throttle: '1m', + actions: [], + params: {}, + }) + .expect(200); + return createdAlert; + } + + describe('alerts', function() { + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + const alertsTab = await testSubjects.find('alertsTab'); + await alertsTab.click(); + }); + + it('should search for alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + name: createdAlert.name, + tagsText: 'foo, bar', + alertType: 'Test: Noop', + interval: '1m', + }, + ]); + }); + + it('should search for tags', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(`${createdAlert.name} foo`); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + name: createdAlert.name, + tagsText: 'foo, bar', + alertType: 'Test: Noop', + interval: '1m', + }, + ]); + }); + + // Flaky until https://github.com/elastic/eui/issues/2612 fixed + it.skip('should disable single alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const enableSwitch = await testSubjects.find('enableSwitch'); + await enableSwitch.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterDisable = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterDisable.click(); + + const enableSwitchAfterDisable = await testSubjects.find('enableSwitch'); + const isChecked = await enableSwitchAfterDisable.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + }); + + // Flaky until https://github.com/elastic/eui/issues/2612 fixed + it.skip('should re-enable single alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const enableSwitch = await testSubjects.find('enableSwitch'); + await enableSwitch.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterDisable = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterDisable.click(); + + const enableSwitchAfterDisable = await testSubjects.find('enableSwitch'); + await enableSwitchAfterDisable.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterReEnable = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterReEnable.click(); + + const enableSwitchAfterReEnable = await testSubjects.find('enableSwitch'); + const isChecked = await enableSwitchAfterReEnable.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + }); + + // Flaky until https://github.com/elastic/eui/issues/2612 fixed + it.skip('should mute single alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const muteSwitch = await testSubjects.find('muteSwitch'); + await muteSwitch.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterMute = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterMute.click(); + + const muteSwitchAfterMute = await testSubjects.find('muteSwitch'); + const isChecked = await muteSwitchAfterMute.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + }); + + // Flaky until https://github.com/elastic/eui/issues/2612 fixed + it.skip('should unmute single alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const muteSwitch = await testSubjects.find('muteSwitch'); + await muteSwitch.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterMute = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterMute.click(); + + const muteSwitchAfterMute = await testSubjects.find('muteSwitch'); + await muteSwitchAfterMute.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterUnmute = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterUnmute.click(); + + const muteSwitchAfterUnmute = await testSubjects.find('muteSwitch'); + const isChecked = await muteSwitchAfterUnmute.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/53956 + it.skip('should delete single alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const deleteBtn = await testSubjects.find('deleteAlert'); + await deleteBtn.click(); + + retry.try(async () => { + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults.length).to.eql(0); + }); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830 + it.skip('should mute all selection', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`); + await checkbox.click(); + + const bulkActionBtn = await testSubjects.find('bulkAction'); + await bulkActionBtn.click(); + + const muteAllBtn = await testSubjects.find('muteAll'); + await muteAllBtn.click(); + + // Unmute all button shows after clicking mute all + await testSubjects.existOrFail('unmuteAll'); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const muteSwitch = await testSubjects.find('muteSwitch'); + const isChecked = await muteSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830 + it.skip('should unmute all selection', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`); + await checkbox.click(); + + const bulkActionBtn = await testSubjects.find('bulkAction'); + await bulkActionBtn.click(); + + const muteAllBtn = await testSubjects.find('muteAll'); + await muteAllBtn.click(); + + const unmuteAllBtn = await testSubjects.find('unmuteAll'); + await unmuteAllBtn.click(); + + // Mute all button shows after clicking unmute all + await testSubjects.existOrFail('muteAll'); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const muteSwitch = await testSubjects.find('muteSwitch'); + const isChecked = await muteSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830 + it.skip('should disable all selection', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`); + await checkbox.click(); + + const bulkActionBtn = await testSubjects.find('bulkAction'); + await bulkActionBtn.click(); + + const disableAllBtn = await testSubjects.find('disableAll'); + await disableAllBtn.click(); + + // Enable all button shows after clicking disable all + await testSubjects.existOrFail('enableAll'); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const enableSwitch = await testSubjects.find('enableSwitch'); + const isChecked = await enableSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830 + it.skip('should enable all selection', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`); + await checkbox.click(); + + const bulkActionBtn = await testSubjects.find('bulkAction'); + await bulkActionBtn.click(); + + const disableAllBtn = await testSubjects.find('disableAll'); + await disableAllBtn.click(); + + const enableAllBtn = await testSubjects.find('enableAll'); + await enableAllBtn.click(); + + // Disable all button shows after clicking enable all + await testSubjects.existOrFail('disableAll'); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const enableSwitch = await testSubjects.find('enableSwitch'); + const isChecked = await enableSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/53956 + it.skip('should delete all selection', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`); + await checkbox.click(); + + const bulkActionBtn = await testSubjects.find('bulkAction'); + await bulkActionBtn.click(); + + const deleteAllBtn = await testSubjects.find('deleteAll'); + await deleteAllBtn.click(); + + retry.try(async () => { + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults.length).to.eql(0); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts new file mode 100644 index 00000000000000..7b60685225ac6c --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +function generateUniqueKey() { + return uuid.v4().replace(/-/g, ''); +} + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const find = getService('find'); + + describe('Connectors', function() { + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + const alertsTab = await testSubjects.find('connectorsTab'); + await alertsTab.click(); + }); + + it('should create a connector', async () => { + const connectorName = generateUniqueKey(); + + await pageObjects.triggersActionsUI.clickCreateConnectorButton(); + + const serverLogCard = await testSubjects.find('.server-log-card'); + await serverLogCard.click(); + + const nameInput = await testSubjects.find('nameInput'); + await nameInput.click(); + await nameInput.clearValue(); + await nameInput.type(connectorName); + + const saveButton = await find.byCssSelector( + '[data-test-subj="saveActionButton"]:not(disabled)' + ); + await saveButton.click(); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created '${connectorName}'`); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResults = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResults).to.eql([ + { + name: connectorName, + actionType: 'Server log', + referencedByCount: '0', + }, + ]); + }); + + it('should edit a connector', async () => { + const connectorName = generateUniqueKey(); + const updatedConnectorName = `${connectorName}updated`; + + await pageObjects.triggersActionsUI.clickCreateConnectorButton(); + + const serverLogCard = await testSubjects.find('.server-log-card'); + await serverLogCard.click(); + + const nameInput = await testSubjects.find('nameInput'); + await nameInput.click(); + await nameInput.clearValue(); + await nameInput.type(connectorName); + + const saveButton = await find.byCssSelector( + '[data-test-subj="saveActionButton"]:not(disabled)' + ); + await saveButton.click(); + + await pageObjects.common.closeToast(); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsBeforeEdit.length).to.eql(1); + + const editConnectorBtn = await find.byCssSelector( + '[data-test-subj="connectorsTableCell-name"] button' + ); + await editConnectorBtn.click(); + + const nameInputToUpdate = await testSubjects.find('nameInput'); + await nameInputToUpdate.click(); + await nameInputToUpdate.clearValue(); + await nameInputToUpdate.type(updatedConnectorName); + + const saveEditButton = await find.byCssSelector( + '[data-test-subj="saveActionButton"]:not(disabled)' + ); + await saveEditButton.click(); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Updated '${updatedConnectorName}'`); + + await pageObjects.triggersActionsUI.searchConnectors(updatedConnectorName); + + const searchResultsAfterEdit = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsAfterEdit).to.eql([ + { + name: updatedConnectorName, + actionType: 'Server log', + referencedByCount: '0', + }, + ]); + }); + + it('should delete a connector', async () => { + async function createConnector(connectorName: string) { + await pageObjects.triggersActionsUI.clickCreateConnectorButton(); + + const serverLogCard = await testSubjects.find('.server-log-card'); + await serverLogCard.click(); + + const nameInput = await testSubjects.find('nameInput'); + await nameInput.click(); + await nameInput.clearValue(); + await nameInput.type(connectorName); + + const saveButton = await find.byCssSelector( + '[data-test-subj="saveActionButton"]:not(disabled)' + ); + await saveButton.click(); + await pageObjects.common.closeToast(); + } + const connectorName = generateUniqueKey(); + await createConnector(connectorName); + + await createConnector(generateUniqueKey()); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsBeforeDelete = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsBeforeDelete.length).to.eql(1); + + const deleteConnectorBtn = await testSubjects.find('deleteConnector'); + await deleteConnectorBtn.click(); + await testSubjects.existOrFail('deleteConnectorsConfirmation'); + await testSubjects.click('deleteConnectorsConfirmation > confirmModalConfirmButton'); + await testSubjects.missingOrFail('deleteConnectorsConfirmation'); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsAfterDelete = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsAfterDelete.length).to.eql(0); + }); + + it('should bulk delete connectors', async () => { + async function createConnector(connectorName: string) { + await pageObjects.triggersActionsUI.clickCreateConnectorButton(); + + const serverLogCard = await testSubjects.find('.server-log-card'); + await serverLogCard.click(); + + const nameInput = await testSubjects.find('nameInput'); + await nameInput.click(); + await nameInput.clearValue(); + await nameInput.type(connectorName); + + const saveButton = await find.byCssSelector( + '[data-test-subj="saveActionButton"]:not(disabled)' + ); + await saveButton.click(); + await pageObjects.common.closeToast(); + } + + const connectorName = generateUniqueKey(); + await createConnector(connectorName); + + await createConnector(generateUniqueKey()); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsBeforeDelete = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsBeforeDelete.length).to.eql(1); + + const deleteCheckbox = await find.byCssSelector( + '.euiTableRowCellCheckbox .euiCheckbox__input' + ); + await deleteCheckbox.click(); + + const bulkDeleteBtn = await testSubjects.find('bulkDelete'); + await bulkDeleteBtn.click(); + await testSubjects.existOrFail('deleteConnectorsConfirmation'); + await testSubjects.click('deleteConnectorsConfirmation > confirmModalConfirmButton'); + await testSubjects.missingOrFail('deleteConnectorsConfirmation'); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsAfterDelete = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsAfterDelete.length).to.eql(0); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts new file mode 100644 index 00000000000000..13f50a505b0b6f --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const log = getService('log'); + const browser = getService('browser'); + + describe('Home page', function() { + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + }); + + it('Loads the app', async () => { + await log.debug('Checking for section heading to say Triggers and Actions.'); + + const headingText = await pageObjects.triggersActionsUI.getSectionHeadingText(); + expect(headingText).to.be('Alerts and Actions'); + }); + + describe('Connectors tab', () => { + it('renders the connectors tab', async () => { + // Navigate to the connectors tab + pageObjects.triggersActionsUI.changeTabs('connectorsTab'); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify url + const url = await browser.getCurrentUrl(); + expect(url).to.contain(`/connectors`); + + // Verify content + await testSubjects.existOrFail('actionsList'); + }); + }); + + describe('Alerts tab', () => { + it('renders the alerts tab', async () => { + // Navigate to the alerts tab + pageObjects.triggersActionsUI.changeTabs('alertsTab'); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify url + const url = await browser.getCurrentUrl(); + expect(url).to.contain(`/alerts`); + + // Verify content + await testSubjects.existOrFail('alertsList'); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts new file mode 100644 index 00000000000000..c76f477c8cfbef --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ loadTestFile, getService }: FtrProviderContext) => { + describe('Actions and Triggers app', function() { + this.tags('ciGroup3'); + loadTestFile(require.resolve('./home_page')); + loadTestFile(require.resolve('./connectors')); + loadTestFile(require.resolve('./alerts')); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts new file mode 100644 index 00000000000000..1a9736b0b47736 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve, join } from 'path'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from './services'; +import { pageObjects } from './page_objects'; + +// eslint-disable-next-line import/no-default-export +export default async function({ readConfigFile }: FtrConfigProviderContext) { + const xpackFunctionalConfig = await readConfigFile(require.resolve('../functional/config.js')); + + const servers = { + ...xpackFunctionalConfig.get('servers'), + elasticsearch: { + ...xpackFunctionalConfig.get('servers.elasticsearch'), + protocol: 'https', + }, + }; + + const returnedObject = { + ...xpackFunctionalConfig.getAll(), + servers, + services, + pageObjects, + // list paths to the files that contain your plugins tests + testFiles: [resolve(__dirname, './apps/triggers_actions_ui')], + apps: { + ...xpackFunctionalConfig.get('apps'), + triggersActions: { + pathname: '/app/kibana', + hash: '/management/kibana/triggersActions', + }, + }, + esTestCluster: { + ...xpackFunctionalConfig.get('esTestCluster'), + ssl: true, + }, + kbnTestServer: { + ...xpackFunctionalConfig.get('kbnTestServer'), + serverArgs: [ + ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), + `--elasticsearch.hosts=https://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, + '--xpack.actions.enabled=true', + '--xpack.alerting.enabled=true', + '--xpack.triggers_actions_ui.enabled=true', + '--xpack.triggers_actions_ui.createAlertUiEnabled=true', + ], + }, + }; + + return returnedObject; +} diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts new file mode 100644 index 00000000000000..df651c67c2c281 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertType } from '../../../../../legacy/plugins/alerting'; + +// eslint-disable-next-line import/no-default-export +export default function(kibana: any) { + return new kibana.Plugin({ + require: ['alerting'], + name: 'alerts', + init(server: any) { + const noopAlertType: AlertType = { + id: 'test.noop', + name: 'Test: Noop', + actionGroups: ['default'], + async executor() {}, + }; + server.plugins.alerting.setup.registerType(noopAlertType); + }, + }); +} diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json new file mode 100644 index 00000000000000..836fa09855d8fd --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json @@ -0,0 +1,7 @@ +{ + "name": "alerts", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/functional_with_es_ssl/ftr_provider_context.d.ts b/x-pack/test/functional_with_es_ssl/ftr_provider_context.d.ts new file mode 100644 index 00000000000000..bb257cdcbfe1b5 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/functional_with_es_ssl/page_objects/index.ts b/x-pack/test/functional_with_es_ssl/page_objects/index.ts new file mode 100644 index 00000000000000..a068ba7dfe81d3 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/page_objects/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pageObjects as xpackFunctionalPageObjects } from '../../functional/page_objects'; +import { TriggersActionsPageProvider } from './triggers_actions_ui_page'; + +export const pageObjects = { + ...xpackFunctionalPageObjects, + triggersActionsUI: TriggersActionsPageProvider, +}; diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts new file mode 100644 index 00000000000000..ce68109771487b --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -0,0 +1,97 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; + +const ENTER_KEY = '\uE007'; + +export function TriggersActionsPageProvider({ getService }: FtrProviderContext) { + const find = getService('find'); + const testSubjects = getService('testSubjects'); + + return { + async getSectionHeadingText() { + return await testSubjects.getVisibleText('appTitle'); + }, + async clickCreateConnectorButton() { + const createBtn = await find.byCssSelector( + '[data-test-subj="createActionButton"],[data-test-subj="createFirstActionButton"]' + ); + await createBtn.click(); + }, + async searchConnectors(searchText: string) { + const searchBox = await find.byCssSelector('[data-test-subj="actionsList"] .euiFieldSearch'); + await searchBox.click(); + await searchBox.clearValue(); + await searchBox.type(searchText); + await searchBox.pressKeys(ENTER_KEY); + await find.byCssSelector( + '.euiBasicTable[data-test-subj="actionsTable"]:not(.euiBasicTable-loading)' + ); + }, + async searchAlerts(searchText: string) { + const searchBox = await testSubjects.find('alertSearchField'); + await searchBox.click(); + await searchBox.clearValue(); + await searchBox.type(searchText); + await searchBox.pressKeys(ENTER_KEY); + await find.byCssSelector( + '.euiBasicTable[data-test-subj="alertsList"]:not(.euiBasicTable-loading)' + ); + }, + async getConnectorsList() { + const table = await find.byCssSelector('[data-test-subj="actionsList"] table'); + const $ = await table.parseDomContent(); + return $.findTestSubjects('connectors-row') + .toArray() + .map(row => { + return { + name: $(row) + .findTestSubject('connectorsTableCell-name') + .find('.euiTableCellContent') + .text(), + actionType: $(row) + .findTestSubject('connectorsTableCell-actionType') + .find('.euiTableCellContent') + .text(), + referencedByCount: $(row) + .findTestSubject('connectorsTableCell-referencedByCount') + .find('.euiTableCellContent') + .text(), + }; + }); + }, + async getAlertsList() { + const table = await find.byCssSelector('[data-test-subj="alertsList"] table'); + const $ = await table.parseDomContent(); + return $.findTestSubjects('alert-row') + .toArray() + .map(row => { + return { + name: $(row) + .findTestSubject('alertsTableCell-name') + .find('.euiTableCellContent') + .text(), + tagsText: $(row) + .findTestSubject('alertsTableCell-tagsText') + .find('.euiTableCellContent') + .text(), + alertType: $(row) + .findTestSubject('alertsTableCell-alertType') + .find('.euiTableCellContent') + .text(), + interval: $(row) + .findTestSubject('alertsTableCell-interval') + .find('.euiTableCellContent') + .text(), + }; + }); + }, + async changeTabs(tab: 'alertsTab' | 'connectorsTab') { + return await testSubjects.click(tab); + }, + }; +} diff --git a/x-pack/test/functional_with_es_ssl/services/index.ts b/x-pack/test/functional_with_es_ssl/services/index.ts new file mode 100644 index 00000000000000..6e96921c25a316 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/services/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { services as xpackFunctionalServices } from '../../functional/services'; + +export const services = { + ...xpackFunctionalServices, +}; From e5c17fb0cdc6ce00e0c64da75ec4a7d45db9e52d Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Mon, 13 Jan 2020 21:53:35 -0500 Subject: [PATCH 43/62] Skip failing uptime tests --- .../location_map/__tests__/location_status_tags.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx index 21e5881654533f..40ce2169fa00f3 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx @@ -10,7 +10,8 @@ import { renderWithIntl } from 'test_utils/enzyme_helpers'; import { MonitorLocation } from '../../../../../common/runtime_types/monitor'; import { LocationStatusTags } from '../'; -describe('StatusByLocation component', () => { +// Failing: https://github.com/elastic/kibana/issues/54672 +describe.skip('StatusByLocation component', () => { let monitorLocations: MonitorLocation[]; const start = moment('2020-01-10T12:22:32.567Z'); From 72dd68e3b424f3c0510e45d279b455b0ded9b845 Mon Sep 17 00:00:00 2001 From: Andrew Cholakian Date: Mon, 13 Jan 2020 21:05:07 -0600 Subject: [PATCH 44/62] [Uptime] Temporarily skip flakey tests (#54675) * [Uptime] Temporarily skip flakey tests * Fix further flakey tests due to hardcoding times + using snapshots --- .../location_map/__tests__/location_status_tags.test.tsx | 1 + .../__test__/status_by_location.test.tsx | 2 +- x-pack/test/functional/apps/uptime/overview.ts | 6 ++++-- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx index 40ce2169fa00f3..de04347148bb24 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx @@ -10,6 +10,7 @@ import { renderWithIntl } from 'test_utils/enzyme_helpers'; import { MonitorLocation } from '../../../../../common/runtime_types/monitor'; import { LocationStatusTags } from '../'; +// These tests use absolute time // Failing: https://github.com/elastic/kibana/issues/54672 describe.skip('StatusByLocation component', () => { let monitorLocations: MonitorLocation[]; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx index 38864103564ca3..ac6a1baf8a1102 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx @@ -9,7 +9,7 @@ import { renderWithIntl } from 'test_utils/enzyme_helpers'; import { MonitorLocation } from '../../../../../common/runtime_types'; import { StatusByLocations } from '../'; -describe('StatusByLocation component', () => { +describe.skip('StatusByLocation component', () => { let monitorLocations: MonitorLocation[]; it('renders when up in all locations', () => { diff --git a/x-pack/test/functional/apps/uptime/overview.ts b/x-pack/test/functional/apps/uptime/overview.ts index bcfb72967b75a1..ba701da6e517dd 100644 --- a/x-pack/test/functional/apps/uptime/overview.ts +++ b/x-pack/test/functional/apps/uptime/overview.ts @@ -50,7 +50,8 @@ export default ({ getPageObjects }: FtrProviderContext) => { ]); }); - it('pagination is cleared when filter criteria changes', async () => { + // flakey see https://github.com/elastic/kibana/issues/54527 + it.skip('pagination is cleared when filter criteria changes', async () => { await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await pageObjects.uptime.changePage('next'); // there should now be pagination data in the URL @@ -86,7 +87,8 @@ export default ({ getPageObjects }: FtrProviderContext) => { ]); }); - describe('snapshot counts', () => { + // Flakey, see https://github.com/elastic/kibana/issues/54541 + describe.skip('snapshot counts', () => { it('updates the snapshot count when status filter is set to down', async () => { await pageObjects.uptime.goToUptimePageAndSetDateRange( DEFAULT_DATE_START, From 748a753923b83b1995905070bac5fefc02d2cce7 Mon Sep 17 00:00:00 2001 From: Eli Perelman Date: Tue, 14 Jan 2020 00:39:56 -0600 Subject: [PATCH 45/62] Remove extraneous public import to prevent failing Kibana startup (#54676) Co-authored-by: Elastic Machine --- src/core/server/types.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/server/types.ts b/src/core/server/types.ts index 9919c7f0386b56..2433aad1a2be57 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -23,4 +23,3 @@ export * from './saved_objects/types'; export * from './ui_settings/types'; export * from './legacy/types'; export { EnvironmentMode, PackageInfo } from './config/types'; -export { ICspConfig } from './csp'; From f71142dcd39e68aa4ddd9ea86bd903e124c2853b Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 14 Jan 2020 08:40:15 +0100 Subject: [PATCH 46/62] Fix chromeless NP apps not using full page width (#54550) * add missing conditional classes on app-wrapper and application containers * update snapshot * refactor and add unit tests for service * typo * use consistent classNames naming --- src/core/public/chrome/chrome_service.tsx | 2 +- .../public/rendering/app_containers.test.tsx | 105 ++++++++++++ src/core/public/rendering/app_containers.tsx | 37 +++++ .../rendering/rendering_service.test.tsx | 151 ++++++++++++------ .../public/rendering/rendering_service.tsx | 7 +- .../test_suites/core_plugins/applications.ts | 14 ++ 6 files changed, 264 insertions(+), 52 deletions(-) create mode 100644 src/core/public/rendering/app_containers.test.tsx create mode 100644 src/core/public/rendering/app_containers.tsx diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index a674b49a8e1344..09ea1afe35766c 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -127,7 +127,7 @@ export class ChromeService { ) ) ); - this.isVisible$ = combineLatest(this.appHidden$, this.toggleHidden$).pipe( + this.isVisible$ = combineLatest([this.appHidden$, this.toggleHidden$]).pipe( map(([appHidden, toggleHidden]) => !(appHidden || toggleHidden)), takeUntil(this.stop$) ); diff --git a/src/core/public/rendering/app_containers.test.tsx b/src/core/public/rendering/app_containers.test.tsx new file mode 100644 index 00000000000000..746e37b1214d92 --- /dev/null +++ b/src/core/public/rendering/app_containers.test.tsx @@ -0,0 +1,105 @@ +/* + * 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 } from 'rxjs'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import React from 'react'; + +import { AppWrapper, AppContainer } from './app_containers'; + +describe('AppWrapper', () => { + it('toggles the `hidden-chrome` class depending on the chrome visibility state', () => { + const chromeVisible$ = new BehaviorSubject(true); + + const component = mount(app-content); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => chromeVisible$.next(false)); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => chromeVisible$.next(true)); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + }); +}); + +describe('AppContainer', () => { + it('adds classes supplied by chrome', () => { + const appClasses$ = new BehaviorSubject([]); + + const component = mount(app-content); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => appClasses$.next(['classA', 'classB'])); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => appClasses$.next(['classC'])); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => appClasses$.next([])); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + }); +}); diff --git a/src/core/public/rendering/app_containers.tsx b/src/core/public/rendering/app_containers.tsx new file mode 100644 index 00000000000000..72faaeac588bef --- /dev/null +++ b/src/core/public/rendering/app_containers.tsx @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { Observable } from 'rxjs'; +import useObservable from 'react-use/lib/useObservable'; +import classNames from 'classnames'; + +export const AppWrapper: React.FunctionComponent<{ + chromeVisible$: Observable; +}> = ({ chromeVisible$, children }) => { + const visible = useObservable(chromeVisible$); + return
{children}
; +}; + +export const AppContainer: React.FunctionComponent<{ + classes$: Observable; +}> = ({ classes$, children }) => { + const classes = useObservable(classes$); + return
{children}
; +}; diff --git a/src/core/public/rendering/rendering_service.test.tsx b/src/core/public/rendering/rendering_service.test.tsx index ed835574a32f9c..437a602a3d4473 100644 --- a/src/core/public/rendering/rendering_service.test.tsx +++ b/src/core/public/rendering/rendering_service.test.tsx @@ -18,72 +18,129 @@ */ import React from 'react'; +import { act } from 'react-dom/test-utils'; -import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { RenderingService } from './rendering_service'; -import { InternalApplicationStart } from '../application'; +import { applicationServiceMock } from '../application/application_service.mock'; +import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; +import { BehaviorSubject } from 'rxjs'; describe('RenderingService#start', () => { - const getService = ({ legacyMode = false }: { legacyMode?: boolean } = {}) => { - const rendering = new RenderingService(); - const application = { - getComponent: () =>
Hello application!
, - } as InternalApplicationStart; - const chrome = chromeServiceMock.createStartContract(); + let application: ReturnType; + let chrome: ReturnType; + let overlays: ReturnType; + let injectedMetadata: ReturnType; + let targetDomElement: HTMLDivElement; + let rendering: RenderingService; + + beforeEach(() => { + application = applicationServiceMock.createInternalStartContract(); + application.getComponent.mockReturnValue(
Hello application!
); + + chrome = chromeServiceMock.createStartContract(); chrome.getHeaderComponent.mockReturnValue(
Hello chrome!
); - const overlays = overlayServiceMock.createStartContract(); + + overlays = overlayServiceMock.createStartContract(); overlays.banners.getComponent.mockReturnValue(
I'm a banner!
); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(legacyMode); - const targetDomElement = document.createElement('div'); - const start = rendering.start({ + injectedMetadata = injectedMetadataServiceMock.createStartContract(); + + targetDomElement = document.createElement('div'); + + rendering = new RenderingService(); + }); + + const startService = () => { + return rendering.start({ application, chrome, injectedMetadata, overlays, targetDomElement, }); - return { start, targetDomElement }; }; - it('renders application service into provided DOM element', () => { - const { targetDomElement } = getService(); - expect(targetDomElement.querySelector('div.application')).toMatchInlineSnapshot(` -
-
- Hello application! -
-
- `); - }); + describe('standard mode', () => { + beforeEach(() => { + injectedMetadata.getLegacyMode.mockReturnValue(false); + }); - it('contains wrapper divs', () => { - const { targetDomElement } = getService(); - expect(targetDomElement.querySelector('div.app-wrapper')).toBeDefined(); - expect(targetDomElement.querySelector('div.app-wrapper-pannel')).toBeDefined(); - }); + it('renders application service into provided DOM element', () => { + startService(); + expect(targetDomElement.querySelector('div.application')).toMatchInlineSnapshot(` +
+
+ Hello application! +
+
+ `); + }); + + it('adds the `chrome-hidden` class to the AppWrapper when chrome is hidden', () => { + const isVisible$ = new BehaviorSubject(true); + chrome.getIsVisible$.mockReturnValue(isVisible$); + startService(); + + const appWrapper = targetDomElement.querySelector('div.app-wrapper')!; + expect(appWrapper.className).toEqual('app-wrapper'); + + act(() => isVisible$.next(false)); + expect(appWrapper.className).toEqual('app-wrapper hidden-chrome'); - it('renders the banner UI', () => { - const { targetDomElement } = getService(); - expect(targetDomElement.querySelector('#globalBannerList')).toMatchInlineSnapshot(` -
-
- I'm a banner! -
-
- `); + act(() => isVisible$.next(true)); + expect(appWrapper.className).toEqual('app-wrapper'); + }); + + it('adds the application classes to the AppContainer', () => { + const applicationClasses$ = new BehaviorSubject([]); + chrome.getApplicationClasses$.mockReturnValue(applicationClasses$); + startService(); + + const appContainer = targetDomElement.querySelector('div.application')!; + expect(appContainer.className).toEqual('application'); + + act(() => applicationClasses$.next(['classA', 'classB'])); + expect(appContainer.className).toEqual('application classA classB'); + + act(() => applicationClasses$.next(['classC'])); + expect(appContainer.className).toEqual('application classC'); + + act(() => applicationClasses$.next([])); + expect(appContainer.className).toEqual('application'); + }); + + it('contains wrapper divs', () => { + startService(); + expect(targetDomElement.querySelector('div.app-wrapper')).toBeDefined(); + expect(targetDomElement.querySelector('div.app-wrapper-pannel')).toBeDefined(); + }); + + it('renders the banner UI', () => { + startService(); + expect(targetDomElement.querySelector('#globalBannerList')).toMatchInlineSnapshot(` +
+
+ I'm a banner! +
+
+ `); + }); }); - describe('legacyMode', () => { + describe('legacy mode', () => { + beforeEach(() => { + injectedMetadata.getLegacyMode.mockReturnValue(true); + }); + it('renders into provided DOM element', () => { - const { targetDomElement } = getService({ legacyMode: true }); + startService(); + expect(targetDomElement).toMatchInlineSnapshot(`
{ }); it('returns a div for the legacy service to render into', () => { - const { - start: { legacyTargetDomElement }, - targetDomElement, - } = getService({ legacyMode: true }); + const { legacyTargetDomElement } = startService(); + expect(targetDomElement.contains(legacyTargetDomElement!)).toBe(true); }); }); diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 7a747faa2673f5..58b8c1921e333b 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -25,6 +25,7 @@ import { InternalChromeStart } from '../chrome'; import { InternalApplicationStart } from '../application'; import { InjectedMetadataStart } from '../injected_metadata'; import { OverlayStart } from '../overlays'; +import { AppWrapper, AppContainer } from './app_containers'; interface StartDeps { application: InternalApplicationStart; @@ -65,12 +66,12 @@ export class RenderingService { {chromeUi} {!legacyMode && ( -
+
{bannerUi}
-
{appUi}
+ {appUi}
-
+ )} {legacyMode &&
} diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index a3c9d9d63e3534..231458fad155b3 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -27,12 +27,18 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider const browser = getService('browser'); const appsMenu = getService('appsMenu'); const testSubjects = getService('testSubjects'); + const find = getService('find'); const loadingScreenNotShown = async () => expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false); const loadingScreenShown = () => testSubjects.existOrFail('kbnLoadingMessage'); + const getAppWrapperWidth = async () => { + const wrapper = await find.byClassName('app-wrapper'); + return (await wrapper.getSize()).width; + }; + const getKibanaUrl = (pathname?: string, search?: string) => url.format({ protocol: 'http:', @@ -99,12 +105,20 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider await PageObjects.common.navigateToApp('chromeless'); await loadingScreenNotShown(); expect(await testSubjects.exists('headerGlobalNav')).to.be(false); + + const wrapperWidth = await getAppWrapperWidth(); + const windowWidth = (await browser.getWindowSize()).width; + expect(wrapperWidth).to.eql(windowWidth); }); it('navigating away from chromeless application shows chrome', async () => { await PageObjects.common.navigateToApp('foo'); await loadingScreenNotShown(); expect(await testSubjects.exists('headerGlobalNav')).to.be(true); + + const wrapperWidth = await getAppWrapperWidth(); + const windowWidth = (await browser.getWindowSize()).width; + expect(wrapperWidth).to.be.below(windowWidth); }); it.skip('can navigate from NP apps to legacy apps', async () => { From 7c4a531ae7b42d306ca353a283d096d5521c605c Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Tue, 14 Jan 2020 08:50:49 +0100 Subject: [PATCH 47/62] =?UTF-8?q?[SIEM]=20Fix=20Inspect=20query=20'request?= =?UTF-8?q?=20timestamp'=20value=20changes=20when=20curso=E2=80=A6=20(#542?= =?UTF-8?q?23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../events_viewer/events_viewer.tsx | 307 +++-- .../components/events_viewer/index.test.tsx | 38 +- .../public/components/events_viewer/index.tsx | 249 ++-- .../components/header_section/index.test.tsx | 30 - .../components/header_section/index.tsx | 78 +- .../public/components/inspect/index.test.tsx | 62 +- .../siem/public/components/inspect/index.tsx | 196 +-- .../components/matrix_histogram/index.tsx | 63 +- .../page/hosts/host_overview/index.tsx | 45 +- .../page/network/ip_overview/index.tsx | 45 +- .../page/overview/overview_host/index.tsx | 23 +- .../page/overview/overview_network/index.tsx | 24 +- .../__snapshots__/index.test.tsx.snap | 2 +- .../components/paginated_table/index.tsx | 228 ++-- .../__snapshots__/index.test.tsx.snap | 1138 ++++++++--------- .../public/components/stat_items/index.tsx | 128 +- .../timeline/properties/properties_right.tsx | 97 +- 17 files changed, 1327 insertions(+), 1426 deletions(-) diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx index aaf88e68684ca0..2c669c259d07e3 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx @@ -59,7 +59,6 @@ interface Props { kqlMode: KqlMode; onChangeItemsPerPage: OnChangeItemsPerPage; query: Query; - showInspect: boolean; start: number; sort: Sort; timelineTypeContext: TimelineTypeContextProps; @@ -67,171 +66,171 @@ interface Props { utilityBar?: (totalCount: number) => React.ReactNode; } -export const EventsViewer = React.memo( - ({ - browserFields, - columns, +const EventsViewerComponent: React.FC = ({ + browserFields, + columns, + dataProviders, + deletedEventIds, + end, + filters, + headerFilterGroup, + height = DEFAULT_EVENTS_VIEWER_HEIGHT, + id, + indexPattern, + isLive, + itemsPerPage, + itemsPerPageOptions, + kqlMode, + onChangeItemsPerPage, + query, + start, + sort, + timelineTypeContext, + toggleColumn, + utilityBar, +}) => { + const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; + const kibana = useKibana(); + const combinedQueries = combineQueries({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), dataProviders, - deletedEventIds, - end, - filters, - headerFilterGroup, - height = DEFAULT_EVENTS_VIEWER_HEIGHT, - id, indexPattern, - isLive, - itemsPerPage, - itemsPerPageOptions, + browserFields, + filters, + kqlQuery: query, kqlMode, - onChangeItemsPerPage, - query, - showInspect, start, - sort, - timelineTypeContext, - toggleColumn, - utilityBar, - }) => { - const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; - const kibana = useKibana(); - const combinedQueries = combineQueries({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - dataProviders, - indexPattern, - browserFields, - filters, - kqlQuery: query, - kqlMode, - start, - end, - isEventViewer: true, - }); - const queryFields = useMemo( - () => - union( - columnsHeader.map(c => c.id), - timelineTypeContext.queryFields ?? [] - ), - [columnsHeader, timelineTypeContext.queryFields] - ); + end, + isEventViewer: true, + }); + const queryFields = useMemo( + () => + union( + columnsHeader.map(c => c.id), + timelineTypeContext.queryFields ?? [] + ), + [columnsHeader, timelineTypeContext.queryFields] + ); - return ( - - - {({ measureRef, content: { width = 0 } }) => ( - <> - -
- + return ( + + + {({ measureRef, content: { width = 0 } }) => ( + <> + +
+ - {combinedQueries != null ? ( - - {({ - events, - getUpdatedAt, - inspect, - loading, - loadMore, - pageInfo, - refetch, - totalCount = 0, - }) => { - const totalCountMinusDeleted = - totalCount > 0 ? totalCount - deletedEventIds.length : 0; + {combinedQueries != null ? ( + + {({ + events, + getUpdatedAt, + inspect, + loading, + loadMore, + pageInfo, + refetch, + totalCount = 0, + }) => { + const totalCountMinusDeleted = + totalCount > 0 ? totalCount - deletedEventIds.length : 0; - // TODO: Reset eventDeletedIds/eventLoadingIds on refresh/loadmore (getUpdatedAt) - return ( - <> - - {headerFilterGroup} - + // TODO: Reset eventDeletedIds/eventLoadingIds on refresh/loadmore (getUpdatedAt) + return ( + <> + + {headerFilterGroup} + - {utilityBar?.(totalCountMinusDeleted)} + {utilityBar?.(totalCountMinusDeleted)} -
+ - - + refetch={refetch} + /> + + !deletedEventIds.includes(e._id))} + id={id} + isEventViewer={true} + height={height} + sort={sort} + toggleColumn={toggleColumn} + /> - !deletedEventIds.includes(e._id))} - id={id} - isEventViewer={true} - height={height} - sort={sort} - toggleColumn={toggleColumn} - /> +
+ +
+ + ); + }} +
+ ) : null} + + )} + + + ); +}; -
- -
- - ); - }} - - ) : null} - - )} -
-
- ); - }, +export const EventsViewer = React.memo( + EventsViewerComponent, (prevProps, nextProps) => prevProps.browserFields === nextProps.browserFields && prevProps.columns === nextProps.columns && @@ -247,10 +246,8 @@ export const EventsViewer = React.memo( prevProps.itemsPerPageOptions === nextProps.itemsPerPageOptions && prevProps.kqlMode === nextProps.kqlMode && isEqual(prevProps.query, nextProps.query) && - prevProps.showInspect === nextProps.showInspect && prevProps.start === nextProps.start && prevProps.sort === nextProps.sort && isEqual(prevProps.timelineTypeContext, nextProps.timelineTypeContext) && prevProps.utilityBar === nextProps.utilityBar ); -EventsViewer.displayName = 'EventsViewer'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx index 1e225dabb25410..ec8d329f1dfe3e 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx @@ -57,7 +57,8 @@ describe('StatefulEventsViewer', () => { ).toBe(true); }); - test('it renders a transparent inspect button when it does NOT have mouse focus', async () => { + // InspectButtonContainer controls displaying InspectButton components + test('it renders InspectButtonContainer', async () => { const wrapper = mount( @@ -74,39 +75,6 @@ describe('StatefulEventsViewer', () => { await wait(); wrapper.update(); - expect( - wrapper - .find(`[data-test-subj="transparent-inspect-container"]`) - .first() - .exists() - ).toBe(true); - }); - - test('it renders an opaque inspect button when it has mouse focus', async () => { - const wrapper = mount( - - - - - - ); - - await wait(); - wrapper.update(); - - wrapper.simulate('mouseenter'); - wrapper.update(); - - expect( - wrapper - .find(`[data-test-subj="opaque-inspect-container"]`) - .first() - .exists() - ).toBe(true); + expect(wrapper.find(`InspectButtonContainer`).exists()).toBe(true); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx index 9b8ec243d5f387..99d174d74f3f8f 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import { inputsModel, inputsSelectors, State, timelineSelectors } from '../../store'; @@ -23,6 +23,7 @@ import { InputsModelId } from '../../store/inputs/constants'; import { useFetchIndexPatterns } from '../../containers/detection_engine/rules/fetch_index_patterns'; import { TimelineTypeContextProps } from '../timeline/timeline_context'; import { DEFAULT_INDEX_KEY } from '../../../common/constants'; +import { InspectButtonContainer } from '../inspect'; import * as i18n from './translations'; export interface OwnProps { @@ -83,133 +84,102 @@ interface DispatchProps { type Props = OwnProps & StateReduxProps & DispatchProps; -const StatefulEventsViewerComponent = React.memo( - ({ - createTimeline, - columns, - dataProviders, - defaultModel, - deletedEventIds, - defaultIndices, - deleteEventQuery, - end, - filters, - headerFilterGroup, - id, - isLive, - itemsPerPage, - itemsPerPageOptions, - kqlMode, - pageFilters = [], - query, - removeColumn, - start, - showCheckboxes, - showRowRenderers, - sort, - timelineTypeContext = { - loadingText: i18n.LOADING_EVENTS, - }, - updateItemsPerPage, - upsertColumn, - utilityBar, - }) => { - const [showInspect, setShowInspect] = useState(false); - const [{ browserFields, indexPatterns }] = useFetchIndexPatterns( - defaultIndices ?? useUiSetting(DEFAULT_INDEX_KEY) - ); - - useEffect(() => { - if (createTimeline != null) { - createTimeline({ id, columns, sort, itemsPerPage, showCheckboxes, showRowRenderers }); - } - return () => { - deleteEventQuery({ id, inputId: 'global' }); - }; - }, []); - - const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback( - itemsChangedPerPage => updateItemsPerPage({ id, itemsPerPage: itemsChangedPerPage }), - [id, updateItemsPerPage] - ); - - const toggleColumn = useCallback( - (column: ColumnHeader) => { - const exists = columns.findIndex(c => c.id === column.id) !== -1; - - if (!exists && upsertColumn != null) { - upsertColumn({ - column, - id, - index: 1, - }); - } - - if (exists && removeColumn != null) { - removeColumn({ - columnId: column.id, - id, - }); - } - }, - [columns, id, upsertColumn, removeColumn] - ); - - const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); - const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - - return ( -
- -
- ); +const StatefulEventsViewerComponent: React.FC = ({ + createTimeline, + columns, + dataProviders, + deletedEventIds, + defaultIndices, + deleteEventQuery, + end, + filters, + headerFilterGroup, + id, + isLive, + itemsPerPage, + itemsPerPageOptions, + kqlMode, + pageFilters = [], + query, + removeColumn, + start, + showCheckboxes, + showRowRenderers, + sort, + timelineTypeContext = { + loadingText: i18n.LOADING_EVENTS, }, - (prevProps, nextProps) => - prevProps.id === nextProps.id && - isEqual(prevProps.columns, nextProps.columns) && - isEqual(prevProps.dataProviders, nextProps.dataProviders) && - prevProps.deletedEventIds === nextProps.deletedEventIds && - prevProps.end === nextProps.end && - isEqual(prevProps.filters, nextProps.filters) && - prevProps.isLive === nextProps.isLive && - prevProps.itemsPerPage === nextProps.itemsPerPage && - isEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && - prevProps.kqlMode === nextProps.kqlMode && - isEqual(prevProps.query, nextProps.query) && - prevProps.pageCount === nextProps.pageCount && - isEqual(prevProps.sort, nextProps.sort) && - prevProps.start === nextProps.start && - isEqual(prevProps.pageFilters, nextProps.pageFilters) && - prevProps.showCheckboxes === nextProps.showCheckboxes && - prevProps.showRowRenderers === nextProps.showRowRenderers && - prevProps.start === nextProps.start && - isEqual(prevProps.timelineTypeContext, nextProps.timelineTypeContext) && - prevProps.utilityBar === nextProps.utilityBar -); + updateItemsPerPage, + upsertColumn, + utilityBar, +}) => { + const [{ browserFields, indexPatterns }] = useFetchIndexPatterns( + defaultIndices ?? useUiSetting(DEFAULT_INDEX_KEY) + ); + + useEffect(() => { + if (createTimeline != null) { + createTimeline({ id, columns, sort, itemsPerPage, showCheckboxes, showRowRenderers }); + } + return () => { + deleteEventQuery({ id, inputId: 'global' }); + }; + }, []); + + const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback( + itemsChangedPerPage => updateItemsPerPage({ id, itemsPerPage: itemsChangedPerPage }), + [id, updateItemsPerPage] + ); + + const toggleColumn = useCallback( + (column: ColumnHeader) => { + const exists = columns.findIndex(c => c.id === column.id) !== -1; + + if (!exists && upsertColumn != null) { + upsertColumn({ + column, + id, + index: 1, + }); + } -StatefulEventsViewerComponent.displayName = 'StatefulEventsViewerComponent'; + if (exists && removeColumn != null) { + removeColumn({ + columnId: column.id, + id, + }); + } + }, + [columns, id, upsertColumn, removeColumn] + ); + + return ( + + + + ); +}; const makeMapStateToProps = () => { const getInputsTimeline = inputsSelectors.getTimelineSelector(); @@ -256,4 +226,29 @@ export const StatefulEventsViewer = connect(makeMapStateToProps, { updateItemsPerPage: timelineActions.updateItemsPerPage, removeColumn: timelineActions.removeColumn, upsertColumn: timelineActions.upsertColumn, -})(StatefulEventsViewerComponent); +})( + React.memo( + StatefulEventsViewerComponent, + (prevProps, nextProps) => + prevProps.id === nextProps.id && + isEqual(prevProps.columns, nextProps.columns) && + isEqual(prevProps.dataProviders, nextProps.dataProviders) && + prevProps.deletedEventIds === nextProps.deletedEventIds && + prevProps.end === nextProps.end && + isEqual(prevProps.filters, nextProps.filters) && + prevProps.isLive === nextProps.isLive && + prevProps.itemsPerPage === nextProps.itemsPerPage && + isEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && + prevProps.kqlMode === nextProps.kqlMode && + isEqual(prevProps.query, nextProps.query) && + prevProps.pageCount === nextProps.pageCount && + isEqual(prevProps.sort, nextProps.sort) && + prevProps.start === nextProps.start && + isEqual(prevProps.pageFilters, nextProps.pageFilters) && + prevProps.showCheckboxes === nextProps.showCheckboxes && + prevProps.showRowRenderers === nextProps.showRowRenderers && + prevProps.start === nextProps.start && + isEqual(prevProps.timelineTypeContext, nextProps.timelineTypeContext) && + prevProps.utilityBar === nextProps.utilityBar + ) +); diff --git a/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx index 2bc80be20e42da..bc4692b6fe0c58 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx @@ -63,36 +63,6 @@ describe('HeaderSection', () => { ).toBe(false); }); - test('it renders a transparent inspect button when showInspect is false', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper - .find('[data-test-subj="transparent-inspect-container"]') - .first() - .exists() - ).toBe(true); - }); - - test('it renders an opaque inspect button when showInspect is true', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper - .find('[data-test-subj="opaque-inspect-container"]') - .first() - .exists() - ).toBe(true); - }); - test('it renders supplements when children provided', () => { const wrapper = mount( diff --git a/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx b/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx index 14af10eb6cd9bf..3153e785a8a32e 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx @@ -35,48 +35,54 @@ export interface HeaderSectionProps extends HeaderProps { id?: string; split?: boolean; subtitle?: string | React.ReactNode; - showInspect?: boolean; title: string | React.ReactNode; tooltip?: string; } -export const HeaderSection = React.memo( - ({ border, children, id, showInspect = false, split, subtitle, title, tooltip }) => ( -
- - - - - -

- {title} - {tooltip && ( - <> - {' '} - - - )} -

-
+const HeaderSectionComponent: React.FC = ({ + border, + children, + id, + split, + subtitle, + title, + tooltip, +}) => ( +
+ + + + + +

+ {title} + {tooltip && ( + <> + {' '} + + + )} +

+
- {subtitle && } + {subtitle && } +
+ + {id && ( + + + )} +
+
- {id && ( - - - - )} -
+ {children && ( + + {children} - - {children && ( - - {children} - - )} - -
- ) + )} +
+
); -HeaderSection.displayName = 'HeaderSection'; + +export const HeaderSection = React.memo(HeaderSectionComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/inspect/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/inspect/index.test.tsx index 26c5f499717e9c..9492002717e2bf 100644 --- a/x-pack/legacy/plugins/siem/public/components/inspect/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/inspect/index.test.tsx @@ -17,7 +17,7 @@ import { import { createStore, State } from '../../store'; import { UpdateQueryParams, upsertQuery } from '../../store/inputs/helpers'; -import { InspectButton } from '.'; +import { InspectButton, InspectButtonContainer, BUTTON_CLASS } from '.'; import { cloneDeep } from 'lodash/fp'; describe('Inspect Button', () => { @@ -44,7 +44,7 @@ describe('Inspect Button', () => { test('Eui Empty Button', () => { const wrapper = mount( - + ); expect( @@ -58,13 +58,7 @@ describe('Inspect Button', () => { test('it does NOT render the Eui Empty Button when timeline is timeline and compact is true', () => { const wrapper = mount( - + ); expect( @@ -78,7 +72,7 @@ describe('Inspect Button', () => { test('Eui Icon Button', () => { const wrapper = mount( - + ); expect( @@ -92,13 +86,7 @@ describe('Inspect Button', () => { test('renders the Icon Button when inputId does NOT equal global, but compact is true', () => { const wrapper = mount( - + ); expect( @@ -112,7 +100,7 @@ describe('Inspect Button', () => { test('Eui Empty Button disabled', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiButtonIcon').get(0).props.disabled).toBe(true); @@ -121,11 +109,41 @@ describe('Inspect Button', () => { test('Eui Icon Button disabled', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiButtonIcon').get(0).props.disabled).toBe(true); }); + + describe('InspectButtonContainer', () => { + test('it renders a transparent inspect button by default', async () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(`InspectButtonContainer`)).toHaveStyleRule('opacity', '0', { + modifier: `.${BUTTON_CLASS}`, + }); + }); + + test('it renders an opaque inspect button when it has mouse focus', async () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(`InspectButtonContainer`)).toHaveStyleRule('opacity', '1', { + modifier: `:hover .${BUTTON_CLASS}`, + }); + }); + }); }); describe('Modal Inspect - happy path', () => { @@ -143,7 +161,7 @@ describe('Inspect Button', () => { const wrapper = mount( - + ); @@ -167,7 +185,7 @@ describe('Inspect Button', () => { const wrapper = mount( - + ); @@ -197,7 +215,7 @@ describe('Inspect Button', () => { test('Do not Open Inspect Modal if it is loading', () => { const wrapper = mount( - + ); store.getState().inputs.global.queries[0].loading = true; diff --git a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx index a2a0ffdde34a50..32e71b3db575f5 100644 --- a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx @@ -9,7 +9,7 @@ import { getOr } from 'lodash/fp'; import React, { useCallback } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { inputsModel, inputsSelectors, State } from '../../store'; import { InputsModelId } from '../../store/inputs/constants'; @@ -18,14 +18,31 @@ import { inputsActions } from '../../store/inputs'; import { ModalInspectQuery } from './modal'; import * as i18n from './translations'; -const InspectContainer = styled.div<{ showInspect: boolean }>` - .euiButtonIcon { - ${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0;')} +export const BUTTON_CLASS = 'inspectButtonComponent'; + +export const InspectButtonContainer = styled.div<{ show?: boolean }>` + display: flex; + flex-grow: 1; + + .${BUTTON_CLASS} { + opacity: 0; transition: opacity ${props => getOr(250, 'theme.eui.euiAnimSpeedNormal', props)} ease; } + + ${({ show }) => + show && + css` + &:hover .${BUTTON_CLASS} { + opacity: 1; + } + `} `; -InspectContainer.displayName = 'InspectContainer'; +InspectButtonContainer.displayName = 'InspectButtonContainer'; + +InspectButtonContainer.defaultProps = { + show: true, +}; interface OwnProps { compact?: boolean; @@ -34,7 +51,6 @@ interface OwnProps { inspectIndex?: number; isDisabled?: boolean; onCloseInspect?: () => void; - show: boolean; title: string | React.ReactElement | React.ReactNode; } @@ -57,89 +73,84 @@ interface InspectButtonDispatch { type InspectButtonProps = OwnProps & InspectButtonReducer & InspectButtonDispatch; -const InspectButtonComponent = React.memo( - ({ - compact = false, - inputId = 'global', - inspect, - isDisabled, - isInspected, - loading, - inspectIndex = 0, - onCloseInspect, - queryId = '', - selectedInspectIndex, - setIsInspected, - show, - title = '', - }: InspectButtonProps) => { - const handleClick = useCallback(() => { - setIsInspected({ - id: queryId, - inputId, - isInspected: true, - selectedInspectIndex: inspectIndex, - }); - }, [setIsInspected, queryId, inputId, inspectIndex]); - - const handleCloseModal = useCallback(() => { - if (onCloseInspect != null) { - onCloseInspect(); - } - setIsInspected({ - id: queryId, - inputId, - isInspected: false, - selectedInspectIndex: inspectIndex, - }); - }, [onCloseInspect, setIsInspected, queryId, inputId, inspectIndex]); - - return ( - - {inputId === 'timeline' && !compact && ( - - {i18n.INSPECT} - - )} - {(inputId === 'global' || compact) && ( - - )} - 0 ? inspect.dsl[inspectIndex] : null} - response={ - inspect != null && inspect.response.length > 0 ? inspect.response[inspectIndex] : null - } - title={title} - data-test-subj="inspect-modal" - /> - - ); - } -); +const InspectButtonComponent: React.FC = ({ + compact = false, + inputId = 'global', + inspect, + isDisabled, + isInspected, + loading, + inspectIndex = 0, + onCloseInspect, + queryId = '', + selectedInspectIndex, + setIsInspected, + title = '', +}) => { + const isShowingModal = !loading && selectedInspectIndex === inspectIndex && isInspected; + const handleClick = useCallback(() => { + setIsInspected({ + id: queryId, + inputId, + isInspected: true, + selectedInspectIndex: inspectIndex, + }); + }, [setIsInspected, queryId, inputId, inspectIndex]); -InspectButtonComponent.displayName = 'InspectButtonComponent'; + const handleCloseModal = useCallback(() => { + if (onCloseInspect != null) { + onCloseInspect(); + } + setIsInspected({ + id: queryId, + inputId, + isInspected: false, + selectedInspectIndex: inspectIndex, + }); + }, [onCloseInspect, setIsInspected, queryId, inputId, inspectIndex]); + + return ( + <> + {inputId === 'timeline' && !compact && ( + + {i18n.INSPECT} + + )} + {(inputId === 'global' || compact) && ( + + )} + 0 ? inspect.dsl[inspectIndex] : null} + response={ + inspect != null && inspect.response.length > 0 ? inspect.response[inspectIndex] : null + } + title={title} + data-test-subj="inspect-modal" + /> + + ); +}; const makeMapStateToProps = () => { const getGlobalQuery = inputsSelectors.globalQueryByIdSelector(); @@ -150,6 +161,11 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -export const InspectButton = connect(makeMapStateToProps, { +const mapDispatchToProps = { setIsInspected: inputsActions.setInspectionParameter, -})(InspectButtonComponent); +}; + +export const InspectButton = connect( + makeMapStateToProps, + mapDispatchToProps +)(React.memo(InspectButtonComponent)); diff --git a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx index c29b5282e13af2..94c05d00d5462d 100644 --- a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import { ScaleType } from '@elastic/charts'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; @@ -17,10 +17,11 @@ import { DEFAULT_DARK_MODE } from '../../../common/constants'; import { useUiSetting$ } from '../../lib/kibana'; import { Loader } from '../loader'; import { Panel } from '../panel'; +import { InspectButtonContainer } from '../inspect'; import { getBarchartConfigs, getCustomChartData } from './utils'; import { MatrixHistogramProps, MatrixHistogramDataTypes } from './types'; -export const MatrixHistogram = ({ +export const MatrixHistogramComponent: React.FC> = ({ data, dataKey, endDate, @@ -35,7 +36,7 @@ export const MatrixHistogram = ({ updateDateRange, yTickFormatter, showLegend, -}: MatrixHistogramProps) => { +}) => { const barchartConfigs = getBarchartConfigs({ from: startDate, to: endDate, @@ -44,7 +45,6 @@ export const MatrixHistogram = ({ yTickFormatter, showLegend, }); - const [showInspect, setShowInspect] = useState(false); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); const [loadingInitial, setLoadingInitial] = useState(false); @@ -56,40 +56,31 @@ export const MatrixHistogram = ({ } }, [loading, loadingInitial, totalCount]); - const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); - const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - return ( - - + + + - {loadingInitial ? ( - - ) : ( - <> - + {loadingInitial ? ( + + ) : ( + <> + - {loading && ( - - )} - - )} - + {loading && ( + + )} + + )} + + ); }; + +export const MatrixHistogram = React.memo(MatrixHistogramComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx index 9e3f8f91d5cf78..bf32a33af1eacf 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx @@ -8,14 +8,14 @@ import { EuiFlexItem } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; import { getOr } from 'lodash/fp'; -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext } from 'react'; import { DEFAULT_DARK_MODE } from '../../../../../common/constants'; import { DescriptionList } from '../../../../../common/utility_types'; import { useUiSetting$ } from '../../../../lib/kibana'; import { getEmptyTagValue } from '../../../empty_value'; import { DefaultFieldRenderer, hostIdRenderer } from '../../../field_renderers/field_renderers'; -import { InspectButton } from '../../../inspect'; +import { InspectButton, InspectButtonContainer } from '../../../inspect'; import { HostItem } from '../../../../graphql/types'; import { Loader } from '../../../loader'; import { IPDetailsLink } from '../../../links'; @@ -56,7 +56,6 @@ export const HostOverview = React.memo( anomaliesData, narrowDateRange, }) => { - const [showInspect, setShowInspect] = useState(false); const capabilities = useContext(MlCapabilitiesContext); const userPermissions = hasMlUserPermissions(capabilities); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); @@ -165,32 +164,26 @@ export const HostOverview = React.memo( ], ]; - const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); - const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - return ( - - + + + - {descriptionLists.map((descriptionList, index) => - getDescriptionList(descriptionList, index) - )} + {descriptionLists.map((descriptionList, index) => + getDescriptionList(descriptionList, index) + )} - {loading && ( - - )} - + {loading && ( + + )} + + ); } ); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx index 0c4e5943995176..901b82210a6618 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx @@ -7,7 +7,7 @@ import { EuiFlexItem } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext } from 'react'; import { DEFAULT_DARK_MODE } from '../../../../../common/constants'; import { DescriptionList } from '../../../../../common/utility_types'; @@ -32,7 +32,7 @@ import { Anomalies, NarrowDateRange } from '../../../ml/types'; import { AnomalyScores } from '../../../ml/score/anomaly_scores'; import { MlCapabilitiesContext } from '../../../ml/permissions/ml_capabilities_provider'; import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions'; -import { InspectButton } from '../../../inspect'; +import { InspectButton, InspectButtonContainer } from '../../../inspect'; interface OwnProps { data: IpOverviewData; @@ -71,7 +71,6 @@ export const IpOverview = React.memo( anomaliesData, narrowDateRange, }) => { - const [showInspect, setShowInspect] = useState(false); const capabilities = useContext(MlCapabilitiesContext); const userPermissions = hasMlUserPermissions(capabilities); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); @@ -140,32 +139,26 @@ export const IpOverview = React.memo( ], ]; - const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); - const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - return ( - - + + + - {descriptionLists.map((descriptionList, index) => - getDescriptionList(descriptionList, index) - )} + {descriptionLists.map((descriptionList, index) => + getDescriptionList(descriptionList, index) + )} - {loading && ( - - )} - + {loading && ( + + )} + + ); } ); diff --git a/x-pack/legacy/plugins/siem/public/components/page/overview/overview_host/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/overview/overview_host/index.tsx index 302917c3de93e3..a70d9d00802718 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/overview/overview_host/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/overview/overview_host/index.tsx @@ -6,7 +6,7 @@ import { EuiButton, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { HeaderSection } from '../../../header_section'; import { manageQuery } from '../../../page/manage_query'; @@ -17,6 +17,7 @@ import { import { inputsModel } from '../../../../store/inputs'; import { OverviewHostStats } from '../overview_host_stats'; import { getHostsUrl } from '../../../link_to'; +import { InspectButtonContainer } from '../../../inspect'; export interface OwnProps { startDate: number; @@ -36,18 +37,14 @@ export interface OwnProps { const OverviewHostStatsManage = manageQuery(OverviewHostStats); type OverviewHostProps = OwnProps; -export const OverviewHost = React.memo(({ endDate, startDate, setQuery }) => { - const [isHover, setIsHover] = useState(false); - const handleMouseEnter = useCallback(() => setIsHover(true), [setIsHover]); - const handleMouseLeave = useCallback(() => setIsHover(false), [setIsHover]); - return ( - - +const OverviewHostComponent: React.FC = ({ endDate, startDate, setQuery }) => ( + + + (({ endDate, startDate, )} - - ); -}); + + +); -OverviewHost.displayName = 'OverviewHost'; +export const OverviewHost = React.memo(OverviewHostComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/page/overview/overview_network/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/overview/overview_network/index.tsx index f60957cf7b4851..af8c87ff385968 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/overview/overview_network/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/overview/overview_network/index.tsx @@ -6,7 +6,7 @@ import { EuiButton, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { HeaderSection } from '../../../header_section'; import { manageQuery } from '../../../page/manage_query'; @@ -17,6 +17,7 @@ import { import { inputsModel } from '../../../../store/inputs'; import { OverviewNetworkStats } from '../overview_network_stats'; import { getNetworkUrl } from '../../../link_to'; +import { InspectButtonContainer } from '../../../inspect'; export interface OwnProps { startDate: number; @@ -36,18 +37,13 @@ export interface OwnProps { const OverviewNetworkStatsManage = manageQuery(OverviewNetworkStats); -export const OverviewNetwork = React.memo(({ endDate, startDate, setQuery }) => { - const [isHover, setIsHover] = useState(false); - const handleMouseEnter = useCallback(() => setIsHover(true), [setIsHover]); - const handleMouseLeave = useCallback(() => setIsHover(false), [setIsHover]); - - return ( - - +const OverviewNetworkComponent: React.FC = ({ endDate, startDate, setQuery }) => ( + + + (({ endDate, startDate, setQu )} - - ); -}); + + +); -OverviewNetwork.displayName = 'OverviewNetwork'; +export const OverviewNetwork = React.memo(OverviewNetworkComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap index 9c3bf7b11e3edb..98c9fc202dd6b1 100644 --- a/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -520,7 +520,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta } } > - { width?: string; } -export const PaginatedTable = memo( - ({ - activePage, - columns, - dataTestSubj = DEFAULT_DATA_TEST_SUBJ, - headerCount, - headerSupplement, - headerTitle, - headerTooltip, - headerUnit, - id, - isInspect, - itemsPerRow, - limit, - loading, - loadPage, - onChange = noop, - pageOfItems, - showMorePagesIndicator, - sorting = null, - totalCount, - updateActivePage, - updateLimitPagination, - }) => { - const [myLoading, setMyLoading] = useState(loading); - const [myActivePage, setActivePage] = useState(activePage); - const [showInspect, setShowInspect] = useState(false); - const [loadingInitial, setLoadingInitial] = useState(headerCount === -1); - const [isPopoverOpen, setPopoverOpen] = useState(false); - - const pageCount = Math.ceil(totalCount / limit); - const dispatchToaster = useStateToaster()[1]; - - useEffect(() => { - setActivePage(activePage); - }, [activePage]); - - useEffect(() => { - if (headerCount >= 0 && loadingInitial) { - setLoadingInitial(false); - } - }, [loadingInitial, headerCount]); - - useEffect(() => { - setMyLoading(loading); - }, [loading]); - - const onButtonClick = () => { - setPopoverOpen(!isPopoverOpen); - }; - - const closePopover = () => { - setPopoverOpen(false); - }; - - const goToPage = (newActivePage: number) => { - if ((newActivePage + 1) * limit >= DEFAULT_MAX_TABLE_QUERY_SIZE) { - const toast: Toast = { - id: 'PaginationWarningMsg', - title: headerTitle + i18n.TOAST_TITLE, - color: 'warning', - iconType: 'alert', - toastLifeTimeMs: 10000, - text: i18n.TOAST_TEXT, - }; - return dispatchToaster({ - type: 'addToaster', - toast, - }); - } - setActivePage(newActivePage); - loadPage(newActivePage); - updateActivePage(newActivePage); - }; - - const button = ( - - {`${i18n.ROWS}: ${limit}`} - - ); - - const rowItems = - itemsPerRow && - itemsPerRow.map((item: ItemsPerRow) => ( - { - closePopover(); - updateLimitPagination(item.numberOfRow); - updateActivePage(0); // reset results to first page - }} - > - {item.text} - - )); - const PaginationWrapper = showMorePagesIndicator ? PaginationEuiFlexItem : EuiFlexItem; - const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); - const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - - return ( - = ({ + activePage, + columns, + dataTestSubj = DEFAULT_DATA_TEST_SUBJ, + headerCount, + headerSupplement, + headerTitle, + headerTooltip, + headerUnit, + id, + isInspect, + itemsPerRow, + limit, + loading, + loadPage, + onChange = noop, + pageOfItems, + showMorePagesIndicator, + sorting = null, + totalCount, + updateActivePage, + updateLimitPagination, +}) => { + const [myLoading, setMyLoading] = useState(loading); + const [myActivePage, setActivePage] = useState(activePage); + const [loadingInitial, setLoadingInitial] = useState(headerCount === -1); + const [isPopoverOpen, setPopoverOpen] = useState(false); + + const pageCount = Math.ceil(totalCount / limit); + const dispatchToaster = useStateToaster()[1]; + + useEffect(() => { + setActivePage(activePage); + }, [activePage]); + + useEffect(() => { + if (headerCount >= 0 && loadingInitial) { + setLoadingInitial(false); + } + }, [loadingInitial, headerCount]); + + useEffect(() => { + setMyLoading(loading); + }, [loading]); + + const onButtonClick = () => { + setPopoverOpen(!isPopoverOpen); + }; + + const closePopover = () => { + setPopoverOpen(false); + }; + + const goToPage = (newActivePage: number) => { + if ((newActivePage + 1) * limit >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + const toast: Toast = { + id: 'PaginationWarningMsg', + title: headerTitle + i18n.TOAST_TITLE, + color: 'warning', + iconType: 'alert', + toastLifeTimeMs: 10000, + text: i18n.TOAST_TEXT, + }; + return dispatchToaster({ + type: 'addToaster', + toast, + }); + } + setActivePage(newActivePage); + loadPage(newActivePage); + updateActivePage(newActivePage); + }; + + const button = ( + + {`${i18n.ROWS}: ${limit}`} + + ); + + const rowItems = + itemsPerRow && + itemsPerRow.map((item: ItemsPerRow) => ( + { + closePopover(); + updateLimitPagination(item.numberOfRow); + updateActivePage(0); // reset results to first page + }} > + {item.text} + + )); + const PaginationWrapper = showMorePagesIndicator ? PaginationEuiFlexItem : EuiFlexItem; + + return ( + + = 0 ? headerCount.toLocaleString() : 0} ${headerUnit}` @@ -306,11 +298,11 @@ export const PaginatedTable = memo( )} - ); - } -); + + ); +}; -PaginatedTable.displayName = 'PaginatedTable'; +export const PaginatedTable = memo(PaginatedTableComponent); type BasicTableType = ComponentType>; // eslint-disable-line @typescript-eslint/no-explicit-any const BasicTable: typeof EuiBasicTable & { displayName: string } = styled( diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index 5ed750b519cbfb..ca06c484dc8a29 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -45,71 +45,63 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] = className="euiFlexItem sc-AykKG krmHWP" data-test-subj="stat-item" > -
- +
- -
- -
- HOSTS -
-
-
-
-
- +
+ +
+ HOSTS +
+
+
+ + - - -
-
-
-
+ + +
+
- -
- - -
- - + +
-
-
- - +
-
- - + +
-

- - + +

- — - - - -

- - -
-
- + + + — + + + +

+
+
+
+ + +
+
-
-
- - + + +
+
+ +
+
-
- -
- +
- +
@@ -287,71 +279,63 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] = className="euiFlexItem sc-AykKG krmHWP" data-test-subj="stat-item" > -
- +
- -
- -
- HOSTS -
-
-
-
-
- +
+ +
+ HOSTS +
+
+
+ + - - -
-
-
-
+ + +
+
- -
-
- -
- - + +
-
-
- 0 - - +
-
- - + +
-

- - + +

- — - - - -

- - -
-
- + + + — + + + +

+
+
+
+ + +
+
-
-
- - + + +
+
+ +
+
-
- -
- +
- +
@@ -599,71 +583,63 @@ exports[`Stat Items Component rendering kpis with charts it renders the default className="euiFlexItem sc-AykKG krmHWP" data-test-subj="stat-item" > -
- +
- -
- -
- UNIQUE_PRIVATE_IPS -
-
-
-
-
- +
+ +
+ UNIQUE_PRIVATE_IPS +
+
+
+ + - - -
-
-
-
+ + +
+
- -
-
- -
- - + +
-
-
- - -
- - - + - - -
-
-
- - -
- - + + + +
+
+
+ + +
-

- 1,714 - - Source -

- - -
-
-
+ + +

+ 1,714 + + Source +

+
+
+
+ + +
+
-
-
- - - - -
+ + -
- - -
- - - + - - -
-
-
- - -
- - + + + +
+
+
+ + +
-

- 2,359 - - Dest. -

- - -
-
-
+ + +

+ 2,359 + + Dest. +

+
+
+
+ +
+
+
- -
- - -
- - -
-
- -
- - + +
+
+ +
+
+ +
-
- + +
+ +
+ +
+ + + +
- -
-
-
- - -
- + -
- -
- - + } + } + > +
+ +
+ + +
+
- +
- +
diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx b/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx index 2d081468599a2a..a5c883ebd0e059 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx @@ -21,7 +21,7 @@ import { IconType, } from '@elastic/eui'; import { get, getOr } from 'lodash/fp'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; import { KpiHostsData, KpiNetworkData } from '../../graphql/types'; @@ -30,7 +30,7 @@ import { BarChart } from '../charts/barchart'; import { ChartSeriesData, ChartData, ChartSeriesConfigs, UpdateDateRange } from '../charts/common'; import { getEmptyTagValue } from '../empty_value'; -import { InspectButton } from '../inspect'; +import { InspectButton, InspectButtonContainer } from '../inspect'; const FlexItem = styled(EuiFlexItem)` min-width: 0; @@ -209,9 +209,6 @@ export const StatItemsComponent = React.memo( statKey = 'item', to, }) => { - const [isHover, setIsHover] = useState(false); - const handleMouseEnter = useCallback(() => setIsHover(true), [setIsHover]); - const handleMouseLeave = useCallback(() => setIsHover(false), [setIsHover]); const isBarChartDataAvailable = barChart && barChart.length && @@ -223,72 +220,69 @@ export const StatItemsComponent = React.memo( return ( - - - - -
{description}
-
-
- - - -
+ + + + + +
{description}
+
+
+ + + +
- - {fields.map(field => ( - - - {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( - - - - )} + + {fields.map(field => ( + + + {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( + + + + )} - - -

- {field.value != null ? field.value.toLocaleString() : getEmptyTagValue()}{' '} - {field.description} -

-
-
-
-
- ))} -
+ + +

+ {field.value != null ? field.value.toLocaleString() : getEmptyTagValue()}{' '} + {field.description} +

+
+
+
+
+ ))} +
- {(enableAreaChart || enableBarChart) && } - - {enableBarChart && ( - - - - )} + {(enableAreaChart || enableBarChart) && } + + {enableBarChart && ( + + + + )} - {enableAreaChart && from != null && to != null && ( - - - - )} - -
+ {enableAreaChart && from != null && to != null && ( + + + + )} +
+ + ); } diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/properties/properties_right.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/properties/properties_right.tsx index 4027682282dcd3..b21ab5063441e8 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/properties/properties_right.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/properties/properties_right.tsx @@ -17,7 +17,7 @@ import { import { NewTimeline, Description, NotesButton } from './helpers'; import { OpenTimelineModalButton } from '../../open_timeline/open_timeline_modal/open_timeline_modal_button'; import { OpenTimelineModal } from '../../open_timeline/open_timeline_modal'; -import { InspectButton } from '../../inspect'; +import { InspectButton, InspectButtonContainer } from '../../inspect'; import * as i18n from './translations'; import { AssociateNote } from '../../notes/helpers'; @@ -82,32 +82,32 @@ interface Props { updateNote: UpdateNote; } -export const PropertiesRight = React.memo( - ({ - onButtonClick, - showActions, - onClosePopover, - createTimeline, - timelineId, - isDataInTimeline, - showNotesFromWidth, - showNotes, - showDescription, - showUsersView, - usersViewing, - description, - updateDescription, - associateNote, - getNotesByIds, - noteIds, - onToggleShowNotes, - updateNote, - showTimelineModal, - onCloseTimelineModal, - onOpenTimelineModal, - }) => ( - - +const PropertiesRightComponent: React.FC = ({ + onButtonClick, + showActions, + onClosePopover, + createTimeline, + timelineId, + isDataInTimeline, + showNotesFromWidth, + showNotes, + showDescription, + showUsersView, + usersViewing, + description, + updateDescription, + associateNote, + getNotesByIds, + noteIds, + onToggleShowNotes, + updateNote, + showTimelineModal, + onCloseTimelineModal, + onOpenTimelineModal, +}) => ( + + + ( inspectIndex={0} isDisabled={!isDataInTimeline} onCloseInspect={onClosePopover} - show={true} title={i18n.INSPECT_TIMELINE_TITLE} /> @@ -177,26 +176,26 @@ export const PropertiesRight = React.memo( ) : null} - - - {showUsersView - ? usersViewing.map(user => ( - // Hide the hard-coded elastic user avatar as the 7.2 release does not implement - // support for multi-user-collaboration as proposed in elastic/ingest-dev#395 - - - - - - )) - : null} - - {showTimelineModal ? : null} - - ) + + + + {showUsersView + ? usersViewing.map(user => ( + // Hide the hard-coded elastic user avatar as the 7.2 release does not implement + // support for multi-user-collaboration as proposed in elastic/ingest-dev#395 + + + + + + )) + : null} + + {showTimelineModal ? : null} + ); -PropertiesRight.displayName = 'PropertiesRight'; +export const PropertiesRight = React.memo(PropertiesRightComponent); From 9a871d2a7a8c4861a3f620ac4571eefb501c1b7d Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 14 Jan 2020 08:58:43 +0100 Subject: [PATCH 48/62] [ML] MML calculator enhancements for multi-metric job wizard (#54573) * [ML] fix fieldNames provided to calculateModelMemoryLimit * [ML] calculateModelMemoryLimit when the influencers are changed --- .../new_job/common/job_creator/multi_metric_job_creator.ts | 3 +-- .../pick_fields_step/components/influencers/influencers.tsx | 3 +++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts index 947c1bcf6c1a42..8a4411bf9025f7 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts @@ -92,12 +92,11 @@ export class MultiMetricJobCreator extends JobCreator { // not split field, use the default this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; } else { - const fieldNames = this._detectors.map(d => d.field_name).filter(fn => fn !== undefined); const { modelMemoryLimit } = await ml.calculateModelMemoryLimit({ indexPattern: this._indexPatternTitle, splitFieldName: this._splitField.name, query: this._datafeed_config.query, - fieldNames, + fieldNames: this.fields.map(f => f.id), influencerNames: this._influencers, timeFieldName: this._job_config.data_description.time_field, earliestMs: this._start, diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx index c55bdeef4dde8f..5c5ba38b1c5a10 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx @@ -25,6 +25,9 @@ export const Influencers: FC = () => { useEffect(() => { jobCreator.removeAllInfluencers(); influencers.forEach(i => jobCreator.addInfluencer(i)); + if (jobCreator instanceof MultiMetricJobCreator) { + jobCreator.calculateModelMemoryLimit(); + } jobCreatorUpdate(); }, [influencers.join()]); From 9380b6408b2e3bf296e0bd6d9f81d31fc3a5c055 Mon Sep 17 00:00:00 2001 From: "Christiane (Tina) Heiligers" Date: Tue, 14 Jan 2020 01:32:56 -0700 Subject: [PATCH 49/62] Np migration tsvb route validation (#51850) --- .../visualize_embeddable.ts | 1 + .../core_plugins/vis_type_timeseries/index.ts | 14 + .../vis_type_timeseries/public/metrics_fn.ts | 8 + .../public/request_handler.js | 10 +- .../vis_type_timeseries/server/init.ts | 9 +- .../server/routes/post_vis_schema.ts | 247 ++++++++++++++++++ .../server/routes/{vis.js => vis.ts} | 22 +- .../np_ready/public/legacy/build_pipeline.ts | 18 +- src/plugins/vis_type_timeseries/kibana.json | 5 +- .../vis_type_timeseries/server/index.ts | 2 + .../vis_type_timeseries/server/plugin.ts | 20 +- .../server/validation_telemetry/index.ts | 20 ++ .../validation_telemetry_service.ts | 84 ++++++ 13 files changed, 447 insertions(+), 13 deletions(-) create mode 100644 src/legacy/core_plugins/vis_type_timeseries/server/routes/post_vis_schema.ts rename src/legacy/core_plugins/vis_type_timeseries/server/routes/{vis.js => vis.ts} (59%) create mode 100644 src/plugins/vis_type_timeseries/server/validation_telemetry/index.ts create mode 100644 src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts diff --git a/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts index fc91742c53cca5..b7a3a0f000d720 100644 --- a/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts @@ -379,6 +379,7 @@ export class VisualizeEmbeddable extends Embeddable ({}), + mappings: { + 'tsvb-validation-telemetry': { + properties: { + failedRequests: { + type: 'long', + }, + }, + }, + }, + savedObjectSchemas: { + 'tsvb-validation-telemetry': { + isNamespaceAgnostic: true, + }, + }, }, init: (server: Legacy.Server) => { const visTypeTimeSeriesPlugin = server.newPlatform.setup.plugins diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts b/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts index 8740f84dab3b9e..225d81b71b8e08 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts +++ b/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts @@ -31,6 +31,7 @@ type Context = KibanaContext | null; interface Arguments { params: string; uiState: string; + savedObjectId: string | null; } type VisParams = Required; @@ -64,10 +65,16 @@ export const createMetricsFn = (): ExpressionFunction { +export const metricsRequestHandler = async ({ + uiState, + timeRange, + filters, + query, + visParams, + savedObjectId, +}) => { const config = getUISettings(); const timezone = timezoneProvider(config)(); const uiStateObj = uiState.get(visParams.type, {}); @@ -49,6 +56,7 @@ export const metricsRequestHandler = async ({ uiState, timeRange, filters, query filters, panels: [visParams], state: uiStateObj, + savedObjectId: savedObjectId || 'unsaved', }), }); diff --git a/src/legacy/core_plugins/vis_type_timeseries/server/init.ts b/src/legacy/core_plugins/vis_type_timeseries/server/init.ts index 7b42ae8098016d..ae6eebc00fc1ba 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/server/init.ts +++ b/src/legacy/core_plugins/vis_type_timeseries/server/init.ts @@ -26,12 +26,17 @@ import { SearchStrategiesRegister } from './lib/search_strategies/search_strateg // @ts-ignore import { getVisData } from './lib/get_vis_data'; import { Framework } from '../../../../plugins/vis_type_timeseries/server'; +import { ValidationTelemetryServiceSetup } from '../../../../plugins/vis_type_timeseries/server'; -export const init = async (framework: Framework, __LEGACY: any) => { +export const init = async ( + framework: Framework, + __LEGACY: any, + validationTelemetry: ValidationTelemetryServiceSetup +) => { const { core } = framework; const router = core.http.createRouter(); - visDataRoutes(router, framework); + visDataRoutes(router, framework, validationTelemetry); // [LEGACY_TODO] fieldsRoutes(__LEGACY.server); diff --git a/src/legacy/core_plugins/vis_type_timeseries/server/routes/post_vis_schema.ts b/src/legacy/core_plugins/vis_type_timeseries/server/routes/post_vis_schema.ts new file mode 100644 index 00000000000000..3aca50b5b4710f --- /dev/null +++ b/src/legacy/core_plugins/vis_type_timeseries/server/routes/post_vis_schema.ts @@ -0,0 +1,247 @@ +/* + * 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 Joi from 'joi'; +const stringOptionalNullable = Joi.string() + .allow('', null) + .optional(); +const stringRequired = Joi.string() + .allow('') + .required(); +const arrayNullable = Joi.array().allow(null); +const numberIntegerOptional = Joi.number() + .integer() + .optional(); +const numberIntegerRequired = Joi.number() + .integer() + .required(); +const numberOptional = Joi.number().optional(); +const numberRequired = Joi.number().required(); +const queryObject = Joi.object({ + language: Joi.string().allow(''), + query: Joi.string().allow(''), +}); + +const annotationsItems = Joi.object({ + color: stringOptionalNullable, + fields: stringOptionalNullable, + hidden: Joi.boolean().optional(), + icon: stringOptionalNullable, + id: stringOptionalNullable, + ignore_global_filters: numberIntegerOptional, + ignore_panel_filters: numberIntegerOptional, + index_pattern: stringOptionalNullable, + query_string: queryObject.optional(), + template: stringOptionalNullable, + time_field: stringOptionalNullable, +}); + +const backgroundColorRulesItems = Joi.object({ + value: Joi.number() + .allow(null) + .optional(), + id: stringOptionalNullable, + background_color: stringOptionalNullable, + color: stringOptionalNullable, +}); + +const gaugeColorRulesItems = Joi.object({ + gauge: stringOptionalNullable, + id: stringOptionalNullable, + operator: stringOptionalNullable, + value: Joi.number(), +}); +const metricsItems = Joi.object({ + field: stringOptionalNullable, + id: stringRequired, + metric_agg: stringOptionalNullable, + numerator: stringOptionalNullable, + denominator: stringOptionalNullable, + sigma: stringOptionalNullable, + function: stringOptionalNullable, + script: stringOptionalNullable, + variables: Joi.array() + .items( + Joi.object({ + field: stringOptionalNullable, + id: stringRequired, + name: stringOptionalNullable, + }) + ) + .optional(), + type: stringRequired, + value: stringOptionalNullable, + values: Joi.array() + .items(Joi.string().allow('', null)) + .allow(null) + .optional(), +}); + +const splitFiltersItems = Joi.object({ + id: stringOptionalNullable, + color: stringOptionalNullable, + filter: Joi.object({ + language: Joi.string().allow(''), + query: Joi.string().allow(''), + }).optional(), + label: stringOptionalNullable, +}); + +const seriesItems = Joi.object({ + aggregate_by: stringOptionalNullable, + aggregate_function: stringOptionalNullable, + axis_position: stringRequired, + axis_max: stringOptionalNullable, + axis_min: stringOptionalNullable, + chart_type: stringRequired, + color: stringRequired, + color_rules: Joi.array() + .items( + Joi.object({ + value: numberOptional, + id: stringRequired, + text: stringOptionalNullable, + operator: stringOptionalNullable, + }) + ) + .optional(), + fill: numberOptional, + filter: Joi.object({ + query: stringRequired, + language: stringOptionalNullable, + }).optional(), + formatter: stringRequired, + hide_in_legend: numberIntegerOptional, + hidden: Joi.boolean().optional(), + id: stringRequired, + label: stringOptionalNullable, + line_width: numberOptional, + metrics: Joi.array().items(metricsItems), + offset_time: stringOptionalNullable, + override_index_pattern: numberOptional, + point_size: numberRequired, + separate_axis: numberIntegerOptional, + seperate_axis: numberIntegerOptional, + series_index_pattern: stringOptionalNullable, + series_time_field: stringOptionalNullable, + series_interval: stringOptionalNullable, + series_drop_last_bucket: numberIntegerOptional, + split_color_mode: stringOptionalNullable, + split_filters: Joi.array() + .items(splitFiltersItems) + .optional(), + split_mode: stringRequired, + stacked: stringRequired, + steps: numberIntegerOptional, + terms_field: stringOptionalNullable, + terms_order_by: stringOptionalNullable, + terms_size: stringOptionalNullable, + terms_direction: stringOptionalNullable, + terms_include: stringOptionalNullable, + terms_exclude: stringOptionalNullable, + time_range_mode: stringOptionalNullable, + trend_arrows: numberOptional, + type: stringOptionalNullable, + value_template: stringOptionalNullable, + var_name: stringOptionalNullable, +}); + +export const visPayloadSchema = Joi.object({ + filters: arrayNullable, + panels: Joi.array().items( + Joi.object({ + annotations: Joi.array() + .items(annotationsItems) + .optional(), + axis_formatter: stringRequired, + axis_position: stringRequired, + axis_scale: stringRequired, + axis_min: stringOptionalNullable, + axis_max: stringOptionalNullable, + bar_color_rules: arrayNullable.optional(), + background_color: stringOptionalNullable, + background_color_rules: Joi.array() + .items(backgroundColorRulesItems) + .optional(), + default_index_pattern: stringOptionalNullable, + default_timefield: stringOptionalNullable, + drilldown_url: stringOptionalNullable, + drop_last_bucket: numberIntegerOptional, + filter: Joi.alternatives( + stringOptionalNullable, + Joi.object({ + language: stringOptionalNullable, + query: stringOptionalNullable, + }) + ), + gauge_color_rules: Joi.array() + .items(gaugeColorRulesItems) + .optional(), + gauge_width: [stringOptionalNullable, numberOptional], + gauge_inner_color: stringOptionalNullable, + gauge_inner_width: Joi.alternatives(stringOptionalNullable, numberIntegerOptional), + gauge_style: stringOptionalNullable, + gauge_max: stringOptionalNullable, + id: stringRequired, + ignore_global_filters: numberOptional, + ignore_global_filter: numberOptional, + index_pattern: stringRequired, + interval: stringRequired, + isModelInvalid: Joi.boolean().optional(), + legend_position: stringOptionalNullable, + markdown: stringOptionalNullable, + markdown_scrollbars: numberIntegerOptional, + markdown_openLinksInNewTab: numberIntegerOptional, + markdown_vertical_align: stringOptionalNullable, + markdown_less: stringOptionalNullable, + markdown_css: stringOptionalNullable, + pivot_id: stringOptionalNullable, + pivot_label: stringOptionalNullable, + pivot_type: stringOptionalNullable, + pivot_rows: stringOptionalNullable, + series: Joi.array() + .items(seriesItems) + .required(), + show_grid: numberIntegerRequired, + show_legend: numberIntegerRequired, + time_field: stringOptionalNullable, + time_range_mode: stringOptionalNullable, + type: stringRequired, + }) + ), + // general + query: Joi.array() + .items(queryObject) + .allow(null) + .required(), + state: Joi.object({ + sort: Joi.object({ + column: stringRequired, + order: Joi.string() + .valid(['asc', 'desc']) + .required(), + }).optional(), + }).required(), + savedObjectId: Joi.string().optional(), + timerange: Joi.object({ + timezone: stringRequired, + min: stringRequired, + max: stringRequired, + }).required(), +}); diff --git a/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.js b/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.ts similarity index 59% rename from src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.js rename to src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.ts index d2ded81309ffab..32e87f5a3f666a 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.ts @@ -17,12 +17,22 @@ * under the License. */ +import { IRouter } from 'kibana/server'; import { schema } from '@kbn/config-schema'; import { getVisData } from '../lib/get_vis_data'; +import { visPayloadSchema } from './post_vis_schema'; +import { + Framework, + ValidationTelemetryServiceSetup, +} from '../../../../../plugins/vis_type_timeseries/server'; const escapeHatch = schema.object({}, { allowUnknowns: true }); -export const visDataRoutes = (router, framework) => { +export const visDataRoutes = ( + router: IRouter, + framework: Framework, + { logFailedValidation }: ValidationTelemetryServiceSetup +) => { router.post( { path: '/api/metrics/vis/data', @@ -31,6 +41,16 @@ export const visDataRoutes = (router, framework) => { }, }, async (requestContext, request, response) => { + const { error: validationError } = visPayloadSchema.validate(request.body); + if (validationError) { + logFailedValidation(); + const savedObjectId = + (typeof request.body === 'object' && (request.body as any).savedObjectId) || + 'unavailable'; + framework.logger.warn( + `Request validation error: ${validationError.message} (saved object id: ${savedObjectId}). This most likely means your TSVB visualization contains outdated configuration. You can report this problem under https://github.com/elastic/kibana/issues/new?template=Bug_report.md` + ); + } try { const results = await getVisData(requestContext, request.body, framework); return response.ok({ body: results }); diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts index cc2ab133941db5..ab1664d612b35e 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts @@ -59,7 +59,12 @@ export interface Schemas { [key: string]: any[] | undefined; } -type buildVisFunction = (visState: VisState, schemas: Schemas, uiState: any) => string; +type buildVisFunction = ( + visState: VisState, + schemas: Schemas, + uiState: any, + meta?: { savedObjectId?: string } +) => string; type buildVisConfigFunction = (schemas: Schemas, visParams?: VisParams) => VisParams; interface BuildPipelineVisFunction { @@ -248,11 +253,13 @@ export const buildPipelineVisFunction: BuildPipelineVisFunction = { input_control_vis: visState => { return `input_control_vis ${prepareJson('visConfig', visState.params)}`; }, - metrics: (visState, schemas, uiState = {}) => { + metrics: (visState, schemas, uiState = {}, meta) => { const paramsJson = prepareJson('params', visState.params); const uiStateJson = prepareJson('uiState', uiState); + const savedObjectIdParam = prepareString('savedObjectId', meta?.savedObjectId); - return `tsvb ${paramsJson} ${uiStateJson}`; + const params = [paramsJson, uiStateJson, savedObjectIdParam].filter(param => Boolean(param)); + return `tsvb ${params.join(' ')}`; }, timelion: visState => { const expression = prepareString('expression', visState.params.expression); @@ -488,6 +495,7 @@ export const buildPipeline = async ( params: { searchSource: ISearchSource; timeRange?: any; + savedObjectId?: string; } ) => { const { searchSource } = params; @@ -521,7 +529,9 @@ export const buildPipeline = async ( const schemas = getSchemas(vis, params.timeRange); if (buildPipelineVisFunction[vis.type.name]) { - pipeline += buildPipelineVisFunction[vis.type.name](visState, schemas, uiState); + pipeline += buildPipelineVisFunction[vis.type.name](visState, schemas, uiState, { + savedObjectId: params.savedObjectId, + }); } else if (vislibCharts.includes(vis.type.name)) { const visConfig = visState.params; visConfig.dimensions = await buildVislibDimensions(vis, params); diff --git a/src/plugins/vis_type_timeseries/kibana.json b/src/plugins/vis_type_timeseries/kibana.json index f9a368e85ed491..d77f4ac92da16f 100644 --- a/src/plugins/vis_type_timeseries/kibana.json +++ b/src/plugins/vis_type_timeseries/kibana.json @@ -2,5 +2,6 @@ "id": "metrics", "version": "8.0.0", "kibanaVersion": "kibana", - "server": true -} \ No newline at end of file + "server": true, + "optionalPlugins": ["usageCollection"] +} diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts index 599726612a936a..dfb2394af237bf 100644 --- a/src/plugins/vis_type_timeseries/server/index.ts +++ b/src/plugins/vis_type_timeseries/server/index.ts @@ -30,6 +30,8 @@ export const config = { export type VisTypeTimeseriesConfig = TypeOf; +export { ValidationTelemetryServiceSetup } from './validation_telemetry'; + export function plugin(initializerContext: PluginInitializerContext) { return new VisTypeTimeseriesPlugin(initializerContext); } diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index f508aa250454fa..dcd0cd500bbc31 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -35,11 +35,17 @@ import { GetVisData, GetVisDataOptions, } from '../../../legacy/core_plugins/vis_type_timeseries/server'; +import { ValidationTelemetryService } from './validation_telemetry/validation_telemetry_service'; +import { UsageCollectionSetup } from '../../usage_collection/server'; export interface LegacySetup { server: Server; } +interface VisTypeTimeseriesPluginSetupDependencies { + usageCollection?: UsageCollectionSetup; +} + export interface VisTypeTimeseriesSetup { /** @deprecated */ __legacy: { @@ -61,11 +67,14 @@ export interface Framework { } export class VisTypeTimeseriesPlugin implements Plugin { + private validationTelementryService: ValidationTelemetryService; + constructor(private readonly initializerContext: PluginInitializerContext) { this.initializerContext = initializerContext; + this.validationTelementryService = new ValidationTelemetryService(); } - public setup(core: CoreSetup, plugins: any) { + public setup(core: CoreSetup, plugins: VisTypeTimeseriesPluginSetupDependencies) { const logger = this.initializerContext.logger.get('visTypeTimeseries'); const config$ = this.initializerContext.config.create(); // Global config contains things like the ES shard timeout @@ -82,8 +91,13 @@ export class VisTypeTimeseriesPlugin implements Plugin { return { __legacy: { config$, - registerLegacyAPI: once((__LEGACY: LegacySetup) => { - init(framework, __LEGACY); + registerLegacyAPI: once(async (__LEGACY: LegacySetup) => { + const validationTelemetrySetup = await this.validationTelementryService.setup(core, { + ...plugins, + globalConfig$, + }); + + await init(framework, __LEGACY, validationTelemetrySetup); }), }, getVisData: async (requestContext: RequestHandlerContext, options: GetVisDataOptions) => { diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/index.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/index.ts new file mode 100644 index 00000000000000..140f61fa2f3fda --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './validation_telemetry_service'; diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts new file mode 100644 index 00000000000000..136f5b9e5cfad8 --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts @@ -0,0 +1,84 @@ +/* + * 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 { APICaller, CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server'; +import { UsageCollectionSetup } from '../../../usage_collection/server'; + +export interface ValidationTelemetryServiceSetup { + logFailedValidation: () => void; +} + +export class ValidationTelemetryService implements Plugin { + private kibanaIndex: string = ''; + async setup( + core: CoreSetup, + { + usageCollection, + globalConfig$, + }: { + usageCollection?: UsageCollectionSetup; + globalConfig$: PluginInitializerContext['config']['legacy']['globalConfig$']; + } + ) { + globalConfig$.subscribe(config => { + this.kibanaIndex = config.kibana.index; + }); + if (usageCollection) { + usageCollection.registerCollector( + usageCollection.makeUsageCollector({ + type: 'tsvb-validation', + isReady: () => this.kibanaIndex !== '', + fetch: async (callCluster: APICaller) => { + try { + const response = await callCluster('get', { + index: this.kibanaIndex, + id: 'tsvb-validation-telemetry:tsvb-validation-telemetry', + ignore: [404], + }); + return { + failed_validations: + response?._source?.['tsvb-validation-telemetry']?.failedRequests || 0, + }; + } catch (err) { + return { + failed_validations: 0, + }; + } + }, + }) + ); + } + const internalRepository = core.savedObjects.createInternalRepository(); + + return { + logFailedValidation: async () => { + try { + await internalRepository.incrementCounter( + 'tsvb-validation-telemetry', + 'tsvb-validation-telemetry', + 'failedRequests' + ); + } catch (e) { + // swallow error, validation telemetry shouldn't fail anything else + } + }, + }; + } + start() {} +} From 3f46e2bec6efea84f390c43e881c0a330fe0d921 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 14 Jan 2020 09:37:22 +0100 Subject: [PATCH 50/62] [APM] Support error.{log,exception}.stacktrace.classname (#54577) In elastic/apm-server/pull/3096, an alternative to stacktrace.filename was introduced: stacktrace.classname. This change makes sure classname is properly represented in the UI and in our types. --- .../public/components/shared/Stacktrace/FrameHeading.tsx | 6 +++++- .../apm/typings/es_schemas/raw/fields/Stackframe.ts | 9 ++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx index 84c2801a45049b..51056fae503602 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx @@ -35,9 +35,13 @@ const FrameHeading: React.FC = ({ stackframe, isLibraryFrame }) => { ? LibraryFrameFileDetail : AppFrameFileDetail; const lineNumber = stackframe.line.number; + + const name = + 'filename' in stackframe ? stackframe.filename : stackframe.classname; + return ( - {stackframe.filename} in{' '} + {name} in{' '} {stackframe.function} {lineNumber > 0 && ( diff --git a/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts b/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts index a1b1a8198bb356..993fac46ad7cb5 100644 --- a/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts +++ b/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -interface IStackframeBase { - filename: string; +type IStackframeBase = { function?: string; library_frame?: boolean; exclude_from_grouping?: boolean; @@ -19,13 +18,13 @@ interface IStackframeBase { line: { number: number; }; -} +} & ({ classname: string } | { filename: string }); -export interface IStackframeWithLineContext extends IStackframeBase { +export type IStackframeWithLineContext = IStackframeBase & { line: { number: number; context: string; }; -} +}; export type IStackframe = IStackframeBase | IStackframeWithLineContext; From b93d8100e5933953a39393a3274351a34eb9bdab Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Tue, 14 Jan 2020 10:24:27 +0100 Subject: [PATCH 51/62] Take page offset into account too (#54567) --- .../models/legacy_core_editor/legacy_core_editor.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts index 6262c304e307bf..8301daa675b5ce 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts @@ -303,7 +303,9 @@ export class LegacyCoreEditor implements CoreEditor { const maxLineLength = this.getWrapLimit() - 5; const isWrapping = firstLine.length > maxLineLength; const getScreenCoords = (line: number) => - this.editor.renderer.textToScreenCoordinates(line - 1, startColumn).pageY - offsetFromPage; + this.editor.renderer.textToScreenCoordinates(line - 1, startColumn).pageY - + offsetFromPage + + (window.pageYOffset || 0); const topOfReq = getScreenCoords(startLine); if (topOfReq >= 0) { From cd9ead87c5f7a908c68eda0f18c4087324ba2cd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20St=C3=BCrmer?= Date: Tue, 14 Jan 2020 12:00:36 +0100 Subject: [PATCH 52/62] [Logs UI] Reduce panel paddings in ML integration result tabs (#54574) This reduces the panel paddings on the log rate and categorization result tabs from `l` to `m` as per elastic/logs#7 and brings a title padding in line with the rest. --- .../logs/log_entry_categories/page_results_content.tsx | 4 ++-- .../pages/logs/log_entry_rate/page_results_content.tsx | 6 +++--- .../pages/logs/log_entry_rate/sections/anomalies/index.tsx | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index ffffba0691749d..a810ce447d3696 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -156,7 +156,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent = () => { - + @@ -188,7 +188,7 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent = () => { ) : null} - + { - + {logEntryRate ? ( @@ -196,7 +196,7 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { - + {isFirstUse && !hasResults ? ( <> @@ -212,7 +212,7 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { - + - +

{title}

From 0326f34948e100f882fc2e45ea8228bb5f692d8b Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Tue, 14 Jan 2020 12:34:10 +0100 Subject: [PATCH 53/62] Make promotion behave the same as alias button (#54608) Co-authored-by: Elastic Machine --- .../wizard/__snapshots__/new_vis_modal.test.tsx.snap | 4 ++-- .../np_ready/wizard/type_selection/new_vis_help.test.tsx | 4 ++-- .../np_ready/wizard/type_selection/new_vis_help.tsx | 5 +++-- .../np_ready/wizard/type_selection/type_selection.tsx | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/__snapshots__/new_vis_modal.test.tsx.snap b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/__snapshots__/new_vis_modal.test.tsx.snap index 0b44c7dc4e8603..f0905cf0dce829 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/__snapshots__/new_vis_modal.test.tsx.snap +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/__snapshots__/new_vis_modal.test.tsx.snap @@ -1594,7 +1594,7 @@ exports[`NewVisModal filter for visualization types should render as expected 1` /> @@ -3218,7 +3218,7 @@ exports[`NewVisModal should render as expected 1`] = ` /> diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.test.tsx b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.test.tsx index 3093499a030c8d..a33a82c252fb32 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.test.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.test.tsx @@ -41,7 +41,7 @@ describe('NewVisHelp', () => { stage: 'production', }, ]} - addBasePath={(url: string) => `testbasepath${url}`} + onPromotionClicked={() => {}} /> ) ).toMatchInlineSnapshot(` @@ -60,9 +60,9 @@ describe('NewVisHelp', () => {

Do it now! diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.tsx b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.tsx index 107cbc0e754b51..2f7effb7a33c88 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/new_vis_help.tsx @@ -21,10 +21,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { Fragment } from 'react'; import { EuiText, EuiButton } from '@elastic/eui'; import { VisTypeAliasListEntry } from './type_selection'; +import { VisTypeAlias } from '../../../../../../visualizations/public'; interface Props { promotedTypes: VisTypeAliasListEntry[]; - addBasePath: (path: string) => string; + onPromotionClicked: (visType: VisTypeAlias) => void; } export function NewVisHelp(props: Props) { @@ -42,7 +43,7 @@ export function NewVisHelp(props: Props) { {t.promotion!.description}

props.onPromotionClicked(t)} fill size="s" iconType="popout" diff --git a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/type_selection.tsx b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/type_selection.tsx index 28cafde45a7148..44da7cc8f2c450 100644 --- a/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/type_selection.tsx +++ b/src/legacy/core_plugins/kibana/public/visualize/np_ready/wizard/type_selection/type_selection.tsx @@ -154,7 +154,7 @@ class TypeSelection extends React.Component t.promotion)} - addBasePath={this.props.addBasePath} + onPromotionClicked={this.props.onVisTypeSelected} /> )} From 47222e7263548be9e6954ee3e8d721df2bcd0b64 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 14 Jan 2020 12:39:56 +0100 Subject: [PATCH 54/62] fix np app navlink url (#54691) Co-authored-by: Elastic Machine --- src/core/public/chrome/ui/header/header.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 0447add4917882..d05a6bb53405c5 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -57,7 +57,7 @@ import { } from '../..'; import { HttpStart } from '../../../http'; import { ChromeHelpExtension } from '../../chrome_service'; -import { ApplicationStart, InternalApplicationStart } from '../../../application/types'; +import { InternalApplicationStart } from '../../../application/types'; // Providing a buffer between the limit and the cut off index // protects from truncating just the last couple (6) characters @@ -108,7 +108,7 @@ function extendRecentlyAccessedHistoryItem( }; } -function extendNavLink(navLink: ChromeNavLink, urlForApp: ApplicationStart['getUrlForApp']) { +function extendNavLink(navLink: ChromeNavLink) { if (navLink.legacy) { return { ...navLink, @@ -118,7 +118,7 @@ function extendNavLink(navLink: ChromeNavLink, urlForApp: ApplicationStart['getU return { ...navLink, - href: urlForApp(navLink.id), + href: navLink.baseUrl, }; } @@ -229,9 +229,7 @@ class HeaderUI extends Component { appTitle, isVisible, forceNavigation, - navLinks: navLinks.map(navLink => - extendNavLink(navLink, this.props.application.getUrlForApp) - ), + navLinks: navLinks.map(extendNavLink), recentlyAccessed: recentlyAccessed.map(ra => extendRecentlyAccessedHistoryItem(navLinks, ra, this.props.basePath) ), From ca6beecb0d384e6f89a9bae966f366d2dbae9199 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Tue, 14 Jan 2020 13:16:06 +0100 Subject: [PATCH 55/62] Fix invalid version check logging for the legacy plugins (#54690) * use NP env to retrieve kibana version. settings has none * update tests --- src/core/server/legacy/legacy_service.test.ts | 2 +- src/core/server/legacy/legacy_service.ts | 6 +++++- .../server/legacy/plugins/find_legacy_plugin_specs.ts | 11 ++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 608392e4943f91..af4db68ee95e16 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -424,7 +424,7 @@ describe('#discoverPlugins()', () => { await legacyService.discoverPlugins(); expect(findLegacyPluginSpecs).toHaveBeenCalledTimes(1); - expect(findLegacyPluginSpecs).toHaveBeenCalledWith(expect.any(Object), logger); + expect(findLegacyPluginSpecs).toHaveBeenCalledWith(expect.any(Object), logger, env.packageInfo); }); it(`register legacy plugin's deprecation providers`, async () => { diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index cc36b90ec526d7..7a03cefc38c1a5 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -125,7 +125,11 @@ export class LegacyService implements CoreService { disabledPluginSpecs, uiExports, navLinks, - } = await findLegacyPluginSpecs(this.settings, this.coreContext.logger); + } = await findLegacyPluginSpecs( + this.settings, + this.coreContext.logger, + this.coreContext.env.packageInfo + ); this.legacyPlugins = { pluginSpecs, diff --git a/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts b/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts index d2e7a39236d0a8..9867274d224bd0 100644 --- a/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts +++ b/src/core/server/legacy/plugins/find_legacy_plugin_specs.ts @@ -29,6 +29,8 @@ import { import { collectUiExports as collectLegacyUiExports } from '../../../../legacy/ui/ui_exports/collect_ui_exports'; import { LoggerFactory } from '../../logging'; +import { PackageInfo } from '../../config'; + import { LegacyUiExports, LegacyNavLink, @@ -92,7 +94,11 @@ function getNavLinks(uiExports: LegacyUiExports, pluginSpecs: LegacyPluginSpec[] .sort((a, b) => a.order - b.order); } -export async function findLegacyPluginSpecs(settings: unknown, loggerFactory: LoggerFactory) { +export async function findLegacyPluginSpecs( + settings: unknown, + loggerFactory: LoggerFactory, + packageInfo: PackageInfo +) { const configToMutate: LegacyConfig = defaultConfig(settings); const { pack$, @@ -152,8 +158,7 @@ export async function findLegacyPluginSpecs(settings: unknown, loggerFactory: Lo map(spec => { const name = spec.getId(); const pluginVersion = spec.getExpectedKibanaVersion(); - // @ts-ignore - const kibanaVersion = settings.pkg.version; + const kibanaVersion = packageInfo.version; return `Plugin "${name}" was disabled because it expected Kibana version "${pluginVersion}", and found "${kibanaVersion}".`; }), distinct(), From 7c7be987caf5ca424486f131626a43211cda1ab8 Mon Sep 17 00:00:00 2001 From: Daniil Suleiman <31325372+sulemanof@users.noreply.github.com> Date: Tue, 14 Jan 2020 15:36:22 +0300 Subject: [PATCH 56/62] Fix color ranges validation (#54253) Co-authored-by: Elastic Machine --- .../public/components/common/color_ranges.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/common/color_ranges.tsx b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/common/color_ranges.tsx index 276e765ae7fe67..947c7ae7e6e362 100644 --- a/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/common/color_ranges.tsx +++ b/src/legacy/core_plugins/kbn_vislib_vis_types/public/components/common/color_ranges.tsx @@ -48,6 +48,10 @@ function ColorRanges({ const validateRange = useCallback( ({ from, to }, index) => { + if (!colorsRange[index]) { + return [false, false]; + } + const leftBound = index === 0 ? -Infinity : colorsRange[index - 1].to || 0; const isFromValid = from >= leftBound; const isToValid = to >= from; From 6b6bc39450ef5e207f7999043bd1fd4a15a1d4fc Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Tue, 14 Jan 2020 13:37:19 +0100 Subject: [PATCH 57/62] =?UTF-8?q?Update=20`fstream`=20dependency=20(`1.0.1?= =?UTF-8?q?1`=20=E2=86=92=20`1.0.12`).=20(#53932)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- yarn.lock | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/yarn.lock b/yarn.lock index ff098b7b9c891f..768c7be7006673 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13257,17 +13257,7 @@ fsevents@~2.1.0: resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.0.tgz#ce1a5f9ac71c6d75278b0c5bd236d7dfece4cbaa" integrity sha512-+iXhW3LuDQsno8dOIrCIT/CBjeBWuP7PXe8w9shnj9Lebny/Gx1ZjVBYwexLz36Ri2jKuXMNpV6CYNh8lHHgrQ== -fstream@^1.0.0: - version "1.0.11" - resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.11.tgz#5c1fb1f117477114f0632a0eb4b71b3cb0fd3171" - integrity sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE= - dependencies: - graceful-fs "^4.1.2" - inherits "~2.0.0" - mkdirp ">=0.5 0" - rimraf "2" - -fstream@^1.0.12: +fstream@^1.0.0, fstream@^1.0.12: version "1.0.12" resolved "https://registry.yarnpkg.com/fstream/-/fstream-1.0.12.tgz#4e8ba8ee2d48be4f7d0de505455548eae5932045" integrity sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg== From c976094f5446ea91b7113c55cc5885099fcaea2d Mon Sep 17 00:00:00 2001 From: "Devin W. Hurley" Date: Tue, 14 Jan 2020 07:59:57 -0500 Subject: [PATCH 58/62] [SIEM][Detection Engine] Rule Status Monitoring (#54452) * Working status updates in executor. Need to update read rules api endpoint to only respond with 'status' and not status info. Will create another endpoint to get status details for a rule which will include last five errors (if there are any). Still need tests * adds new route for getting statuses for a list of given alert ids, adds try-catch and more logic in executor for logging errors, adds scripts and rules for testing, updates find_rules endpoint to display statuses too. Would like to look into using the alerts executor state to better manage logic for statuses, and need to update some types. Also needs unit tests still. * updated types for routes, updated how merging of alert-to-rule and rule status happens when formatting REST response. * typecast test server as ServerFacade type * fix bug where we were not awaiting the accumulated result in the reducer * update rule status saved object interfaces to play nicely with interfaces provided by saved objects module. Update tests to pass - Need to write new unit tests in an upcoming commit. Next commit will be cleanup from comments then new unit tests. * fix missed conflicts after rebase * replace id param with rule.id when searching in statuses, adds sort fields to the saved objects find queries. * fixes bug where 'executing' statuses were being written into failing historical status list * camelCase to snake_case in new statuses route, also fix merge conflict * add deletion of rule statuses to delete_rules_bulk_route. Statuses are created inside of executor so we will not be needing to create statuses directly inside of the create rules bulk route, so I removed that extraneous code. * pr feedback I forgot to fix earlier * remove unused import. fixes type check error generated in previous commit * removes status information from rule when saved to signals index and updates tests to represent this change. Also removes extraneous quotes inserted around alertId field when creating a new historical status. * adds new bash script to delete all rule statuses, updates error messages in rule statuses to just store actual message, moved querying of rules statuses under a null check, initialize everything to null when first creating rule status, update number of results returned when querying saved objects based on usage, updates saved objects mapping types to use date for dates and keyword for alertId. * use lodash snake case and update total number of saved objects to return for find rules, delete rules, and read rules. * updates how statuses are transformed inside of read_rules_route, only update updated_at in rule on update of rule, removes unlabeled todo comment, updates scripts descriptions, removes interval from query_with_rule_id.json sample query, removes debug statement, removes verbose from curl script. * display rule status on update --- x-pack/legacy/plugins/siem/index.ts | 1 + .../plugins/siem/server/kibana.index.ts | 2 + .../routes/__mocks__/_mock_server.ts | 12 +- .../routes/__mocks__/request_responses.ts | 10 +- .../privileges/read_privileges_route.test.ts | 3 +- .../rules/create_rules_bulk_route.test.ts | 9 +- .../routes/rules/create_rules_bulk_route.ts | 6 +- .../routes/rules/create_rules_route.test.ts | 31 ++- .../routes/rules/create_rules_route.ts | 21 +- .../rules/delete_rules_bulk_route.test.ts | 16 +- .../routes/rules/delete_rules_bulk_route.ts | 21 +- .../routes/rules/delete_rules_route.test.ts | 21 +- .../routes/rules/delete_rules_route.ts | 23 +- .../routes/rules/find_rules_route.test.ts | 10 +- .../routes/rules/find_rules_route.ts | 24 +- .../routes/rules/find_rules_status_route.ts | 82 ++++++ .../routes/rules/read_rules_route.test.ts | 17 +- .../routes/rules/read_rules_route.ts | 21 +- .../routes/rules/update_rules_bulk.test.ts | 10 +- .../routes/rules/update_rules_route.test.ts | 21 +- .../routes/rules/update_rules_route.ts | 21 +- .../detection_engine/routes/rules/utils.ts | 56 +++- .../schemas/find_rules_statuses_schema.ts | 11 + .../routes/signals/open_close_signals.test.ts | 4 +- .../signals/query_signals_route.test.ts | 4 +- .../rules/saved_object_mappings.ts | 40 +++ .../lib/detection_engine/rules/types.ts | 61 +++++ .../scripts/delete_all_statuses.sh | 23 ++ .../scripts/find_rules_statuses_by_ids.sh | 17 ++ .../detection_engine/scripts/hard_reset.sh | 1 + .../rules/test_cases/query_with_errors.json | 12 + .../signals/signal_rule_alert_type.ts | 252 ++++++++++++++---- .../siem/server/lib/detection_engine/types.ts | 41 ++- .../plugins/siem/server/saved_objects.ts | 2 + x-pack/legacy/plugins/siem/server/types.ts | 2 + .../utils/typed_elasticsearch_mappings.ts | 2 +- 36 files changed, 755 insertions(+), 155 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/find_rules_statuses_schema.ts create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts create mode 100755 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_statuses.sh create mode 100755 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_rules_statuses_by_ids.sh create mode 100644 x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/query_with_errors.json diff --git a/x-pack/legacy/plugins/siem/index.ts b/x-pack/legacy/plugins/siem/index.ts index c5038626fdfc20..fe8b16471a7ce5 100644 --- a/x-pack/legacy/plugins/siem/index.ts +++ b/x-pack/legacy/plugins/siem/index.ts @@ -147,6 +147,7 @@ export const siem = (kibana: any) => { alerting: plugins.alerting, elasticsearch: plugins.elasticsearch, spaces: plugins.spaces, + savedObjects: server.savedObjects.SavedObjectsClient, }, route: route.bind(server), }; diff --git a/x-pack/legacy/plugins/siem/server/kibana.index.ts b/x-pack/legacy/plugins/siem/server/kibana.index.ts index 7060a3f6629148..9ceb8e02864ec2 100644 --- a/x-pack/legacy/plugins/siem/server/kibana.index.ts +++ b/x-pack/legacy/plugins/siem/server/kibana.index.ts @@ -27,6 +27,7 @@ import { updateRulesBulkRoute } from './lib/detection_engine/routes/rules/update import { deleteRulesBulkRoute } from './lib/detection_engine/routes/rules/delete_rules_bulk_route'; import { importRulesRoute } from './lib/detection_engine/routes/rules/import_rules_route'; import { exportRulesRoute } from './lib/detection_engine/routes/rules/export_rules_route'; +import { findRulesStatusesRoute } from './lib/detection_engine/routes/rules/find_rules_status_route'; const APP_ID = 'siem'; @@ -54,6 +55,7 @@ export const initServerWithKibana = (context: PluginInitializerContext, __legacy deleteRulesBulkRoute(__legacy); importRulesRoute(__legacy); exportRulesRoute(__legacy); + findRulesStatusesRoute(__legacy); // Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals // POST /api/detection_engine/signals/status diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts index 8b7f6c765e7101..1e475f2014fa23 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/_mock_server.ts @@ -7,7 +7,7 @@ import Hapi from 'hapi'; import { KibanaConfig } from 'src/legacy/server/kbn_server'; import { ElasticsearchPlugin } from 'src/legacy/core_plugins/elasticsearch'; - +import { savedObjectsClientMock } from '../../../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../../alerting/server/alerts_client.mock'; import { actionsClientMock } from '../../../../../../actions/server/actions_client.mock'; @@ -48,6 +48,7 @@ export const createMockServer = (config: Record = defaultConfig) const actionsClient = actionsClientMock.create(); const alertsClient = alertsClientMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); const elasticsearch = { getCluster: jest.fn().mockImplementation(() => ({ callWithRequest: jest.fn(), @@ -57,8 +58,15 @@ export const createMockServer = (config: Record = defaultConfig) server.decorate('request', 'getBasePath', () => '/s/default'); server.decorate('request', 'getActionsClient', () => actionsClient); server.plugins.elasticsearch = (elasticsearch as unknown) as ElasticsearchPlugin; + server.decorate('request', 'getSavedObjectsClient', () => savedObjectsClient); - return { server, alertsClient, actionsClient, elasticsearch }; + return { + server, + alertsClient, + actionsClient, + elasticsearch, + savedObjectsClient, + }; }; export const createMockServerWithoutAlertClientDecoration = ( diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 8ed5333bd2a250..01f30a3ebbdeaf 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -5,6 +5,7 @@ */ import { ServerInjectOptions } from 'hapi'; +import { SavedObjectsFindResponse } from 'kibana/server'; import { ActionResult } from '../../../../../../actions/server/types'; import { SignalsStatusRestParams, SignalsQueryRestParams } from '../../signals/types'; import { @@ -15,7 +16,7 @@ import { INTERNAL_RULE_ID_KEY, INTERNAL_IMMUTABLE_KEY, } from '../../../../../common/constants'; -import { RuleAlertType } from '../../rules/types'; +import { RuleAlertType, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; import { RuleAlertParamsRest } from '../../types'; export const fullRuleAlertParamsRest = (): RuleAlertParamsRest => ({ @@ -383,3 +384,10 @@ export const getMockPrivileges = () => ({ application: {}, isAuthenticated: false, }); + +export const getFindResultStatus = (): SavedObjectsFindResponse => ({ + page: 1, + per_page: 1, + total: 0, + saved_objects: [], +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts index 1ea681afb79491..54872f80a4c6df 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/privileges/read_privileges_route.test.ts @@ -7,6 +7,7 @@ import { createMockServer } from '../__mocks__/_mock_server'; import { getPrivilegeRequest, getMockPrivileges } from '../__mocks__/request_responses'; import { readPrivilegesRoute } from './read_privileges_route'; +import { ServerFacade } from '../../../../types'; import * as myUtils from '../utils'; describe('read_privileges', () => { @@ -18,7 +19,7 @@ describe('read_privileges', () => { elasticsearch.getCluster = jest.fn(() => ({ callWithRequest: jest.fn(() => getMockPrivileges()), })); - readPrivilegesRoute(server); + readPrivilegesRoute((server as unknown) as ServerFacade); }); afterEach(() => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index d3890f82c8abf7..d5650b078e678d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -12,6 +12,7 @@ import { } from '../__mocks__/_mock_server'; import { createRulesRoute } from './create_rules_route'; import { ServerInjectOptions } from 'hapi'; +import { ServerFacade } from '../../../../types'; import { getFindResult, getResult, @@ -32,7 +33,7 @@ describe('create_rules_bulk', () => { callWithRequest: jest.fn().mockImplementation(() => true), })); - createRulesBulkRoute(server); + createRulesBulkRoute((server as unknown) as ServerFacade); }); describe('status codes with actionClient and alertClient', () => { @@ -47,14 +48,14 @@ describe('create_rules_bulk', () => { test('returns 404 if actionClient is not available on the route', async () => { const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); - createRulesRoute(serverWithoutActionClient); + createRulesRoute((serverWithoutActionClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionClient.inject(getReadBulkRequest()); expect(statusCode).toBe(404); }); test('returns 404 if alertClient is not available on the route', async () => { const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); - createRulesRoute(serverWithoutAlertClient); + createRulesRoute((serverWithoutAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutAlertClient.inject(getReadBulkRequest()); expect(statusCode).toBe(404); }); @@ -63,7 +64,7 @@ describe('create_rules_bulk', () => { const { serverWithoutActionOrAlertClient, } = createMockServerWithoutActionOrAlertClientDecoration(); - createRulesRoute(serverWithoutActionOrAlertClient); + createRulesRoute((serverWithoutActionOrAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionOrAlertClient.inject(getReadBulkRequest()); expect(statusCode).toBe(404); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 3d9719a7b248b2..15e3361986ab95 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -40,8 +40,10 @@ export const createCreateRulesBulkRoute = (server: ServerFacade): Hapi.ServerRou const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; - - if (!alertsClient || !actionsClient) { + const savedObjectsClient = isFunction(request.getSavedObjectsClient) + ? request.getSavedObjectsClient() + : null; + if (!alertsClient || !actionsClient || !savedObjectsClient) { return headers.response().code(404); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index 10dc14f7ed6106..466d150d584662 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -12,28 +12,43 @@ import { } from '../__mocks__/_mock_server'; import { createRulesRoute } from './create_rules_route'; import { ServerInjectOptions } from 'hapi'; +import { ServerFacade } from '../../../../types'; + import { getFindResult, getResult, createActionResult, getCreateRequest, typicalPayload, + getFindResultStatus, } from '../__mocks__/request_responses'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; describe('create_rules', () => { - let { server, alertsClient, actionsClient, elasticsearch } = createMockServer(); + let { + server, + alertsClient, + actionsClient, + elasticsearch, + savedObjectsClient, + } = createMockServer(); beforeEach(() => { jest.resetAllMocks(); - ({ server, alertsClient, actionsClient, elasticsearch } = createMockServer()); + ({ + server, + alertsClient, + actionsClient, + elasticsearch, + savedObjectsClient, + } = createMockServer()); elasticsearch.getCluster = jest.fn().mockImplementation(() => ({ callWithRequest: jest .fn() .mockImplementation((endpoint, params) => ({ _shards: { total: 1 } })), })); - createRulesRoute(server); + createRulesRoute((server as unknown) as ServerFacade); }); describe('status codes with actionClient and alertClient', () => { @@ -42,20 +57,21 @@ describe('create_rules', () => { alertsClient.get.mockResolvedValue(getResult()); actionsClient.create.mockResolvedValue(createActionResult()); alertsClient.create.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const { statusCode } = await server.inject(getCreateRequest()); expect(statusCode).toBe(200); }); test('returns 404 if actionClient is not available on the route', async () => { const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); - createRulesRoute(serverWithoutActionClient); + createRulesRoute((serverWithoutActionClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionClient.inject(getCreateRequest()); expect(statusCode).toBe(404); }); test('returns 404 if alertClient is not available on the route', async () => { const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); - createRulesRoute(serverWithoutAlertClient); + createRulesRoute((serverWithoutAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutAlertClient.inject(getCreateRequest()); expect(statusCode).toBe(404); }); @@ -64,7 +80,7 @@ describe('create_rules', () => { const { serverWithoutActionOrAlertClient, } = createMockServerWithoutActionOrAlertClientDecoration(); - createRulesRoute(serverWithoutActionOrAlertClient); + createRulesRoute((serverWithoutActionOrAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionOrAlertClient.inject(getCreateRequest()); expect(statusCode).toBe(404); }); @@ -76,6 +92,7 @@ describe('create_rules', () => { alertsClient.get.mockResolvedValue(getResult()); actionsClient.create.mockResolvedValue(createActionResult()); alertsClient.create.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // missing rule_id should return 200 as it will be auto generated if not given const { rule_id, ...noRuleId } = typicalPayload(); const request: ServerInjectOptions = { @@ -92,6 +109,7 @@ describe('create_rules', () => { alertsClient.get.mockResolvedValue(getResult()); actionsClient.create.mockResolvedValue(createActionResult()); alertsClient.create.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const { type, ...noType } = typicalPayload(); const request: ServerInjectOptions = { method: 'POST', @@ -110,6 +128,7 @@ describe('create_rules', () => { alertsClient.get.mockResolvedValue(getResult()); actionsClient.create.mockResolvedValue(createActionResult()); alertsClient.create.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const { type, ...noType } = typicalPayload(); const request: ServerInjectOptions = { method: 'POST', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index cf8fb2a28288f0..3e173e358557ac 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -10,10 +10,11 @@ import Boom from 'boom'; import uuid from 'uuid'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { createRules } from '../../rules/create_rules'; -import { RulesRequest } from '../../rules/types'; +import { RulesRequest, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; import { createRulesSchema } from '../schemas/create_rules_schema'; import { ServerFacade } from '../../../../types'; import { readRules } from '../../rules/read_rules'; +import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; import { transformOrError } from './utils'; import { getIndexExists } from '../../index/get_index_exists'; import { callWithRequestFactory, getIndex, transformError } from '../utils'; @@ -65,8 +66,10 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; - - if (!alertsClient || !actionsClient) { + const savedObjectsClient = isFunction(request.getSavedObjectsClient) + ? request.getSavedObjectsClient() + : null; + if (!alertsClient || !actionsClient || !savedObjectsClient) { return headers.response().code(404); } @@ -120,7 +123,17 @@ export const createCreateRulesRoute = (server: ServerFacade): Hapi.ServerRoute = references, version: 1, }); - return transformOrError(createdRule); + const ruleStatuses = await savedObjectsClient.find< + IRuleSavedAttributesSavedObjectAttributes + >({ + type: ruleStatusSavedObjectType, + perPage: 1, + sortField: 'statusDate', + sortOrder: 'desc', + search: `${createdRule.id}`, + searchFields: ['alertId'], + }); + return transformOrError(createdRule, ruleStatuses.saved_objects[0]); } catch (err) { return transformError(err); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts index bab05b065f6f73..3c429c3e38f3a9 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.test.ts @@ -20,18 +20,20 @@ import { getDeleteBulkRequestById, getDeleteAsPostBulkRequest, getDeleteAsPostBulkRequestById, + getFindResultStatus, } from '../__mocks__/request_responses'; +import { ServerFacade } from '../../../../types'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { deleteRulesBulkRoute } from './delete_rules_bulk_route'; import { BulkError } from '../utils'; describe('delete_rules', () => { - let { server, alertsClient } = createMockServer(); + let { server, alertsClient, savedObjectsClient } = createMockServer(); beforeEach(() => { - ({ server, alertsClient } = createMockServer()); - deleteRulesBulkRoute(server); + ({ server, alertsClient, savedObjectsClient } = createMockServer()); + deleteRulesBulkRoute((server as unknown) as ServerFacade); }); afterEach(() => { @@ -83,6 +85,8 @@ describe('delete_rules', () => { alertsClient.find.mockResolvedValue(getFindResult()); alertsClient.get.mockResolvedValue(getResult()); alertsClient.delete.mockResolvedValue({}); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + savedObjectsClient.delete.mockResolvedValue({}); const { payload } = await server.inject(getDeleteBulkRequest()); const parsed: BulkError[] = JSON.parse(payload); const expected: BulkError[] = [ @@ -96,14 +100,14 @@ describe('delete_rules', () => { test('returns 404 if actionClient is not available on the route', async () => { const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); - deleteRulesBulkRoute(serverWithoutActionClient); + deleteRulesBulkRoute((serverWithoutActionClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionClient.inject(getDeleteBulkRequest()); expect(statusCode).toBe(404); }); test('returns 404 if alertClient is not available on the route', async () => { const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); - deleteRulesBulkRoute(serverWithoutAlertClient); + deleteRulesBulkRoute((serverWithoutAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutAlertClient.inject(getDeleteBulkRequest()); expect(statusCode).toBe(404); }); @@ -112,7 +116,7 @@ describe('delete_rules', () => { const { serverWithoutActionOrAlertClient, } = createMockServerWithoutActionOrAlertClientDecoration(); - deleteRulesBulkRoute(serverWithoutActionOrAlertClient); + deleteRulesBulkRoute((serverWithoutActionOrAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionOrAlertClient.inject(getDeleteBulkRequest()); expect(statusCode).toBe(404); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts index a0801930f879a0..b77ae56a486c87 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_bulk_route.ts @@ -13,7 +13,8 @@ import { ServerFacade } from '../../../../types'; import { queryRulesBulkSchema } from '../schemas/query_rules_bulk_schema'; import { transformOrBulkError, getIdBulkError } from './utils'; import { transformBulkError } from '../utils'; -import { QueryBulkRequest } from '../../rules/types'; +import { QueryBulkRequest, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; +import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; export const createDeleteRulesBulkRoute: Hapi.ServerRoute = { method: ['POST', 'DELETE'], // allow both POST and DELETE in case their client does not support bodies in DELETE @@ -30,8 +31,10 @@ export const createDeleteRulesBulkRoute: Hapi.ServerRoute = { async handler(request: QueryBulkRequest, headers) { const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; - - if (alertsClient == null || actionsClient == null) { + const savedObjectsClient = isFunction(request.getSavedObjectsClient) + ? request.getSavedObjectsClient() + : null; + if (!alertsClient || !actionsClient || !savedObjectsClient) { return headers.response().code(404); } const rules = Promise.all( @@ -45,8 +48,18 @@ export const createDeleteRulesBulkRoute: Hapi.ServerRoute = { id, ruleId, }); - if (rule != null) { + const ruleStatuses = await savedObjectsClient.find< + IRuleSavedAttributesSavedObjectAttributes + >({ + type: ruleStatusSavedObjectType, + perPage: 6, + search: rule.id, + searchFields: ['alertId'], + }); + ruleStatuses.saved_objects.forEach(async obj => + savedObjectsClient.delete(ruleStatusSavedObjectType, obj.id) + ); return transformOrBulkError(idOrRuleIdOrUnknown, rule); } else { return getIdBulkError({ id, ruleId }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts index cacafcf741e6a8..ee4edada52b6ac 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.test.ts @@ -13,21 +13,24 @@ import { import { deleteRulesRoute } from './delete_rules_route'; import { ServerInjectOptions } from 'hapi'; +import { ServerFacade } from '../../../../types'; + import { getFindResult, getResult, getDeleteRequest, getFindResultWithSingleHit, getDeleteRequestById, + getFindResultStatus, } from '../__mocks__/request_responses'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; describe('delete_rules', () => { - let { server, alertsClient } = createMockServer(); + let { server, alertsClient, savedObjectsClient } = createMockServer(); beforeEach(() => { - ({ server, alertsClient } = createMockServer()); - deleteRulesRoute(server); + ({ server, alertsClient, savedObjectsClient } = createMockServer()); + deleteRulesRoute((server as unknown) as ServerFacade); }); afterEach(() => { @@ -39,6 +42,8 @@ describe('delete_rules', () => { alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); alertsClient.get.mockResolvedValue(getResult()); alertsClient.delete.mockResolvedValue({}); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + savedObjectsClient.delete.mockResolvedValue({}); const { statusCode } = await server.inject(getDeleteRequest()); expect(statusCode).toBe(200); }); @@ -47,6 +52,8 @@ describe('delete_rules', () => { alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); alertsClient.get.mockResolvedValue(getResult()); alertsClient.delete.mockResolvedValue({}); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + savedObjectsClient.delete.mockResolvedValue({}); const { statusCode } = await server.inject(getDeleteRequestById()); expect(statusCode).toBe(200); }); @@ -55,20 +62,22 @@ describe('delete_rules', () => { alertsClient.find.mockResolvedValue(getFindResult()); alertsClient.get.mockResolvedValue(getResult()); alertsClient.delete.mockResolvedValue({}); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); + savedObjectsClient.delete.mockResolvedValue({}); const { statusCode } = await server.inject(getDeleteRequest()); expect(statusCode).toBe(404); }); test('returns 404 if actionClient is not available on the route', async () => { const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); - deleteRulesRoute(serverWithoutActionClient); + deleteRulesRoute((serverWithoutActionClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionClient.inject(getDeleteRequest()); expect(statusCode).toBe(404); }); test('returns 404 if alertClient is not available on the route', async () => { const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); - deleteRulesRoute(serverWithoutAlertClient); + deleteRulesRoute((serverWithoutAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutAlertClient.inject(getDeleteRequest()); expect(statusCode).toBe(404); }); @@ -77,7 +86,7 @@ describe('delete_rules', () => { const { serverWithoutActionOrAlertClient, } = createMockServerWithoutActionOrAlertClientDecoration(); - deleteRulesRoute(serverWithoutActionOrAlertClient); + deleteRulesRoute((serverWithoutActionOrAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionOrAlertClient.inject(getDeleteRequest()); expect(statusCode).toBe(404); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts index 8f771236269d95..ea8b54a79a5e3c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/delete_rules_route.ts @@ -13,7 +13,8 @@ import { ServerFacade } from '../../../../types'; import { queryRulesSchema } from '../schemas/query_rules_schema'; import { getIdError, transformOrError } from './utils'; import { transformError } from '../utils'; -import { QueryRequest } from '../../rules/types'; +import { QueryRequest, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; +import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; export const createDeleteRulesRoute: Hapi.ServerRoute = { method: 'DELETE', @@ -31,8 +32,10 @@ export const createDeleteRulesRoute: Hapi.ServerRoute = { const { id, rule_id: ruleId } = request.query; const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; - - if (alertsClient == null || actionsClient == null) { + const savedObjectsClient = isFunction(request.getSavedObjectsClient) + ? request.getSavedObjectsClient() + : null; + if (!alertsClient || !actionsClient || !savedObjectsClient) { return headers.response().code(404); } @@ -43,9 +46,19 @@ export const createDeleteRulesRoute: Hapi.ServerRoute = { id, ruleId, }); - if (rule != null) { - return transformOrError(rule); + const ruleStatuses = await savedObjectsClient.find< + IRuleSavedAttributesSavedObjectAttributes + >({ + type: ruleStatusSavedObjectType, + perPage: 6, + search: rule.id, + searchFields: ['alertId'], + }); + ruleStatuses.saved_objects.forEach(async obj => + savedObjectsClient.delete(ruleStatusSavedObjectType, obj.id) + ); + return transformOrError(rule, ruleStatuses.saved_objects[0]); } else { return getIdError({ id, ruleId }); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts index 38937c13d302c2..639e4877f7f728 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.test.ts @@ -13,6 +13,8 @@ import { import { findRulesRoute } from './find_rules_route'; import { ServerInjectOptions } from 'hapi'; +import { ServerFacade } from '../../../../types'; + import { getFindResult, getResult, getFindRequest } from '../__mocks__/request_responses'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; @@ -21,7 +23,7 @@ describe('find_rules', () => { beforeEach(() => { ({ server, alertsClient, actionsClient } = createMockServer()); - findRulesRoute(server); + findRulesRoute((server as unknown) as ServerFacade); }); afterEach(() => { @@ -44,14 +46,14 @@ describe('find_rules', () => { test('returns 404 if actionClient is not available on the route', async () => { const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); - findRulesRoute(serverWithoutActionClient); + findRulesRoute((serverWithoutActionClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionClient.inject(getFindRequest()); expect(statusCode).toBe(404); }); test('returns 404 if alertClient is not available on the route', async () => { const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); - findRulesRoute(serverWithoutAlertClient); + findRulesRoute((serverWithoutAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutAlertClient.inject(getFindRequest()); expect(statusCode).toBe(404); }); @@ -60,7 +62,7 @@ describe('find_rules', () => { const { serverWithoutActionOrAlertClient, } = createMockServerWithoutActionOrAlertClientDecoration(); - findRulesRoute(serverWithoutActionOrAlertClient); + findRulesRoute((serverWithoutActionOrAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionOrAlertClient.inject(getFindRequest()); expect(statusCode).toBe(404); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts index 6e89ddb19017dc..6cbc794751f9fb 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_route.ts @@ -8,11 +8,12 @@ import Hapi from 'hapi'; import { isFunction } from 'lodash/fp'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { findRules } from '../../rules/find_rules'; -import { FindRulesRequest } from '../../rules/types'; +import { FindRulesRequest, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; import { findRulesSchema } from '../schemas/find_rules_schema'; import { ServerFacade } from '../../../../types'; import { transformFindAlertsOrError } from './utils'; import { transformError } from '../utils'; +import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; export const createFindRulesRoute: Hapi.ServerRoute = { method: 'GET', @@ -30,8 +31,10 @@ export const createFindRulesRoute: Hapi.ServerRoute = { const { query } = request; const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; - - if (!alertsClient || !actionsClient) { + const savedObjectsClient = isFunction(request.getSavedObjectsClient) + ? request.getSavedObjectsClient() + : null; + if (!alertsClient || !actionsClient || !savedObjectsClient) { return headers.response().code(404); } @@ -44,7 +47,20 @@ export const createFindRulesRoute: Hapi.ServerRoute = { sortOrder: query.sort_order, filter: query.filter, }); - return transformFindAlertsOrError(rules); + const ruleStatuses = await Promise.all( + rules.data.map(async rule => { + const results = await savedObjectsClient.find({ + type: ruleStatusSavedObjectType, + perPage: 1, + sortField: 'statusDate', + sortOrder: 'desc', + search: rule.id, + searchFields: ['alertId'], + }); + return results; + }) + ); + return transformFindAlertsOrError(rules, ruleStatuses); } catch (err) { return transformError(err); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts new file mode 100644 index 00000000000000..e56c440f5a415e --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/find_rules_status_route.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Hapi from 'hapi'; +import { isFunction, snakeCase } from 'lodash/fp'; + +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { ServerFacade } from '../../../../types'; +import { findRulesStatusesSchema } from '../schemas/find_rules_statuses_schema'; +import { + FindRulesStatusesRequest, + IRuleSavedAttributesSavedObjectAttributes, +} from '../../rules/types'; +import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; + +const convertToSnakeCase = (obj: IRuleSavedAttributesSavedObjectAttributes) => { + return Object.keys(obj).reduce((acc, item) => { + const newKey = snakeCase(item); + return { ...acc, [newKey]: obj[item] }; + }, {}); +}; + +export const createFindRulesStatusRoute: Hapi.ServerRoute = { + method: 'GET', + path: `${DETECTION_ENGINE_RULES_URL}/_find_statuses`, + options: { + tags: ['access:siem'], + validate: { + options: { + abortEarly: false, + }, + query: findRulesStatusesSchema, + }, + }, + async handler(request: FindRulesStatusesRequest, headers) { + const { query } = request; + const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; + const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; + const savedObjectsClient = isFunction(request.getSavedObjectsClient) + ? request.getSavedObjectsClient() + : null; + if (!alertsClient || !actionsClient || !savedObjectsClient) { + return headers.response().code(404); + } + + // build return object with ids as keys and errors as values. + /* looks like this + { + "someAlertId": [{"myerrorobject": "some error value"}, etc..], + "anotherAlertId": ... + } + */ + const statuses = await query.ids.reduce(async (acc, id) => { + const lastFiveErrorsForId = await savedObjectsClient.find< + IRuleSavedAttributesSavedObjectAttributes + >({ + type: ruleStatusSavedObjectType, + perPage: 6, + sortField: 'statusDate', + sortOrder: 'desc', + search: id, + searchFields: ['alertId'], + }); + const toDisplay = + lastFiveErrorsForId.saved_objects.length <= 5 + ? lastFiveErrorsForId.saved_objects + : lastFiveErrorsForId.saved_objects.slice(1); + return { + ...(await acc), + [id]: toDisplay.map(errorItem => convertToSnakeCase(errorItem.attributes)), + }; + }, {}); + return statuses; + }, +}; + +export const findRulesStatusesRoute = (server: ServerFacade): void => { + server.route(createFindRulesStatusRoute); +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts index 0d77583573c136..92db4be1c9ff9a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.test.ts @@ -13,20 +13,23 @@ import { import { readRulesRoute } from './read_rules_route'; import { ServerInjectOptions } from 'hapi'; +import { ServerFacade } from '../../../../types'; + import { getFindResult, getResult, getReadRequest, getFindResultWithSingleHit, + getFindResultStatus, } from '../__mocks__/request_responses'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; describe('read_signals', () => { - let { server, alertsClient } = createMockServer(); + let { server, alertsClient, savedObjectsClient } = createMockServer(); beforeEach(() => { - ({ server, alertsClient } = createMockServer()); - readRulesRoute(server); + ({ server, alertsClient, savedObjectsClient } = createMockServer()); + readRulesRoute((server as unknown) as ServerFacade); }); afterEach(() => { @@ -37,20 +40,21 @@ describe('read_signals', () => { test('returns 200 when reading a single rule with a valid actionClient and alertClient', async () => { alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); alertsClient.get.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const { statusCode } = await server.inject(getReadRequest()); expect(statusCode).toBe(200); }); test('returns 404 if actionClient is not available on the route', async () => { const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); - readRulesRoute(serverWithoutActionClient); + readRulesRoute((serverWithoutActionClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionClient.inject(getReadRequest()); expect(statusCode).toBe(404); }); test('returns 404 if alertClient is not available on the route', async () => { const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); - readRulesRoute(serverWithoutAlertClient); + readRulesRoute((serverWithoutAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutAlertClient.inject(getReadRequest()); expect(statusCode).toBe(404); }); @@ -59,7 +63,7 @@ describe('read_signals', () => { const { serverWithoutActionOrAlertClient, } = createMockServerWithoutActionOrAlertClientDecoration(); - readRulesRoute(serverWithoutActionOrAlertClient); + readRulesRoute((serverWithoutActionOrAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionOrAlertClient.inject(getReadRequest()); expect(statusCode).toBe(404); }); @@ -70,6 +74,7 @@ describe('read_signals', () => { alertsClient.find.mockResolvedValue(getFindResult()); alertsClient.get.mockResolvedValue(getResult()); alertsClient.delete.mockResolvedValue({}); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const request: ServerInjectOptions = { method: 'GET', url: DETECTION_ENGINE_RULES_URL, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts index 4ff26ae99bd136..25d8e6770f6999 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/read_rules_route.ts @@ -13,7 +13,8 @@ import { transformError } from '../utils'; import { readRules } from '../../rules/read_rules'; import { ServerFacade } from '../../../../types'; import { queryRulesSchema } from '../schemas/query_rules_schema'; -import { QueryRequest } from '../../rules/types'; +import { QueryRequest, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; +import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; export const createReadRulesRoute: Hapi.ServerRoute = { method: 'GET', @@ -31,8 +32,10 @@ export const createReadRulesRoute: Hapi.ServerRoute = { const { id, rule_id: ruleId } = request.query; const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; - - if (!alertsClient || !actionsClient) { + const savedObjectsClient = isFunction(request.getSavedObjectsClient) + ? request.getSavedObjectsClient() + : null; + if (!alertsClient || !actionsClient || !savedObjectsClient) { return headers.response().code(404); } try { @@ -42,7 +45,17 @@ export const createReadRulesRoute: Hapi.ServerRoute = { ruleId, }); if (rule != null) { - return transformOrError(rule); + const ruleStatuses = await savedObjectsClient.find< + IRuleSavedAttributesSavedObjectAttributes + >({ + type: ruleStatusSavedObjectType, + perPage: 1, + sortField: 'statusDate', + sortOrder: 'desc', + search: rule.id, + searchFields: ['alertId'], + }); + return transformOrError(rule, ruleStatuses.saved_objects[0]); } else { return getIdError({ id, ruleId }); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts index 2410dcee203f61..2ab2610834195a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk.test.ts @@ -13,6 +13,8 @@ import { import { updateRulesRoute } from './update_rules_route'; import { ServerInjectOptions } from 'hapi'; +import { ServerFacade } from '../../../../types'; + import { getFindResult, getResult, @@ -31,7 +33,7 @@ describe('update_rules_bulk', () => { beforeEach(() => { jest.resetAllMocks(); ({ server, alertsClient, actionsClient } = createMockServer()); - updateRulesBulkRoute(server); + updateRulesBulkRoute((server as unknown) as ServerFacade); }); describe('status codes with actionClient and alertClient', () => { @@ -71,14 +73,14 @@ describe('update_rules_bulk', () => { test('returns 404 if actionClient is not available on the route', async () => { const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); - updateRulesRoute(serverWithoutActionClient); + updateRulesRoute((serverWithoutActionClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionClient.inject(getUpdateBulkRequest()); expect(statusCode).toBe(404); }); test('returns 404 if alertClient is not available on the route', async () => { const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); - updateRulesRoute(serverWithoutAlertClient); + updateRulesRoute((serverWithoutAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutAlertClient.inject(getUpdateBulkRequest()); expect(statusCode).toBe(404); }); @@ -87,7 +89,7 @@ describe('update_rules_bulk', () => { const { serverWithoutActionOrAlertClient, } = createMockServerWithoutActionOrAlertClientDecoration(); - updateRulesRoute(serverWithoutActionOrAlertClient); + updateRulesRoute((serverWithoutActionOrAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionOrAlertClient.inject(getUpdateBulkRequest()); expect(statusCode).toBe(404); }); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index 3cf5c07655d92a..d33989b8a7ce5d 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -13,8 +13,11 @@ import { import { updateRulesRoute } from './update_rules_route'; import { ServerInjectOptions } from 'hapi'; +import { ServerFacade } from '../../../../types'; + import { getFindResult, + getFindResultStatus, getResult, updateActionResult, getUpdateRequest, @@ -24,12 +27,12 @@ import { import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; describe('update_rules', () => { - let { server, alertsClient, actionsClient } = createMockServer(); + let { server, alertsClient, actionsClient, savedObjectsClient } = createMockServer(); beforeEach(() => { jest.resetAllMocks(); - ({ server, alertsClient, actionsClient } = createMockServer()); - updateRulesRoute(server); + ({ server, alertsClient, actionsClient, savedObjectsClient } = createMockServer()); + updateRulesRoute((server as unknown) as ServerFacade); }); describe('status codes with actionClient and alertClient', () => { @@ -38,6 +41,7 @@ describe('update_rules', () => { alertsClient.get.mockResolvedValue(getResult()); actionsClient.update.mockResolvedValue(updateActionResult()); alertsClient.update.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const { statusCode } = await server.inject(getUpdateRequest()); expect(statusCode).toBe(200); }); @@ -47,20 +51,21 @@ describe('update_rules', () => { alertsClient.get.mockResolvedValue(getResult()); actionsClient.update.mockResolvedValue(updateActionResult()); alertsClient.update.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const { statusCode } = await server.inject(getUpdateRequest()); expect(statusCode).toBe(404); }); test('returns 404 if actionClient is not available on the route', async () => { const { serverWithoutActionClient } = createMockServerWithoutActionClientDecoration(); - updateRulesRoute(serverWithoutActionClient); + updateRulesRoute((serverWithoutActionClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionClient.inject(getUpdateRequest()); expect(statusCode).toBe(404); }); test('returns 404 if alertClient is not available on the route', async () => { const { serverWithoutAlertClient } = createMockServerWithoutAlertClientDecoration(); - updateRulesRoute(serverWithoutAlertClient); + updateRulesRoute((serverWithoutAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutAlertClient.inject(getUpdateRequest()); expect(statusCode).toBe(404); }); @@ -69,7 +74,7 @@ describe('update_rules', () => { const { serverWithoutActionOrAlertClient, } = createMockServerWithoutActionOrAlertClientDecoration(); - updateRulesRoute(serverWithoutActionOrAlertClient); + updateRulesRoute((serverWithoutActionOrAlertClient as unknown) as ServerFacade); const { statusCode } = await serverWithoutActionOrAlertClient.inject(getUpdateRequest()); expect(statusCode).toBe(404); }); @@ -79,6 +84,7 @@ describe('update_rules', () => { test('returns 400 if id is not given in either the body or the url', async () => { alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); alertsClient.get.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const { rule_id, ...noId } = typicalPayload(); const request: ServerInjectOptions = { method: 'PUT', @@ -95,6 +101,7 @@ describe('update_rules', () => { alertsClient.find.mockResolvedValue(getFindResult()); actionsClient.update.mockResolvedValue(updateActionResult()); alertsClient.update.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const request: ServerInjectOptions = { method: 'PUT', url: DETECTION_ENGINE_RULES_URL, @@ -109,6 +116,7 @@ describe('update_rules', () => { alertsClient.get.mockResolvedValue(getResult()); actionsClient.update.mockResolvedValue(updateActionResult()); alertsClient.update.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const request: ServerInjectOptions = { method: 'PUT', url: DETECTION_ENGINE_RULES_URL, @@ -123,6 +131,7 @@ describe('update_rules', () => { alertsClient.get.mockResolvedValue(getResult()); actionsClient.update.mockResolvedValue(updateActionResult()); alertsClient.update.mockResolvedValue(getResult()); + savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); const { type, ...noType } = typicalPayload(); const request: ServerInjectOptions = { method: 'PUT', diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index 6db8a8902915aa..147f3f9afa549e 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -8,11 +8,12 @@ import Hapi from 'hapi'; import { isFunction } from 'lodash/fp'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { updateRules } from '../../rules/update_rules'; -import { UpdateRulesRequest } from '../../rules/types'; +import { UpdateRulesRequest, IRuleSavedAttributesSavedObjectAttributes } from '../../rules/types'; import { updateRulesSchema } from '../schemas/update_rules_schema'; import { ServerFacade } from '../../../../types'; import { getIdError, transformOrError } from './utils'; import { transformError } from '../utils'; +import { ruleStatusSavedObjectType } from '../../rules/saved_object_mappings'; export const createUpdateRulesRoute: Hapi.ServerRoute = { method: 'PUT', @@ -59,8 +60,10 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = { const alertsClient = isFunction(request.getAlertsClient) ? request.getAlertsClient() : null; const actionsClient = isFunction(request.getActionsClient) ? request.getActionsClient() : null; - - if (!alertsClient || !actionsClient) { + const savedObjectsClient = isFunction(request.getSavedObjectsClient) + ? request.getSavedObjectsClient() + : null; + if (!alertsClient || !actionsClient || !savedObjectsClient) { return headers.response().code(404); } @@ -97,7 +100,17 @@ export const createUpdateRulesRoute: Hapi.ServerRoute = { version, }); if (rule != null) { - return transformOrError(rule); + const ruleStatuses = await savedObjectsClient.find< + IRuleSavedAttributesSavedObjectAttributes + >({ + type: ruleStatusSavedObjectType, + perPage: 1, + sortField: 'statusDate', + sortOrder: 'desc', + search: rule.id, + searchFields: ['alertId'], + }); + return transformOrError(rule, ruleStatuses.saved_objects[0]); } else { return getIdError({ id, ruleId }); } diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts index 21972905a50632..7d4fb43b58ef1a 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/rules/utils.ts @@ -6,8 +6,17 @@ import Boom from 'boom'; import { pickBy } from 'lodash/fp'; +import { SavedObject } from 'kibana/server'; import { INTERNAL_IDENTIFIER } from '../../../../../common/constants'; -import { RuleAlertType, isAlertType, isAlertTypes } from '../../rules/types'; +import { + RuleAlertType, + isAlertType, + isAlertTypes, + IRuleSavedAttributesSavedObjectAttributes, + isRuleStatusFindType, + isRuleStatusFindTypes, + isRuleStatusSavedObjectType, +} from '../../rules/types'; import { OutputRuleAlertRest } from '../../types'; import { createBulkErrorObject, @@ -67,7 +76,10 @@ export const transformTags = (tags: string[]): string[] => { // Transforms the data but will remove any null or undefined it encounters and not include // those on the export -export const transformAlertToRule = (alert: RuleAlertType): Partial => { +export const transformAlertToRule = ( + alert: RuleAlertType, + ruleStatus?: SavedObject +): Partial => { return pickBy((value: unknown) => value != null, { created_at: alert.params.createdAt, updated_at: alert.params.updatedAt, @@ -100,6 +112,12 @@ export const transformAlertToRule = (alert: RuleAlertType): Partial transformAlertToRule(alert)); }; -export const transformFindAlertsOrError = (findResults: { data: unknown[] }): unknown | Boom => { - if (isAlertTypes(findResults.data)) { +export const transformFindAlertsOrError = ( + findResults: { data: unknown[] }, + ruleStatuses?: unknown[] +): unknown | Boom => { + if (!ruleStatuses && isAlertTypes(findResults.data)) { findResults.data = findResults.data.map(alert => transformAlertToRule(alert)); return findResults; + } + if (isAlertTypes(findResults.data) && isRuleStatusFindTypes(ruleStatuses)) { + findResults.data = findResults.data.map((alert, idx) => + transformAlertToRule(alert, ruleStatuses[idx].saved_objects[0]) + ); + return findResults; } else { return new Boom('Internal error transforming', { statusCode: 500 }); } }; -export const transformOrError = (alert: unknown): Partial | Boom => { - if (isAlertType(alert)) { +export const transformOrError = ( + alert: unknown, + ruleStatus?: unknown +): Partial | Boom => { + if (!ruleStatus && isAlertType(alert)) { return transformAlertToRule(alert); + } + if (isAlertType(alert) && isRuleStatusFindType(ruleStatus)) { + return transformAlertToRule(alert, ruleStatus.saved_objects[0]); + } else if (isAlertType(alert) && isRuleStatusSavedObjectType(ruleStatus)) { + return transformAlertToRule(alert, ruleStatus); } else { return new Boom('Internal error transforming', { statusCode: 500 }); } @@ -137,10 +172,15 @@ export const transformOrError = (alert: unknown): Partial | export const transformOrBulkError = ( ruleId: string, - alert: unknown + alert: unknown, + ruleStatus?: unknown ): Partial | BulkError => { if (isAlertType(alert)) { - return transformAlertToRule(alert); + if (isRuleStatusFindType(ruleStatus)) { + return transformAlertToRule(alert, ruleStatus?.saved_objects[0] ?? ruleStatus); + } else { + return transformAlertToRule(alert); + } } else { return createBulkErrorObject({ ruleId, diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/find_rules_statuses_schema.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/find_rules_statuses_schema.ts new file mode 100644 index 00000000000000..5b8661e69e206d --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/schemas/find_rules_statuses_schema.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Joi from 'joi'; + +export const findRulesStatusesSchema = Joi.object({ + ids: Joi.array().items(Joi.string()), +}); diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/open_close_signals.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/open_close_signals.test.ts index 3cb64586d6cb53..2b7ee443880e5c 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/open_close_signals.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/open_close_signals.test.ts @@ -8,6 +8,8 @@ import { createMockServer } from '../__mocks__/_mock_server'; import { setSignalsStatusRoute } from './open_close_signals_route'; import * as myUtils from '../utils'; import { ServerInjectOptions } from 'hapi'; +import { ServerFacade } from '../../../../types'; + import { getSetSignalStatusByIdsRequest, getSetSignalStatusByQueryRequest, @@ -27,7 +29,7 @@ describe('set signal status', () => { elasticsearch.getCluster = jest.fn(() => ({ callWithRequest: jest.fn(() => true), })); - setSignalsStatusRoute(server); + setSignalsStatusRoute((server as unknown) as ServerFacade); }); describe('status on signal', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/query_signals_route.test.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/query_signals_route.test.ts index 1b990e8c1ff572..aef499675b8849 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/query_signals_route.test.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/routes/signals/query_signals_route.test.ts @@ -8,6 +8,8 @@ import { createMockServer } from '../__mocks__/_mock_server'; import { querySignalsRoute } from './query_signals_route'; import * as myUtils from '../utils'; import { ServerInjectOptions } from 'hapi'; +import { ServerFacade } from '../../../../types'; + import { getSignalsQueryRequest, getSignalsAggsQueryRequest, @@ -26,7 +28,7 @@ describe('query for signal', () => { elasticsearch.getCluster = jest.fn(() => ({ callWithRequest: jest.fn(() => true), })); - querySignalsRoute(server); + querySignalsRoute((server as unknown) as ServerFacade); }); describe('query and agg on signals index', () => { diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts new file mode 100644 index 00000000000000..fd3d35e9f67854 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/saved_object_mappings.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ElasticsearchMappingOf } from '../../../utils/typed_elasticsearch_mappings'; +import { IRuleStatusAttributes } from './types'; + +export const ruleStatusSavedObjectType = 'siem-detection-engine-rule-status'; + +export const ruleStatusSavedObjectMappings: { + [ruleStatusSavedObjectType]: ElasticsearchMappingOf; +} = { + [ruleStatusSavedObjectType]: { + properties: { + alertId: { + type: 'keyword', + }, + status: { + type: 'keyword', + }, + statusDate: { + type: 'date', + }, + lastFailureAt: { + type: 'date', + }, + lastSuccessAt: { + type: 'date', + }, + lastFailureMessage: { + type: 'text', + }, + lastSuccessMessage: { + type: 'text', + }, + }, + }, +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts index 4d9073e4b38d95..5a3f19c0bf0ef8 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/rules/types.ts @@ -7,6 +7,7 @@ import { get } from 'lodash/fp'; import { Readable } from 'stream'; +import { SavedObject, SavedObjectAttributes, SavedObjectsFindResponse } from 'kibana/server'; import { SIGNALS_ID } from '../../../../common/constants'; import { AlertsClient } from '../../../../../alerting/server/alerts_client'; import { ActionsClient } from '../../../../../actions/server/actions_client'; @@ -40,6 +41,38 @@ export interface RuleAlertType extends Alert { params: RuleTypeParams; } +export interface IRuleStatusAttributes { + alertId: string; // created alert id. + statusDate: string; + lastFailureAt: string | null | undefined; + lastFailureMessage: string | null | undefined; + lastSuccessAt: string | null | undefined; + lastSuccessMessage: string | null | undefined; + status: RuleStatusString; +} + +export interface IRuleSavedAttributesSavedObjectAttributes + extends IRuleStatusAttributes, + SavedObjectAttributes {} + +export interface IRuleStatusSavedObject { + type: string; + id: string; + attributes: Array>; + references: unknown[]; + updated_at: string; + version: string; +} + +export interface IRuleStatusFindType { + page: number; + per_page: number; + total: number; + saved_objects: IRuleStatusSavedObject[]; +} + +export type RuleStatusString = 'succeeded' | 'failed' | 'going to run' | 'executing'; + export interface RulesRequest extends RequestFacade { payload: RuleAlertParamsRest; } @@ -96,6 +129,12 @@ export interface FindRulesRequest extends Omit { }; } +export interface FindRulesStatusesRequest extends Omit { + query: { + ids: string[]; + }; +} + export interface Clients { alertsClient: AlertsClient; actionsClient: ActionsClient; @@ -125,3 +164,25 @@ export const isAlertTypes = (obj: unknown[]): obj is RuleAlertType[] => { export const isAlertType = (obj: unknown): obj is RuleAlertType => { return get('alertTypeId', obj) === SIGNALS_ID; }; + +export const isRuleStatusAttributes = (obj: unknown): obj is IRuleStatusAttributes => { + return get('lastSuccessMessage', obj) != null; +}; + +export const isRuleStatusSavedObjectType = ( + obj: unknown +): obj is SavedObject => { + return get('attributes', obj) != null; +}; + +export const isRuleStatusFindType = ( + obj: unknown +): obj is SavedObjectsFindResponse => { + return get('saved_objects', obj) != null; +}; + +export const isRuleStatusFindTypes = ( + obj: unknown[] | undefined +): obj is Array> => { + return obj ? obj.every(ruleStatus => isRuleStatusFindType(ruleStatus)) : false; +}; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_statuses.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_statuses.sh new file mode 100755 index 00000000000000..1a19cdb7552d11 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/delete_all_statuses.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +# +# 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. +# + +set -e +./check_env_variables.sh + +# Example: ./delete_all_statuses.sh +# https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html +curl -s -k \ + -H "Content-Type: application/json" \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${ELASTICSEARCH_URL}/${KIBANA_INDEX}*/_delete_by_query \ + --data '{ + "query": { + "exists": { "field": "siem-detection-engine-rule-status" } + } + }' \ + | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_rules_statuses_by_ids.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_rules_statuses_by_ids.sh new file mode 100755 index 00000000000000..543c019067e8e6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/find_rules_statuses_by_ids.sh @@ -0,0 +1,17 @@ +#!/bin/sh + +# +# 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. +# + +set -e +./check_env_variables.sh + + +# Example: ./find_rules_statuses_by_ids.sh '["12345","6789abc"]' +curl -g -k \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/detection_engine/rules/_find_statuses?ids=$1" \ + | jq . diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/hard_reset.sh b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/hard_reset.sh index 3cd0bff7ee8ab7..a56d788d69c16c 100755 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/hard_reset.sh +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/hard_reset.sh @@ -12,5 +12,6 @@ set -e ./delete_all_actions.sh ./delete_all_alerts.sh ./delete_all_alert_tasks.sh +./delete_all_statuses.sh ./delete_signal_index.sh ./post_signal_index.sh diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/query_with_errors.json b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/query_with_errors.json new file mode 100644 index 00000000000000..6ac276f787cd32 --- /dev/null +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/scripts/rules/test_cases/query_with_errors.json @@ -0,0 +1,12 @@ +{ + "name": "Query with a rule_id that causes an error", + "description": "Query with a rule_id that acts like an external id", + "rule_id": "query-rule-id-errors-2", + "risk_score": 1, + "interval": "30s", + "severity": "high", + "type": "query", + "from": "now-6m", + "to": "now", + "query": "user.name: root badstringcauseserror user.name: admin" +} diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 774afb6d7deb07..d80eadd2c088ba 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -19,6 +19,8 @@ import { searchAfterAndBulkCreate } from './search_after_bulk_create'; import { getFilter } from './get_filter'; import { SignalRuleAlertTypeDefinition } from './types'; import { getGapBetweenRuns } from './utils'; +import { ruleStatusSavedObjectType } from '../rules/saved_object_mappings'; +import { IRuleSavedAttributesSavedObjectAttributes } from '../rules/types'; export const signalRulesAlertType = ({ logger, @@ -75,6 +77,46 @@ export const signalRulesAlertType = ({ } = params; // TODO: Remove this hard extraction of name once this is fixed: https://github.com/elastic/kibana/issues/50522 const savedObject = await services.savedObjectsClient.get('alert', alertId); + const ruleStatusSavedObjects = await services.savedObjectsClient.find< + IRuleSavedAttributesSavedObjectAttributes + >({ + type: ruleStatusSavedObjectType, + perPage: 6, // 0th element is current status, 1-5 is last 5 failures. + sortField: 'statusDate', + sortOrder: 'desc', + search: `${alertId}`, + searchFields: ['alertId'], + }); + let currentStatusSavedObject; + if (ruleStatusSavedObjects.saved_objects.length === 0) { + // create + const date = new Date().toISOString(); + currentStatusSavedObject = await services.savedObjectsClient.create< + IRuleSavedAttributesSavedObjectAttributes + >(ruleStatusSavedObjectType, { + alertId, // do a search for this id. + statusDate: date, + status: 'executing', + lastFailureAt: null, + lastSuccessAt: null, + lastFailureMessage: null, + lastSuccessMessage: null, + }); + } else { + // update 0th to executing. + currentStatusSavedObject = ruleStatusSavedObjects.saved_objects[0]; + const sDate = new Date().toISOString(); + currentStatusSavedObject.attributes.status = 'executing'; + currentStatusSavedObject.attributes.statusDate = sDate; + await services.savedObjectsClient.update( + ruleStatusSavedObjectType, + currentStatusSavedObject.id, + { + ...currentStatusSavedObject.attributes, + } + ); + } + const name: string = savedObject.attributes.name; const tags: string[] = savedObject.attributes.tags; @@ -98,77 +140,169 @@ export const signalRulesAlertType = ({ DEFAULT_SEARCH_AFTER_PAGE_SIZE <= params.maxSignals ? DEFAULT_SEARCH_AFTER_PAGE_SIZE : params.maxSignals; - - const inputIndex = await getInputIndex(services, version, index); - const esFilter = await getFilter({ - type, - filters, - language, - query, - savedId, - services, - index: inputIndex, - }); - - const noReIndex = buildEventsSearchQuery({ - index: inputIndex, - from, - to, - filter: esFilter, - size: searchAfterSize, - searchAfterSortId: undefined, - }); - try { - logger.debug( - `Starting signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` - ); - logger.debug( - `[+] Initial search call of signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` - ); - const noReIndexResult = await services.callCluster('search', noReIndex); - if (noReIndexResult.hits.total.value !== 0) { - logger.info( - `Found ${ - noReIndexResult.hits.total.value - } signals from the indexes of "[${inputIndex.join( - ', ' - )}]" using signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", pushing signals to index "${outputIndex}"` - ); - } - - const bulkIndexResult = await searchAfterAndBulkCreate({ - someResult: noReIndexResult, - ruleParams: params, + const inputIndex = await getInputIndex(services, version, index); + const esFilter = await getFilter({ + type, + filters, + language, + query, + savedId, services, - logger, - id: alertId, - signalsIndex: outputIndex, + index: inputIndex, + }); + + const noReIndex = buildEventsSearchQuery({ + index: inputIndex, + from, + to, filter: esFilter, - name, - createdBy, - updatedBy, - interval, - enabled, - pageSize: searchAfterSize, - tags, + size: searchAfterSize, + searchAfterSortId: undefined, }); - if (bulkIndexResult) { + try { + logger.debug( + `Starting signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` + ); logger.debug( - `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` + `[+] Initial search call of signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` ); - } else { + const noReIndexResult = await services.callCluster('search', noReIndex); + if (noReIndexResult.hits.total.value !== 0) { + logger.info( + `Found ${ + noReIndexResult.hits.total.value + } signals from the indexes of "[${inputIndex.join( + ', ' + )}]" using signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}", pushing signals to index "${outputIndex}"` + ); + } + + const bulkIndexResult = await searchAfterAndBulkCreate({ + someResult: noReIndexResult, + ruleParams: params, + services, + logger, + id: alertId, + signalsIndex: outputIndex, + filter: esFilter, + name, + createdBy, + updatedBy, + interval, + enabled, + pageSize: searchAfterSize, + tags, + }); + + if (bulkIndexResult) { + logger.debug( + `Finished signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` + ); + const sDate = new Date().toISOString(); + currentStatusSavedObject.attributes.status = 'succeeded'; + currentStatusSavedObject.attributes.statusDate = sDate; + currentStatusSavedObject.attributes.lastSuccessAt = sDate; + currentStatusSavedObject.attributes.lastSuccessMessage = 'succeeded'; + await services.savedObjectsClient.update( + ruleStatusSavedObjectType, + currentStatusSavedObject.id, + { + ...currentStatusSavedObject.attributes, + } + ); + } else { + logger.error( + `Error processing signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` + ); + const sDate = new Date().toISOString(); + currentStatusSavedObject.attributes.status = 'failed'; + currentStatusSavedObject.attributes.statusDate = sDate; + currentStatusSavedObject.attributes.lastFailureAt = sDate; + currentStatusSavedObject.attributes.lastFailureMessage = `Bulk Indexing signals failed. Check logs for further details \nRule name: "${name}"\nid: "${alertId}"\nrule_id: "${ruleId}"\n`; + // current status is failing + await services.savedObjectsClient.update( + ruleStatusSavedObjectType, + currentStatusSavedObject.id, + { + ...currentStatusSavedObject.attributes, + } + ); + // create new status for historical purposes + await services.savedObjectsClient.create(ruleStatusSavedObjectType, { + ...currentStatusSavedObject.attributes, + }); + + if (ruleStatusSavedObjects.saved_objects.length >= 6) { + // delete fifth status and prepare to insert a newer one. + const toDelete = ruleStatusSavedObjects.saved_objects.slice(5); + await toDelete.forEach(async item => + services.savedObjectsClient.delete(ruleStatusSavedObjectType, item.id) + ); + } + } + } catch (err) { + // TODO: Error handling and writing of errors into a signal that has error + // handling/conditions logger.error( - `Error processing signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` + `Error from signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}"` + ); + const sDate = new Date().toISOString(); + currentStatusSavedObject.attributes.status = 'failed'; + currentStatusSavedObject.attributes.statusDate = sDate; + currentStatusSavedObject.attributes.lastFailureAt = sDate; + currentStatusSavedObject.attributes.lastFailureMessage = err.message; + // current status is failing + await services.savedObjectsClient.update( + ruleStatusSavedObjectType, + currentStatusSavedObject.id, + { + ...currentStatusSavedObject.attributes, + } ); + // create new status for historical purposes + await services.savedObjectsClient.create(ruleStatusSavedObjectType, { + ...currentStatusSavedObject.attributes, + }); + + if (ruleStatusSavedObjects.saved_objects.length >= 6) { + // delete fifth status and prepare to insert a newer one. + const toDelete = ruleStatusSavedObjects.saved_objects.slice(5); + await toDelete.forEach(async item => + services.savedObjectsClient.delete(ruleStatusSavedObjectType, item.id) + ); + } } - } catch (err) { - // TODO: Error handling and writing of errors into a signal that has error - // handling/conditions + } catch (exception) { logger.error( - `Error from signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" message: ${err.message}` + `Error from signal rule name: "${name}", id: "${alertId}", rule_id: "${ruleId}" message: ${exception.message}` + ); + const sDate = new Date().toISOString(); + currentStatusSavedObject.attributes.status = 'failed'; + currentStatusSavedObject.attributes.statusDate = sDate; + currentStatusSavedObject.attributes.lastFailureAt = sDate; + currentStatusSavedObject.attributes.lastFailureMessage = exception.message; + // current status is failing + await services.savedObjectsClient.update( + ruleStatusSavedObjectType, + currentStatusSavedObject.id, + { + ...currentStatusSavedObject.attributes, + } ); + // create new status for historical purposes + await services.savedObjectsClient.create(ruleStatusSavedObjectType, { + ...currentStatusSavedObject.attributes, + }); + + if (ruleStatusSavedObjects.saved_objects.length >= 6) { + // delete fifth status and prepare to insert a newer one. + const toDelete = ruleStatusSavedObjects.saved_objects.slice(5); + await toDelete.forEach(async item => + services.savedObjectsClient.delete(ruleStatusSavedObjectType, item.id) + ); + } } }, }; diff --git a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts index 968c7d9cb1cf04..8a9e050c039b4f 100644 --- a/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/detection_engine/types.ts @@ -5,6 +5,7 @@ */ import { esFilters } from '../../../../../../../src/plugins/data/server'; +import { IRuleStatusAttributes } from './rules/types'; export type PartialFilter = Partial; @@ -65,18 +66,34 @@ export type RuleAlertParamsRest = Omit< | 'outputIndex' | 'updatedAt' | 'createdAt' -> & { - rule_id: RuleAlertParams['ruleId']; - false_positives: RuleAlertParams['falsePositives']; - saved_id: RuleAlertParams['savedId']; - timeline_id: RuleAlertParams['timelineId']; - timeline_title: RuleAlertParams['timelineTitle']; - max_signals: RuleAlertParams['maxSignals']; - risk_score: RuleAlertParams['riskScore']; - output_index: RuleAlertParams['outputIndex']; - created_at: RuleAlertParams['createdAt']; - updated_at: RuleAlertParams['updatedAt']; -}; +> & + Omit< + IRuleStatusAttributes, + | 'status' + | 'alertId' + | 'statusDate' + | 'lastFailureAt' + | 'lastSuccessAt' + | 'lastSuccessMessage' + | 'lastFailureMessage' + > & { + rule_id: RuleAlertParams['ruleId']; + false_positives: RuleAlertParams['falsePositives']; + saved_id: RuleAlertParams['savedId']; + timeline_id: RuleAlertParams['timelineId']; + timeline_title: RuleAlertParams['timelineTitle']; + max_signals: RuleAlertParams['maxSignals']; + risk_score: RuleAlertParams['riskScore']; + output_index: RuleAlertParams['outputIndex']; + created_at: RuleAlertParams['createdAt']; + updated_at: RuleAlertParams['updatedAt']; + status?: IRuleStatusAttributes['status'] | undefined; + status_date?: IRuleStatusAttributes['statusDate'] | undefined; + last_failure_at?: IRuleStatusAttributes['lastFailureAt'] | undefined; + last_success_at?: IRuleStatusAttributes['lastSuccessAt'] | undefined; + last_failure_message?: IRuleStatusAttributes['lastFailureMessage'] | undefined; + last_success_message?: IRuleStatusAttributes['lastSuccessMessage'] | undefined; + }; export type OutputRuleAlertRest = RuleAlertParamsRest & { id: string; diff --git a/x-pack/legacy/plugins/siem/server/saved_objects.ts b/x-pack/legacy/plugins/siem/server/saved_objects.ts index 8311d17f00ce9a..1d65e351fc86a9 100644 --- a/x-pack/legacy/plugins/siem/server/saved_objects.ts +++ b/x-pack/legacy/plugins/siem/server/saved_objects.ts @@ -12,10 +12,12 @@ import { timelineSavedObjectType, timelineSavedObjectMappings, } from './lib/timeline/saved_object_mappings'; +import { ruleStatusSavedObjectMappings } from './lib/detection_engine/rules/saved_object_mappings'; export { noteSavedObjectType, pinnedEventSavedObjectType, timelineSavedObjectType }; export const savedObjectMappings = { ...timelineSavedObjectMappings, ...noteSavedObjectMappings, ...pinnedEventSavedObjectMappings, + ...ruleStatusSavedObjectMappings, }; diff --git a/x-pack/legacy/plugins/siem/server/types.ts b/x-pack/legacy/plugins/siem/server/types.ts index 989aaf2b6eeea3..bbcfbe7b9c0ec8 100644 --- a/x-pack/legacy/plugins/siem/server/types.ts +++ b/x-pack/legacy/plugins/siem/server/types.ts @@ -12,6 +12,7 @@ export interface ServerFacade { alerting?: Legacy.Server['plugins']['alerting']; elasticsearch: Legacy.Server['plugins']['elasticsearch']; spaces: Legacy.Server['plugins']['spaces']; + savedObjects: Legacy.Server['savedObjects']['SavedObjectsClient']; }; route: Legacy.Server['route']; } @@ -20,6 +21,7 @@ export interface RequestFacade { auth: Legacy.Request['auth']; getAlertsClient?: Legacy.Request['getAlertsClient']; getActionsClient?: Legacy.Request['getActionsClient']; + getSavedObjectsClient?: Legacy.Request['getSavedObjectsClient']; headers: Legacy.Request['headers']; method: Legacy.Request['method']; params: Legacy.Request['params']; diff --git a/x-pack/legacy/plugins/siem/server/utils/typed_elasticsearch_mappings.ts b/x-pack/legacy/plugins/siem/server/utils/typed_elasticsearch_mappings.ts index f34c6ab72b3ec3..a777236345d89c 100644 --- a/x-pack/legacy/plugins/siem/server/utils/typed_elasticsearch_mappings.ts +++ b/x-pack/legacy/plugins/siem/server/utils/typed_elasticsearch_mappings.ts @@ -17,7 +17,7 @@ export type ElasticsearchMappingOf = Type extends string | string[] : never; export interface ElasticsearchStringFieldMapping { - type: 'keyword' | 'text'; + type: 'keyword' | 'text' | 'date'; } export interface ElasticsearchBooleanFieldMapping { From 5216b382f4e2b4c79125264f78f1c10441079c66 Mon Sep 17 00:00:00 2001 From: Gidi Meir Morris Date: Tue, 14 Jan 2020 13:49:43 +0000 Subject: [PATCH 59/62] fix broken configuration in Task manager (#54695) Fixes an issue that prevents custom configuration in Task Manager --- x-pack/legacy/plugins/task_manager/server/index.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/task_manager/server/index.ts b/x-pack/legacy/plugins/task_manager/server/index.ts index 56135bb27326ba..98d67a38c20d0c 100644 --- a/x-pack/legacy/plugins/task_manager/server/index.ts +++ b/x-pack/legacy/plugins/task_manager/server/index.ts @@ -39,7 +39,9 @@ export function taskManager(kibana: any) { .description('The name of the index used to store task information.') .default('.kibana_task_manager') .invalid(['.tasks']), - }).default(); + }) + .unknown(true) + .default(); }, init(server: Legacy.Server) { /* From 2feba4fa24a1f1d10b8c4ccc4ea8e2d89e4dbf4f Mon Sep 17 00:00:00 2001 From: Silvia Mitter Date: Tue, 14 Jan 2020 14:51:21 +0100 Subject: [PATCH 60/62] [apm] update apm index pattern (#54693) --- .../kibana/server/tutorials/apm/index_pattern.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/legacy/core_plugins/kibana/server/tutorials/apm/index_pattern.json b/src/legacy/core_plugins/kibana/server/tutorials/apm/index_pattern.json index 090586a612d4fa..4052f4050a31b6 100644 --- a/src/legacy/core_plugins/kibana/server/tutorials/apm/index_pattern.json +++ b/src/legacy/core_plugins/kibana/server/tutorials/apm/index_pattern.json @@ -1,7 +1,7 @@ { "attributes": { - "fieldFormatMap": "{\"client.bytes\":{\"id\":\"bytes\"},\"client.nat.port\":{\"id\":\"string\"},\"client.port\":{\"id\":\"string\"},\"destination.bytes\":{\"id\":\"bytes\"},\"destination.nat.port\":{\"id\":\"string\"},\"destination.port\":{\"id\":\"string\"},\"event.duration\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"nanoseconds\",\"outputFormat\":\"asMilliseconds\",\"outputPrecision\":1}},\"event.sequence\":{\"id\":\"string\"},\"event.severity\":{\"id\":\"string\"},\"http.request.body.bytes\":{\"id\":\"bytes\"},\"http.request.bytes\":{\"id\":\"bytes\"},\"http.response.body.bytes\":{\"id\":\"bytes\"},\"http.response.bytes\":{\"id\":\"bytes\"},\"http.response.status_code\":{\"id\":\"string\"},\"log.syslog.facility.code\":{\"id\":\"string\"},\"log.syslog.priority\":{\"id\":\"string\"},\"network.bytes\":{\"id\":\"bytes\"},\"package.size\":{\"id\":\"string\"},\"process.pgid\":{\"id\":\"string\"},\"process.pid\":{\"id\":\"string\"},\"process.ppid\":{\"id\":\"string\"},\"process.thread.id\":{\"id\":\"string\"},\"server.bytes\":{\"id\":\"bytes\"},\"server.nat.port\":{\"id\":\"string\"},\"server.port\":{\"id\":\"string\"},\"source.bytes\":{\"id\":\"bytes\"},\"source.nat.port\":{\"id\":\"string\"},\"source.port\":{\"id\":\"string\"},\"system.cpu.total.norm.pct\":{\"id\":\"percent\"},\"system.memory.actual.free\":{\"id\":\"bytes\"},\"system.memory.total\":{\"id\":\"bytes\"},\"system.process.cpu.total.norm.pct\":{\"id\":\"percent\"},\"system.process.memory.rss.bytes\":{\"id\":\"bytes\"},\"system.process.memory.size\":{\"id\":\"bytes\"},\"url.port\":{\"id\":\"string\"},\"view spans\":{\"id\":\"url\",\"params\":{\"labelTemplate\":\"View Spans\"}}}", - "fields": "[{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"@timestamp\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"labels\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"message\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tags\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.ephemeral_id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"as.number\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"as.organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.address\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.as.number\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.as.organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.nat.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.nat.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.packets\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.account.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.availability_zone\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.instance.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.instance.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.machine.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.provider\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.region\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.image.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.image.tag\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.labels\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.runtime\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.address\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.as.number\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.as.organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.nat.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.nat.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.packets\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers.class\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers.data\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers.ttl\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.header_flags\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.op_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.class\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.subdomain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.resolved_ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.response_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"ecs.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":4,\"doc_values\":true,\"indexed\":true,\"name\":\"error.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.message\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.stack_trace\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.action\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.category\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.created\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.dataset\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.duration\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.end\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.kind\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.module\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.original\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.outcome\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.provider\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.risk_score\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.risk_score_norm\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.sequence\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.severity\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.start\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.timezone\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.accessed\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.created\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.ctime\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.device\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.directory\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.extension\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.gid\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.group\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.hash.md5\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.hash.sha1\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.hash.sha256\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.hash.sha512\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.inode\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.mode\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.mtime\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.owner\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.path\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.size\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.target_path\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.uid\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"hash.md5\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"hash.sha1\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"hash.sha256\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"hash.sha512\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.architecture\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.hostname\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.family\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.full\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.kernel\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.platform\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.uptime\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.body.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.body.content\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.method\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.referrer\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.body.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.body.content\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.status_code\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.level\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.logger\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.origin.file.line\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.origin.file.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.origin.function\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.original\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog.facility.code\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog.facility.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog.priority\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog.severity.code\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog.severity.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.application\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.community_id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.direction\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.forwarded_ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.iana_number\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.packets\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.protocol\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.transport\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.hostname\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.family\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.full\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.kernel\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.platform\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.product\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.serial_number\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.vendor\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"organization.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.family\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.full\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.kernel\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.platform\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.architecture\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.checksum\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.description\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.install_scope\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.installed\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.license\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.path\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.size\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.args\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.executable\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.hash.md5\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.hash.sha1\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.hash.sha256\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.hash.sha512\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.pgid\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.pid\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.ppid\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.start\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.thread.id\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.thread.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.title\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.uptime\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.working_directory\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"related.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.address\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.as.number\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.as.organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.nat.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.nat.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.packets\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.ephemeral_id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.node.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.state\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.address\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.as.number\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.as.organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.nat.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.nat.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.packets\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.framework\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.tactic.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.tactic.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.tactic.reference\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.technique.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.technique.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.technique.reference\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tracing.trace.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tracing.transaction.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.extension\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.fragment\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.full\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.original\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.password\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.path\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.query\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.scheme\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.username\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.device.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.original\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.family\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.full\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.kernel\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.platform\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.hostname\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"fields\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"timeseries.instance\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.project.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.image.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"docker.container.labels\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.containerized\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.build\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.codename\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.pod.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.pod.uid\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.namespace\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.node.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.labels.*\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.annotations.*\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.replicaset.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.deployment.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.statefulset.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.container.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.container.image\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"processor.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"processor.event\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"timestamp.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"enabled\":false,\"indexed\":false,\"name\":\"http.request.headers\",\"scripted\":false,\"searchable\":false},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.finished\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"enabled\":false,\"indexed\":false,\"name\":\"http.response.headers\",\"scripted\":false,\"searchable\":false},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.environment\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.language.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.language.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.runtime.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.runtime.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.framework.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.framework.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.sampled\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.duration.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.duration.sum.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.self_time.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.self_time.sum.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.breakdown.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.subtype\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.self_time.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.self_time.sum.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"trace.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"parent.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.listening\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.version_major\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.original.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"experimental\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":2,\"doc_values\":true,\"indexed\":true,\"name\":\"error.culprit\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.grouping_key\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.exception.code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":2,\"doc_values\":true,\"indexed\":true,\"name\":\"error.exception.message\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.exception.module\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":4,\"doc_values\":true,\"indexed\":true,\"name\":\"error.exception.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":2,\"doc_values\":true,\"indexed\":true,\"name\":\"error.exception.handled\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.log.level\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.log.logger_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":2,\"doc_values\":true,\"indexed\":true,\"name\":\"error.log.message\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.log.param_message\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.cpu.total.norm.pct\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.memory.total\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.memory.actual.free\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.process.cpu.total.norm.pct\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.process.memory.size\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.process.memory.rss.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.cpu.ns\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.samples.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.alloc_objects.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.alloc_space.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.inuse_objects.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.inuse_space.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.duration\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.top.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.top.function\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.top.filename\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.top.line\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.stack.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.stack.function\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.stack.filename\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.stack.line\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"sourcemap.service.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"sourcemap.service.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"sourcemap.bundle_filepath\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"view spans\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.action\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.start.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.duration.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.sync\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.db.link\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.db.rows_affected\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.destination.service.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.destination.service.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.destination.service.resource\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.message.queue.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.message.age.ms\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.duration.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.result\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.marks\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.marks.*.*\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.span_count.dropped\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.message.queue.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.message.age.ms\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"indexed\":false,\"name\":\"_id\",\"scripted\":false,\"searchable\":false,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"indexed\":false,\"name\":\"_type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"indexed\":false,\"name\":\"_index\",\"scripted\":false,\"searchable\":false,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"indexed\":false,\"name\":\"_score\",\"scripted\":false,\"searchable\":false,\"type\":\"number\"}]", + "fieldFormatMap": "{\"client.bytes\":{\"id\":\"bytes\"},\"client.nat.port\":{\"id\":\"string\"},\"client.port\":{\"id\":\"string\"},\"destination.bytes\":{\"id\":\"bytes\"},\"destination.nat.port\":{\"id\":\"string\"},\"destination.port\":{\"id\":\"string\"},\"event.duration\":{\"id\":\"duration\",\"params\":{\"inputFormat\":\"nanoseconds\",\"outputFormat\":\"asMilliseconds\",\"outputPrecision\":1}},\"event.sequence\":{\"id\":\"string\"},\"event.severity\":{\"id\":\"string\"},\"http.request.body.bytes\":{\"id\":\"bytes\"},\"http.request.bytes\":{\"id\":\"bytes\"},\"http.response.body.bytes\":{\"id\":\"bytes\"},\"http.response.bytes\":{\"id\":\"bytes\"},\"http.response.status_code\":{\"id\":\"string\"},\"log.syslog.facility.code\":{\"id\":\"string\"},\"log.syslog.priority\":{\"id\":\"string\"},\"network.bytes\":{\"id\":\"bytes\"},\"package.size\":{\"id\":\"string\"},\"process.parent.pgid\":{\"id\":\"string\"},\"process.parent.pid\":{\"id\":\"string\"},\"process.parent.ppid\":{\"id\":\"string\"},\"process.parent.thread.id\":{\"id\":\"string\"},\"process.pgid\":{\"id\":\"string\"},\"process.pid\":{\"id\":\"string\"},\"process.ppid\":{\"id\":\"string\"},\"process.thread.id\":{\"id\":\"string\"},\"server.bytes\":{\"id\":\"bytes\"},\"server.nat.port\":{\"id\":\"string\"},\"server.port\":{\"id\":\"string\"},\"source.bytes\":{\"id\":\"bytes\"},\"source.nat.port\":{\"id\":\"string\"},\"source.port\":{\"id\":\"string\"},\"system.cpu.total.norm.pct\":{\"id\":\"percent\"},\"system.memory.actual.free\":{\"id\":\"bytes\"},\"system.memory.total\":{\"id\":\"bytes\"},\"system.process.cpu.total.norm.pct\":{\"id\":\"percent\"},\"system.process.memory.rss.bytes\":{\"id\":\"bytes\"},\"system.process.memory.size\":{\"id\":\"bytes\"},\"url.port\":{\"id\":\"string\"},\"view spans\":{\"id\":\"url\",\"params\":{\"labelTemplate\":\"View Spans\"}}}", + "fields": "[{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"@timestamp\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"labels\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"message\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tags\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.ephemeral_id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"as.number\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"as.organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"as.organization.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.address\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.as.number\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.as.organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.as.organization.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.nat.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.nat.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.packets\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.full_name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"client.user.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.account.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.availability_zone\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.instance.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.instance.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.machine.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.provider\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.region\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.image.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.image.tag\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.labels\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"container.runtime\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.address\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.as.number\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.as.organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.as.organization.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.nat.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.nat.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.packets\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.full_name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"destination.user.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers.class\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers.data\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers.ttl\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.answers.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.header_flags\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.op_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.class\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.subdomain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.question.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.resolved_ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.response_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"dns.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"ecs.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":4,\"doc_values\":true,\"indexed\":true,\"name\":\"error.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.message\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.stack_trace\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.stack_trace.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.action\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.category\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.created\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.dataset\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.duration\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.end\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.ingested\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.kind\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.module\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.original\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.outcome\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.provider\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.risk_score\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.risk_score_norm\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.sequence\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.severity\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.start\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.timezone\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"event.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.accessed\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.attributes\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.created\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.ctime\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.device\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.directory\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.drive_letter\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.extension\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.gid\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.group\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.hash.md5\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.hash.sha1\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.hash.sha256\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.hash.sha512\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.inode\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.mode\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.mtime\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.owner\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.path\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.path.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.size\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.target_path\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.target_path.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"file.uid\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"hash.md5\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"hash.sha1\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"hash.sha256\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"hash.sha512\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.architecture\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.hostname\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.family\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.full\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.full.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.kernel\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.platform\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.uptime\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.full_name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.user.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.body.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.body.content\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.body.content.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.method\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.request.referrer\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.body.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.body.content\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.body.content.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.status_code\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.level\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.logger\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.origin.file.line\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.origin.file.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.origin.function\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.original\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog.facility.code\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog.facility.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog.priority\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog.severity.code\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"log.syslog.severity.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.application\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.community_id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.direction\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.forwarded_ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.iana_number\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.packets\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.protocol\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.transport\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"network.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.hostname\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.family\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.full\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.full.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.kernel\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.platform\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.os.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.product\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.serial_number\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.vendor\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"organization.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"organization.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.family\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.full\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.full.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.kernel\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.platform\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"os.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.architecture\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.build_version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.checksum\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.description\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.install_scope\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.installed\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.license\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.path\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.reference\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.size\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"package.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.args\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.args_count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.command_line\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.command_line.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.executable\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.executable.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.exit_code\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.hash.md5\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.hash.sha1\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.hash.sha256\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.hash.sha512\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.args\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.args_count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.command_line\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.command_line.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.executable\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.executable.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.exit_code\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.pgid\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.pid\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.ppid\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.start\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.thread.id\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.thread.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.title\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.title.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.uptime\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.working_directory\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.parent.working_directory.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.pgid\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.pid\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.ppid\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.start\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.thread.id\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.thread.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.title\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.title.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.uptime\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.working_directory\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"process.working_directory.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"registry.data.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"registry.data.strings\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"registry.data.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"registry.hive\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"registry.key\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"registry.path\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"registry.value\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"related.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"related.user\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"rule.category\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"rule.description\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"rule.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"rule.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"rule.reference\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"rule.ruleset\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"rule.uuid\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"rule.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.address\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.as.number\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.as.organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.as.organization.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.nat.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.nat.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.packets\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.full_name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"server.user.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.ephemeral_id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.node.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.state\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.address\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.as.number\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.as.organization.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.as.organization.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.city_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.continent_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.country_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.country_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.location\",\"scripted\":false,\"searchable\":true,\"type\":\"geo_point\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.region_iso_code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.geo.region_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.mac\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.nat.ip\",\"scripted\":false,\"searchable\":true,\"type\":\"ip\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.nat.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.packets\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.full_name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"source.user.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.framework\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.tactic.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.tactic.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.tactic.reference\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.technique.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.technique.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.technique.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"threat.technique.reference\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.cipher\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.certificate\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.certificate_chain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.hash.md5\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.hash.sha1\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.hash.sha256\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.issuer\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.ja3\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.not_after\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.not_before\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.server_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.subject\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.client.supported_ciphers\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.curve\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.established\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.next_protocol\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.resumed\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.server.certificate\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.server.certificate_chain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.server.hash.md5\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.server.hash.sha1\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.server.hash.sha256\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.server.issuer\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.server.ja3s\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.server.not_after\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.server.not_before\",\"scripted\":false,\"searchable\":true,\"type\":\"date\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.server.subject\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tls.version_protocol\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tracing.trace.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"tracing.transaction.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.extension\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.fragment\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.full\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.full.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.original\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.original.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.password\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.path\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.port\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.query\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.registered_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.scheme\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.top_level_domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"url.username\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.email\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.full_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.full_name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.group.domain\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.group.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.group.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.hash\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.device.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.original\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.original.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.family\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.full\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.full.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.kernel\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.platform\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.os.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"user_agent.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.category\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.classification\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.description\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.description.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.enumeration\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.reference\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.report_id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.scanner.vendor\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.score.base\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.score.environmental\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.score.temporal\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.score.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"vulnerability.severity\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"agent.hostname\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"fields\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"timeseries.instance\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.project.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"cloud.image.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"docker.container.labels\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.containerized\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.build\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"host.os.codename\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.pod.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.pod.uid\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.namespace\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.node.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.labels.*\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.annotations.*\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.replicaset.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.deployment.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.statefulset.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.container.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"kubernetes.container.image\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"processor.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"processor.event\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"timestamp.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"enabled\":false,\"indexed\":false,\"name\":\"http.request.headers\",\"scripted\":false,\"searchable\":false},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"http.response.finished\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"enabled\":false,\"indexed\":false,\"name\":\"http.response.headers\",\"scripted\":false,\"searchable\":false},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.environment\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.language.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.language.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.runtime.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.runtime.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.framework.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"service.framework.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.sampled\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.name.text\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.duration.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.duration.sum.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.self_time.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.self_time.sum.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.breakdown.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.subtype\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.self_time.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.self_time.sum.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"trace.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"parent.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.listening\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"observer.version_major\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"experimental\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":2,\"doc_values\":true,\"indexed\":true,\"name\":\"error.culprit\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.grouping_key\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.exception.code\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":2,\"doc_values\":true,\"indexed\":true,\"name\":\"error.exception.message\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.exception.module\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":4,\"doc_values\":true,\"indexed\":true,\"name\":\"error.exception.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":2,\"doc_values\":true,\"indexed\":true,\"name\":\"error.exception.handled\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.log.level\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.log.logger_name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":2,\"doc_values\":true,\"indexed\":true,\"name\":\"error.log.message\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"error.log.param_message\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.cpu.total.norm.pct\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.memory.total\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.memory.actual.free\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.process.cpu.total.norm.pct\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.process.memory.size\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"system.process.memory.rss.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.cpu.ns\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.samples.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.alloc_objects.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.alloc_space.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.inuse_objects.count\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.inuse_space.bytes\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.duration\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.top.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.top.function\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.top.filename\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.top.line\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.stack.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.stack.function\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.stack.filename\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"profile.stack.line\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"sourcemap.service.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"sourcemap.service.version\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"sourcemap.bundle_filepath\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"view spans\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.id\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.action\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.start.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":1,\"doc_values\":true,\"indexed\":true,\"name\":\"span.duration.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.sync\",\"scripted\":false,\"searchable\":true,\"type\":\"boolean\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.db.link\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.db.rows_affected\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.destination.service.type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.destination.service.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.destination.service.resource\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.message.queue.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"span.message.age.ms\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.duration.us\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.result\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.marks\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.marks.*.*\",\"scripted\":false,\"searchable\":true},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.span_count.dropped\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.message.queue.name\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":true,\"indexed\":true,\"name\":\"transaction.message.age.ms\",\"scripted\":false,\"searchable\":true,\"type\":\"number\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"indexed\":false,\"name\":\"_id\",\"scripted\":false,\"searchable\":false,\"type\":\"string\"},{\"aggregatable\":true,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"indexed\":false,\"name\":\"_type\",\"scripted\":false,\"searchable\":true,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"indexed\":false,\"name\":\"_index\",\"scripted\":false,\"searchable\":false,\"type\":\"string\"},{\"aggregatable\":false,\"analyzed\":false,\"count\":0,\"doc_values\":false,\"indexed\":false,\"name\":\"_score\",\"scripted\":false,\"searchable\":false,\"type\":\"number\"}]", "sourceFilters": "[{\"value\":\"sourcemap.sourcemap\"}]", "timeFieldName": "@timestamp" }, From 45f8ca90a3082a1b7baf8e57e17ee264d0352c5e Mon Sep 17 00:00:00 2001 From: Katrin Freihofner Date: Tue, 14 Jan 2020 14:52:52 +0100 Subject: [PATCH 61/62] changes alignment of location column in monitor details view (#54709) --- .../ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap | 2 +- .../uptime/public/components/functional/ping_list/ping_list.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap index 692675baa89502..58bbf721e05de3 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/__tests__/__snapshots__/ping_list.test.tsx.snap @@ -121,7 +121,7 @@ exports[`PingList component renders sorted list without errors 1`] = ` "render": [Function], }, Object { - "align": "center", + "align": "left", "field": "observer.geo.name", "name": "Location", "render": [Function], diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx index 71b1255b3b37e0..3f0ec298e32893 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/ping_list/ping_list.tsx @@ -139,7 +139,7 @@ export const PingListComponent = ({ ), }, { - align: 'center', + align: 'left', field: 'observer.geo.name', name: i18n.translate('xpack.uptime.pingList.locationNameColumnLabel', { defaultMessage: 'Location', From 69730cef73d7c4bd927a1b02e76af0f68321c3b0 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 14 Jan 2020 08:58:36 -0500 Subject: [PATCH 62/62] [ML] DataFrame Analytics use field caps api to set column type (#54543) * wip: initialize newJobCaps service in parent element * wip: use jobCaps service to create columns * add render and types to talble columns * add keyword suffix when constructing query. ensure pagination works * Ensure search query and sorting works * wip: update regression table to use jobCaps api * move shared resources to central location * ensure 0 and false values show up in table * add error handling to jobCaps initialization * ensure outlier detection table can toggle columns * check for undefined before using moment to create date * add tests for fix for getNestedProperty --- .../legacy/plugins/ml/common/types/fields.ts | 2 +- .../data_frame_analytics/common/analytics.ts | 10 +- .../data_frame_analytics/common/fields.ts | 107 ++++++- .../data_frame_analytics/common/index.ts | 2 + .../classification_exploration.tsx | 64 +++- .../evaluate_panel.tsx | 32 +- .../results_table.tsx | 294 ++++++++++-------- .../use_explore_data.ts | 52 ++-- .../components/exploration/exploration.tsx | 4 +- .../regression_exploration.tsx | 64 +++- .../regression_exploration/results_table.tsx | 293 +++++++++-------- .../use_explore_data.ts | 52 ++-- .../form_options_validation.ts | 15 +- .../application/util/object_utils.test.ts | 19 ++ .../public/application/util/object_utils.ts | 6 +- 15 files changed, 623 insertions(+), 393 deletions(-) diff --git a/x-pack/legacy/plugins/ml/common/types/fields.ts b/x-pack/legacy/plugins/ml/common/types/fields.ts index 9e1b992eec9075..4860ab955d0668 100644 --- a/x-pack/legacy/plugins/ml/common/types/fields.ts +++ b/x-pack/legacy/plugins/ml/common/types/fields.ts @@ -23,7 +23,7 @@ export interface Field { id: FieldId; name: string; type: ES_FIELD_TYPES; - aggregatable: boolean; + aggregatable?: boolean; aggIds?: AggId[]; aggs?: Aggregation[]; } diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index ce832513c4adc1..12d441a9a23ecb 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -13,6 +13,7 @@ import { ml } from '../../services/ml_api_service'; import { Dictionary } from '../../../../common/types/common'; import { getErrorMessage } from '../pages/analytics_management/hooks/use_create_analytics_form'; import { SavedSearchQuery } from '../../contexts/kibana'; +import { SortDirection } from '../../components/ml_in_memory_table'; export type IndexName = string; export type IndexPattern = string; @@ -39,6 +40,13 @@ interface ClassificationAnalysis { }; } +export interface LoadExploreDataArg { + field: string; + direction: SortDirection; + searchQuery: SavedSearchQuery; + requiresKeyword?: boolean; +} + export const SEARCH_SIZE = 1000; export const defaultSearchQuery = { @@ -182,7 +190,7 @@ export const getPredictedFieldName = ( const defaultPredictionField = `${getDependentVar(analysis)}_prediction`; const predictedField = `${resultsField}.${ predictionFieldName ? predictionFieldName : defaultPredictionField - }${isClassificationAnalysis(analysis) && !forSort ? '.keyword' : ''}`; + }`; return predictedField; }; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/fields.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/fields.ts index 216836db4ccbc2..e7d5afeba371fa 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/fields.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/fields.ts @@ -5,7 +5,15 @@ */ import { getNestedProperty } from '../../util/object_utils'; -import { DataFrameAnalyticsConfig, getPredictedFieldName, getDependentVar } from './analytics'; +import { + DataFrameAnalyticsConfig, + getPredictedFieldName, + getDependentVar, + getPredictionFieldName, +} from './analytics'; +import { Field } from '../../../../common/types/fields'; +import { ES_FIELD_TYPES } from '../../../../../../../../src/plugins/data/public'; +import { newJobCapsService } from '../../services/new_job_capabilities_service'; export type EsId = string; export type EsDocSource = Record; @@ -19,8 +27,41 @@ export interface EsDoc extends Record { export const MAX_COLUMNS = 20; export const DEFAULT_REGRESSION_COLUMNS = 8; +export const BASIC_NUMERICAL_TYPES = new Set([ + ES_FIELD_TYPES.LONG, + ES_FIELD_TYPES.INTEGER, + ES_FIELD_TYPES.SHORT, + ES_FIELD_TYPES.BYTE, +]); + +export const EXTENDED_NUMERICAL_TYPES = new Set([ + ES_FIELD_TYPES.DOUBLE, + ES_FIELD_TYPES.FLOAT, + ES_FIELD_TYPES.HALF_FLOAT, + ES_FIELD_TYPES.SCALED_FLOAT, +]); + const ML__ID_COPY = 'ml__id_copy'; +export const isKeywordAndTextType = (fieldName: string): boolean => { + const { fields } = newJobCapsService; + + const fieldType = fields.find(field => field.name === fieldName)?.type; + let isBothTypes = false; + + // If it's a keyword type - check if it has a corresponding text type + if (fieldType !== undefined && fieldType === ES_FIELD_TYPES.KEYWORD) { + const field = newJobCapsService.getFieldById(fieldName.replace(/\.keyword$/, '')); + isBothTypes = field !== null && field.type === ES_FIELD_TYPES.TEXT; + } else if (fieldType !== undefined && fieldType === ES_FIELD_TYPES.TEXT) { + // If text, check if has corresponding keyword type + const field = newJobCapsService.getFieldById(`${fieldName}.keyword`); + isBothTypes = field !== null && field.type === ES_FIELD_TYPES.KEYWORD; + } + + return isBothTypes; +}; + // Used to sort columns: // - string based columns are moved to the left // - followed by the outlier_score column @@ -90,10 +131,10 @@ export const sortRegressionResultsFields = ( if (b === predictedField) { return 1; } - if (a === dependentVariable) { + if (a === dependentVariable || a === dependentVariable.replace(/\.keyword$/, '')) { return -1; } - if (b === dependentVariable) { + if (b === dependentVariable || b === dependentVariable.replace(/\.keyword$/, '')) { return 1; } @@ -200,6 +241,50 @@ export function getFlattenedFields(obj: EsDocSource, resultsField: string): EsFi return flatDocFields.filter(f => f !== ML__ID_COPY); } +export const getDefaultFieldsFromJobCaps = ( + fields: Field[], + jobConfig: DataFrameAnalyticsConfig +): { selectedFields: Field[]; docFields: Field[] } => { + const fieldsObj = { selectedFields: [], docFields: [] }; + if (fields.length === 0) { + return fieldsObj; + } + + const dependentVariable = getDependentVar(jobConfig.analysis); + const type = newJobCapsService.getFieldById(dependentVariable)?.type; + const predictionFieldName = getPredictionFieldName(jobConfig.analysis); + // default is 'ml' + const resultsField = jobConfig.dest.results_field; + + const defaultPredictionField = `${dependentVariable}_prediction`; + const predictedField = `${resultsField}.${ + predictionFieldName ? predictionFieldName : defaultPredictionField + }`; + + const allFields: any = [ + { + id: `${resultsField}.is_training`, + name: `${resultsField}.is_training`, + type: ES_FIELD_TYPES.BOOLEAN, + }, + { id: predictedField, name: predictedField, type }, + ...fields, + ].sort(({ name: a }, { name: b }) => sortRegressionResultsFields(a, b, jobConfig)); + + let selectedFields = allFields + .slice(0, DEFAULT_REGRESSION_COLUMNS * 2) + .filter((field: any) => field.name === predictedField || !field.name.includes('.keyword')); + + if (selectedFields.length > DEFAULT_REGRESSION_COLUMNS) { + selectedFields = selectedFields.slice(0, DEFAULT_REGRESSION_COLUMNS); + } + + return { + selectedFields, + docFields: allFields, + }; +}; + export const getDefaultClassificationFields = ( docs: EsDoc[], jobConfig: DataFrameAnalyticsConfig @@ -290,11 +375,12 @@ export const getDefaultSelectableFields = (docs: EsDoc[], resultsField: string): .slice(0, MAX_COLUMNS); }; -export const toggleSelectedField = ( +export const toggleSelectedFieldSimple = ( selectedFields: EsFieldName[], column: EsFieldName ): EsFieldName[] => { const index = selectedFields.indexOf(column); + if (index === -1) { selectedFields.push(column); } else { @@ -302,3 +388,16 @@ export const toggleSelectedField = ( } return selectedFields; }; + +export const toggleSelectedField = (selectedFields: Field[], column: EsFieldName): Field[] => { + const index = selectedFields.map(field => field.name).indexOf(column); + if (index === -1) { + const columnField = newJobCapsService.getFieldById(column); + if (columnField !== null) { + selectedFields.push(columnField); + } + } else { + selectedFields.splice(index, 1); + } + return selectedFields; +}; diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts index f7794af8b5861a..62ef73670d8f53 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -33,11 +33,13 @@ export { getDefaultSelectableFields, getDefaultRegressionFields, getDefaultClassificationFields, + getDefaultFieldsFromJobCaps, getFlattenedFields, sortColumns, sortRegressionResultsColumns, sortRegressionResultsFields, toggleSelectedField, + toggleSelectedFieldSimple, EsId, EsDoc, EsDocSource, diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx index f424ebee581204..95e1b15d548c1c 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/classification_exploration.tsx @@ -14,6 +14,10 @@ import { ResultsTable } from './results_table'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; import { ResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics'; import { LoadingPanel } from '../loading_panel'; +import { getIndexPatternIdFromName } from '../../../../../util/index_utils'; +import { IIndexPattern } from '../../../../../../../../../../../src/plugins/data/common/index_patterns'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { useKibanaContext } from '../../../../../contexts/kibana'; interface GetDataFrameAnalyticsResponse { count: number; @@ -31,6 +35,21 @@ export const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => (
); +const jobConfigErrorTitle = i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.jobConfigurationFetchError', + { + defaultMessage: + 'Unable to fetch results. An error occurred loading the job configuration data.', + } +); + +const jobCapsErrorTitle = i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.jobCapsFetchError', + { + defaultMessage: "Unable to fetch results. An error occurred loading the index's field data.", + } +); + interface Props { jobId: string; jobStatus: DATA_FRAME_TASK_STATE; @@ -39,8 +58,13 @@ interface Props { export const ClassificationExploration: FC = ({ jobId, jobStatus }) => { const [jobConfig, setJobConfig] = useState(undefined); const [isLoadingJobConfig, setIsLoadingJobConfig] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState(undefined); + const [jobCapsServiceErrorMessage, setJobCapsServiceErrorMessage] = useState( + undefined + ); const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); + const kibanaContext = useKibanaContext(); const loadJobConfig = async () => { setIsLoadingJobConfig(true); @@ -78,23 +102,41 @@ export const ClassificationExploration: FC = ({ jobId, jobStatus }) => { loadJobConfig(); }, []); - if (jobConfigErrorMessage !== undefined) { + const initializeJobCapsService = async () => { + if (jobConfig !== undefined) { + try { + const sourceIndex = jobConfig.source.index[0]; + const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; + const indexPattern: IIndexPattern = await kibanaContext.indexPatterns.get(indexPatternId); + if (indexPattern !== undefined) { + await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); + } + setIsInitialized(true); + } catch (e) { + if (e.message !== undefined) { + setJobCapsServiceErrorMessage(e.message); + } else { + setJobCapsServiceErrorMessage(JSON.stringify(e)); + } + } + } + }; + + useEffect(() => { + initializeJobCapsService(); + }, [JSON.stringify(jobConfig)]); + + if (jobConfigErrorMessage !== undefined || jobCapsServiceErrorMessage !== undefined) { return ( -

{jobConfigErrorMessage}

+

{jobConfigErrorMessage ? jobConfigErrorMessage : jobCapsServiceErrorMessage}

); @@ -103,12 +145,12 @@ export const ClassificationExploration: FC = ({ jobId, jobStatus }) => { return ( {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && ( + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( )} {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && ( + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( = ({ jobConfig, jobStatus, searchQuery }) const [visibleColumns, setVisibleColumns] = useState(() => columns.map(({ id }: { id: string }) => id) ); - const kibanaContext = useKibanaContext(); const index = jobConfig.dest.index; - const sourceIndex = jobConfig.source.index[0]; const dependentVariable = getDependentVar(jobConfig.analysis); const predictionFieldName = getPredictionFieldName(jobConfig.analysis); // default is 'ml' @@ -86,25 +80,7 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery }) setIsLoading(true); try { - const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; - const indexPattern: IIndexPattern = await kibanaContext.indexPatterns.get(indexPatternId); - - if (indexPattern !== undefined) { - await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); - // If dependent_variable is of type keyword and text .keyword suffix is required for evaluate endpoint - const { fields } = newJobCapsService; - const depVarFieldType = fields.find(field => field.name === dependentVariable)?.type; - - // If it's a keyword type - check if it has a corresponding text type - if (depVarFieldType !== undefined && depVarFieldType === ES_FIELD_TYPES.KEYWORD) { - const field = newJobCapsService.getFieldById(dependentVariable.replace(/\.keyword$/, '')); - requiresKeyword = field !== null && field.type === ES_FIELD_TYPES.TEXT; - } else if (depVarFieldType !== undefined && depVarFieldType === ES_FIELD_TYPES.TEXT) { - // If text, check if has corresponding keyword type - const field = newJobCapsService.getFieldById(`${dependentVariable}.keyword`); - requiresKeyword = field !== null && field.type === ES_FIELD_TYPES.KEYWORD; - } - } + requiresKeyword = isKeywordAndTextType(dependentVariable); } catch (e) { // Additional error handling due to missing field type is handled by loadEvalData console.error('Unable to load new field types', error); // eslint-disable-line no-console @@ -359,9 +335,9 @@ export const EvaluatePanel: FC = ({ jobConfig, jobStatus, searchQuery })
- + = React.memo( ({ jobConfig, jobStatus, setEvaluateSearchQuery }) => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(25); - const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); + const [selectedFields, setSelectedFields] = useState([] as Field[]); + const [docFields, setDocFields] = useState([] as Field[]); const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false); const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const [searchError, setSearchError] = useState(undefined); const [searchString, setSearchString] = useState(undefined); + const predictedFieldName = getPredictedFieldName( + jobConfig.dest.results_field, + jobConfig.analysis + ); + + const dependentVariable = getDependentVar(jobConfig.analysis); + function toggleColumnsPopover() { setColumnsPopoverVisible(!isColumnsPopoverVisible); } @@ -99,147 +113,140 @@ export const ResultsTable: FC = React.memo( sortDirection, status, tableItems, - } = useExploreData(jobConfig, selectedFields, setSelectedFields); - - let docFields: EsFieldName[] = []; - let docFieldsCount = 0; - if (tableItems.length > 0) { - docFields = Object.keys(tableItems[0]); - docFields.sort((a, b) => sortRegressionResultsFields(a, b, jobConfig)); - docFieldsCount = docFields.length; - } - - const columns: Array> = []; - - if (jobConfig !== undefined && selectedFields.length > 0 && tableItems.length > 0) { - columns.push( - ...selectedFields.sort(sortRegressionResultsColumns(tableItems[0], jobConfig)).map(k => { - const column: ColumnType = { - field: k, - name: k, - sortable: true, - truncateText: true, - }; - - const render = (d: any, fullItem: EsDoc) => { - if (Array.isArray(d) && d.every(item => typeof item === 'string')) { - // If the cells data is an array of strings, return as a comma separated list. - // The list will get limited to 5 items with `…` at the end if there's more in the original array. - return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; - } else if (Array.isArray(d)) { - // If the cells data is an array of e.g. objects, display a 'array' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.classificationExploration.indexArrayBadgeContent', - { - defaultMessage: 'array', - } - )} - - - ); - } else if (typeof d === 'object' && d !== null) { - // If the cells data is an object, display a 'object' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.classificationExploration.indexObjectBadgeContent', - { - defaultMessage: 'object', - } - )} - - - ); - } - - return d; - }; + } = useExploreData(jobConfig, selectedFields, setSelectedFields, setDocFields); + + const columns: Array> = selectedFields.map(field => { + const { type } = field; + const isNumber = + type !== undefined && + (BASIC_NUMERICAL_TYPES.has(type) || EXTENDED_NUMERICAL_TYPES.has(type)); + + const column: ColumnType = { + field: field.name, + name: field.name, + sortable: true, + truncateText: true, + }; - let columnType; + const render = (d: any, fullItem: EsDoc) => { + if (Array.isArray(d) && d.every(item => typeof item === 'string')) { + // If the cells data is an array of strings, return as a comma separated list. + // The list will get limited to 5 items with `…` at the end if there's more in the original array. + return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; + } else if (Array.isArray(d)) { + // If the cells data is an array of e.g. objects, display a 'array' badge with a + // tooltip that explains that this type of field is not supported in this table. + return ( + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.classificationExploration.indexArrayBadgeContent', + { + defaultMessage: 'array', + } + )} + + + ); + } - if (tableItems.length > 0) { - columnType = typeof tableItems[0][k]; - } + return d; + }; - if (typeof columnType !== 'undefined') { - switch (columnType) { - case 'boolean': - column.dataType = 'boolean'; - break; - case 'Date': - column.align = 'right'; - column.render = (d: any) => - formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); - break; - case 'number': - column.dataType = 'number'; - column.render = render; - break; - default: - column.render = render; - break; - } - } else { + if (isNumber) { + column.dataType = 'number'; + column.render = render; + } else if (typeof type !== 'undefined') { + switch (type) { + case ES_FIELD_TYPES.BOOLEAN: + column.dataType = ES_FIELD_TYPES.BOOLEAN; + break; + case ES_FIELD_TYPES.DATE: + column.align = 'right'; + column.render = (d: any) => { + if (d !== undefined) { + return formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); + } + return d; + }; + break; + default: column.render = render; - } + break; + } + } else { + column.render = render; + } - return column; - }) - ); - } + return column; + }); + + const docFieldsCount = docFields.length; useEffect(() => { - if (jobConfig !== undefined) { - const predictedFieldName = getPredictedFieldName( - jobConfig.dest.results_field, - jobConfig.analysis - ); - const predictedFieldSelected = selectedFields.includes(predictedFieldName); + if ( + jobConfig !== undefined && + columns.length > 0 && + selectedFields.length > 0 && + sortField !== undefined && + sortDirection !== undefined && + selectedFields.some(field => field.name === sortField) + ) { + let field = sortField; + // If sorting by predictedField use dependentVar type + if (predictedFieldName === sortField) { + field = dependentVariable; + } + const requiresKeyword = isKeywordAndTextType(field); - const field = predictedFieldSelected ? predictedFieldName : selectedFields[0]; - const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - loadExploreData({ field, direction, searchQuery }); + loadExploreData({ + field: sortField, + direction: sortDirection, + searchQuery, + requiresKeyword, + }); } }, [JSON.stringify(searchQuery)]); useEffect(() => { - // by default set the sorting to descending on the prediction field (`_prediction`). - // if that's not available sort ascending on the first column. - // also check if the current sorting field is still available. - if (jobConfig !== undefined && columns.length > 0 && !selectedFields.includes(sortField)) { - const predictedFieldName = getPredictedFieldName( - jobConfig.dest.results_field, - jobConfig.analysis + // By default set sorting to descending on the prediction field (`_prediction`). + // if that's not available sort ascending on the first column. Check if the current sorting field is still available. + if ( + jobConfig !== undefined && + columns.length > 0 && + selectedFields.length > 0 && + !selectedFields.some(field => field.name === sortField) + ) { + const predictedFieldSelected = selectedFields.some( + field => field.name === predictedFieldName ); - const predictedFieldSelected = selectedFields.includes(predictedFieldName); - const field = predictedFieldSelected ? predictedFieldName : selectedFields[0]; + // CHECK IF keyword suffix is needed (if predicted field is selected we have to check the dependent variable type) + let sortByField = predictedFieldSelected ? dependentVariable : selectedFields[0].name; + + const requiresKeyword = isKeywordAndTextType(sortByField); + + sortByField = predictedFieldSelected ? predictedFieldName : sortByField; + const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - loadExploreData({ field, direction, searchQuery }); + loadExploreData({ field: sortByField, direction, searchQuery, requiresKeyword }); } - }, [jobConfig, columns.length, sortField, sortDirection, tableItems.length]); + }, [ + jobConfig, + columns.length, + selectedFields.length, + sortField, + sortDirection, + tableItems.length, + ]); let sorting: SortingPropType = false; let onTableChange; @@ -261,7 +268,17 @@ export const ResultsTable: FC = React.memo( setPageSize(size); if (sort.field !== sortField || sort.direction !== sortDirection) { - loadExploreData({ ...sort, searchQuery }); + let field = sort.field; + // If sorting by predictedField use depVar for type check + if (predictedFieldName === sort.field) { + field = dependentVariable; + } + + loadExploreData({ + ...sort, + searchQuery, + requiresKeyword: isKeywordAndTextType(field), + }); } }; } @@ -422,14 +439,17 @@ export const ResultsTable: FC = React.memo( )}
- {docFields.map(d => ( + {docFields.map(({ name }) => ( toggleColumn(d)} - disabled={selectedFields.includes(d) && selectedFields.length === 1} + key={name} + id={name} + label={name} + checked={selectedFields.some(field => field.name === name)} + onChange={() => toggleColumn(name)} + disabled={ + selectedFields.some(field => field.name === name) && + selectedFields.length === 1 + } /> ))}
diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts index ba12fcab98a36d..b61f9f65241161 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/classification_exploration/use_explore_data.ts @@ -17,27 +17,22 @@ import { SortDirection, SORT_DIRECTION } from '../../../../../components/ml_in_m import { ml } from '../../../../../services/ml_api_service'; import { getNestedProperty } from '../../../../../util/object_utils'; -import { SavedSearchQuery } from '../../../../../contexts/kibana'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { Field } from '../../../../../../../common/types/fields'; +import { LoadExploreDataArg } from '../../../../common/analytics'; import { - getDefaultClassificationFields, + getDefaultFieldsFromJobCaps, getFlattenedFields, DataFrameAnalyticsConfig, EsFieldName, - getPredictedFieldName, INDEX_STATUS, SEARCH_SIZE, - defaultSearchQuery, SearchQuery, } from '../../../../common'; export type TableItem = Record; -interface LoadExploreDataArg { - field: string; - direction: SortDirection; - searchQuery: SavedSearchQuery; -} export interface UseExploreDataReturnType { errorMessage: string; loadExploreData: (arg: LoadExploreDataArg) => void; @@ -49,8 +44,9 @@ export interface UseExploreDataReturnType { export const useExploreData = ( jobConfig: DataFrameAnalyticsConfig | undefined, - selectedFields: EsFieldName[], - setSelectedFields: React.Dispatch> + selectedFields: Field[], + setSelectedFields: React.Dispatch>, + setDocFields: React.Dispatch> ): UseExploreDataReturnType => { const [errorMessage, setErrorMessage] = useState(''); const [status, setStatus] = useState(INDEX_STATUS.UNUSED); @@ -58,7 +54,26 @@ export const useExploreData = ( const [sortField, setSortField] = useState(''); const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); - const loadExploreData = async ({ field, direction, searchQuery }: LoadExploreDataArg) => { + const getDefaultSelectedFields = () => { + const { fields } = newJobCapsService; + + if (selectedFields.length === 0 && jobConfig !== undefined) { + const { selectedFields: defaultSelected, docFields } = getDefaultFieldsFromJobCaps( + fields, + jobConfig + ); + + setSelectedFields(defaultSelected); + setDocFields(docFields); + } + }; + + const loadExploreData = async ({ + field, + direction, + searchQuery, + requiresKeyword, + }: LoadExploreDataArg) => { if (jobConfig !== undefined) { setErrorMessage(''); setStatus(INDEX_STATUS.LOADING); @@ -72,7 +87,7 @@ export const useExploreData = ( if (field !== undefined) { body.sort = [ { - [field]: { + [`${field}${requiresKeyword ? '.keyword' : ''}`]: { order: direction, }, }, @@ -96,11 +111,6 @@ export const useExploreData = ( return; } - if (selectedFields.length === 0) { - const newSelectedFields = getDefaultClassificationFields(docs, jobConfig); - setSelectedFields(newSelectedFields); - } - // Create a version of the doc's source with flattened field names. // This avoids confusion later on if a field name has dots in its name // or is a nested fields when displaying it via EuiInMemoryTable. @@ -144,11 +154,7 @@ export const useExploreData = ( useEffect(() => { if (jobConfig !== undefined) { - loadExploreData({ - field: getPredictedFieldName(jobConfig.dest.results_field, jobConfig.analysis), - direction: SORT_DIRECTION.DESC, - searchQuery: defaultSearchQuery, - }); + getDefaultSelectedFields(); } }, [jobConfig && jobConfig.id]); diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx index 31e6d409b1c4fd..9691a0706121cb 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration/exploration.tsx @@ -46,7 +46,7 @@ import { ml } from '../../../../../services/ml_api_service'; import { sortColumns, - toggleSelectedField, + toggleSelectedFieldSimple, DataFrameAnalyticsConfig, EsFieldName, EsDoc, @@ -138,7 +138,7 @@ export const Exploration: FC = React.memo(({ jobId, jobStatus }) => { function toggleColumn(column: EsFieldName) { if (tableItems.length > 0 && jobConfig !== undefined) { // spread to a new array otherwise the component wouldn't re-render - setSelectedFields([...toggleSelectedField(selectedFields, column)]); + setSelectedFields([...toggleSelectedFieldSimple(selectedFields, column)]); } } diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx index 12a41e1e7d851e..7399828bcd642b 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/regression_exploration.tsx @@ -14,6 +14,10 @@ import { ResultsTable } from './results_table'; import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common'; import { ResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics'; import { LoadingPanel } from '../loading_panel'; +import { getIndexPatternIdFromName } from '../../../../../util/index_utils'; +import { IIndexPattern } from '../../../../../../../../../../../src/plugins/data/common/index_patterns'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { useKibanaContext } from '../../../../../contexts/kibana'; interface GetDataFrameAnalyticsResponse { count: number; @@ -31,6 +35,21 @@ export const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => ( ); +const jobConfigErrorTitle = i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.jobConfigurationFetchError', + { + defaultMessage: + 'Unable to fetch results. An error occurred loading the job configuration data.', + } +); + +const jobCapsErrorTitle = i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.jobCapsFetchError', + { + defaultMessage: "Unable to fetch results. An error occurred loading the index's field data.", + } +); + interface Props { jobId: string; jobStatus: DATA_FRAME_TASK_STATE; @@ -39,8 +58,13 @@ interface Props { export const RegressionExploration: FC = ({ jobId, jobStatus }) => { const [jobConfig, setJobConfig] = useState(undefined); const [isLoadingJobConfig, setIsLoadingJobConfig] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState(undefined); + const [jobCapsServiceErrorMessage, setJobCapsServiceErrorMessage] = useState( + undefined + ); const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); + const kibanaContext = useKibanaContext(); const loadJobConfig = async () => { setIsLoadingJobConfig(true); @@ -69,23 +93,41 @@ export const RegressionExploration: FC = ({ jobId, jobStatus }) => { loadJobConfig(); }, []); - if (jobConfigErrorMessage !== undefined) { + const initializeJobCapsService = async () => { + if (jobConfig !== undefined) { + try { + const sourceIndex = jobConfig.source.index[0]; + const indexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex; + const indexPattern: IIndexPattern = await kibanaContext.indexPatterns.get(indexPatternId); + if (indexPattern !== undefined) { + await newJobCapsService.initializeFromIndexPattern(indexPattern, false, false); + } + setIsInitialized(true); + } catch (e) { + if (e.message !== undefined) { + setJobCapsServiceErrorMessage(e.message); + } else { + setJobCapsServiceErrorMessage(JSON.stringify(e)); + } + } + } + }; + + useEffect(() => { + initializeJobCapsService(); + }, [JSON.stringify(jobConfig)]); + + if (jobConfigErrorMessage !== undefined || jobCapsServiceErrorMessage !== undefined) { return ( -

{jobConfigErrorMessage}

+

{jobConfigErrorMessage ? jobConfigErrorMessage : jobCapsServiceErrorMessage}

); @@ -94,12 +136,12 @@ export const RegressionExploration: FC = ({ jobId, jobStatus }) => { return ( {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && ( + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( )} {isLoadingJobConfig === true && jobConfig === undefined && } - {isLoadingJobConfig === false && jobConfig !== undefined && ( + {isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && ( = React.memo( ({ jobConfig, jobStatus, setEvaluateSearchQuery }) => { const [pageIndex, setPageIndex] = useState(0); const [pageSize, setPageSize] = useState(25); - const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]); + const [selectedFields, setSelectedFields] = useState([] as Field[]); + const [docFields, setDocFields] = useState([] as Field[]); const [isColumnsPopoverVisible, setColumnsPopoverVisible] = useState(false); const [searchQuery, setSearchQuery] = useState(defaultSearchQuery); const [searchError, setSearchError] = useState(undefined); const [searchString, setSearchString] = useState(undefined); + const predictedFieldName = getPredictedFieldName( + jobConfig.dest.results_field, + jobConfig.analysis + ); + + const dependentVariable = getDependentVar(jobConfig.analysis); + function toggleColumnsPopover() { setColumnsPopoverVisible(!isColumnsPopoverVisible); } @@ -100,147 +114,140 @@ export const ResultsTable: FC = React.memo( sortDirection, status, tableItems, - } = useExploreData(jobConfig, selectedFields, setSelectedFields); - - let docFields: EsFieldName[] = []; - let docFieldsCount = 0; - if (tableItems.length > 0) { - docFields = Object.keys(tableItems[0]); - docFields.sort((a, b) => sortRegressionResultsFields(a, b, jobConfig)); - docFieldsCount = docFields.length; - } - - const columns: Array> = []; - - if (jobConfig !== undefined && selectedFields.length > 0 && tableItems.length > 0) { - columns.push( - ...selectedFields.sort(sortRegressionResultsColumns(tableItems[0], jobConfig)).map(k => { - const column: ColumnType = { - field: k, - name: k, - sortable: true, - truncateText: true, - }; - - const render = (d: any, fullItem: EsDoc) => { - if (Array.isArray(d) && d.every(item => typeof item === 'string')) { - // If the cells data is an array of strings, return as a comma separated list. - // The list will get limited to 5 items with `…` at the end if there's more in the original array. - return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; - } else if (Array.isArray(d)) { - // If the cells data is an array of e.g. objects, display a 'array' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.indexArrayBadgeContent', - { - defaultMessage: 'array', - } - )} - - - ); - } else if (typeof d === 'object' && d !== null) { - // If the cells data is an object, display a 'object' badge with a - // tooltip that explains that this type of field is not supported in this table. - return ( - - - {i18n.translate( - 'xpack.ml.dataframe.analytics.regressionExploration.indexObjectBadgeContent', - { - defaultMessage: 'object', - } - )} - - - ); - } - - return d; - }; + } = useExploreData(jobConfig, selectedFields, setSelectedFields, setDocFields); + + const columns: Array> = selectedFields.map(field => { + const { type } = field; + const isNumber = + type !== undefined && + (BASIC_NUMERICAL_TYPES.has(type) || EXTENDED_NUMERICAL_TYPES.has(type)); + + const column: ColumnType = { + field: field.name, + name: field.name, + sortable: true, + truncateText: true, + }; - let columnType; + const render = (d: any, fullItem: EsDoc) => { + if (Array.isArray(d) && d.every(item => typeof item === 'string')) { + // If the cells data is an array of strings, return as a comma separated list. + // The list will get limited to 5 items with `…` at the end if there's more in the original array. + return `${d.slice(0, 5).join(', ')}${d.length > 5 ? ', …' : ''}`; + } else if (Array.isArray(d)) { + // If the cells data is an array of e.g. objects, display a 'array' badge with a + // tooltip that explains that this type of field is not supported in this table. + return ( + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.regressionExploration.indexArrayBadgeContent', + { + defaultMessage: 'array', + } + )} + + + ); + } - if (tableItems.length > 0) { - columnType = typeof tableItems[0][k]; - } + return d; + }; - if (typeof columnType !== 'undefined') { - switch (columnType) { - case 'boolean': - column.dataType = 'boolean'; - break; - case 'Date': - column.align = 'right'; - column.render = (d: any) => - formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); - break; - case 'number': - column.dataType = 'number'; - column.render = render; - break; - default: - column.render = render; - break; - } - } else { + if (isNumber) { + column.dataType = 'number'; + column.render = render; + } else if (typeof type !== 'undefined') { + switch (type) { + case ES_FIELD_TYPES.BOOLEAN: + column.dataType = ES_FIELD_TYPES.BOOLEAN; + break; + case ES_FIELD_TYPES.DATE: + column.align = 'right'; + column.render = (d: any) => { + if (d !== undefined) { + return formatHumanReadableDateTimeSeconds(moment(d).unix() * 1000); + } + return d; + }; + break; + default: column.render = render; - } + break; + } + } else { + column.render = render; + } - return column; - }) - ); - } + return column; + }); + + const docFieldsCount = docFields.length; useEffect(() => { - if (jobConfig !== undefined) { - const predictedFieldName = getPredictedFieldName( - jobConfig.dest.results_field, - jobConfig.analysis - ); - const predictedFieldSelected = selectedFields.includes(predictedFieldName); + if ( + jobConfig !== undefined && + columns.length > 0 && + selectedFields.length > 0 && + sortField !== undefined && + sortDirection !== undefined && + selectedFields.some(field => field.name === sortField) + ) { + let field = sortField; + // If sorting by predictedField use dependentVar type + if (predictedFieldName === sortField) { + field = dependentVariable; + } + const requiresKeyword = isKeywordAndTextType(field); - const field = predictedFieldSelected ? predictedFieldName : selectedFields[0]; - const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - loadExploreData({ field, direction, searchQuery }); + loadExploreData({ + field: sortField, + direction: sortDirection, + searchQuery, + requiresKeyword, + }); } }, [JSON.stringify(searchQuery)]); useEffect(() => { - // by default set the sorting to descending on the prediction field (`_prediction`). - // if that's not available sort ascending on the first column. - // also check if the current sorting field is still available. - if (jobConfig !== undefined && columns.length > 0 && !selectedFields.includes(sortField)) { - const predictedFieldName = getPredictedFieldName( - jobConfig.dest.results_field, - jobConfig.analysis + // By default set sorting to descending on the prediction field (`_prediction`). + // if that's not available sort ascending on the first column. Check if the current sorting field is still available. + if ( + jobConfig !== undefined && + columns.length > 0 && + selectedFields.length > 0 && + !selectedFields.some(field => field.name === sortField) + ) { + const predictedFieldSelected = selectedFields.some( + field => field.name === predictedFieldName ); - const predictedFieldSelected = selectedFields.includes(predictedFieldName); - const field = predictedFieldSelected ? predictedFieldName : selectedFields[0]; + // CHECK IF keyword suffix is needed (if predicted field is selected we have to check the dependent variable type) + let sortByField = predictedFieldSelected ? dependentVariable : selectedFields[0].name; + + const requiresKeyword = isKeywordAndTextType(sortByField); + + sortByField = predictedFieldSelected ? predictedFieldName : sortByField; + const direction = predictedFieldSelected ? SORT_DIRECTION.DESC : SORT_DIRECTION.ASC; - loadExploreData({ field, direction, searchQuery }); + loadExploreData({ field: sortByField, direction, searchQuery, requiresKeyword }); } - }, [jobConfig, columns.length, sortField, sortDirection, tableItems.length]); + }, [ + jobConfig, + columns.length, + selectedFields.length, + sortField, + sortDirection, + tableItems.length, + ]); let sorting: SortingPropType = false; let onTableChange; @@ -262,7 +269,17 @@ export const ResultsTable: FC = React.memo( setPageSize(size); if (sort.field !== sortField || sort.direction !== sortDirection) { - loadExploreData({ ...sort, searchQuery }); + let field = sort.field; + // If sorting by predictedField use depVar for type check + if (predictedFieldName === sort.field) { + field = dependentVariable; + } + + loadExploreData({ + ...sort, + searchQuery, + requiresKeyword: isKeywordAndTextType(field), + }); } }; } @@ -423,14 +440,16 @@ export const ResultsTable: FC = React.memo( )}
- {docFields.map(d => ( + {docFields.map(({ name }) => ( toggleColumn(d)} - disabled={selectedFields.includes(d) && selectedFields.length === 1} + id={name} + label={name} + checked={selectedFields.some(field => field.name === name)} + onChange={() => toggleColumn(name)} + disabled={ + selectedFields.some(field => field.name === name) && + selectedFields.length === 1 + } /> ))}
diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts index 8e9cf45c14ec77..f6aa3e490f3ec7 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/regression_exploration/use_explore_data.ts @@ -12,27 +12,22 @@ import { SortDirection, SORT_DIRECTION } from '../../../../../components/ml_in_m import { ml } from '../../../../../services/ml_api_service'; import { getNestedProperty } from '../../../../../util/object_utils'; -import { SavedSearchQuery } from '../../../../../contexts/kibana'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; import { - getDefaultRegressionFields, + getDefaultFieldsFromJobCaps, getFlattenedFields, DataFrameAnalyticsConfig, EsFieldName, - getPredictedFieldName, INDEX_STATUS, SEARCH_SIZE, - defaultSearchQuery, SearchQuery, } from '../../../../common'; +import { Field } from '../../../../../../../common/types/fields'; +import { LoadExploreDataArg } from '../../../../common/analytics'; export type TableItem = Record; -interface LoadExploreDataArg { - field: string; - direction: SortDirection; - searchQuery: SavedSearchQuery; -} export interface UseExploreDataReturnType { errorMessage: string; loadExploreData: (arg: LoadExploreDataArg) => void; @@ -44,8 +39,9 @@ export interface UseExploreDataReturnType { export const useExploreData = ( jobConfig: DataFrameAnalyticsConfig | undefined, - selectedFields: EsFieldName[], - setSelectedFields: React.Dispatch> + selectedFields: Field[], + setSelectedFields: React.Dispatch>, + setDocFields: React.Dispatch> ): UseExploreDataReturnType => { const [errorMessage, setErrorMessage] = useState(''); const [status, setStatus] = useState(INDEX_STATUS.UNUSED); @@ -53,7 +49,26 @@ export const useExploreData = ( const [sortField, setSortField] = useState(''); const [sortDirection, setSortDirection] = useState(SORT_DIRECTION.ASC); - const loadExploreData = async ({ field, direction, searchQuery }: LoadExploreDataArg) => { + const getDefaultSelectedFields = () => { + const { fields } = newJobCapsService; + + if (selectedFields.length === 0 && jobConfig !== undefined) { + const { selectedFields: defaultSelected, docFields } = getDefaultFieldsFromJobCaps( + fields, + jobConfig + ); + + setSelectedFields(defaultSelected); + setDocFields(docFields); + } + }; + + const loadExploreData = async ({ + field, + direction, + searchQuery, + requiresKeyword, + }: LoadExploreDataArg) => { if (jobConfig !== undefined) { setErrorMessage(''); setStatus(INDEX_STATUS.LOADING); @@ -67,7 +82,7 @@ export const useExploreData = ( if (field !== undefined) { body.sort = [ { - [field]: { + [`${field}${requiresKeyword ? '.keyword' : ''}`]: { order: direction, }, }, @@ -91,11 +106,6 @@ export const useExploreData = ( return; } - if (selectedFields.length === 0) { - const newSelectedFields = getDefaultRegressionFields(docs, jobConfig); - setSelectedFields(newSelectedFields); - } - // Create a version of the doc's source with flattened field names. // This avoids confusion later on if a field name has dots in its name // or is a nested fields when displaying it via EuiInMemoryTable. @@ -139,11 +149,7 @@ export const useExploreData = ( useEffect(() => { if (jobConfig !== undefined) { - loadExploreData({ - field: getPredictedFieldName(jobConfig.dest.results_field, jobConfig.analysis), - direction: SORT_DIRECTION.DESC, - searchQuery: defaultSearchQuery, - }); + getDefaultSelectedFields(); } }, [jobConfig && jobConfig.id]); diff --git a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts index 337d3768f24088..51982541ccc3b1 100644 --- a/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts +++ b/x-pack/legacy/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_form/form_options_validation.ts @@ -7,20 +7,7 @@ import { ES_FIELD_TYPES } from '../../../../../../../../../../../src/plugins/data/public'; import { Field, EVENT_RATE_FIELD_ID } from '../../../../../../../common/types/fields'; import { JOB_TYPES, AnalyticsJobType } from '../../hooks/use_create_analytics_form/state'; - -const BASIC_NUMERICAL_TYPES = new Set([ - ES_FIELD_TYPES.LONG, - ES_FIELD_TYPES.INTEGER, - ES_FIELD_TYPES.SHORT, - ES_FIELD_TYPES.BYTE, -]); - -const EXTENDED_NUMERICAL_TYPES = new Set([ - ES_FIELD_TYPES.DOUBLE, - ES_FIELD_TYPES.FLOAT, - ES_FIELD_TYPES.HALF_FLOAT, - ES_FIELD_TYPES.SCALED_FLOAT, -]); +import { BASIC_NUMERICAL_TYPES, EXTENDED_NUMERICAL_TYPES } from '../../../../common/fields'; const CATEGORICAL_TYPES = new Set(['ip', 'keyword', 'text']); diff --git a/x-pack/legacy/plugins/ml/public/application/util/object_utils.test.ts b/x-pack/legacy/plugins/ml/public/application/util/object_utils.test.ts index ffd0adcbe542da..7ac68b41b625c5 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/object_utils.test.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/object_utils.test.ts @@ -16,6 +16,17 @@ describe('object_utils', () => { }, }; + const falseyObj = { + the: { + nested: { + value: false, + }, + other_nested: { + value: 0, + }, + }, + }; + const test1 = getNestedProperty(testObj, 'the'); expect(typeof test1).toBe('object'); expect(Object.keys(test1)).toStrictEqual(['nested']); @@ -47,5 +58,13 @@ describe('object_utils', () => { const test9 = getNestedProperty(testObj, 'the.nested.value.doesntExist', 'the-default-value'); expect(typeof test9).toBe('string'); expect(test9).toBe('the-default-value'); + + const test10 = getNestedProperty(falseyObj, 'the.nested.value'); + expect(typeof test10).toBe('boolean'); + expect(test10).toBe(false); + + const test11 = getNestedProperty(falseyObj, 'the.other_nested.value'); + expect(typeof test11).toBe('number'); + expect(test11).toBe(0); }); }); diff --git a/x-pack/legacy/plugins/ml/public/application/util/object_utils.ts b/x-pack/legacy/plugins/ml/public/application/util/object_utils.ts index 589803b33e11cc..dfdcd0959260d9 100644 --- a/x-pack/legacy/plugins/ml/public/application/util/object_utils.ts +++ b/x-pack/legacy/plugins/ml/public/application/util/object_utils.ts @@ -11,5 +11,9 @@ export const getNestedProperty = ( accessor: string, defaultValue?: any ) => { - return accessor.split('.').reduce((o, i) => o?.[i], obj) || defaultValue; + const value = accessor.split('.').reduce((o, i) => o?.[i], obj); + + if (value === undefined) return defaultValue; + + return value; };