From 1f3379a0e21b1180aeb54e4d60459bad01b4fe67 Mon Sep 17 00:00:00 2001 From: Brandon Morelli Date: Thu, 4 Jun 2020 08:03:08 -0700 Subject: [PATCH 01/13] docs: use correct inequality symbols (#67986) --- docs/apm/advanced-queries.asciidoc | 2 +- docs/apm/service-maps.asciidoc | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/apm/advanced-queries.asciidoc b/docs/apm/advanced-queries.asciidoc index f89f994e59e57..7b771eb662616 100644 --- a/docs/apm/advanced-queries.asciidoc +++ b/docs/apm/advanced-queries.asciidoc @@ -11,7 +11,7 @@ or, to only show transactions that are slower than a specified time threshold. ==== Example APM app queries * Exclude response times slower than 2000 ms: `transaction.duration.us > 2000000` -* Filter by response status code: `context.response.status_code >= 400` +* Filter by response status code: `context.response.status_code ≥ 400` * Filter by single user ID: `context.user.id : 12` When querying in the APM app, you're merely searching and selecting data from fields in Elasticsearch documents. diff --git a/docs/apm/service-maps.asciidoc b/docs/apm/service-maps.asciidoc index 3a6a96fca9d09..db2f85c54c762 100644 --- a/docs/apm/service-maps.asciidoc +++ b/docs/apm/service-maps.asciidoc @@ -62,9 +62,9 @@ Machine learning jobs can be created to calculate anomaly scores on APM transact When these jobs are active, service maps will display a color-coded anomaly indicator based on the detected anomaly score: [horizontal] -image:apm/images/green-service.png[APM green service]:: Max anomaly score **<=25**. Service is healthy. +image:apm/images/green-service.png[APM green service]:: Max anomaly score **≤25**. Service is healthy. image:apm/images/yellow-service.png[APM yellow service]:: Max anomaly score **26-74**. Anomalous activity detected. Service may be degraded. -image:apm/images/red-service.png[APM red service]:: Max anomaly score **>=75**. Anomalous activity detected. Service is unhealthy. +image:apm/images/red-service.png[APM red service]:: Max anomaly score **≥75**. Anomalous activity detected. Service is unhealthy. [role="screenshot"] image::apm/images/apm-service-map-anomaly.png[Example view of anomaly scores on service maps in the APM app] @@ -92,10 +92,10 @@ Type and subtype are based on `span.type`, and `span.subtype`. Service maps are supported for the following Agent versions: [horizontal] -Go Agent:: >= v1.7.0 -Java Agent:: >= v1.13.0 -.NET Agent:: >= v1.3.0 -Node.js Agent:: >= v3.6.0 -Python Agent:: >= v5.5.0 -Ruby Agent:: >= v3.6.0 -Real User Monitoring (RUM) Agent:: >= v4.7.0 +Go Agent:: ≥ v1.7.0 +Java Agent:: ≥ v1.13.0 +.NET Agent:: ≥ v1.3.0 +Node.js Agent:: ≥ v3.6.0 +Python Agent:: ≥ v5.5.0 +Ruby Agent:: ≥ v3.6.0 +Real User Monitoring (RUM) Agent:: ≥ v4.7.0 From c5e9ad513ed1559d6acc3e2959f75b3260a11244 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 4 Jun 2020 12:04:34 -0400 Subject: [PATCH 02/13] [SIEM][Exceptions] - Part 1 of ExceptionViewer UI (#68027) ### Summary The ExceptionViewer is a component that displays all of a user's exception list items. It will allow them to delete, edit, search and add exception items. This is part 1 of the UI for the ExceptionViewer. Trying to keep PRs relatively small and found this was one way to split it up. This first part accomplishes the following: - adds helper functions that will be used in the ExceptionBuilder as well and offer ways to format and access the new exception list item structure - creates ExceptionItem component, this is the component that displays the exception item information - moves the and_or_badge component into the common folder - adds stories for the ExceptionItem to easily test changes (Note that the color of some things like the and_or_badge is a bit off, as the light gray color used for it isn't picked up in the mock eui theme) --- .../__examples__/index.stories.tsx | 39 ++ .../components/and_or_badge/index.test.tsx | 48 ++ .../common/components/and_or_badge/index.tsx | 108 ++++ .../components}/and_or_badge/translations.ts | 0 .../exceptions/__examples__/index.stories.tsx | 118 +++++ .../components/exceptions/helpers.test.tsx | 467 ++++++++++++++++++ .../common/components/exceptions/helpers.tsx | 192 +++++++ .../common/components/exceptions/mocks.ts | 89 ++++ .../common/components/exceptions/operators.ts | 91 ++++ .../components/exceptions/translations.ts | 49 ++ .../common/components/exceptions/types.ts | 78 +++ .../viewer/exception_details.test.tsx | 226 +++++++++ .../exceptions/viewer/exception_details.tsx | 85 ++++ .../viewer/exception_entries.test.tsx | 150 ++++++ .../exceptions/viewer/exception_entries.tsx | 169 +++++++ .../exceptions/viewer/index.test.tsx | 116 +++++ .../components/exceptions/viewer/index.tsx | 99 ++++ .../public/lists_plugin_deps.ts | 1 + .../__examples__/index.stories.tsx | 12 - .../timeline/and_or_badge/index.tsx | 49 -- .../timeline/data_providers/empty.tsx | 2 +- .../timeline/data_providers/providers.tsx | 2 +- .../timeline/search_or_filter/helpers.tsx | 2 +- .../security_solution/scripts/storybook.js | 2 +- 24 files changed, 2129 insertions(+), 65 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/and_or_badge/__examples__/index.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/and_or_badge/index.tsx rename x-pack/plugins/security_solution/public/{timelines/components/timeline => common/components}/and_or_badge/translations.ts (100%) create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/index.stories.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/operators.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/types.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/and_or_badge/__examples__/index.stories.tsx delete mode 100644 x-pack/plugins/security_solution/public/timelines/components/timeline/and_or_badge/index.tsx diff --git a/x-pack/plugins/security_solution/public/common/components/and_or_badge/__examples__/index.stories.tsx b/x-pack/plugins/security_solution/public/common/components/and_or_badge/__examples__/index.stories.tsx new file mode 100644 index 0000000000000..f939cf81d1bd3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/and_or_badge/__examples__/index.stories.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 { storiesOf } from '@storybook/react'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; + +import { AndOrBadge } from '..'; + +const sampleText = + 'Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys. You are doing me the shock smol borking doggo with a long snoot for pats wow very biscit, length boy. Doggo ipsum i am bekom fat snoot wow such tempt waggy wags floofs, ruff heckin good boys and girls mlem. Ruff heckin good boys and girls mlem stop it fren borkf borking doggo very hand that feed shibe, you are doing me the shock big ol heck smol borking doggo with a long snoot for pats heckin good boys.'; + +storiesOf('components/AndOrBadge', module) + .add('and', () => ( + ({ eui: euiLightVars, darkMode: true })}> + + + )) + .add('or', () => ( + ({ eui: euiLightVars, darkMode: true })}> + + + )) + .add('antennas', () => ( + ({ eui: euiLightVars, darkMode: true })}> + + + + + +

{sampleText}

+
+
+
+ )); diff --git a/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx new file mode 100644 index 0000000000000..ed918a59a514a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { AndOrBadge } from './'; + +describe('AndOrBadge', () => { + test('it renders top and bottom antenna bars when "includeAntennas" is true', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + expect(wrapper.find('EuiFlexItem[data-test-subj="andOrBadgeBarTop"]')).toHaveLength(1); + expect(wrapper.find('EuiFlexItem[data-test-subj="andOrBadgeBarBottom"]')).toHaveLength(1); + }); + + test('it renders "and" when "type" is "and"', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('AND'); + expect(wrapper.find('EuiFlexItem[data-test-subj="and-or-badge-bar"]')).toHaveLength(0); + }); + + test('it renders "or" when "type" is "or"', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="and-or-badge"]').at(0).text()).toEqual('OR'); + expect(wrapper.find('EuiFlexItem[data-test-subj="and-or-badge-bar"]')).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.tsx b/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.tsx new file mode 100644 index 0000000000000..ba3f880d9757e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/and_or_badge/index.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiBadge, EuiFlexItem } from '@elastic/eui'; +import React from 'react'; +import styled, { css } from 'styled-components'; + +import * as i18n from './translations'; + +const AndOrBadgeAntenna = styled(EuiFlexItem)` + ${({ theme }) => css` + background: ${theme.eui.euiColorLightShade}; + position: relative; + width: 2px; + &:after { + background: ${theme.eui.euiColorLightShade}; + content: ''; + height: 8px; + right: -4px; + position: absolute; + width: 9px; + clip-path: circle(); + } + &.topAndOrBadgeAntenna { + &:after { + top: -1px; + } + } + &.bottomAndOrBadgeAntenna { + &:after { + bottom: -1px; + } + } + &.euiFlexItem { + margin: 0 12px 0 0; + } + `} +`; + +const EuiFlexItemWrapper = styled(EuiFlexItem)` + &.euiFlexItem { + margin: 0 12px 0 0; + } +`; + +const RoundedBadge = (styled(EuiBadge)` + align-items: center; + border-radius: 100%; + display: inline-flex; + font-size: 9px; + height: 34px; + justify-content: center; + margin: 0 5px 0 5px; + padding: 7px 6px 4px 6px; + user-select: none; + width: 34px; + .euiBadge__content { + position: relative; + top: -1px; + } + .euiBadge__text { + text-overflow: clip; + } +` as unknown) as typeof EuiBadge; + +RoundedBadge.displayName = 'RoundedBadge'; + +export type AndOr = 'and' | 'or'; + +/** Displays AND / OR in a round badge */ +// Ref: https://github.com/elastic/eui/issues/1655 +export const AndOrBadge = React.memo<{ type: AndOr; includeAntennas?: boolean }>( + ({ type, includeAntennas = false }) => { + const getBadge = () => ( + + {type === 'and' ? i18n.AND : i18n.OR} + + ); + + const getBadgeWithAntennas = () => ( + + + {getBadge()} + + + ); + + return includeAntennas ? getBadgeWithAntennas() : getBadge(); + } +); + +AndOrBadge.displayName = 'AndOrBadge'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/and_or_badge/translations.ts b/x-pack/plugins/security_solution/public/common/components/and_or_badge/translations.ts similarity index 100% rename from x-pack/plugins/security_solution/public/timelines/components/timeline/and_or_badge/translations.ts rename to x-pack/plugins/security_solution/public/common/components/and_or_badge/translations.ts diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/index.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/index.stories.tsx new file mode 100644 index 0000000000000..b6620ed103bc8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/__examples__/index.stories.tsx @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { ExceptionItem } from '../viewer'; +import { Operator } from '../types'; +import { getExceptionItemMock } from '../mocks'; + +storiesOf('components/exceptions', module) + .add('ExceptionItem/with os', () => { + const payload = getExceptionItemMock(); + payload.description = ''; + payload.comments = []; + payload.entries = [ + { + field: 'actingProcess.file.signer', + type: 'match', + operator: Operator.INCLUSION, + value: 'Elastic, N.V.', + }, + ]; + + return ( + ({ eui: euiLightVars, darkMode: false })}> + {}} + handleEdit={() => {}} + /> + + ); + }) + .add('ExceptionItem/with description', () => { + const payload = getExceptionItemMock(); + payload._tags = []; + payload.comments = []; + payload.entries = [ + { + field: 'actingProcess.file.signer', + type: 'match', + operator: Operator.INCLUSION, + value: 'Elastic, N.V.', + }, + ]; + + return ( + ({ eui: euiLightVars, darkMode: false })}> + {}} + handleEdit={() => {}} + /> + + ); + }) + .add('ExceptionItem/with comments', () => { + const payload = getExceptionItemMock(); + payload._tags = []; + payload.description = ''; + payload.entries = [ + { + field: 'actingProcess.file.signer', + type: 'match', + operator: Operator.INCLUSION, + value: 'Elastic, N.V.', + }, + ]; + + return ( + ({ eui: euiLightVars, darkMode: false })}> + {}} + handleEdit={() => {}} + /> + + ); + }) + .add('ExceptionItem/with nested entries', () => { + const payload = getExceptionItemMock(); + payload._tags = []; + payload.description = ''; + payload.comments = []; + + return ( + ({ eui: euiLightVars, darkMode: false })}> + {}} + handleEdit={() => {}} + /> + + ); + }) + .add('ExceptionItem/with everything', () => { + const payload = getExceptionItemMock(); + + return ( + ({ eui: euiLightVars, darkMode: false })}> + {}} + handleEdit={() => {}} + /> + + ); + }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx new file mode 100644 index 0000000000000..223eabb0ea4ee --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -0,0 +1,467 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { mount } from 'enzyme'; +import moment from 'moment-timezone'; + +import { + getOperatorType, + getExceptionOperatorSelect, + determineIfIsNested, + getFormattedEntries, + formatEntry, + getOperatingSystems, + getTagsInclude, + getDescriptionListContent, + getFormattedComments, +} from './helpers'; +import { + OperatorType, + Operator, + NestedExceptionEntry, + FormattedEntry, + DescriptionListItem, +} from './types'; +import { + isOperator, + isNotOperator, + isOneOfOperator, + isNotOneOfOperator, + isInListOperator, + isNotInListOperator, + existsOperator, + doesNotExistOperator, +} from './operators'; +import { getExceptionItemEntryMock, getExceptionItemMock } from './mocks'; + +describe('Exception helpers', () => { + beforeEach(() => { + moment.tz.setDefault('UTC'); + }); + + afterEach(() => { + moment.tz.setDefault('Browser'); + }); + + describe('#getOperatorType', () => { + test('returns operator type "match" if entry.type is "match"', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'match'; + const operatorType = getOperatorType(payload); + + expect(operatorType).toEqual(OperatorType.PHRASE); + }); + + test('returns operator type "match" if entry.type is "nested"', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'nested'; + const operatorType = getOperatorType(payload); + + expect(operatorType).toEqual(OperatorType.PHRASE); + }); + + test('returns operator type "match_any" if entry.type is "match_any"', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'match_any'; + const operatorType = getOperatorType(payload); + + expect(operatorType).toEqual(OperatorType.PHRASES); + }); + + test('returns operator type "list" if entry.type is "list"', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'list'; + const operatorType = getOperatorType(payload); + + expect(operatorType).toEqual(OperatorType.LIST); + }); + + test('returns operator type "exists" if entry.type is "exists"', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'exists'; + const operatorType = getOperatorType(payload); + + expect(operatorType).toEqual(OperatorType.EXISTS); + }); + }); + + describe('#getExceptionOperatorSelect', () => { + test('it returns "isOperator" when "operator" is "included" and operator type is "match"', () => { + const payload = getExceptionItemEntryMock(); + const result = getExceptionOperatorSelect(payload); + + expect(result).toEqual(isOperator); + }); + + test('it returns "isNotOperator" when "operator" is "excluded" and operator type is "match"', () => { + const payload = getExceptionItemEntryMock(); + payload.operator = Operator.EXCLUSION; + const result = getExceptionOperatorSelect(payload); + + expect(result).toEqual(isNotOperator); + }); + + test('it returns "isOneOfOperator" when "operator" is "included" and operator type is "match_any"', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'match_any'; + payload.operator = Operator.INCLUSION; + const result = getExceptionOperatorSelect(payload); + + expect(result).toEqual(isOneOfOperator); + }); + + test('it returns "isNotOneOfOperator" when "operator" is "excluded" and operator type is "match_any"', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'match_any'; + payload.operator = Operator.EXCLUSION; + const result = getExceptionOperatorSelect(payload); + + expect(result).toEqual(isNotOneOfOperator); + }); + + test('it returns "existsOperator" when "operator" is "included" and no operator type is provided', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'exists'; + payload.operator = Operator.INCLUSION; + const result = getExceptionOperatorSelect(payload); + + expect(result).toEqual(existsOperator); + }); + + test('it returns "doesNotExistsOperator" when "operator" is "excluded" and no operator type is provided', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'exists'; + payload.operator = Operator.EXCLUSION; + const result = getExceptionOperatorSelect(payload); + + expect(result).toEqual(doesNotExistOperator); + }); + + test('it returns "isInList" when "operator" is "included" and operator type is "list"', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'list'; + payload.operator = Operator.INCLUSION; + const result = getExceptionOperatorSelect(payload); + + expect(result).toEqual(isInListOperator); + }); + + test('it returns "isNotInList" when "operator" is "excluded" and operator type is "list"', () => { + const payload = getExceptionItemEntryMock(); + payload.type = 'list'; + payload.operator = Operator.EXCLUSION; + const result = getExceptionOperatorSelect(payload); + + expect(result).toEqual(isNotInListOperator); + }); + }); + + describe('#determineIfIsNested', () => { + test('it returns true if type NestedExceptionEntry', () => { + const payload: NestedExceptionEntry = { + field: 'actingProcess.file.signer', + type: 'nested', + entries: [], + }; + const result = determineIfIsNested(payload); + + expect(result).toBeTruthy(); + }); + + test('it returns false if NOT type NestedExceptionEntry', () => { + const payload = getExceptionItemEntryMock(); + const result = determineIfIsNested(payload); + + expect(result).toBeFalsy(); + }); + }); + + describe('#getFormattedEntries', () => { + test('it returns empty array if no entries passed', () => { + const result = getFormattedEntries([]); + + expect(result).toEqual([]); + }); + + test('it formats nested entries as expected', () => { + const payload = [ + { + field: 'file.signature', + type: 'nested', + entries: [ + { + field: 'signer', + type: 'match', + operator: Operator.INCLUSION, + value: 'Evil', + }, + { + field: 'trusted', + type: 'match', + operator: Operator.INCLUSION, + value: 'true', + }, + ], + }, + ]; + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ + { + fieldName: 'file.signature', + operator: null, + value: null, + isNested: false, + }, + { + fieldName: 'file.signature.signer', + isNested: true, + operator: 'is', + value: 'Evil', + }, + { + fieldName: 'file.signature.trusted', + isNested: true, + operator: 'is', + value: 'true', + }, + ]; + expect(result).toEqual(expected); + }); + + test('it formats non-nested entries as expected', () => { + const payload = [ + { + field: 'actingProcess.file.signer', + type: 'match', + operator: Operator.INCLUSION, + value: 'Elastic, N.V.', + }, + { + field: 'actingProcess.file.signer', + type: 'match', + operator: Operator.EXCLUSION, + value: 'Global Signer', + }, + ]; + const result = getFormattedEntries(payload); + const expected: FormattedEntry[] = [ + { + fieldName: 'actingProcess.file.signer', + isNested: false, + operator: 'is', + value: 'Elastic, N.V.', + }, + { + fieldName: 'actingProcess.file.signer', + isNested: false, + operator: 'is not', + value: 'Global Signer', + }, + ]; + expect(result).toEqual(expected); + }); + + test('it formats a mix of nested and non-nested entries as expected', () => { + const payload = getExceptionItemMock(); + const result = getFormattedEntries(payload.entries); + const expected: FormattedEntry[] = [ + { + fieldName: 'actingProcess.file.signer', + isNested: false, + operator: 'is', + value: 'Elastic, N.V.', + }, + { + fieldName: 'host.name', + isNested: false, + operator: 'is not', + value: 'Global Signer', + }, + { + fieldName: 'file.signature', + isNested: false, + operator: null, + value: null, + }, + { + fieldName: 'file.signature.signer', + isNested: true, + operator: 'is', + value: 'Evil', + }, + { + fieldName: 'file.signature.trusted', + isNested: true, + operator: 'is', + value: 'true', + }, + ]; + expect(result).toEqual(expected); + }); + }); + + describe('#formatEntry', () => { + test('it formats an entry', () => { + const payload = getExceptionItemEntryMock(); + const formattedEntry = formatEntry({ isNested: false, item: payload }); + const expected: FormattedEntry = { + fieldName: 'actingProcess.file.signer', + isNested: false, + operator: 'is', + value: 'Elastic, N.V.', + }; + + expect(formattedEntry).toEqual(expected); + }); + + test('it formats a nested entry', () => { + const payload = getExceptionItemEntryMock(); + const formattedEntry = formatEntry({ isNested: true, parent: 'parent', item: payload }); + const expected: FormattedEntry = { + fieldName: 'parent.actingProcess.file.signer', + isNested: true, + operator: 'is', + value: 'Elastic, N.V.', + }; + + expect(formattedEntry).toEqual(expected); + }); + }); + + describe('#getOperatingSystems', () => { + test('it returns null if no operating system tag specified', () => { + const result = getOperatingSystems(['some tag', 'some other tag']); + + expect(result).toEqual(''); + }); + + test('it returns null if operating system tag malformed', () => { + const result = getOperatingSystems(['some tag', 'jibberos:mac,windows', 'some other tag']); + + expect(result).toEqual(''); + }); + + test('it returns formatted operating systems if space included in os tag', () => { + const result = getOperatingSystems(['some tag', 'os: mac', 'some other tag']); + + expect(result).toEqual('Mac'); + }); + + test('it returns formatted operating systems if multiple os tags specified', () => { + const result = getOperatingSystems(['some tag', 'os: mac', 'some other tag', 'os:windows']); + + expect(result).toEqual('Mac, Windows'); + }); + }); + + describe('#getTagsInclude', () => { + test('it returns a tuple of "false" and "null" if no matches found', () => { + const result = getTagsInclude({ tags: ['some', 'tags', 'here'], regex: /(no match)/ }); + + expect(result).toEqual([false, null]); + }); + + test('it returns a tuple of "true" and matching string if matches found', () => { + const result = getTagsInclude({ tags: ['some', 'tags', 'here'], regex: /(some)/ }); + + expect(result).toEqual([true, 'some']); + }); + }); + + describe('#getDescriptionListContent', () => { + test('it returns formatted description list with os if one is specified', () => { + const payload = getExceptionItemMock(); + payload.description = ''; + const result = getDescriptionListContent(payload); + const expected: DescriptionListItem[] = [ + { + description: 'Windows', + title: 'OS', + }, + { + description: 'April 23rd 2020 @ 00:19:13', + title: 'Date created', + }, + { + description: 'user_name', + title: 'Created by', + }, + ]; + + expect(result).toEqual(expected); + }); + + test('it returns formatted description list with a description if one specified', () => { + const payload = getExceptionItemMock(); + payload._tags = []; + payload.description = 'Im a description'; + const result = getDescriptionListContent(payload); + const expected: DescriptionListItem[] = [ + { + description: 'April 23rd 2020 @ 00:19:13', + title: 'Date created', + }, + { + description: 'user_name', + title: 'Created by', + }, + { + description: 'Im a description', + title: 'Comment', + }, + ]; + + expect(result).toEqual(expected); + }); + + test('it returns just user and date created if no other fields specified', () => { + const payload = getExceptionItemMock(); + payload._tags = []; + payload.description = ''; + const result = getDescriptionListContent(payload); + const expected: DescriptionListItem[] = [ + { + description: 'April 23rd 2020 @ 00:19:13', + title: 'Date created', + }, + { + description: 'user_name', + title: 'Created by', + }, + ]; + + expect(result).toEqual(expected); + }); + }); + + describe('#getFormattedComments', () => { + test('it returns formatted comment object with username and timestamp', () => { + const payload = getExceptionItemMock().comments; + const result = getFormattedComments(payload); + + expect(result[0].username).toEqual('user_name'); + expect(result[0].timestamp).toEqual('on Apr 23rd 2020 @ 00:19:13'); + }); + + test('it returns formatted timeline icon with comment users initial', () => { + const payload = getExceptionItemMock().comments; + const result = getFormattedComments(payload); + + const wrapper = mount(result[0].timelineIcon as React.ReactElement); + + expect(wrapper.text()).toEqual('U'); + }); + + test('it returns comment text', () => { + const payload = getExceptionItemMock().comments; + const result = getFormattedComments(payload); + + const wrapper = mount(result[0].children as React.ReactElement); + + expect(wrapper.text()).toEqual('Comment goes here'); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx new file mode 100644 index 0000000000000..bd22de636bf6c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -0,0 +1,192 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { EuiText, EuiCommentProps, EuiAvatar } from '@elastic/eui'; +import { capitalize } from 'lodash'; +import moment from 'moment'; + +import * as i18n from './translations'; +import { + FormattedEntry, + OperatorType, + OperatorOption, + ExceptionEntry, + NestedExceptionEntry, + DescriptionListItem, + Comment, + ExceptionListItemSchema, +} from './types'; +import { EXCEPTION_OPERATORS, isOperator } from './operators'; + +/** + * Returns the operator type, may not need this if using io-ts types + * + * @param entry a single ExceptionItem entry + */ +export const getOperatorType = (entry: ExceptionEntry): OperatorType => { + switch (entry.type) { + case 'nested': + case 'match': + return OperatorType.PHRASE; + case 'match_any': + return OperatorType.PHRASES; + case 'list': + return OperatorType.LIST; + default: + return OperatorType.EXISTS; + } +}; + +/** + * Determines operator selection (is/is not/is one of, etc.) + * Default operator is "is" + * + * @param entry a single ExceptionItem entry + */ +export const getExceptionOperatorSelect = (entry: ExceptionEntry): OperatorOption => { + const operatorType = getOperatorType(entry); + const foundOperator = EXCEPTION_OPERATORS.find((operatorOption) => { + return entry.operator === operatorOption.operator && operatorType === operatorOption.type; + }); + + return foundOperator ?? isOperator; +}; + +export const determineIfIsNested = ( + tbd: ExceptionEntry | NestedExceptionEntry +): tbd is NestedExceptionEntry => { + if (tbd.type === 'nested') { + return true; + } + return false; +}; + +/** + * Formats ExceptionItem entries into simple field, operator, value + * for use in rendering items in table + * + * @param entries an ExceptionItem's entries + */ +export const getFormattedEntries = ( + entries: Array +): FormattedEntry[] => { + const formattedEntries = entries.map((entry) => { + if (determineIfIsNested(entry)) { + const parent = { fieldName: entry.field, operator: null, value: null, isNested: false }; + return entry.entries.reduce( + (acc, nestedEntry) => { + const formattedEntry = formatEntry({ + isNested: true, + parent: entry.field, + item: nestedEntry, + }); + return [...acc, { ...formattedEntry }]; + }, + [parent] + ); + } else { + return formatEntry({ isNested: false, item: entry }); + } + }); + + return formattedEntries.flat(); +}; + +/** + * Helper method for `getFormattedEntries` + */ +export const formatEntry = ({ + isNested, + parent, + item, +}: { + isNested: boolean; + parent?: string; + item: ExceptionEntry; +}): FormattedEntry => { + const operator = getExceptionOperatorSelect(item); + const operatorType = getOperatorType(item); + const value = operatorType === OperatorType.EXISTS ? null : item.value; + + return { + fieldName: isNested ? `${parent}.${item.field}` : item.field, + operator: operator.message, + value, + isNested, + }; +}; + +export const getOperatingSystems = (tags: string[]): string => { + const osMatches = tags + .filter((tag) => tag.startsWith('os:')) + .map((os) => capitalize(os.substring(3).trim())) + .join(', '); + + return osMatches; +}; + +export const getTagsInclude = ({ + tags, + regex, +}: { + tags: string[]; + regex: RegExp; +}): [boolean, string | null] => { + const matches: string[] | null = tags.join(';').match(regex); + const match = matches != null ? matches[1] : null; + return [matches != null, match]; +}; + +/** + * Formats ExceptionItem information for description list component + * + * @param exceptionItem an ExceptionItem + */ +export const getDescriptionListContent = ( + exceptionItem: ExceptionListItemSchema +): DescriptionListItem[] => { + const details = [ + { + title: i18n.OPERATING_SYSTEM, + value: getOperatingSystems(exceptionItem._tags), + }, + { + title: i18n.DATE_CREATED, + value: moment(exceptionItem.created_at).format('MMMM Do YYYY @ HH:mm:ss'), + }, + { + title: i18n.CREATED_BY, + value: exceptionItem.created_by, + }, + { + title: i18n.COMMENT, + value: exceptionItem.description, + }, + ]; + + return details.reduce((acc, { value, title }) => { + if (value != null && value.trim() !== '') { + return [...acc, { title, description: value }]; + } else { + return acc; + } + }, []); +}; + +/** + * Formats ExceptionItem.comments into EuiCommentList format + * + * @param comments ExceptionItem.comments + */ +export const getFormattedComments = (comments: Comment[]): EuiCommentProps[] => + comments.map((comment) => ({ + username: comment.user, + timestamp: moment(comment.timestamp).format('on MMM Do YYYY @ HH:mm:ss'), + event: i18n.COMMENT_EVENT, + timelineIcon: , + children: {comment.comment}, + })); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts new file mode 100644 index 0000000000000..15aec3533b325 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/mocks.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + Operator, + ExceptionListItemSchema, + ExceptionEntry, + NestedExceptionEntry, + FormattedEntry, +} from './types'; + +export const getExceptionItemEntryMock = (): ExceptionEntry => ({ + field: 'actingProcess.file.signer', + type: 'match', + operator: Operator.INCLUSION, + value: 'Elastic, N.V.', +}); + +export const getNestedExceptionItemEntryMock = (): NestedExceptionEntry => ({ + field: 'actingProcess.file.signer', + type: 'nested', + entries: [{ ...getExceptionItemEntryMock() }], +}); + +export const getFormattedEntryMock = (isNested = false): FormattedEntry => ({ + fieldName: 'host.name', + operator: 'is', + value: 'some name', + isNested, +}); + +export const getExceptionItemMock = (): ExceptionListItemSchema => ({ + id: 'uuid_here', + item_id: 'item-id', + created_at: '2020-04-23T00:19:13.289Z', + created_by: 'user_name', + list_id: 'test-exception', + tie_breaker_id: '77fd1909-6786-428a-a671-30229a719c1f', + updated_at: '2020-04-23T00:19:13.289Z', + updated_by: 'user_name', + namespace_type: 'single', + name: '', + description: 'This is a description', + comments: [ + { + user: 'user_name', + timestamp: '2020-04-23T00:19:13.289Z', + comment: 'Comment goes here', + }, + ], + _tags: ['os:windows'], + tags: [], + type: 'simple', + entries: [ + { + field: 'actingProcess.file.signer', + type: 'match', + operator: Operator.INCLUSION, + value: 'Elastic, N.V.', + }, + { + field: 'host.name', + type: 'match', + operator: Operator.EXCLUSION, + value: 'Global Signer', + }, + { + field: 'file.signature', + type: 'nested', + entries: [ + { + field: 'signer', + type: 'match', + operator: Operator.INCLUSION, + value: 'Evil', + }, + { + field: 'trusted', + type: 'match', + operator: Operator.INCLUSION, + value: 'true', + }, + ], + }, + ], +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/operators.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/operators.ts new file mode 100644 index 0000000000000..19c726893e682 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/operators.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 { i18n } from '@kbn/i18n'; +import { OperatorOption, OperatorType, Operator } from './types'; + +export const isOperator: OperatorOption = { + message: i18n.translate('xpack.securitySolution.exceptions.isOperatorLabel', { + defaultMessage: 'is', + }), + value: 'is', + type: OperatorType.PHRASE, + operator: Operator.INCLUSION, +}; + +export const isNotOperator: OperatorOption = { + message: i18n.translate('xpack.securitySolution.exceptions.isNotOperatorLabel', { + defaultMessage: 'is not', + }), + value: 'is_not', + type: OperatorType.PHRASE, + operator: Operator.EXCLUSION, +}; + +export const isOneOfOperator: OperatorOption = { + message: i18n.translate('xpack.securitySolution.exceptions.isOneOfOperatorLabel', { + defaultMessage: 'is one of', + }), + value: 'is_one_of', + type: OperatorType.PHRASES, + operator: Operator.INCLUSION, +}; + +export const isNotOneOfOperator: OperatorOption = { + message: i18n.translate('xpack.securitySolution.exceptions.isNotOneOfOperatorLabel', { + defaultMessage: 'is not one of', + }), + value: 'is_not_one_of', + type: OperatorType.PHRASES, + operator: Operator.EXCLUSION, +}; + +export const existsOperator: OperatorOption = { + message: i18n.translate('xpack.securitySolution.exceptions.existsOperatorLabel', { + defaultMessage: 'exists', + }), + value: 'exists', + type: OperatorType.EXISTS, + operator: Operator.INCLUSION, +}; + +export const doesNotExistOperator: OperatorOption = { + message: i18n.translate('xpack.securitySolution.exceptions.doesNotExistOperatorLabel', { + defaultMessage: 'does not exist', + }), + value: 'does_not_exist', + type: OperatorType.EXISTS, + operator: Operator.EXCLUSION, +}; + +export const isInListOperator: OperatorOption = { + message: i18n.translate('xpack.securitySolution.exceptions.isInListOperatorLabel', { + defaultMessage: 'is in list', + }), + value: 'is_in_list', + type: OperatorType.LIST, + operator: Operator.INCLUSION, +}; + +export const isNotInListOperator: OperatorOption = { + message: i18n.translate('xpack.securitySolution.exceptions.isNotInListOperatorLabel', { + defaultMessage: 'is not in list', + }), + value: 'is_not_in_list', + type: OperatorType.LIST, + operator: Operator.EXCLUSION, +}; + +export const EXCEPTION_OPERATORS: OperatorOption[] = [ + isOperator, + isNotOperator, + isOneOfOperator, + isNotOneOfOperator, + existsOperator, + doesNotExistOperator, + isInListOperator, + isNotInListOperator, +]; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts new file mode 100644 index 0000000000000..704849430daf9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; + +export const EDIT = i18n.translate('xpack.securitySolution.exceptions.editButtonLabel', { + defaultMessage: 'Edit', +}); + +export const REMOVE = i18n.translate('xpack.securitySolution.exceptions.removeButtonLabel', { + defaultMessage: 'Remove', +}); + +export const COMMENTS_SHOW = (comments: number) => + i18n.translate('xpack.securitySolution.exceptions.showCommentsLabel', { + values: { comments }, + defaultMessage: 'Show ({comments}) {comments, plural, =1 {Comment} other {Comments}}', + }); + +export const COMMENTS_HIDE = (comments: number) => + i18n.translate('xpack.securitySolution.exceptions.hideCommentsLabel', { + values: { comments }, + defaultMessage: 'Hide ({comments}) {comments, plural, =1 {Comment} other {Comments}}', + }); + +export const DATE_CREATED = i18n.translate('xpack.securitySolution.exceptions.dateCreatedLabel', { + defaultMessage: 'Date created', +}); + +export const CREATED_BY = i18n.translate('xpack.securitySolution.exceptions.createdByLabel', { + defaultMessage: 'Created by', +}); + +export const COMMENT = i18n.translate('xpack.securitySolution.exceptions.commentLabel', { + defaultMessage: 'Comment', +}); + +export const COMMENT_EVENT = i18n.translate('xpack.securitySolution.exceptions.commentEventLabel', { + defaultMessage: 'added a comment', +}); + +export const OPERATING_SYSTEM = i18n.translate( + 'xpack.securitySolution.exceptions.operatingSystemLabel', + { + defaultMessage: 'OS', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/types.ts new file mode 100644 index 0000000000000..e8393610e459d --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/types.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 { ReactNode } from 'react'; + +export interface OperatorOption { + message: string; + value: string; + operator: Operator; + type: OperatorType; +} + +export enum Operator { + INCLUSION = 'included', + EXCLUSION = 'excluded', +} + +export enum OperatorType { + NESTED = 'nested', + PHRASE = 'match', + PHRASES = 'match_any', + EXISTS = 'exists', + LIST = 'list', +} + +export interface FormattedEntry { + fieldName: string; + operator: string | null; + value: string | null; + isNested: boolean; +} + +export interface NestedExceptionEntry { + field: string; + type: string; + entries: ExceptionEntry[]; +} + +export interface ExceptionEntry { + field: string; + type: string; + operator: Operator; + value: string; +} + +export interface DescriptionListItem { + title: NonNullable; + description: NonNullable; +} + +export interface Comment { + user: string; + timestamp: string; + comment: string; +} + +// TODO: Delete once types are updated +export interface ExceptionListItemSchema { + _tags: string[]; + comments: Comment[]; + created_at: string; + created_by: string; + description?: string; + entries: Array; + id: string; + item_id: string; + list_id: string; + meta?: unknown; + name: string; + namespace_type: 'single' | 'agnostic'; + tags: string[]; + tie_breaker_id: string; + type: string; + updated_at: string; + updated_by: string; +} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.test.tsx new file mode 100644 index 0000000000000..536d005c57b6e --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.test.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import moment from 'moment-timezone'; + +import { ExceptionDetails } from './exception_details'; +import { getExceptionItemMock } from '../mocks'; + +describe('ExceptionDetails', () => { + beforeEach(() => { + moment.tz.setDefault('UTC'); + }); + + afterEach(() => { + moment.tz.setDefault('Browser'); + }); + + test('it renders no comments button if no comments exist', () => { + const exceptionItem = getExceptionItemMock(); + exceptionItem.comments = []; + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]')).toHaveLength(0); + }); + + test('it renders comments button if comments exist', () => { + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect( + wrapper.find('.euiButtonEmpty[data-test-subj="exceptionsViewerItemCommentsBtn"]') + ).toHaveLength(1); + }); + + test('it renders correct number of comments', () => { + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0).text()).toEqual( + 'Show (1) Comment' + ); + }); + + test('it renders comments plural if more than one', () => { + const exceptionItem = getExceptionItemMock(); + exceptionItem.comments = [ + { + user: 'user_1', + timestamp: '2020-04-23T00:19:13.289Z', + comment: 'Comment goes here', + }, + { + user: 'user_2', + timestamp: '2020-04-23T00:19:13.289Z', + comment: 'Comment goes here', + }, + ]; + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0).text()).toEqual( + 'Show (2) Comments' + ); + }); + + test('it renders comments show text if "showComments" is false', () => { + const exceptionItem = getExceptionItemMock(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0).text()).toEqual( + 'Show (1) Comment' + ); + }); + + test('it renders comments hide text if "showComments" is true', () => { + const exceptionItem = getExceptionItemMock(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0).text()).toEqual( + 'Hide (1) Comment' + ); + }); + + test('it invokes "onCommentsClick" when comments button clicked', () => { + const mockOnCommentsClick = jest.fn(); + const exceptionItem = getExceptionItemMock(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const commentsBtn = wrapper.find('[data-test-subj="exceptionsViewerItemCommentsBtn"]').at(0); + commentsBtn.simulate('click'); + + expect(mockOnCommentsClick).toHaveBeenCalledTimes(1); + }); + + test('it renders the operating system if one is specified in the exception item', () => { + const exceptionItem = getExceptionItemMock(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('EuiDescriptionListTitle').at(0).text()).toEqual('OS'); + expect(wrapper.find('EuiDescriptionListDescription').at(0).text()).toEqual('Windows'); + }); + + test('it renders the exception item creator', () => { + const exceptionItem = getExceptionItemMock(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('EuiDescriptionListTitle').at(1).text()).toEqual('Date created'); + expect(wrapper.find('EuiDescriptionListDescription').at(1).text()).toEqual( + 'April 23rd 2020 @ 00:19:13' + ); + }); + + test('it renders the exception item creation timestamp', () => { + const exceptionItem = getExceptionItemMock(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('EuiDescriptionListTitle').at(2).text()).toEqual('Created by'); + expect(wrapper.find('EuiDescriptionListDescription').at(2).text()).toEqual('user_name'); + }); + + test('it renders the description if one is included on the exception item', () => { + const exceptionItem = getExceptionItemMock(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('EuiDescriptionListTitle').at(3).text()).toEqual('Comment'); + expect(wrapper.find('EuiDescriptionListDescription').at(3).text()).toEqual( + 'This is a description' + ); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.tsx new file mode 100644 index 0000000000000..8745e80a21548 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_details.tsx @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexItem, EuiFlexGroup, EuiDescriptionList, EuiButtonEmpty } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import styled, { css } from 'styled-components'; +import { transparentize } from 'polished'; + +import { ExceptionListItemSchema } from '../types'; +import { getDescriptionListContent } from '../helpers'; +import * as i18n from '../translations'; + +const StyledExceptionDetails = styled(EuiFlexItem)` + ${({ theme }) => css` + background-color: ${transparentize(0.95, theme.eui.euiColorPrimary)}; + padding: ${theme.eui.euiSize}; + + .euiDescriptionList__title.listTitle--width { + width: 40%; + } + + .euiDescriptionList__description.listDescription--width { + width: 60%; + } + `} +`; + +const ExceptionDetailsComponent = ({ + showComments, + onCommentsClick, + exceptionItem, +}: { + showComments: boolean; + exceptionItem: ExceptionListItemSchema; + onCommentsClick: () => void; +}): JSX.Element => { + const descriptionList = useMemo(() => getDescriptionListContent(exceptionItem), [exceptionItem]); + + const commentsSection = useMemo((): JSX.Element => { + const { comments } = exceptionItem; + if (comments.length > 0) { + return ( + + {!showComments + ? i18n.COMMENTS_SHOW(comments.length) + : i18n.COMMENTS_HIDE(comments.length)} + + ); + } else { + return <>; + } + }, [showComments, onCommentsClick, exceptionItem]); + + return ( + + + + + + {commentsSection} + + + ); +}; + +ExceptionDetailsComponent.displayName = 'ExceptionDetailsComponent'; + +export const ExceptionDetails = React.memo(ExceptionDetailsComponent); + +ExceptionDetails.displayName = 'ExceptionDetails'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.test.tsx new file mode 100644 index 0000000000000..e0c62f51d032a --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.test.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { ExceptionEntries } from './exception_entries'; +import { getFormattedEntryMock } from '../mocks'; +import { getEmptyValue } from '../../empty_value'; + +describe('ExceptionEntries', () => { + test('it does NOT render the and badge if only one exception item entry exists', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsViewerAndBadge"]')).toHaveLength(0); + }); + + test('it renders the and badge if more than one exception item exists', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('[data-test-subj="exceptionsViewerAndBadge"]')).toHaveLength(1); + }); + + test('it invokes "handlEdit" when edit button clicked', () => { + const mockHandleEdit = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const editBtn = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button').at(0); + editBtn.simulate('click'); + + expect(mockHandleEdit).toHaveBeenCalledTimes(1); + }); + + test('it invokes "handleDelete" when delete button clicked', () => { + const mockHandleDelete = jest.fn(); + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + const deleteBtn = wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0); + deleteBtn.simulate('click'); + + expect(mockHandleDelete).toHaveBeenCalledTimes(1); + }); + + test('it renders nested entry', () => { + const parentEntry = getFormattedEntryMock(); + parentEntry.operator = null; + parentEntry.value = null; + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + const parentField = wrapper + .find('[data-test-subj="exceptionFieldNameCell"] .euiTableCellContent') + .at(0); + const parentOperator = wrapper + .find('[data-test-subj="exceptionFieldOperatorCell"] .euiTableCellContent') + .at(0); + const parentValue = wrapper + .find('[data-test-subj="exceptionFieldValueCell"] .euiTableCellContent') + .at(0); + + const nestedField = wrapper + .find('[data-test-subj="exceptionFieldNameCell"] .euiTableCellContent') + .at(1); + const nestedOperator = wrapper + .find('[data-test-subj="exceptionFieldOperatorCell"] .euiTableCellContent') + .at(1); + const nestedValue = wrapper + .find('[data-test-subj="exceptionFieldValueCell"] .euiTableCellContent') + .at(1); + + expect(parentField.text()).toEqual('host.name'); + expect(parentOperator.text()).toEqual(getEmptyValue()); + expect(parentValue.text()).toEqual(getEmptyValue()); + + expect(nestedField.exists('.euiToolTipAnchor')).toBeTruthy(); + expect(nestedField.text()).toEqual('host.name'); + expect(nestedOperator.text()).toEqual('is'); + expect(nestedValue.text()).toEqual('some name'); + }); + + test('it renders non-nested entries', () => { + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + const field = wrapper + .find('[data-test-subj="exceptionFieldNameCell"] .euiTableCellContent') + .at(0); + const operator = wrapper + .find('[data-test-subj="exceptionFieldOperatorCell"] .euiTableCellContent') + .at(0); + const value = wrapper + .find('[data-test-subj="exceptionFieldValueCell"] .euiTableCellContent') + .at(0); + + expect(field.exists('.euiToolTipAnchor')).toBeFalsy(); + expect(field.text()).toEqual('host.name'); + expect(operator.text()).toEqual('is'); + expect(value.text()).toEqual('some name'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.tsx new file mode 100644 index 0000000000000..d0236adc27c6c --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_entries.tsx @@ -0,0 +1,169 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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, + EuiIconTip, + EuiFlexItem, + EuiFlexGroup, + EuiButton, + EuiTableFieldDataColumnType, +} from '@elastic/eui'; +import React, { useMemo } from 'react'; +import styled, { css } from 'styled-components'; +import { transparentize } from 'polished'; + +import { AndOrBadge } from '../../and_or_badge'; +import { getEmptyValue } from '../../empty_value'; +import * as i18n from '../translations'; +import { FormattedEntry } from '../types'; + +const EntriesDetails = styled(EuiFlexItem)` + padding: ${({ theme }) => theme.eui.euiSize}; +`; + +const StyledEditButton = styled(EuiButton)` + ${({ theme }) => css` + background-color: ${transparentize(0.9, theme.eui.euiColorPrimary)}; + border: none; + font-weight: ${theme.eui.euiFontWeightSemiBold}; + `} +`; + +const StyledRemoveButton = styled(EuiButton)` + ${({ theme }) => css` + background-color: ${transparentize(0.9, theme.eui.euiColorDanger)}; + border: none; + font-weight: ${theme.eui.euiFontWeightSemiBold}; + `} +`; + +const AndOrBadgeContainer = styled(EuiFlexItem)` + padding-top: ${({ theme }) => theme.eui.euiSizeXL}; +`; + +interface ExceptionEntriesComponentProps { + entries: FormattedEntry[]; + handleDelete: () => void; + handleEdit: () => void; +} + +const ExceptionEntriesComponent = ({ + entries, + handleDelete, + handleEdit, +}: ExceptionEntriesComponentProps): JSX.Element => { + const columns = useMemo( + (): Array> => [ + { + field: 'fieldName', + name: 'Field', + sortable: false, + truncateText: true, + 'data-test-subj': 'exceptionFieldNameCell', + width: '30%', + render: (value: string | null, data: FormattedEntry) => { + if (value != null && data.isNested) { + return ( + <> + + {value} + + ); + } else { + return value ?? getEmptyValue(); + } + }, + }, + { + field: 'operator', + name: 'Operator', + sortable: false, + truncateText: true, + 'data-test-subj': 'exceptionFieldOperatorCell', + width: '20%', + render: (value: string | null) => value ?? getEmptyValue(), + }, + { + field: 'value', + name: 'Value', + sortable: false, + truncateText: true, + 'data-test-subj': 'exceptionFieldValueCell', + width: '60%', + render: (values: string | string[] | null) => { + if (Array.isArray(values)) { + return ( + + {values.map((value) => { + return {value}; + })} + + ); + } else { + return values ?? getEmptyValue(); + } + }, + }, + ], + [entries] + ); + + return ( + + + + + {entries.length > 1 && ( + + + + )} + + + + + + + + + + {i18n.EDIT} + + + + + {i18n.REMOVE} + + + + + + + ); +}; + +ExceptionEntriesComponent.displayName = 'ExceptionEntriesComponent'; + +export const ExceptionEntries = React.memo(ExceptionEntriesComponent); + +ExceptionEntries.displayName = 'ExceptionEntries'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx new file mode 100644 index 0000000000000..7d3b7195def80 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { ThemeProvider } from 'styled-components'; +import { mount } from 'enzyme'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +import { ExceptionItem } from './'; +import { getExceptionItemMock } from '../mocks'; + +describe('ExceptionItem', () => { + it('it renders ExceptionDetails and ExceptionEntries', () => { + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('ExceptionDetails')).toHaveLength(1); + expect(wrapper.find('ExceptionEntries')).toHaveLength(1); + }); + + it('it invokes "handleEdit" when edit button clicked', () => { + const mockHandleEdit = jest.fn(); + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + const editBtn = wrapper.find('[data-test-subj="exceptionsViewerEditBtn"] button').at(0); + editBtn.simulate('click'); + + expect(mockHandleEdit).toHaveBeenCalledTimes(1); + }); + + it('it invokes "handleDelete" when delete button clicked', () => { + const mockHandleDelete = jest.fn(); + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + const editBtn = wrapper.find('[data-test-subj="exceptionsViewerDeleteBtn"] button').at(0); + editBtn.simulate('click'); + + expect(mockHandleDelete).toHaveBeenCalledTimes(1); + }); + + it('it renders comment accordion closed to begin with', () => { + const mockHandleDelete = jest.fn(); + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + expect(wrapper.find('.euiAccordion-isOpen')).toHaveLength(0); + }); + + it('it renders comment accordion open when showComments is true', () => { + const mockHandleDelete = jest.fn(); + const exceptionItem = getExceptionItemMock(); + + const wrapper = mount( + ({ eui: euiLightVars, darkMode: false })}> + + + ); + + const commentsBtn = wrapper + .find('.euiButtonEmpty[data-test-subj="exceptionsViewerItemCommentsBtn"]') + .at(0); + commentsBtn.simulate('click'); + + expect(wrapper.find('.euiAccordion-isOpen')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx new file mode 100644 index 0000000000000..f4cdce62f56b3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/index.tsx @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiPanel, + EuiFlexGroup, + EuiCommentProps, + EuiCommentList, + EuiAccordion, + EuiFlexItem, +} from '@elastic/eui'; +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import styled from 'styled-components'; + +import { ExceptionDetails } from './exception_details'; +import { ExceptionEntries } from './exception_entries'; +import { getFormattedEntries, getFormattedComments } from '../helpers'; +import { FormattedEntry, ExceptionListItemSchema } from '../types'; + +const MyFlexItem = styled(EuiFlexItem)` + &.comments--show { + padding: ${({ theme }) => theme.eui.euiSize}; + border-top: ${({ theme }) => `${theme.eui.euiBorderThin}`} + +`; + +interface ExceptionItemProps { + exceptionItem: ExceptionListItemSchema; + commentsAccordionId: string; + handleDelete: ({ id }: { id: string }) => void; + handleEdit: (item: ExceptionListItemSchema) => void; +} + +const ExceptionItemComponent = ({ + exceptionItem, + commentsAccordionId, + handleDelete, + handleEdit, +}: ExceptionItemProps): JSX.Element => { + const [entryItems, setEntryItems] = useState([]); + const [showComments, setShowComments] = useState(false); + + useEffect((): void => { + const formattedEntries = getFormattedEntries(exceptionItem.entries); + setEntryItems(formattedEntries); + }, [exceptionItem.entries]); + + const onDelete = useCallback((): void => { + handleDelete({ id: exceptionItem.id }); + }, [handleDelete, exceptionItem]); + + const onEdit = useCallback((): void => { + handleEdit(exceptionItem); + }, [handleEdit, exceptionItem]); + + const onCommentsClick = useCallback((): void => { + setShowComments(!showComments); + }, [setShowComments, showComments]); + + const formattedComments = useMemo((): EuiCommentProps[] => { + return getFormattedComments(exceptionItem.comments); + }, [exceptionItem]); + + return ( + + + + + + + + + + + + + + + + ); +}; + +ExceptionItemComponent.displayName = 'ExceptionItemComponent'; + +export const ExceptionItem = React.memo(ExceptionItemComponent); + +ExceptionItem.displayName = 'ExceptionItem'; diff --git a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts index d2ee5ae56b7d9..350b53ef52f4e 100644 --- a/x-pack/plugins/security_solution/public/lists_plugin_deps.ts +++ b/x-pack/plugins/security_solution/public/lists_plugin_deps.ts @@ -11,3 +11,4 @@ export { mockNewExceptionItem, mockNewExceptionList, } from '../../lists/public'; +export { ExceptionListItemSchema, Entries } from '../../lists/common/schemas'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/and_or_badge/__examples__/index.stories.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/and_or_badge/__examples__/index.stories.tsx deleted file mode 100644 index f34e9ee214537..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/and_or_badge/__examples__/index.stories.tsx +++ /dev/null @@ -1,12 +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 { storiesOf } from '@storybook/react'; -import React from 'react'; -import { AndOrBadge } from '..'; - -storiesOf('components/AndOrBadge', module) - .add('and', () => ) - .add('or', () => ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/and_or_badge/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/and_or_badge/index.tsx deleted file mode 100644 index 28355372df146..0000000000000 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/and_or_badge/index.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiBadge } from '@elastic/eui'; -import React from 'react'; -import styled from 'styled-components'; - -import * as i18n from './translations'; - -const RoundedBadge = (styled(EuiBadge)` - align-items: center; - border-radius: 100%; - display: inline-flex; - font-size: 9px; - height: 34px; - justify-content: center; - margin: 0 5px 0 5px; - padding: 7px 6px 4px 6px; - user-select: none; - width: 34px; - - .euiBadge__content { - position: relative; - top: -1px; - } - - .euiBadge__text { - text-overflow: clip; - } -` as unknown) as typeof EuiBadge; - -RoundedBadge.displayName = 'RoundedBadge'; - -export type AndOr = 'and' | 'or'; - -/** Displays AND / OR in a round badge */ -// Ref: https://github.com/elastic/eui/issues/1655 -export const AndOrBadge = React.memo<{ type: AndOr }>(({ type }) => { - return ( - - {type === 'and' ? i18n.AND : i18n.OR} - - ); -}); - -AndOrBadge.displayName = 'AndOrBadge'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.tsx index 240b336f4ecce..691c919029261 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/empty.tsx @@ -8,7 +8,7 @@ import { EuiBadge, EuiText } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; -import { AndOrBadge } from '../and_or_badge'; +import { AndOrBadge } from '../../../../common/components/and_or_badge'; import * as i18n from './translations'; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx index bdd5e25eb3a9f..b5d44cf854458 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx @@ -10,7 +10,7 @@ import React, { useMemo } from 'react'; import { Draggable, DraggingStyle, Droppable, NotDraggingStyle } from 'react-beautiful-dnd'; import styled, { css } from 'styled-components'; -import { AndOrBadge } from '../and_or_badge'; +import { AndOrBadge } from '../../../../common/components/and_or_badge'; import { BrowserFields } from '../../../../common/containers/source'; import { getTimelineProviderDroppableId, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx index 77257e367c6f5..beadc13811395 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/helpers.tsx @@ -8,7 +8,7 @@ import { EuiSpacer, EuiText } from '@elastic/eui'; import React from 'react'; import styled from 'styled-components'; -import { AndOrBadge } from '../and_or_badge'; +import { AndOrBadge } from '../../../../common/components/and_or_badge'; import * as i18n from './translations'; import { KqlMode } from '../../../../timelines/store/timeline/model'; diff --git a/x-pack/plugins/security_solution/scripts/storybook.js b/x-pack/plugins/security_solution/scripts/storybook.js index 6566236704936..5f06f2a4ebb12 100644 --- a/x-pack/plugins/security_solution/scripts/storybook.js +++ b/x-pack/plugins/security_solution/scripts/storybook.js @@ -9,5 +9,5 @@ import { join } from 'path'; // eslint-disable-next-line require('@kbn/storybook').runStorybookCli({ name: 'siem', - storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.stories.tsx')], + storyGlobs: [join(__dirname, '..', 'public', '**', 'components', '**', '*.stories.tsx')], }); From a9b2d50e76a2a4c032b5071c737ab41445bf07cd Mon Sep 17 00:00:00 2001 From: Larry Gregory Date: Thu, 4 Jun 2020 12:29:28 -0400 Subject: [PATCH 03/13] Record security feature usage (#67526) Co-authored-by: Elastic Machine --- x-pack/plugins/licensing/server/index.ts | 1 + .../authentication/authenticator.test.ts | 17 ++ .../server/authentication/authenticator.ts | 4 + .../server/authentication/index.test.ts | 6 + .../security/server/authentication/index.ts | 4 + .../feature_usage_service.test.ts | 42 ++++ .../feature_usage/feature_usage_service.ts | 38 ++++ .../server/feature_usage/index.mock.ts | 16 ++ .../security/server/feature_usage/index.ts | 10 + x-pack/plugins/security/server/plugin.test.ts | 4 +- x-pack/plugins/security/server/plugin.ts | 45 ++++- .../routes/authorization/roles/put.test.ts | 186 +++++++++++++++++- .../server/routes/authorization/roles/put.ts | 48 ++++- .../security/server/routes/index.mock.ts | 2 + .../plugins/security/server/routes/index.ts | 4 + .../licensed_feature_usage/feature_usage.ts | 9 +- 16 files changed, 424 insertions(+), 12 deletions(-) create mode 100644 x-pack/plugins/security/server/feature_usage/feature_usage_service.test.ts create mode 100644 x-pack/plugins/security/server/feature_usage/feature_usage_service.ts create mode 100644 x-pack/plugins/security/server/feature_usage/index.mock.ts create mode 100644 x-pack/plugins/security/server/feature_usage/index.ts diff --git a/x-pack/plugins/licensing/server/index.ts b/x-pack/plugins/licensing/server/index.ts index 76e65afc595c4..ba577660d865c 100644 --- a/x-pack/plugins/licensing/server/index.ts +++ b/x-pack/plugins/licensing/server/index.ts @@ -10,6 +10,7 @@ import { LicensingPlugin } from './plugin'; export const plugin = (context: PluginInitializerContext) => new LicensingPlugin(context); export * from '../common/types'; +export { FeatureUsageServiceSetup, FeatureUsageServiceStart } from './services'; export * from './types'; export { config } from './licensing_config'; export { CheckLicense, wrapRouteWithLicenseCheck } from './wrap_route_with_license_check'; diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index 49b7b40659cfc..60d0521a2947e 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -29,6 +29,7 @@ import { AuthenticationResult } from './authentication_result'; import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator'; import { DeauthenticationResult } from './deauthentication_result'; import { BasicAuthenticationProvider, SAMLAuthenticationProvider } from './providers'; +import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; function getMockOptions({ session, @@ -54,6 +55,9 @@ function getMockOptions({ { isTLSEnabled: false } ), sessionStorageFactory: sessionStorageMock.createFactory(), + getFeatureUsageService: jest + .fn() + .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), }; } @@ -1451,6 +1455,9 @@ describe('Authenticator', () => { ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect( + mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage + ).not.toHaveBeenCalled(); }); it('fails if cannot retrieve user session', async () => { @@ -1463,6 +1470,9 @@ describe('Authenticator', () => { ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect( + mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage + ).not.toHaveBeenCalled(); }); it('fails if license doesn allow access agreement acknowledgement', async () => { @@ -1477,6 +1487,9 @@ describe('Authenticator', () => { ); expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect( + mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage + ).not.toHaveBeenCalled(); }); it('properly acknowledges access agreement for the authenticated user', async () => { @@ -1493,6 +1506,10 @@ describe('Authenticator', () => { type: 'basic', name: 'basic1', }); + + expect( + mockOptions.getFeatureUsageService().recordPreAccessAgreementUsage + ).toHaveBeenCalledTimes(1); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index 98342a8494e38..ac5c2a72b9667 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -38,6 +38,7 @@ import { DeauthenticationResult } from './deauthentication_result'; import { Tokens } from './tokens'; import { canRedirectRequest } from './can_redirect_request'; import { HTTPAuthorizationHeader } from './http_authentication'; +import { SecurityFeatureUsageServiceStart } from '../feature_usage'; /** * The shape of the session that is actually stored in the cookie. @@ -94,6 +95,7 @@ export interface ProviderLoginAttempt { export interface AuthenticatorOptions { auditLogger: SecurityAuditLogger; + getFeatureUsageService: () => SecurityFeatureUsageServiceStart; getCurrentUser: (request: KibanaRequest) => AuthenticatedUser | null; config: Pick; basePath: HttpServiceSetup['basePath']; @@ -502,6 +504,8 @@ export class Authenticator { currentUser.username, existingSession.provider ); + + this.options.getFeatureUsageService().recordPreAccessAgreementUsage(); } /** diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 1c1e0ed781f18..c7323509c00d6 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -42,6 +42,8 @@ import { } from './api_keys'; import { SecurityLicense } from '../../common/licensing'; import { SecurityAuditLogger } from '../audit'; +import { SecurityFeatureUsageServiceStart } from '../feature_usage'; +import { securityFeatureUsageServiceMock } from '../feature_usage/index.mock'; describe('setupAuthentication()', () => { let mockSetupAuthenticationParams: { @@ -51,6 +53,7 @@ describe('setupAuthentication()', () => { http: jest.Mocked; clusterClient: jest.Mocked; license: jest.Mocked; + getFeatureUsageService: () => jest.Mocked; }; let mockScopedClusterClient: jest.Mocked>; beforeEach(() => { @@ -69,6 +72,9 @@ describe('setupAuthentication()', () => { clusterClient: elasticsearchServiceMock.createClusterClient(), license: licenseMock.create(), loggers: loggingServiceMock.create(), + getFeatureUsageService: jest + .fn() + .mockReturnValue(securityFeatureUsageServiceMock.createStartContract()), }; mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 779b852195b02..ec48c727a5739 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -17,6 +17,7 @@ import { ConfigType } from '../config'; import { getErrorStatusCode } from '../errors'; import { Authenticator, ProviderSession } from './authenticator'; import { APIKeys, CreateAPIKeyParams, InvalidateAPIKeyParams } from './api_keys'; +import { SecurityFeatureUsageServiceStart } from '../feature_usage'; export { canRedirectRequest } from './can_redirect_request'; export { Authenticator, ProviderLoginAttempt } from './authenticator'; @@ -37,6 +38,7 @@ export { interface SetupAuthenticationParams { auditLogger: SecurityAuditLogger; + getFeatureUsageService: () => SecurityFeatureUsageServiceStart; http: CoreSetup['http']; clusterClient: IClusterClient; config: ConfigType; @@ -48,6 +50,7 @@ export type Authentication = UnwrapPromise { + it('registers all known security features', () => { + const featureUsage = { register: jest.fn() }; + const securityFeatureUsage = new SecurityFeatureUsageService(); + securityFeatureUsage.setup({ featureUsage }); + expect(featureUsage.register).toHaveBeenCalledTimes(2); + expect(featureUsage.register.mock.calls.map((c) => c[0])).toMatchInlineSnapshot(` + Array [ + "Subfeature privileges", + "Pre-access agreement", + ] + `); + }); +}); + +describe('start contract', () => { + it('notifies when sub-feature privileges are in use', () => { + const featureUsage = { notifyUsage: jest.fn(), getLastUsages: jest.fn() }; + const securityFeatureUsage = new SecurityFeatureUsageService(); + const startContract = securityFeatureUsage.start({ featureUsage }); + startContract.recordSubFeaturePrivilegeUsage(); + expect(featureUsage.notifyUsage).toHaveBeenCalledTimes(1); + expect(featureUsage.notifyUsage).toHaveBeenCalledWith('Subfeature privileges'); + }); + + it('notifies when pre-access agreement is used', () => { + const featureUsage = { notifyUsage: jest.fn(), getLastUsages: jest.fn() }; + const securityFeatureUsage = new SecurityFeatureUsageService(); + const startContract = securityFeatureUsage.start({ featureUsage }); + startContract.recordPreAccessAgreementUsage(); + expect(featureUsage.notifyUsage).toHaveBeenCalledTimes(1); + expect(featureUsage.notifyUsage).toHaveBeenCalledWith('Pre-access agreement'); + }); +}); diff --git a/x-pack/plugins/security/server/feature_usage/feature_usage_service.ts b/x-pack/plugins/security/server/feature_usage/feature_usage_service.ts new file mode 100644 index 0000000000000..1bc1e664981bf --- /dev/null +++ b/x-pack/plugins/security/server/feature_usage/feature_usage_service.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FeatureUsageServiceSetup, FeatureUsageServiceStart } from '../../../licensing/server'; + +interface SetupDeps { + featureUsage: FeatureUsageServiceSetup; +} + +interface StartDeps { + featureUsage: FeatureUsageServiceStart; +} + +export interface SecurityFeatureUsageServiceStart { + recordPreAccessAgreementUsage: () => void; + recordSubFeaturePrivilegeUsage: () => void; +} + +export class SecurityFeatureUsageService { + public setup({ featureUsage }: SetupDeps) { + featureUsage.register('Subfeature privileges', 'gold'); + featureUsage.register('Pre-access agreement', 'gold'); + } + + public start({ featureUsage }: StartDeps): SecurityFeatureUsageServiceStart { + return { + recordPreAccessAgreementUsage() { + featureUsage.notifyUsage('Pre-access agreement'); + }, + recordSubFeaturePrivilegeUsage() { + featureUsage.notifyUsage('Subfeature privileges'); + }, + }; + } +} diff --git a/x-pack/plugins/security/server/feature_usage/index.mock.ts b/x-pack/plugins/security/server/feature_usage/index.mock.ts new file mode 100644 index 0000000000000..6ed42145abd76 --- /dev/null +++ b/x-pack/plugins/security/server/feature_usage/index.mock.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 { SecurityFeatureUsageServiceStart } from './feature_usage_service'; + +export const securityFeatureUsageServiceMock = { + createStartContract() { + return { + recordPreAccessAgreementUsage: jest.fn(), + recordSubFeaturePrivilegeUsage: jest.fn(), + } as jest.Mocked; + }, +}; diff --git a/x-pack/plugins/security/server/feature_usage/index.ts b/x-pack/plugins/security/server/feature_usage/index.ts new file mode 100644 index 0000000000000..a3e1f35ee3824 --- /dev/null +++ b/x-pack/plugins/security/server/feature_usage/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + SecurityFeatureUsageService, + SecurityFeatureUsageServiceStart, +} from './feature_usage_service'; diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 3e30ff9447f3e..b7fd2aeae7edc 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -41,7 +41,9 @@ describe('Security Plugin', () => { mockClusterClient = elasticsearchServiceMock.createCustomClusterClient(); mockCoreSetup.elasticsearch.legacy.createClient.mockReturnValue(mockClusterClient); - mockDependencies = { licensing: { license$: of({}) } } as PluginSetupDependencies; + mockDependencies = ({ + licensing: { license$: of({}), featureUsage: { register: jest.fn() } }, + } as unknown) as PluginSetupDependencies; }); describe('setup()', () => { diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index bdda0be9b15a7..b961b4e2f9066 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -12,11 +12,15 @@ import { CoreSetup, Logger, PluginInitializerContext, + CoreStart, } from '../../../../src/core/server'; import { deepFreeze } from '../../../../src/core/server'; import { SpacesPluginSetup } from '../../spaces/server'; -import { PluginSetupContract as FeaturesSetupContract } from '../../features/server'; -import { LicensingPluginSetup } from '../../licensing/server'; +import { + PluginSetupContract as FeaturesSetupContract, + PluginStartContract as FeaturesStartContract, +} from '../../features/server'; +import { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; import { Authentication, setupAuthentication } from './authentication'; import { Authorization, setupAuthorization } from './authorization'; @@ -26,6 +30,7 @@ import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; import { setupSavedObjects } from './saved_objects'; import { AuditService, SecurityAuditLogger, AuditServiceSetup } from './audit'; import { elasticsearchClientPlugin } from './elasticsearch_client_plugin'; +import { SecurityFeatureUsageService, SecurityFeatureUsageServiceStart } from './feature_usage'; export type SpacesService = Pick< SpacesPluginSetup['spacesService'], @@ -72,6 +77,11 @@ export interface PluginSetupDependencies { licensing: LicensingPluginSetup; } +export interface PluginStartDependencies { + features: FeaturesStartContract; + licensing: LicensingPluginStart; +} + /** * Represents Security Plugin instance that will be managed by the Kibana plugin system. */ @@ -80,6 +90,16 @@ export class Plugin { private clusterClient?: ICustomClusterClient; private spacesService?: SpacesService | symbol = Symbol('not accessed'); private securityLicenseService?: SecurityLicenseService; + + private readonly featureUsageService = new SecurityFeatureUsageService(); + private featureUsageServiceStart?: SecurityFeatureUsageServiceStart; + private readonly getFeatureUsageService = () => { + if (!this.featureUsageServiceStart) { + throw new Error(`featureUsageServiceStart is not registered!`); + } + return this.featureUsageServiceStart; + }; + private readonly auditService = new AuditService(this.initializerContext.logger.get('audit')); private readonly getSpacesService = () => { @@ -95,7 +115,10 @@ export class Plugin { this.logger = this.initializerContext.logger.get(); } - public async setup(core: CoreSetup, { features, licensing }: PluginSetupDependencies) { + public async setup( + core: CoreSetup, + { features, licensing }: PluginSetupDependencies + ) { const [config, legacyConfig] = await combineLatest([ this.initializerContext.config.create>().pipe( map((rawConfig) => @@ -118,11 +141,14 @@ export class Plugin { license$: licensing.license$, }); + this.featureUsageService.setup({ featureUsage: licensing.featureUsage }); + const audit = this.auditService.setup({ license, config: config.audit }); const auditLogger = new SecurityAuditLogger(audit.getLogger()); const authc = await setupAuthentication({ auditLogger, + getFeatureUsageService: this.getFeatureUsageService, http: core.http, clusterClient: this.clusterClient, config, @@ -160,6 +186,11 @@ export class Plugin { authc, authz, license, + getFeatures: () => + core + .getStartServices() + .then(([, { features: featuresStart }]) => featuresStart.getFeatures()), + getFeatureUsageService: this.getFeatureUsageService, }); return deepFreeze({ @@ -199,8 +230,11 @@ export class Plugin { }); } - public start() { + public start(core: CoreStart, { licensing }: PluginStartDependencies) { this.logger.debug('Starting plugin'); + this.featureUsageServiceStart = this.featureUsageService.start({ + featureUsage: licensing.featureUsage, + }); } public stop() { @@ -216,6 +250,9 @@ export class Plugin { this.securityLicenseService = undefined; } + if (this.featureUsageServiceStart) { + this.featureUsageServiceStart = undefined; + } this.auditService.stop(); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts index d7710bf669ce1..bec60fa149bcf 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.test.ts @@ -15,6 +15,8 @@ import { httpServerMock, } from '../../../../../../../src/core/server/mocks'; import { routeDefinitionParamsMock } from '../../index.mock'; +import { Feature } from '../../../../../features/server'; +import { securityFeatureUsageServiceMock } from '../../../feature_usage/index.mock'; const application = 'kibana-.kibana'; const privilegeMap = { @@ -47,7 +49,12 @@ interface TestOptions { licenseCheckResult?: LicenseCheck; apiResponses?: Array<() => Promise>; payload?: Record; - asserts: { statusCode: number; result?: Record; apiArguments?: unknown[][] }; + asserts: { + statusCode: number; + result?: Record; + apiArguments?: unknown[][]; + recordSubFeaturePrivilegeUsage?: boolean; + }; } const putRoleTest = ( @@ -71,6 +78,47 @@ const putRoleTest = ( mockScopedClusterClient.callAsCurrentUser.mockImplementationOnce(apiResponse); } + mockRouteDefinitionParams.getFeatureUsageService.mockReturnValue( + securityFeatureUsageServiceMock.createStartContract() + ); + + mockRouteDefinitionParams.getFeatures.mockResolvedValue([ + new Feature({ + id: 'feature_1', + name: 'feature 1', + app: [], + privileges: { + all: { + ui: [], + savedObject: { all: [], read: [] }, + }, + read: { + ui: [], + savedObject: { all: [], read: [] }, + }, + }, + subFeatures: [ + { + name: 'sub feature 1', + privilegeGroups: [ + { + groupType: 'independent', + privileges: [ + { + id: 'sub_feature_privilege_1', + name: 'first sub-feature privilege', + includeIn: 'none', + ui: [], + savedObject: { all: [], read: [] }, + }, + ], + }, + ], + }, + ], + }), + ]); + definePutRolesRoutes(mockRouteDefinitionParams); const [[{ validate }, handler]] = mockRouteDefinitionParams.router.put.mock.calls; @@ -99,6 +147,16 @@ const putRoleTest = ( expect(mockScopedClusterClient.callAsCurrentUser).not.toHaveBeenCalled(); } expect(mockContext.licensing.license.check).toHaveBeenCalledWith('security', 'basic'); + + if (asserts.recordSubFeaturePrivilegeUsage) { + expect( + mockRouteDefinitionParams.getFeatureUsageService().recordSubFeaturePrivilegeUsage + ).toHaveBeenCalledTimes(1); + } else { + expect( + mockRouteDefinitionParams.getFeatureUsageService().recordSubFeaturePrivilegeUsage + ).not.toHaveBeenCalled(); + } }); }; @@ -598,5 +656,131 @@ describe('PUT role', () => { result: undefined, }, }); + + putRoleTest(`notifies when sub-feature privileges are included`, { + name: 'foo-role', + payload: { + kibana: [ + { + spaces: ['*'], + feature: { + feature_1: ['sub_feature_privilege_1'], + }, + }, + ], + }, + apiResponses: [async () => ({}), async () => {}], + asserts: { + recordSubFeaturePrivilegeUsage: true, + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + cluster: [], + indices: [], + run_as: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_feature_1.sub_feature_privilege_1'], + resources: ['*'], + }, + ], + metadata: undefined, + }, + }, + ], + ], + statusCode: 204, + result: undefined, + }, + }); + + putRoleTest(`does not record sub-feature privilege usage for unknown privileges`, { + name: 'foo-role', + payload: { + kibana: [ + { + spaces: ['*'], + feature: { + feature_1: ['unknown_sub_feature_privilege_1'], + }, + }, + ], + }, + apiResponses: [async () => ({}), async () => {}], + asserts: { + recordSubFeaturePrivilegeUsage: false, + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + cluster: [], + indices: [], + run_as: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_feature_1.unknown_sub_feature_privilege_1'], + resources: ['*'], + }, + ], + metadata: undefined, + }, + }, + ], + ], + statusCode: 204, + result: undefined, + }, + }); + + putRoleTest(`does not record sub-feature privilege usage for unknown features`, { + name: 'foo-role', + payload: { + kibana: [ + { + spaces: ['*'], + feature: { + unknown_feature: ['sub_feature_privilege_1'], + }, + }, + ], + }, + apiResponses: [async () => ({}), async () => {}], + asserts: { + recordSubFeaturePrivilegeUsage: false, + apiArguments: [ + ['shield.getRole', { name: 'foo-role', ignore: [404] }], + [ + 'shield.putRole', + { + name: 'foo-role', + body: { + cluster: [], + indices: [], + run_as: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_unknown_feature.sub_feature_privilege_1'], + resources: ['*'], + }, + ], + metadata: undefined, + }, + }, + ], + ], + statusCode: 204, + result: undefined, + }, + }); }); }); diff --git a/x-pack/plugins/security/server/routes/authorization/roles/put.ts b/x-pack/plugins/security/server/routes/authorization/roles/put.ts index 5db83375afa96..d83cf92bcaa0d 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/put.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/put.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; +import { Feature } from '../../../../../features/common'; import { RouteDefinitionParams } from '../../index'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; import { wrapIntoCustomErrorResponse } from '../../../errors'; @@ -14,7 +15,37 @@ import { transformPutPayloadToElasticsearchRole, } from './model'; -export function definePutRolesRoutes({ router, authz, clusterClient }: RouteDefinitionParams) { +const roleGrantsSubFeaturePrivileges = ( + features: Feature[], + role: TypeOf> +) => { + if (!role.kibana) { + return false; + } + + const subFeaturePrivileges = new Map( + features.map((feature) => [ + feature.id, + feature.subFeatures.map((sf) => sf.privilegeGroups.map((pg) => pg.privileges)).flat(2), + ]) + ); + + const hasAnySubFeaturePrivileges = role.kibana.some((kibanaPrivilege) => + Object.entries(kibanaPrivilege.feature ?? {}).some(([featureId, privileges]) => { + return !!subFeaturePrivileges.get(featureId)?.some(({ id }) => privileges.includes(id)); + }) + ); + + return hasAnySubFeaturePrivileges; +}; + +export function definePutRolesRoutes({ + router, + authz, + clusterClient, + getFeatures, + getFeatureUsageService, +}: RouteDefinitionParams) { router.put( { path: '/api/security/role/{name}', @@ -46,9 +77,16 @@ export function definePutRolesRoutes({ router, authz, clusterClient }: RouteDefi rawRoles[name] ? rawRoles[name].applications : [] ); - await clusterClient - .asScoped(request) - .callAsCurrentUser('shield.putRole', { name: request.params.name, body }); + const [features] = await Promise.all([ + getFeatures(), + clusterClient + .asScoped(request) + .callAsCurrentUser('shield.putRole', { name: request.params.name, body }), + ]); + + if (roleGrantsSubFeaturePrivileges(features, request.body)) { + getFeatureUsageService().recordSubFeaturePrivilegeUsage(); + } return response.noContent(); } catch (error) { diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index b0c74b98ee19b..1a93d6701e257 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -29,5 +29,7 @@ export const routeDefinitionParamsMock = { authz: authorizationMock.create(), license: licenseMock.create(), httpResources: httpResourcesMock.createRegistrar(), + getFeatures: jest.fn(), + getFeatureUsageService: jest.fn(), }), }; diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index e43072b95c906..7ef7638fc027e 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Feature } from '../../../features/server'; import { CoreSetup, HttpResources, @@ -23,6 +24,7 @@ import { defineIndicesRoutes } from './indices'; import { defineUsersRoutes } from './users'; import { defineRoleMappingRoutes } from './role_mapping'; import { defineViewRoutes } from './views'; +import { SecurityFeatureUsageServiceStart } from '../feature_usage'; /** * Describes parameters used to define HTTP routes. @@ -37,6 +39,8 @@ export interface RouteDefinitionParams { authc: Authentication; authz: Authorization; license: SecurityLicense; + getFeatures: () => Promise; + getFeatureUsageService: () => SecurityFeatureUsageServiceStart; } export function defineRoutes(params: RouteDefinitionParams) { diff --git a/x-pack/test/plugin_api_integration/test_suites/licensed_feature_usage/feature_usage.ts b/x-pack/test/plugin_api_integration/test_suites/licensed_feature_usage/feature_usage.ts index 5c8fac9586e3a..e16d55f8fad2c 100644 --- a/x-pack/test/plugin_api_integration/test_suites/licensed_feature_usage/feature_usage.ts +++ b/x-pack/test/plugin_api_integration/test_suites/licensed_feature_usage/feature_usage.ts @@ -27,7 +27,14 @@ export default function ({ getService }: FtrProviderContext) { const response = await supertest.get('/api/licensing/feature_usage').expect(200); - expect(response.body).to.eql({ + const testFeaturesResponse = { + ...response.body, + features: response.body.features.filter((feature: { name: string }) => + feature.name.startsWith('Test feature ') + ), + }; + + expect(testFeaturesResponse).to.eql({ features: [ { last_used: null, From efcc2bf6b7a8c5a3d85a184f30bb01e8db41c7e7 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Thu, 4 Jun 2020 09:38:39 -0700 Subject: [PATCH 04/13] Update ILM UI to support presence of searchable snapshot options (#68047) --- .../application/store/selectors/lifecycle.js | 4 +- .../api/policies/register_create_route.ts | 13 ++++- .../index_lifecycle_management/fixtures.js | 55 +++++++++++++++++-- .../index_lifecycle_management/indices.js | 6 +- .../policies.helpers.js | 3 +- .../index_lifecycle_management/policies.js | 14 ++--- .../index_lifecycle_management/templates.js | 2 +- 7 files changed, 75 insertions(+), 22 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js index 789de0f528b1b..03538fad9aa83 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/store/selectors/lifecycle.js @@ -270,7 +270,9 @@ export const getLifecycle = (state) => { if (phaseName === PHASE_DELETE) { accum[phaseName].actions = { ...accum[phaseName].actions, - delete: {}, + delete: { + ...accum[phaseName].actions.delete, + }, }; } } diff --git a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts index 2eb635e19be48..7bf3f96e2b2ef 100644 --- a/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts +++ b/x-pack/plugins/index_lifecycle_management/server/routes/api/policies/register_create_route.ts @@ -67,7 +67,7 @@ const warmPhaseSchema = schema.maybe( actions: schema.object({ set_priority: setPrioritySchema, unfollow: unfollowSchema, - read_only: schema.maybe(schema.object({})), // Readonly has no options + readonly: schema.maybe(schema.object({})), // Readonly has no options allocate: allocateSchema, shrink: schema.maybe( schema.object({ @@ -91,6 +91,11 @@ const coldPhaseSchema = schema.maybe( unfollow: unfollowSchema, allocate: allocateSchema, freeze: schema.maybe(schema.object({})), // Freeze has no options + searchable_snapshot: schema.maybe( + schema.object({ + snapshot_repository: schema.string(), + }) + ), }), }) ); @@ -104,7 +109,11 @@ const deletePhaseSchema = schema.maybe( policy: schema.string(), }) ), - delete: schema.maybe(schema.object({})), // Delete has no options + delete: schema.maybe( + schema.object({ + delete_searchable_snapshot: schema.maybe(schema.boolean()), + }) + ), }), }) ); diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/fixtures.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/fixtures.js index 6dc1982d70f69..12975c484ebee 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/fixtures.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/fixtures.js @@ -4,21 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getRandomString } from './lib'; import { INDEX_TEMPLATE_PATTERN_PREFIX } from './constants'; -export const getPolicyPayload = ({ name = getRandomString() } = {}) => ({ +export const getPolicyPayload = (name) => ({ name, phases: { hot: { + min_age: '1d', actions: { + set_priority: { + priority: 100, + }, + unfollow: {}, rollover: { max_age: '30d', max_size: '50gb', }, - set_priority: { - priority: 100, - }, }, }, warm: { @@ -26,6 +27,26 @@ export const getPolicyPayload = ({ name = getRandomString() } = {}) => ({ set_priority: { priority: 50, }, + unfollow: {}, + readonly: {}, + allocate: { + number_of_replicas: 5, + include: { + a: 'a', + }, + exclude: { + b: 'b', + }, + require: { + c: 'c', + }, + }, + shrink: { + number_of_shards: 1, + }, + forcemerge: { + max_num_segments: 1, + }, }, }, cold: { @@ -34,12 +55,34 @@ export const getPolicyPayload = ({ name = getRandomString() } = {}) => ({ set_priority: { priority: 0, }, + unfollow: {}, + allocate: { + number_of_replicas: 5, + include: { + a: 'a', + }, + exclude: { + b: 'b', + }, + require: { + c: 'c', + }, + }, + freeze: {}, + searchable_snapshot: { + snapshot_repository: 'backing_repo', + }, }, }, delete: { min_age: '10d', actions: { - delete: {}, + wait_for_snapshot: { + policy: 'policy', + }, + delete: { + delete_searchable_snapshot: true, + }, }, }, }, diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/indices.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/indices.js index 6a6b8d790d9e5..af9ff4bf1bd9a 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/indices.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/indices.js @@ -29,7 +29,7 @@ export default function ({ getService }) { describe('policies', () => { it('should add a lifecycle policy to the index', async () => { // Create a policy - const policy = getPolicyPayload(); + const policy = getPolicyPayload('indices-test-policy'); const { name: policyName } = policy; await createPolicy(policy); @@ -52,7 +52,7 @@ export default function ({ getService }) { it('should remove a lifecycle policy from an index', async () => { // Create a policy - const policy = getPolicyPayload(); + const policy = getPolicyPayload('remove-test-policy'); const { name: policyName } = policy; await createPolicy(policy); @@ -77,7 +77,7 @@ export default function ({ getService }) { describe('index management extension', () => { it('should have an endpoint to retry a policy for an index that is in the ERROR step', async () => { // Create a policy - const policy = getPolicyPayload(); + const policy = getPolicyPayload('extension-test-policy'); const { name: policyName } = policy; await createPolicy(policy); diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.helpers.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.helpers.js index a8863c5dc6c78..d2b00365cd3e0 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.helpers.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.helpers.js @@ -5,7 +5,6 @@ */ import { API_BASE_PATH, DEFAULT_POLICY_NAME } from './constants'; -import { getPolicyPayload } from './fixtures'; import { getPolicyNames } from './lib'; export const registerHelpers = ({ supertest }) => { @@ -14,7 +13,7 @@ export const registerHelpers = ({ supertest }) => { ? supertest.get(`${API_BASE_PATH}/policies?withIndices=true`) : supertest.get(`${API_BASE_PATH}/policies`); - const createPolicy = (policy = getPolicyPayload()) => { + const createPolicy = (policy) => { return supertest.post(`${API_BASE_PATH}/policies`).set('kbn-xsrf', 'xxx').send(policy); }; diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js index 9fd38a6b32a60..fad7fb848122d 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/policies.js @@ -32,9 +32,7 @@ export default function ({ getService }) { after(() => Promise.all([cleanUpEsResources(), cleanUpPolicies()])); describe('list', () => { - // Disabled as the underline ES API has changed. Need to investigate - // Opened issue: https://github.com/elastic/kibana/issues/62778 - it.skip('should have a default policy to manage the Watcher history indices', async () => { + it('should have a default policy to manage the Watcher history indices', async () => { const { body } = await loadPolicies().expect(200); const policy = body.find((policy) => policy.name === DEFAULT_POLICY_NAME); @@ -50,7 +48,9 @@ export default function ({ getService }) { delete: { min_age: '7d', actions: { - delete: {}, + delete: { + delete_searchable_snapshot: true, + }, }, }, }, @@ -61,7 +61,7 @@ export default function ({ getService }) { it('should add the indices linked to the policies', async () => { // Create a policy - const policy = getPolicyPayload(); + const policy = getPolicyPayload('link-test-policy'); const { name: policyName } = policy; await createPolicy(policy); @@ -78,7 +78,7 @@ export default function ({ getService }) { describe('create', () => { it('should create a lifecycle policy', async () => { - const policy = getPolicyPayload(); + const policy = getPolicyPayload('create-test-policy'); const { name } = policy; // Load current policies @@ -96,7 +96,7 @@ export default function ({ getService }) { describe('delete', () => { it('should delete the policy created', async () => { - const policy = getPolicyPayload(); + const policy = getPolicyPayload('delete-test-policy'); const { name } = policy; // Create new policy diff --git a/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.js b/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.js index 2287558d9ef32..7fb9b35b8475e 100644 --- a/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.js +++ b/x-pack/test/api_integration/apis/management/index_lifecycle_management/templates.js @@ -51,7 +51,7 @@ export default function ({ getService }) { describe('update', () => { it('should add a policy to a template', async () => { // Create policy - const policy = getPolicyPayload(); + const policy = getPolicyPayload('template-test-policy'); const { name: policyName } = policy; await createPolicy(policy); From edc4d58e124d7a0eadea249dc949704386a0c0c2 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Thu, 4 Jun 2020 13:39:43 -0400 Subject: [PATCH 05/13] [ML] DF Analytics: Creation wizard part 1 (#67564) * create newJob route and start of wizard * wip: create configStep component * finish configStep form and details * wip: create andvanced step components * create details step component * createStep component * ensure advanced options are correct for each job type * add validation to each step * use custom table for excludes * move customSelectionTable to shared components * form validation for advanced fields * wip: source index selection modal * add source index preview * update details * ensure advanced parameters added to config on creation * can create job from savedSearch. can set source query in ui * validate source object has supported fields * eslint updates * update tests. comment out clone action for now * add create button to advanced editor * remove deprecated test helper functions * fix translation errors * update help text. read only once job created. * fix functional tests * add nextStepNav to df service for tests * fix excludes table page jump and hyperParameter not showing in details * fix checkbox width for custom table --- .../custom_selection_table.js | 65 +-- .../custom_selection_table/index.js | 0 .../job_selector_table/job_selector_table.js | 7 +- .../data_frame_analytics/_index.scss | 1 + .../data_frame_analytics/common/analytics.ts | 17 + .../data_frame_analytics/common/index.ts | 1 + .../advanced_step/advanced_step.tsx | 32 ++ .../advanced_step/advanced_step_details.tsx | 274 +++++++++++ .../advanced_step/advanced_step_form.tsx | 332 +++++++++++++ .../advanced_step/hyper_parameters.tsx | 188 ++++++++ .../components/advanced_step/index.ts | 7 + .../outlier_hyper_parameters.tsx | 153 ++++++ .../back_to_list_panel/back_to_list_panel.tsx | 35 ++ .../components/back_to_list_panel/index.ts | 7 + .../analysis_fields_table.tsx | 207 ++++++++ .../configuration_step/configuration_step.tsx | 35 ++ .../configuration_step_details.tsx | 115 +++++ .../configuration_step_form.tsx | 449 ++++++++++++++++++ .../components/configuration_step/index.ts | 7 + .../configuration_step/job_type.tsx | 83 ++++ .../supported_fields_message.tsx | 120 +++++ .../configuration_step/use_saved_search.ts | 50 ++ .../components/continue_button.tsx | 34 ++ .../components/create_step/create_step.tsx | 95 ++++ .../components/create_step/index.ts | 7 + .../components/details_step/details_step.tsx | 32 ++ .../details_step/details_step_details.tsx | 87 ++++ .../details_step/details_step_form.tsx | 221 +++++++++ .../components/details_step/index.ts | 7 + .../analytics_creation/components/index.ts | 10 + .../pages/analytics_creation/hooks/index.ts | 7 + .../hooks/use_index_data.ts | 103 ++++ .../pages/analytics_creation/index.ts | 7 + .../pages/analytics_creation/page.tsx | 191 ++++++++ .../exploration_query_bar.tsx | 30 +- .../components/analytics_list/actions.tsx | 14 +- .../analytics_list/analytics_list.tsx | 15 +- .../create_analytics_advanced_editor.tsx | 204 ++++---- .../create_analytics_button/_index.scss | 4 + .../create_analytics_button.test.tsx | 4 +- .../create_analytics_button.tsx | 19 +- .../form_options_validation.ts | 2 +- .../create_analytics_form/messages.tsx | 1 + .../components/source_selection/index.ts | 7 + .../source_selection/source_selection.tsx | 100 ++++ .../use_create_analytics_form/actions.ts | 1 + .../hooks/use_create_analytics_form/index.ts | 7 +- .../hooks/use_create_analytics_form/state.ts | 96 +++- .../use_create_analytics_form.ts | 22 +- .../analytics_job_creation.tsx | 41 ++ .../routes/data_frame_analytics/index.ts | 1 + .../routes/schemas/data_analytics_schema.ts | 1 + .../classification_creation.ts | 67 +-- .../apps/ml/data_frame_analytics/cloning.ts | 10 - .../apps/ml/data_frame_analytics/index.ts | 2 +- .../outlier_detection_creation.ts | 47 +- .../regression_creation.ts | 67 +-- x-pack/test/functional/services/ml/api.ts | 42 +- .../services/ml/data_frame_analytics.ts | 3 +- .../ml/data_frame_analytics_creation.ts | 128 ++--- .../services/ml/data_frame_analytics_table.ts | 1 + .../services/ml/job_source_selection.ts | 4 + 62 files changed, 3604 insertions(+), 322 deletions(-) rename x-pack/plugins/ml/public/application/components/{job_selector => }/custom_selection_table/custom_selection_table.js (84%) rename x-pack/plugins/ml/public/application/components/{job_selector => }/custom_selection_table/index.js (100%) create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/outlier_hyper_parameters.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_details.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/continue_button.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_details.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/_index.scss create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/index.ts create mode 100644 x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/source_selection/source_selection.tsx create mode 100644 x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx diff --git a/x-pack/plugins/ml/public/application/components/job_selector/custom_selection_table/custom_selection_table.js b/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js similarity index 84% rename from x-pack/plugins/ml/public/application/components/job_selector/custom_selection_table/custom_selection_table.js rename to x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js index af282d53273d1..c86b716b2f49b 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/custom_selection_table/custom_selection_table.js +++ b/x-pack/plugins/ml/public/application/components/custom_selection_table/custom_selection_table.js @@ -29,7 +29,7 @@ import { import { Pager } from '@elastic/eui/lib/services'; import { i18n } from '@kbn/i18n'; -const JOBS_PER_PAGE = 20; +const ITEMS_PER_PAGE = 20; function getError(error) { if (error !== null) { @@ -43,15 +43,18 @@ function getError(error) { } export function CustomSelectionTable({ + checkboxDisabledCheck, columns, filterDefaultFields, filters, items, + itemsPerPage = ITEMS_PER_PAGE, onTableChange, + radioDisabledCheck, selectedIds, singleSelection, sortableProperties, - timeseriesOnly, + tableItemId = 'id', }) { const [itemIdToSelectedMap, setItemIdToSelectedMap] = useState(getCurrentlySelectedItemIdsMap()); const [currentItems, setCurrentItems] = useState(items); @@ -59,7 +62,7 @@ export function CustomSelectionTable({ const [sortedColumn, setSortedColumn] = useState(''); const [pager, setPager] = useState(); const [pagerSettings, setPagerSettings] = useState({ - itemsPerPage: JOBS_PER_PAGE, + itemsPerPage: itemsPerPage, firstItemIndex: 0, lastItemIndex: 1, }); @@ -77,9 +80,9 @@ export function CustomSelectionTable({ }, [selectedIds]); // eslint-disable-line useEffect(() => { - const tablePager = new Pager(currentItems.length, JOBS_PER_PAGE); + const tablePager = new Pager(currentItems.length, itemsPerPage); setPagerSettings({ - itemsPerPage: JOBS_PER_PAGE, + itemsPerPage: itemsPerPage, firstItemIndex: tablePager.getFirstItemIndex(), lastItemIndex: tablePager.getLastItemIndex(), }); @@ -100,7 +103,7 @@ export function CustomSelectionTable({ function handleTableChange({ isSelected, itemId }) { const selectedMapIds = Object.getOwnPropertyNames(itemIdToSelectedMap); - const currentItemIds = currentItems.map((item) => item.id); + const currentItemIds = currentItems.map((item) => item[tableItemId]); let currentSelected = selectedMapIds.filter( (id) => itemIdToSelectedMap[id] === true && id !== itemId @@ -124,11 +127,11 @@ export function CustomSelectionTable({ onTableChange(currentSelected); } - function handleChangeItemsPerPage(itemsPerPage) { - pager.setItemsPerPage(itemsPerPage); + function handleChangeItemsPerPage(numItemsPerPage) { + pager.setItemsPerPage(numItemsPerPage); setPagerSettings({ ...pagerSettings, - itemsPerPage, + itemsPerPage: numItemsPerPage, firstItemIndex: pager.getFirstItemIndex(), lastItemIndex: pager.getLastItemIndex(), }); @@ -161,7 +164,9 @@ export function CustomSelectionTable({ } function areAllItemsSelected() { - const indexOfUnselectedItem = currentItems.findIndex((item) => !isItemSelected(item.id)); + const indexOfUnselectedItem = currentItems.findIndex( + (item) => !isItemSelected(item[tableItemId]) + ); return indexOfUnselectedItem === -1; } @@ -199,7 +204,7 @@ export function CustomSelectionTable({ function toggleAll() { const allSelected = areAllItemsSelected() || itemIdToSelectedMap.all === true; const newItemIdToSelectedMap = {}; - currentItems.forEach((item) => (newItemIdToSelectedMap[item.id] = !allSelected)); + currentItems.forEach((item) => (newItemIdToSelectedMap[item[tableItemId]] = !allSelected)); setItemIdToSelectedMap(newItemIdToSelectedMap); handleTableChange({ isSelected: !allSelected, itemId: 'all' }); } @@ -255,20 +260,23 @@ export function CustomSelectionTable({ {!singleSelection && ( toggleItem(item.id)} + disabled={ + checkboxDisabledCheck !== undefined ? checkboxDisabledCheck(item) : undefined + } + id={`${item[tableItemId]}-checkbox`} + data-test-subj={`${item[tableItemId]}-checkbox`} + checked={isItemSelected(item[tableItemId])} + onChange={() => toggleItem(item[tableItemId])} type="inList" /> )} {singleSelection && ( toggleItem(item.id)} - disabled={timeseriesOnly && item.isSingleMetricViewerJob === false} + id={item[tableItemId]} + data-test-subj={`${item[tableItemId]}-radio-button`} + checked={isItemSelected(item[tableItemId])} + onChange={() => toggleItem(item[tableItemId])} + disabled={radioDisabledCheck !== undefined ? radioDisabledCheck(item) : undefined} /> )} @@ -299,11 +307,11 @@ export function CustomSelectionTable({ return ( {cells} @@ -331,7 +339,7 @@ export function CustomSelectionTable({ - + {renderSelectAll(true)} - + {renderHeaderCells()} {renderRows()} @@ -368,7 +376,7 @@ export function CustomSelectionTable({ handlePageChange(pageIndex)} @@ -379,13 +387,16 @@ export function CustomSelectionTable({ } CustomSelectionTable.propTypes = { + checkboxDisabledCheck: PropTypes.func, columns: PropTypes.array.isRequired, filterDefaultFields: PropTypes.array, filters: PropTypes.array, items: PropTypes.array.isRequired, + itemsPerPage: PropTypes.number, onTableChange: PropTypes.func.isRequired, + radioDisabledCheck: PropTypes.func, selectedId: PropTypes.array, singleSelection: PropTypes.bool, sortableProperties: PropTypes.object, - timeseriesOnly: PropTypes.bool, + tableItemId: PropTypes.string, }; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/custom_selection_table/index.js b/x-pack/plugins/ml/public/application/components/custom_selection_table/index.js similarity index 100% rename from x-pack/plugins/ml/public/application/components/job_selector/custom_selection_table/index.js rename to x-pack/plugins/ml/public/application/components/custom_selection_table/index.js diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js index 4eeef560dd6d1..7b104ea372ae5 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js @@ -6,7 +6,7 @@ import React, { Fragment, useState, useEffect } from 'react'; import { PropTypes } from 'prop-types'; -import { CustomSelectionTable } from '../custom_selection_table'; +import { CustomSelectionTable } from '../../custom_selection_table'; import { JobSelectorBadge } from '../job_selector_badge'; import { TimeRangeBar } from '../timerange_bar'; @@ -107,7 +107,7 @@ export function JobSelectorTable({ id: 'checkbox', isCheckbox: true, textOnly: false, - width: '24px', + width: '32px', }, { label: 'job ID', @@ -157,6 +157,9 @@ export function JobSelectorTable({ filterDefaultFields={!singleSelection ? JOB_FILTER_FIELDS : undefined} items={jobs} onTableChange={(selectionFromTable) => onSelection({ selectionFromTable })} + radioDisabledCheck={(item) => { + return timeseriesOnly && item.isSingleMetricViewerJob === false; + }} selectedIds={selectedIds} singleSelection={singleSelection} sortableProperties={sortableProperties} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss index 89a0018f90401..5508c021d3313 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/_index.scss @@ -2,3 +2,4 @@ @import 'pages/analytics_management/components/analytics_list/index'; @import 'pages/analytics_management/components/create_analytics_form/index'; @import 'pages/analytics_management/components/create_analytics_flyout/index'; +@import 'pages/analytics_management/components/create_analytics_button/index'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts index 7633e07e8f3dc..0b4e6d27b96e5 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/analytics.ts @@ -24,6 +24,21 @@ export enum ANALYSIS_CONFIG_TYPE { CLASSIFICATION = 'classification', } +export enum ANALYSIS_ADVANCED_FIELDS { + FEATURE_INFLUENCE_THRESHOLD = 'feature_influence_threshold', + GAMMA = 'gamma', + LAMBDA = 'lambda', + MAX_TREES = 'max_trees', + NUM_TOP_FEATURE_IMPORTANCE_VALUES = 'num_top_feature_importance_values', +} + +export enum OUTLIER_ANALYSIS_METHOD { + LOF = 'lof', + LDOF = 'ldof', + DISTANCE_KTH_NN = 'distance_kth_nn', + DISTANCE_KNN = 'distance_knn', +} + interface OutlierAnalysis { [key: string]: {}; outlier_detection: {}; @@ -263,11 +278,13 @@ export const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysi }; export const isResultsSearchBoolQuery = (arg: any): arg is ResultsSearchBoolQuery => { + if (arg === undefined) return false; const keys = Object.keys(arg); return keys.length === 1 && keys[0] === 'bool'; }; export const isQueryStringQuery = (arg: any): arg is QueryStringQuery => { + if (arg === undefined) return false; const keys = Object.keys(arg); return keys.length === 1 && keys[0] === 'query_string'; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts index 400902c152c9e..58343e26153cc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/common/index.ts @@ -17,6 +17,7 @@ export { IndexPattern, REFRESH_ANALYTICS_LIST_STATE, ANALYSIS_CONFIG_TYPE, + OUTLIER_ANALYSIS_METHOD, RegressionEvaluateResponse, getValuesFromResponse, loadEvalData, diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step.tsx new file mode 100644 index 0000000000000..f957dcab2e87e --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step.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, { FC } from 'react'; +import { EuiForm } from '@elastic/eui'; + +import { CreateAnalyticsStepProps } from '../../../analytics_management/hooks/use_create_analytics_form'; +import { AdvancedStepForm } from './advanced_step_form'; +import { AdvancedStepDetails } from './advanced_step_details'; +import { ANALYTICS_STEPS } from '../../page'; + +export const AdvancedStep: FC = ({ + actions, + state, + step, + setCurrentStep, + stepActivated, +}) => { + return ( + + {step === ANALYTICS_STEPS.ADVANCED && ( + + )} + {step !== ANALYTICS_STEPS.ADVANCED && stepActivated === true && ( + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx new file mode 100644 index 0000000000000..a9c8b6d4040ad --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_details.tsx @@ -0,0 +1,274 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonEmpty, + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { + UNSET_CONFIG_ITEM, + State, +} from '../../../analytics_management/hooks/use_create_analytics_form/state'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics'; +import { ANALYTICS_STEPS } from '../../page'; + +function getStringValue(value: number | undefined) { + return value !== undefined ? `${value}` : UNSET_CONFIG_ITEM; +} + +export interface ListItems { + title: string; + description: string | JSX.Element; +} + +export const AdvancedStepDetails: FC<{ setCurrentStep: any; state: State }> = ({ + setCurrentStep, + state, +}) => { + const { form, isJobCreated } = state; + const { + computeFeatureInfluence, + dependentVariable, + eta, + featureBagFraction, + featureInfluenceThreshold, + gamma, + jobType, + lambda, + method, + maxTrees, + modelMemoryLimit, + nNeighbors, + numTopClasses, + numTopFeatureImportanceValues, + outlierFraction, + predictionFieldName, + randomizeSeed, + standardizationEnabled, + } = form; + + const isRegOrClassJob = + jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; + + const advancedFirstCol: ListItems[] = []; + const advancedSecondCol: ListItems[] = []; + const advancedThirdCol: ListItems[] = []; + + const hyperFirstCol: ListItems[] = []; + const hyperSecondCol: ListItems[] = []; + const hyperThirdCol: ListItems[] = []; + + if (jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION) { + advancedFirstCol.push({ + title: i18n.translate( + 'xpack.ml.dataframe.analytics.create.configDetails.computeFeatureInfluence', + { + defaultMessage: 'Compute feature influence', + } + ), + description: computeFeatureInfluence, + }); + + advancedSecondCol.push({ + title: i18n.translate( + 'xpack.ml.dataframe.analytics.create.configDetails.featureInfluenceThreshold', + { + defaultMessage: 'Feature influence threshold', + } + ), + description: getStringValue(featureInfluenceThreshold), + }); + + advancedThirdCol.push({ + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.modelMemoryLimit', { + defaultMessage: 'Model memory limit', + }), + description: `${modelMemoryLimit}`, + }); + + hyperFirstCol.push( + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.nNeighbors', { + defaultMessage: 'N neighbors', + }), + description: getStringValue(nNeighbors), + }, + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.outlierFraction', { + defaultMessage: 'Outlier fraction', + }), + description: getStringValue(outlierFraction), + } + ); + + hyperSecondCol.push({ + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.method', { + defaultMessage: 'Method', + }), + description: method !== undefined ? method : UNSET_CONFIG_ITEM, + }); + + hyperThirdCol.push({ + title: i18n.translate( + 'xpack.ml.dataframe.analytics.create.configDetails.standardizationEnabled', + { + defaultMessage: 'Standardization enabled', + } + ), + description: `${standardizationEnabled}`, + }); + } + + if (isRegOrClassJob) { + if (jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION) { + advancedFirstCol.push({ + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.numTopClasses', { + defaultMessage: 'Top classes', + }), + description: `${numTopClasses}`, + }); + } + + advancedFirstCol.push({ + title: i18n.translate( + 'xpack.ml.dataframe.analytics.create.configDetails.numTopFeatureImportanceValues', + { + defaultMessage: 'Top feature importance values', + } + ), + description: `${numTopFeatureImportanceValues}`, + }); + + hyperFirstCol.push( + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.lambdaFields', { + defaultMessage: 'Lambda', + }), + description: getStringValue(lambda), + }, + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.eta', { + defaultMessage: 'Eta', + }), + description: getStringValue(eta), + } + ); + + advancedSecondCol.push({ + title: i18n.translate( + 'xpack.ml.dataframe.analytics.create.configDetails.predictionFieldName', + { + defaultMessage: 'Prediction field name', + } + ), + description: predictionFieldName ? predictionFieldName : `${dependentVariable}_prediction`, + }); + + hyperSecondCol.push( + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.maxTreesFields', { + defaultMessage: 'Max trees', + }), + description: getStringValue(maxTrees), + }, + { + title: i18n.translate( + 'xpack.ml.dataframe.analytics.create.configDetails.featureBagFraction', + { + defaultMessage: 'Feature bag fraction', + } + ), + description: getStringValue(featureBagFraction), + } + ); + + advancedThirdCol.push({ + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.modelMemoryLimit', { + defaultMessage: 'Model memory limit', + }), + description: `${modelMemoryLimit}`, + }); + + hyperThirdCol.push( + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.gamma', { + defaultMessage: 'Gamma', + }), + description: getStringValue(gamma), + }, + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.randomizedSeed', { + defaultMessage: 'Randomized seed', + }), + description: getStringValue(randomizeSeed), + } + ); + } + + return ( + + +

+ {i18n.translate('xpack.ml.dataframe.analytics.create.advancedConfigDetailsTitle', { + defaultMessage: 'Advanced configuration', + })} +

+
+ + + + + + + + + + + + + + +

+ {i18n.translate('xpack.ml.dataframe.analytics.create.hyperParametersDetailsTitle', { + defaultMessage: 'Hyper parameters', + })} +

+
+ + + + + + + + + + + + + + {!isJobCreated && ( + { + setCurrentStep(ANALYTICS_STEPS.ADVANCED); + }} + > + {i18n.translate('xpack.ml.dataframe.analytics.create.advancedDetails.editButtonText', { + defaultMessage: 'Edit', + })} + + )} +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx new file mode 100644 index 0000000000000..8b137ac72361c --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/advanced_step_form.tsx @@ -0,0 +1,332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment, useMemo } from 'react'; +import { + EuiAccordion, + EuiFieldNumber, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { HyperParameters } from './hyper_parameters'; +import { CreateAnalyticsStepProps } from '../../../analytics_management/hooks/use_create_analytics_form'; +import { getModelMemoryLimitErrors } from '../../../analytics_management/hooks/use_create_analytics_form/reducer'; +import { + ANALYSIS_CONFIG_TYPE, + NUM_TOP_FEATURE_IMPORTANCE_VALUES_MIN, +} from '../../../../common/analytics'; +import { DEFAULT_MODEL_MEMORY_LIMIT } from '../../../analytics_management/hooks/use_create_analytics_form/state'; +import { ANALYTICS_STEPS } from '../../page'; +import { ContinueButton } from '../continue_button'; +import { OutlierHyperParameters } from './outlier_hyper_parameters'; + +export function getNumberValue(value?: number) { + return value === undefined ? '' : +value; +} + +export const AdvancedStepForm: FC = ({ + actions, + state, + setCurrentStep, +}) => { + const { setFormState } = actions; + const { form, isJobCreated } = state; + const { + computeFeatureInfluence, + featureInfluenceThreshold, + jobType, + modelMemoryLimit, + modelMemoryLimitValidationResult, + numTopClasses, + numTopFeatureImportanceValues, + numTopFeatureImportanceValuesValid, + predictionFieldName, + } = form; + + const mmlErrors = useMemo(() => getModelMemoryLimitErrors(modelMemoryLimitValidationResult), [ + modelMemoryLimitValidationResult, + ]); + + const isRegOrClassJob = + jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; + + const mmlInvalid = modelMemoryLimitValidationResult !== null; + + const outlierDetectionAdvancedConfig = ( + + + + { + setFormState({ + computeFeatureInfluence: e.target.value, + }); + }} + /> + + + + + + setFormState({ + featureInfluenceThreshold: e.target.value === '' ? undefined : +e.target.value, + }) + } + data-test-subj="mlAnalyticsCreateJobWizardFeatureInfluenceThresholdInput" + min={0} + max={1} + step={0.001} + value={getNumberValue(featureInfluenceThreshold)} + /> + + + + ); + + const regAndClassAdvancedConfig = ( + + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.create.numTopFeatureImportanceValuesErrorText', + { + defaultMessage: 'Invalid maximum number of feature importance values.', + } + )} + , + ] + : []), + ]} + > + + setFormState({ + numTopFeatureImportanceValues: e.target.value === '' ? undefined : +e.target.value, + }) + } + step={1} + value={getNumberValue(numTopFeatureImportanceValues)} + /> + + + + _prediction.', + } + )} + > + setFormState({ predictionFieldName: e.target.value })} + data-test-subj="mlAnalyticsCreateJobWizardPredictionFieldNameInput" + /> + + +
+ ); + + return ( + + +

+ {i18n.translate('xpack.ml.dataframe.analytics.create.advancedConfigSectionTitle', { + defaultMessage: 'Advanced configuration', + })} +

+
+ + {jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && outlierDetectionAdvancedConfig} + {isRegOrClassJob && regAndClassAdvancedConfig} + {jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && ( + + + + setFormState({ + numTopClasses: e.target.value === '' ? undefined : +e.target.value, + }) + } + step={1} + value={getNumberValue(numTopClasses)} + /> + + + )} + + + setFormState({ modelMemoryLimit: e.target.value })} + isInvalid={mmlInvalid} + data-test-subj="mlAnalyticsCreateJobWizardModelMemoryInput" + /> + + + + + +

+ {i18n.translate('xpack.ml.dataframe.analytics.create.hyperParametersSectionTitle', { + defaultMessage: 'Hyper parameters', + })} +

+ + } + initialIsOpen={false} + data-test-subj="mlAnalyticsCreateJobWizardHyperParametersSection" + > + + {jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION && ( + + )} + {isRegOrClassJob && } + +
+ + { + setCurrentStep(ANALYTICS_STEPS.DETAILS); + }} + /> +
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx new file mode 100644 index 0000000000000..144a062106003 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/hyper_parameters.tsx @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment } from 'react'; +import { EuiFieldNumber, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form'; +import { getNumberValue } from './advanced_step_form'; + +const MAX_TREES_LIMIT = 2000; + +export const HyperParameters: FC = ({ actions, state }) => { + const { setFormState } = actions; + + const { eta, featureBagFraction, gamma, lambda, maxTrees, randomizeSeed } = state.form; + + return ( + + + + + setFormState({ lambda: e.target.value === '' ? undefined : +e.target.value }) + } + step={0.001} + min={0} + value={getNumberValue(lambda)} + /> + + + + + + setFormState({ maxTrees: e.target.value === '' ? undefined : +e.target.value }) + } + isInvalid={maxTrees !== undefined && !Number.isInteger(maxTrees)} + step={1} + min={1} + max={MAX_TREES_LIMIT} + value={getNumberValue(maxTrees)} + /> + + + + + + setFormState({ gamma: e.target.value === '' ? undefined : +e.target.value }) + } + step={0.001} + min={0} + value={getNumberValue(gamma)} + /> + + + + + + setFormState({ eta: e.target.value === '' ? undefined : +e.target.value }) + } + step={0.001} + min={0.001} + max={1} + value={getNumberValue(eta)} + /> + + + + + + setFormState({ + featureBagFraction: e.target.value === '' ? undefined : +e.target.value, + }) + } + isInvalid={ + featureBagFraction !== undefined && + (featureBagFraction > 1 || featureBagFraction <= 0) + } + step={0.001} + max={1} + value={getNumberValue(featureBagFraction)} + /> + + + + + + setFormState({ randomizeSeed: e.target.value === '' ? undefined : +e.target.value }) + } + isInvalid={randomizeSeed !== undefined && typeof randomizeSeed !== 'number'} + value={getNumberValue(randomizeSeed)} + step={1} + /> + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/index.ts new file mode 100644 index 0000000000000..6a19e55b533c1 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/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 { AdvancedStep } from './advanced_step'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/outlier_hyper_parameters.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/outlier_hyper_parameters.tsx new file mode 100644 index 0000000000000..dfe7969d8a6d9 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/advanced_step/outlier_hyper_parameters.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment } from 'react'; +import { EuiFieldNumber, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { OUTLIER_ANALYSIS_METHOD } from '../../../../common/analytics'; +import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form'; +import { getNumberValue } from './advanced_step_form'; + +export const OutlierHyperParameters: FC = ({ actions, state }) => { + const { setFormState } = actions; + + const { method, nNeighbors, outlierFraction, standardizationEnabled } = state.form; + + return ( + + + + ({ + value: outlierMethod, + text: outlierMethod, + }))} + value={method} + hasNoInitialSelection={true} + onChange={(e) => { + setFormState({ method: e.target.value }); + }} + data-test-subj="mlAnalyticsCreateJobWizardMethodInput" + /> + + + + + + setFormState({ nNeighbors: e.target.value === '' ? undefined : +e.target.value }) + } + step={1} + min={1} + value={getNumberValue(nNeighbors)} + /> + + + + + + setFormState({ outlierFraction: e.target.value === '' ? undefined : +e.target.value }) + } + step={0.001} + min={0} + max={1} + value={getNumberValue(outlierFraction)} + /> + + + + + { + setFormState({ standardizationEnabled: e.target.value }); + }} + /> + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx new file mode 100644 index 0000000000000..e437d27372a3e --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/back_to_list_panel.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment } from 'react'; +import { EuiCard, EuiHorizontalRule, EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +function redirectToAnalyticsManagementPage() { + window.location.href = '#/data_frame_analytics?'; +} + +export const BackToListPanel: FC = () => ( + + + } + title={i18n.translate('xpack.ml.dataframe.analytics.create.analyticsListCardTitle', { + defaultMessage: 'Data Frame Analytics', + })} + description={i18n.translate( + 'xpack.ml.dataframe.analytics.create.analyticsListCardDescription', + { + defaultMessage: 'Return to the analytics management page.', + } + )} + onClick={redirectToAnalyticsManagementPage} + data-test-subj="analyticsWizardCardManagement" + /> + +); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/index.ts new file mode 100644 index 0000000000000..4da6561562879 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/back_to_list_panel/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 { BackToListPanel } from './back_to_list_panel'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx new file mode 100644 index 0000000000000..ad540285e49f0 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/analysis_fields_table.tsx @@ -0,0 +1,207 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment, memo, useEffect, useState } from 'react'; +import { EuiCallOut, EuiFormRow, EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +// @ts-ignore no declaration +import { LEFT_ALIGNMENT, CENTER_ALIGNMENT, SortableProperties } from '@elastic/eui/lib/services'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { FieldSelectionItem } from '../../../../common/analytics'; +// @ts-ignore could not find declaration file +import { CustomSelectionTable } from '../../../../../components/custom_selection_table'; + +const columns = [ + { + id: 'checkbox', + isCheckbox: true, + textOnly: false, + width: '32px', + }, + { + label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.fieldNameColumn', { + defaultMessage: 'Field name', + }), + id: 'name', + isSortable: true, + alignment: LEFT_ALIGNMENT, + }, + { + id: 'mapping_types', + label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.mappingColumn', { + defaultMessage: 'Mapping', + }), + isSortable: false, + alignment: LEFT_ALIGNMENT, + }, + { + label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.isIncludedColumn', { + defaultMessage: 'Is included', + }), + id: 'is_included', + alignment: LEFT_ALIGNMENT, + isSortable: true, + // eslint-disable-next-line @typescript-eslint/camelcase + render: ({ is_included }: { is_included: boolean }) => (is_included ? 'Yes' : 'No'), + }, + { + label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.isRequiredColumn', { + defaultMessage: 'Is required', + }), + id: 'is_required', + alignment: LEFT_ALIGNMENT, + isSortable: true, + // eslint-disable-next-line @typescript-eslint/camelcase + render: ({ is_required }: { is_required: boolean }) => (is_required ? 'Yes' : 'No'), + }, + { + label: i18n.translate('xpack.ml.dataframe.analytics.create.analyticsTable.reasonColumn', { + defaultMessage: 'Reason', + }), + id: 'reason', + alignment: LEFT_ALIGNMENT, + isSortable: false, + }, +]; + +const checkboxDisabledCheck = (item: FieldSelectionItem) => + (item.is_included === false && !item.reason?.includes('in excludes list')) || + item.is_required === true; + +export const MemoizedAnalysisFieldsTable: FC<{ + excludes: string[]; + loadingItems: boolean; + setFormState: any; + tableItems: FieldSelectionItem[]; +}> = memo( + ({ excludes, loadingItems, setFormState, tableItems }) => { + const [sortableProperties, setSortableProperties] = useState(); + const [currentSelection, setCurrentSelection] = useState([]); + + useEffect(() => { + if (excludes.length > 0) { + setCurrentSelection(excludes); + } + }, []); + + // Only set form state on unmount to prevent re-renders due to props changing if exludes was updated on each selection + useEffect(() => { + return () => { + setFormState({ excludes: currentSelection }); + }; + }, [currentSelection]); + + useEffect(() => { + let sortablePropertyItems = []; + const defaultSortProperty = 'name'; + + sortablePropertyItems = [ + { + name: 'name', + getValue: (item: any) => item.name.toLowerCase(), + isAscending: true, + }, + { + name: 'is_included', + getValue: (item: any) => item.is_included, + isAscending: true, + }, + { + name: 'is_required', + getValue: (item: any) => item.is_required, + isAscending: true, + }, + ]; + const sortableProps = new SortableProperties(sortablePropertyItems, defaultSortProperty); + + setSortableProperties(sortableProps); + }, []); + + const filters = [ + { + type: 'field_value_selection', + field: 'is_included', + name: i18n.translate('xpack.ml.dataframe.analytics.create.excludedFilterLabel', { + defaultMessage: 'Is included', + }), + multiSelect: false, + options: [ + { + value: true, + view: ( + + {i18n.translate('xpack.ml.dataframe.analytics.create.isIncludedOption', { + defaultMessage: 'Yes', + })} + + ), + }, + { + value: false, + view: ( + + {i18n.translate('xpack.ml.dataframe.analytics.create.isNotIncludedOption', { + defaultMessage: 'No', + })} + + ), + }, + ], + }, + ]; + + return ( + + + + + {tableItems.length === 0 && ( + + + + )} + {tableItems.length > 0 && ( + + { + setCurrentSelection(selection); + }} + selectedIds={currentSelection} + singleSelection={false} + sortableProperties={sortableProperties} + tableItemId={'name'} + /> + + )} + + + ); + }, + (prevProps, nextProps) => prevProps.tableItems.length === nextProps.tableItems.length +); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step.tsx new file mode 100644 index 0000000000000..220910535aafe --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { EuiForm } from '@elastic/eui'; + +import { CreateAnalyticsStepProps } from '../../../analytics_management/hooks/use_create_analytics_form'; +import { ConfigurationStepDetails } from './configuration_step_details'; +import { ConfigurationStepForm } from './configuration_step_form'; +import { ANALYTICS_STEPS } from '../../page'; + +export const ConfigurationStep: FC = ({ + actions, + state, + setCurrentStep, + step, + stepActivated, +}) => { + return ( + + {step === ANALYTICS_STEPS.CONFIGURATION && ( + + )} + {step !== ANALYTICS_STEPS.CONFIGURATION && stepActivated === true && ( + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_details.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_details.tsx new file mode 100644 index 0000000000000..6603af9aa302e --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_details.tsx @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonEmpty, + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { + State, + UNSET_CONFIG_ITEM, +} from '../../../analytics_management/hooks/use_create_analytics_form/state'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics'; +import { useMlContext } from '../../../../../contexts/ml'; +import { ANALYTICS_STEPS } from '../../page'; + +interface Props { + setCurrentStep: React.Dispatch>; + state: State; +} + +export const ConfigurationStepDetails: FC = ({ setCurrentStep, state }) => { + const mlContext = useMlContext(); + const { currentIndexPattern } = mlContext; + const { form, isJobCreated } = state; + const { dependentVariable, excludes, jobConfigQueryString, jobType, trainingPercent } = form; + + const isJobTypeWithDepVar = + jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; + + const detailsFirstCol = [ + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.sourceIndex', { + defaultMessage: 'Source index', + }), + description: currentIndexPattern.title || UNSET_CONFIG_ITEM, + }, + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.Query', { + defaultMessage: 'Query', + }), + description: jobConfigQueryString || UNSET_CONFIG_ITEM, + }, + ]; + + const detailsSecondCol = [ + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.jobType', { + defaultMessage: 'Job type', + }), + description: jobType! as string, + }, + ]; + + const detailsThirdCol = [ + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.excludedFields', { + defaultMessage: 'Excluded fields', + }), + description: excludes.length > 0 ? excludes.join(', ') : UNSET_CONFIG_ITEM, + }, + ]; + + if (isJobTypeWithDepVar) { + detailsSecondCol.push({ + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.trainingPercent', { + defaultMessage: 'Training percent', + }), + description: `${trainingPercent}`, + }); + detailsThirdCol.unshift({ + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.dependentVariable', { + defaultMessage: 'Dependent variable', + }), + description: dependentVariable, + }); + } + + return ( + + + + + + + + + + + + + + {!isJobCreated && ( + { + setCurrentStep(ANALYTICS_STEPS.CONFIGURATION); + }} + > + {i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.editButtonText', { + defaultMessage: 'Edit', + })} + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx new file mode 100644 index 0000000000000..9446dfd4ed525 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/configuration_step_form.tsx @@ -0,0 +1,449 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment, useEffect, useRef } from 'react'; +import { EuiBadge, EuiComboBox, EuiFormRow, EuiRange, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { debounce } from 'lodash'; + +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; +import { useMlContext } from '../../../../../contexts/ml'; + +import { + DfAnalyticsExplainResponse, + FieldSelectionItem, + ANALYSIS_CONFIG_TYPE, + TRAINING_PERCENT_MIN, + TRAINING_PERCENT_MAX, +} from '../../../../common/analytics'; +import { CreateAnalyticsStepProps } from '../../../analytics_management/hooks/use_create_analytics_form'; +import { Messages } from '../../../analytics_management/components/create_analytics_form/messages'; +import { + DEFAULT_MODEL_MEMORY_LIMIT, + getJobConfigFromFormState, + State, +} from '../../../analytics_management/hooks/use_create_analytics_form/state'; +import { shouldAddAsDepVarOption } from '../../../analytics_management/components/create_analytics_form/form_options_validation'; +import { ml } from '../../../../../services/ml_api_service'; +import { getToastNotifications } from '../../../../../util/dependency_cache'; + +import { ANALYTICS_STEPS } from '../../page'; +import { ContinueButton } from '../continue_button'; +import { JobType } from './job_type'; +import { SupportedFieldsMessage } from './supported_fields_message'; +import { MemoizedAnalysisFieldsTable } from './analysis_fields_table'; +import { DataGrid } from '../../../../../components/data_grid'; +import { useIndexData } from '../../hooks'; +import { ExplorationQueryBar } from '../../../analytics_exploration/components/exploration_query_bar'; +import { useSavedSearch } from './use_saved_search'; + +const requiredFieldsErrorText = i18n.translate( + 'xpack.ml.dataframe.analytics.createWizard.requiredFieldsErrorMessage', + { + defaultMessage: 'At least one field must be included in the analysis.', + } +); + +export const ConfigurationStepForm: FC = ({ + actions, + state, + setCurrentStep, +}) => { + const mlContext = useMlContext(); + const { currentSavedSearch, currentIndexPattern } = mlContext; + const { savedSearchQuery, savedSearchQueryStr } = useSavedSearch(); + + const { initiateWizard, setEstimatedModelMemoryLimit, setFormState } = actions; + const { estimatedModelMemoryLimit, form, isJobCreated, requestMessages } = state; + const firstUpdate = useRef(true); + const { + dependentVariable, + dependentVariableFetchFail, + dependentVariableOptions, + excludes, + excludesTableItems, + fieldOptionsFetchFail, + jobConfigQuery, + jobConfigQueryString, + jobType, + loadingDepVarOptions, + loadingFieldOptions, + maxDistinctValuesError, + modelMemoryLimit, + previousJobType, + requiredFieldsError, + trainingPercent, + } = form; + + const setJobConfigQuery = ({ query, queryString }: { query: any; queryString: string }) => { + setFormState({ jobConfigQuery: query, jobConfigQueryString: queryString }); + }; + + const indexData = useIndexData( + currentIndexPattern, + savedSearchQuery !== undefined ? savedSearchQuery : jobConfigQuery + ); + + const indexPreviewProps = { + ...indexData, + dataTestSubj: 'mlAnalyticsCreationDataGrid', + toastNotifications: getToastNotifications(), + }; + + const isJobTypeWithDepVar = + jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION; + + const dependentVariableEmpty = isJobTypeWithDepVar && dependentVariable === ''; + + const isStepInvalid = + dependentVariableEmpty || + jobType === undefined || + maxDistinctValuesError !== undefined || + requiredFieldsError !== undefined; + + const loadDepVarOptions = async (formState: State['form']) => { + setFormState({ + loadingDepVarOptions: true, + maxDistinctValuesError: undefined, + }); + try { + if (currentIndexPattern !== undefined) { + const formStateUpdate: { + loadingDepVarOptions: boolean; + dependentVariableFetchFail: boolean; + dependentVariableOptions: State['form']['dependentVariableOptions']; + dependentVariable?: State['form']['dependentVariable']; + } = { + loadingDepVarOptions: false, + dependentVariableFetchFail: false, + dependentVariableOptions: [] as State['form']['dependentVariableOptions'], + }; + + // Get fields and filter for supported types for job type + const { fields } = newJobCapsService; + + let resetDependentVariable = true; + for (const field of fields) { + if (shouldAddAsDepVarOption(field, jobType)) { + formStateUpdate.dependentVariableOptions.push({ + label: field.id, + }); + + if (formState.dependentVariable === field.id) { + resetDependentVariable = false; + } + } + } + + if (resetDependentVariable) { + formStateUpdate.dependentVariable = ''; + } + + setFormState(formStateUpdate); + } + } catch (e) { + setFormState({ loadingDepVarOptions: false, dependentVariableFetchFail: true }); + } + }; + + const debouncedGetExplainData = debounce(async () => { + const jobTypeChanged = previousJobType !== jobType; + const shouldUpdateModelMemoryLimit = !firstUpdate.current || !modelMemoryLimit; + const shouldUpdateEstimatedMml = + !firstUpdate.current || !modelMemoryLimit || estimatedModelMemoryLimit === ''; + + if (firstUpdate.current) { + firstUpdate.current = false; + } + // Reset if jobType changes (jobType requires dependent_variable to be set - + // which won't be the case if switching from outlier detection) + if (jobTypeChanged) { + setFormState({ + loadingFieldOptions: true, + }); + } + + try { + const jobConfig = getJobConfigFromFormState(form); + delete jobConfig.dest; + delete jobConfig.model_memory_limit; + const resp: DfAnalyticsExplainResponse = await ml.dataFrameAnalytics.explainDataFrameAnalytics( + jobConfig + ); + const expectedMemoryWithoutDisk = resp.memory_estimation?.expected_memory_without_disk; + + if (shouldUpdateEstimatedMml) { + setEstimatedModelMemoryLimit(expectedMemoryWithoutDisk); + } + + const fieldSelection: FieldSelectionItem[] | undefined = resp.field_selection; + + let hasRequiredFields = false; + if (fieldSelection) { + for (let i = 0; i < fieldSelection.length; i++) { + const field = fieldSelection[i]; + if (field.is_included === true && field.is_required === false) { + hasRequiredFields = true; + break; + } + } + } + + // If job type has changed load analysis field options again + if (jobTypeChanged) { + setFormState({ + ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), + excludesTableItems: fieldSelection ? fieldSelection : [], + loadingFieldOptions: false, + fieldOptionsFetchFail: false, + maxDistinctValuesError: undefined, + requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, + }); + } else { + setFormState({ + ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: expectedMemoryWithoutDisk } : {}), + requiredFieldsError: !hasRequiredFields ? requiredFieldsErrorText : undefined, + }); + } + } catch (e) { + let errorMessage; + if ( + jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && + e.body && + e.body.message !== undefined && + e.body.message.includes('status_exception') && + (e.body.message.includes('must have at most') || + e.body.message.includes('must have at least')) + ) { + errorMessage = e.body.message; + } + const fallbackModelMemoryLimit = + jobType !== undefined + ? DEFAULT_MODEL_MEMORY_LIMIT[jobType] + : DEFAULT_MODEL_MEMORY_LIMIT.outlier_detection; + setEstimatedModelMemoryLimit(fallbackModelMemoryLimit); + setFormState({ + fieldOptionsFetchFail: true, + maxDistinctValuesError: errorMessage, + loadingFieldOptions: false, + ...(shouldUpdateModelMemoryLimit ? { modelMemoryLimit: fallbackModelMemoryLimit } : {}), + }); + } + }, 300); + + useEffect(() => { + initiateWizard(); + }, []); + + useEffect(() => { + setFormState({ sourceIndex: currentIndexPattern.title }); + }, []); + + useEffect(() => { + if (savedSearchQueryStr !== undefined) { + setFormState({ jobConfigQuery: savedSearchQuery, jobConfigQueryString: savedSearchQueryStr }); + } + }, [JSON.stringify(savedSearchQuery), savedSearchQueryStr]); + + useEffect(() => { + if (isJobTypeWithDepVar) { + loadDepVarOptions(form); + } + }, [jobType]); + + useEffect(() => { + const hasBasicRequiredFields = jobType !== undefined; + + const hasRequiredAnalysisFields = + (isJobTypeWithDepVar && dependentVariable !== '') || + jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION; + + if (hasBasicRequiredFields && hasRequiredAnalysisFields) { + debouncedGetExplainData(); + } + + return () => { + debouncedGetExplainData.cancel(); + }; + }, [jobType, dependentVariable, trainingPercent, JSON.stringify(excludes), jobConfigQueryString]); + + return ( + + + + + {savedSearchQuery === undefined && ( + + + + )} + + {savedSearchQuery !== undefined && ( + + {i18n.translate('xpack.ml.dataframe.analytics.create.savedSearchLabel', { + defaultMessage: 'Saved search', + })} + + )} + + {savedSearchQuery !== undefined + ? currentSavedSearch?.attributes.title + : currentIndexPattern.title} + + + } + fullWidth + > + + + {isJobTypeWithDepVar && ( + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.create.dependentVariableOptionsFetchError', + { + defaultMessage: + 'There was a problem fetching fields. Please refresh the page and try again.', + } + )} + , + ] + : []), + ...(fieldOptionsFetchFail === true && maxDistinctValuesError !== undefined + ? [ + + {i18n.translate( + 'xpack.ml.dataframe.analytics.create.dependentVariableMaxDistictValuesError', + { + defaultMessage: 'Invalid. {message}', + values: { message: maxDistinctValuesError }, + } + )} + , + ] + : []), + ]} + > + + setFormState({ + dependentVariable: selectedOptions[0].label || '', + }) + } + isClearable={false} + isInvalid={dependentVariable === ''} + data-test-subj="mlAnalyticsCreateJobWizardDependentVariableSelect" + /> + + + )} + + + + + {isJobTypeWithDepVar && ( + + setFormState({ trainingPercent: +e.target.value })} + data-test-subj="mlAnalyticsCreateJobWizardTrainingPercentSlider" + /> + + )} + + { + setCurrentStep(ANALYTICS_STEPS.ADVANCED); + }} + /> + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/index.ts new file mode 100644 index 0000000000000..ba67a6f080d45 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/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 { ConfigurationStep } from './configuration_step'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.tsx new file mode 100644 index 0000000000000..f31c9cd28f65a --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/job_type.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, FC } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../common'; + +import { AnalyticsJobType } from '../../../analytics_management/hooks/use_create_analytics_form/state'; + +interface Props { + type: AnalyticsJobType; + setFormState: React.Dispatch>; +} + +export const JobType: FC = ({ type, setFormState }) => { + const outlierHelpText = i18n.translate( + 'xpack.ml.dataframe.analytics.create.outlierDetectionHelpText', + { + defaultMessage: + 'Outlier detection jobs require a source index that is mapped as a table-like data structure and analyze only numeric and boolean fields. Use the advanced editor to add custom options to the configuration.', + } + ); + + const regressionHelpText = i18n.translate( + 'xpack.ml.dataframe.analytics.create.outlierRegressionHelpText', + { + defaultMessage: + 'Regression jobs analyze only numeric fields. Use the advanced editor to apply custom options, such as the prediction field name.', + } + ); + + const classificationHelpText = i18n.translate( + 'xpack.ml.dataframe.analytics.create.classificationHelpText', + { + defaultMessage: + 'Classification jobs require a source index that is mapped as a table-like data structure and support fields that are numeric, boolean, text, keyword, or ip. Use the advanced editor to apply custom options, such as the prediction field name.', + } + ); + + const helpText = { + [ANALYSIS_CONFIG_TYPE.REGRESSION]: regressionHelpText, + [ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION]: outlierHelpText, + [ANALYSIS_CONFIG_TYPE.CLASSIFICATION]: classificationHelpText, + }; + + return ( + + + ({ + value: jobType, + text: jobType.replace(/_/g, ' '), + 'data-test-subj': `mlAnalyticsCreation-${jobType}-option`, + }))} + value={type} + hasNoInitialSelection={true} + onChange={(e) => { + const value = e.target.value as AnalyticsJobType; + setFormState({ + previousJobType: type, + jobType: value, + excludes: [], + requiredFieldsError: undefined, + }); + }} + data-test-subj="mlAnalyticsCreateJobWizardJobTypeSelect" + /> + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx new file mode 100644 index 0000000000000..fe13cc1d6edfc --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/supported_fields_message.tsx @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment, useState, useEffect } from 'react'; +import { EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { AnalyticsJobType } from '../../../analytics_management/hooks/use_create_analytics_form/state'; +import { ANALYSIS_CONFIG_TYPE } from '../../../../common/analytics'; +import { Field, EVENT_RATE_FIELD_ID } from '../../../../../../../common/types/fields'; +import { BASIC_NUMERICAL_TYPES, EXTENDED_NUMERICAL_TYPES } from '../../../../common/fields'; +import { + OMIT_FIELDS, + CATEGORICAL_TYPES, +} from '../../../analytics_management/components/create_analytics_form/form_options_validation'; +import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public'; +import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; + +const containsClassificationFieldsCb = ({ name, type }: Field) => + !OMIT_FIELDS.includes(name) && + name !== EVENT_RATE_FIELD_ID && + (BASIC_NUMERICAL_TYPES.has(type) || + CATEGORICAL_TYPES.has(type) || + type === ES_FIELD_TYPES.BOOLEAN); + +const containsRegressionFieldsCb = ({ name, type }: Field) => + !OMIT_FIELDS.includes(name) && + name !== EVENT_RATE_FIELD_ID && + (BASIC_NUMERICAL_TYPES.has(type) || EXTENDED_NUMERICAL_TYPES.has(type)); + +const containsOutlierFieldsCb = ({ name, type }: Field) => + !OMIT_FIELDS.includes(name) && name !== EVENT_RATE_FIELD_ID && BASIC_NUMERICAL_TYPES.has(type); + +const callbacks: Record boolean> = { + [ANALYSIS_CONFIG_TYPE.CLASSIFICATION]: containsClassificationFieldsCb, + [ANALYSIS_CONFIG_TYPE.REGRESSION]: containsRegressionFieldsCb, + [ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION]: containsOutlierFieldsCb, +}; + +const messages: Record = { + [ANALYSIS_CONFIG_TYPE.CLASSIFICATION]: ( + + ), + [ANALYSIS_CONFIG_TYPE.REGRESSION]: ( + + ), + [ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION]: ( + + ), +}; + +interface Props { + jobType: AnalyticsJobType; +} + +export const SupportedFieldsMessage: FC = ({ jobType }) => { + const [sourceIndexContainsSupportedFields, setSourceIndexContainsSupportedFields] = useState< + boolean + >(true); + const [sourceIndexFieldsCheckFailed, setSourceIndexFieldsCheckFailed] = useState(false); + const { fields } = newJobCapsService; + + // Find out if index pattern contains supported fields for job type. Provides a hint in the form + // that job may not run correctly if no supported fields are found. + const validateFields = () => { + if (fields && jobType !== undefined) { + try { + const containsSupportedFields: boolean = fields.some(callbacks[jobType]); + + setSourceIndexContainsSupportedFields(containsSupportedFields); + setSourceIndexFieldsCheckFailed(false); + } catch (e) { + setSourceIndexFieldsCheckFailed(true); + } + } + }; + + useEffect(() => { + if (jobType !== undefined) { + setSourceIndexContainsSupportedFields(true); + setSourceIndexFieldsCheckFailed(false); + validateFields(); + } + }, [jobType]); + + if (sourceIndexContainsSupportedFields === true) return null; + + if (sourceIndexFieldsCheckFailed === true) { + return ( + + + + + ); + } + + return ( + + + {jobType !== undefined && messages[jobType]} + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.ts new file mode 100644 index 0000000000000..856358538b26f --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/configuration_step/use_saved_search.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 { useState, useEffect } from 'react'; +import { useMlContext } from '../../../../../contexts/ml'; +import { esQuery, esKuery } from '../../../../../../../../../../src/plugins/data/public'; +import { SEARCH_QUERY_LANGUAGE } from '../../../../../../../common/constants/search'; +import { getQueryFromSavedSearch } from '../../../../../util/index_utils'; + +export function useSavedSearch() { + const [savedSearchQuery, setSavedSearchQuery] = useState(undefined); + const [savedSearchQueryStr, setSavedSearchQueryStr] = useState(undefined); + + const mlContext = useMlContext(); + const { currentSavedSearch, currentIndexPattern, kibanaConfig } = mlContext; + + const getQueryData = () => { + let qry; + let qryString; + + if (currentSavedSearch !== null) { + const { query } = getQueryFromSavedSearch(currentSavedSearch); + const queryLanguage = query.language as SEARCH_QUERY_LANGUAGE; + qryString = query.query; + + if (queryLanguage === SEARCH_QUERY_LANGUAGE.KUERY) { + const ast = esKuery.fromKueryExpression(qryString); + qry = esKuery.toElasticsearchQuery(ast, currentIndexPattern); + } else { + qry = esQuery.luceneStringToDsl(qryString); + esQuery.decorateQuery(qry, kibanaConfig.get('query:queryString:options')); + } + + setSavedSearchQuery(qry); + setSavedSearchQueryStr(qryString); + } + }; + + useEffect(() => { + getQueryData(); + }, []); + + return { + savedSearchQuery, + savedSearchQueryStr, + }; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/continue_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/continue_button.tsx new file mode 100644 index 0000000000000..6e95a0a246573 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/continue_button.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, { FC } from 'react'; +import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const continueButtonText = i18n.translate( + 'xpack.ml.dataframe.analytics.creation.continueButtonText', + { + defaultMessage: 'Continue', + } +); + +export const ContinueButton: FC<{ isDisabled: boolean; onClick: any }> = ({ + isDisabled, + onClick, +}) => ( + + + + {continueButtonText} + + + +); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx new file mode 100644 index 0000000000000..2dda5f5d819b7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/create_step.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment, useState } from 'react'; +import { + EuiButton, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { CreateAnalyticsFormProps } from '../../../analytics_management/hooks/use_create_analytics_form'; +import { Messages } from '../../../analytics_management/components/create_analytics_form/messages'; +import { ANALYTICS_STEPS } from '../../page'; +import { BackToListPanel } from '../back_to_list_panel'; + +interface Props extends CreateAnalyticsFormProps { + step: ANALYTICS_STEPS; +} + +export const CreateStep: FC = ({ actions, state, step }) => { + const { createAnalyticsJob, startAnalyticsJob } = actions; + const { + isAdvancedEditorValidJson, + isJobCreated, + isJobStarted, + isModalButtonDisabled, + isValid, + requestMessages, + } = state; + + const [checked, setChecked] = useState(true); + + if (step !== ANALYTICS_STEPS.CREATE) return null; + + const handleCreation = async () => { + await createAnalyticsJob(); + + if (checked) { + startAnalyticsJob(); + } + }; + + return ( + + {!isJobCreated && !isJobStarted && ( + + + + setChecked(e.target.checked)} + /> + + + + + {i18n.translate('xpack.ml.dataframe.analytics.create.wizardCreateButton', { + defaultMessage: 'Create', + })} + + + + )} + + + {isJobCreated === true && } + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/index.ts new file mode 100644 index 0000000000000..01c8e4bff934d --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/create_step/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 { CreateStep } from './create_step'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step.tsx new file mode 100644 index 0000000000000..a40813ed2fc3e --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step.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, { FC } from 'react'; +import { EuiForm } from '@elastic/eui'; + +import { CreateAnalyticsStepProps } from '../../../analytics_management/hooks/use_create_analytics_form'; +import { DetailsStepDetails } from './details_step_details'; +import { DetailsStepForm } from './details_step_form'; +import { ANALYTICS_STEPS } from '../../page'; + +export const DetailsStep: FC = ({ + actions, + state, + setCurrentStep, + step, + stepActivated, +}) => { + return ( + + {step === ANALYTICS_STEPS.DETAILS && ( + + )} + {step !== ANALYTICS_STEPS.DETAILS && stepActivated === true && ( + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_details.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_details.tsx new file mode 100644 index 0000000000000..a4d86b48006e8 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_details.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiButtonEmpty, + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, +} from '@elastic/eui'; +import { State } from '../../../analytics_management/hooks/use_create_analytics_form/state'; +import { ANALYTICS_STEPS } from '../../page'; + +export interface ListItems { + title: string; + description: string | JSX.Element; +} + +export const DetailsStepDetails: FC<{ setCurrentStep: any; state: State }> = ({ + setCurrentStep, + state, +}) => { + const { form, isJobCreated } = state; + const { description, jobId, destinationIndex } = form; + + const detailsFirstCol: ListItems[] = [ + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.jobId', { + defaultMessage: 'Job ID', + }), + description: jobId, + }, + ]; + + const detailsSecondCol: ListItems[] = [ + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.jobDescription', { + defaultMessage: 'Job description', + }), + description, + }, + ]; + + const detailsThirdCol: ListItems[] = [ + { + title: i18n.translate('xpack.ml.dataframe.analytics.create.configDetails.destIndex', { + defaultMessage: 'Destination index', + }), + description: destinationIndex || '', + }, + ]; + + return ( + + + + + + + + + + + + + + {!isJobCreated && ( + { + setCurrentStep(ANALYTICS_STEPS.DETAILS); + }} + > + {i18n.translate('xpack.ml.dataframe.analytics.create.detailsDetails.editButtonText', { + defaultMessage: 'Edit', + })} + + )} + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx new file mode 100644 index 0000000000000..67f8472e7ad14 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/details_step_form.tsx @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, Fragment, useRef } from 'react'; +import { EuiFieldText, EuiFormRow, EuiLink, EuiSpacer, EuiSwitch, EuiTextArea } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { useMlKibana } from '../../../../../contexts/kibana'; +import { CreateAnalyticsStepProps } from '../../../analytics_management/hooks/use_create_analytics_form'; +import { JOB_ID_MAX_LENGTH } from '../../../../../../../common/constants/validation'; +import { ContinueButton } from '../continue_button'; +import { ANALYTICS_STEPS } from '../../page'; + +export const DetailsStepForm: FC = ({ + actions, + state, + setCurrentStep, +}) => { + const { + services: { docLinks }, + } = useMlKibana(); + const { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } = docLinks; + + const { setFormState } = actions; + const { form, isJobCreated } = state; + const { + createIndexPattern, + description, + destinationIndex, + destinationIndexNameEmpty, + destinationIndexNameExists, + destinationIndexNameValid, + destinationIndexPatternTitleExists, + jobId, + jobIdEmpty, + jobIdExists, + jobIdInvalidMaxLength, + jobIdValid, + } = form; + const forceInput = useRef(null); + + const isStepInvalid = + jobIdEmpty === true || + jobIdExists === true || + jobIdValid === false || + destinationIndexNameEmpty === true || + destinationIndexNameValid === false || + (destinationIndexPatternTitleExists === true && createIndexPattern === true); + + return ( + + + { + if (input) { + forceInput.current = input; + } + }} + disabled={isJobCreated} + placeholder={i18n.translate('xpack.ml.dataframe.analytics.create.jobIdPlaceholder', { + defaultMessage: 'Job ID', + })} + value={jobId} + onChange={(e) => setFormState({ jobId: e.target.value })} + aria-label={i18n.translate('xpack.ml.dataframe.analytics.create.jobIdInputAriaLabel', { + defaultMessage: 'Choose a unique analytics job ID.', + })} + isInvalid={(!jobIdEmpty && !jobIdValid) || jobIdExists || jobIdEmpty} + data-test-subj="mlAnalyticsCreateJobFlyoutJobIdInput" + /> + + + { + const value = e.target.value; + setFormState({ description: value }); + }} + data-test-subj="mlDFAnalyticsJobCreationJobDescription" + /> + + + {i18n.translate('xpack.ml.dataframe.analytics.create.destinationIndexInvalidError', { + defaultMessage: 'Invalid destination index name.', + })} +
+ + {i18n.translate( + 'xpack.ml.dataframe.stepDetailsForm.destinationIndexInvalidErrorLink', + { + defaultMessage: 'Learn more about index name limitations.', + } + )} + +
, + ] + } + > + setFormState({ destinationIndex: e.target.value })} + aria-label={i18n.translate( + 'xpack.ml.dataframe.analytics.create.destinationIndexInputAriaLabel', + { + defaultMessage: 'Choose a unique destination index name.', + } + )} + isInvalid={!destinationIndexNameEmpty && !destinationIndexNameValid} + data-test-subj="mlAnalyticsCreateJobFlyoutDestinationIndexInput" + /> + + + setFormState({ createIndexPattern: !createIndexPattern })} + data-test-subj="mlAnalyticsCreateJobWizardCreateIndexPatternSwitch" + /> + + + { + setCurrentStep(ANALYTICS_STEPS.CREATE); + }} + /> + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/index.ts new file mode 100644 index 0000000000000..6cadd87d97e27 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/details_step/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 { DetailsStep } from './details_step'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/index.ts new file mode 100644 index 0000000000000..efd7c0634bed4 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/components/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ConfigurationStep } from './configuration_step/index'; +export { AdvancedStep } from './advanced_step/index'; +export { DetailsStep } from './details_step/index'; +export { CreateStep } from './create_step/index'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/index.ts new file mode 100644 index 0000000000000..5199fa1b6e4c6 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/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 { useIndexData } from './use_index_data'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts new file mode 100644 index 0000000000000..e8f25584201e3 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect } from 'react'; + +import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; +import { + getDataGridSchemaFromKibanaFieldType, + getFieldsFromKibanaIndexPattern, + useDataGrid, + useRenderCellValue, + EsSorting, + SearchResponse7, + UseIndexDataReturnType, +} from '../../../../components/data_grid'; +import { getErrorMessage } from '../../../../../../common/util/errors'; +import { INDEX_STATUS } from '../../../common/analytics'; +import { ml } from '../../../../services/ml_api_service'; + +type IndexSearchResponse = SearchResponse7; + +export const useIndexData = (indexPattern: IndexPattern, query: any): UseIndexDataReturnType => { + const indexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern); + + // EuiDataGrid State + const columns = [ + ...indexPatternFields.map((id) => { + const field = indexPattern.fields.getByName(id); + const schema = getDataGridSchemaFromKibanaFieldType(field); + return { id, schema }; + }), + ]; + + const dataGrid = useDataGrid(columns); + + const { + pagination, + resetPagination, + setErrorMessage, + setRowCount, + setStatus, + setTableItems, + sortingColumns, + tableItems, + } = dataGrid; + + useEffect(() => { + resetPagination(); + // custom comparison + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(query)]); + + const getIndexData = async function () { + setErrorMessage(''); + setStatus(INDEX_STATUS.LOADING); + + const sort: EsSorting = sortingColumns.reduce((s, column) => { + s[column.id] = { order: column.direction }; + return s; + }, {} as EsSorting); + + const esSearchRequest = { + index: indexPattern.title, + body: { + // Instead of using the default query (`*`), fall back to a more efficient `match_all` query. + query, // isDefaultQuery(query) ? matchAllQuery : query, + from: pagination.pageIndex * pagination.pageSize, + size: pagination.pageSize, + ...(Object.keys(sort).length > 0 ? { sort } : {}), + }, + }; + + try { + const resp: IndexSearchResponse = await ml.esSearch(esSearchRequest); + + const docs = resp.hits.hits.map((d) => d._source); + + setRowCount(resp.hits.total.value); + setTableItems(docs); + setStatus(INDEX_STATUS.LOADED); + } catch (e) { + setErrorMessage(getErrorMessage(e)); + setStatus(INDEX_STATUS.ERROR); + } + }; + + useEffect(() => { + getIndexData(); + // custom comparison + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [indexPattern.title, JSON.stringify([query, pagination, sortingColumns])]); + + const renderCellValue = useRenderCellValue(indexPattern, pagination, tableItems); + + return { + ...dataGrid, + columns, + renderCellValue, + }; +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/index.ts new file mode 100644 index 0000000000000..7e2d651439ae3 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/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 { Page } from './page'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx new file mode 100644 index 0000000000000..def862b859162 --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/page.tsx @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiSteps, + EuiStepStatus, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useMlContext } from '../../../contexts/ml'; +import { newJobCapsService } from '../../../services/new_job_capabilities_service'; +import { useCreateAnalyticsForm } from '../analytics_management/hooks/use_create_analytics_form'; +import { CreateAnalyticsAdvancedEditor } from '../analytics_management/components/create_analytics_advanced_editor'; +import { AdvancedStep, ConfigurationStep, CreateStep, DetailsStep } from './components'; + +export enum ANALYTICS_STEPS { + CONFIGURATION, + ADVANCED, + DETAILS, + CREATE, +} + +export const Page: FC = () => { + const [currentStep, setCurrentStep] = useState(ANALYTICS_STEPS.CONFIGURATION); + const [activatedSteps, setActivatedSteps] = useState([true, false, false, false]); + + const mlContext = useMlContext(); + const { currentIndexPattern } = mlContext; + + const createAnalyticsForm = useCreateAnalyticsForm(); + const { isAdvancedEditorEnabled } = createAnalyticsForm.state; + const { jobType } = createAnalyticsForm.state.form; + const { switchToAdvancedEditor } = createAnalyticsForm.actions; + + useEffect(() => { + if (activatedSteps[currentStep] === false) { + activatedSteps.splice(currentStep, 1, true); + setActivatedSteps(activatedSteps); + } + }, [currentStep]); + + useEffect(() => { + if (currentIndexPattern) { + (async function () { + await newJobCapsService.initializeFromIndexPattern(currentIndexPattern, false, false); + })(); + } + }, []); + + const analyticsWizardSteps = [ + { + title: i18n.translate('xpack.ml.dataframe.analytics.creation.configurationStepTitle', { + defaultMessage: 'Configuration', + }), + children: ( + + ), + status: + currentStep >= ANALYTICS_STEPS.CONFIGURATION ? undefined : ('incomplete' as EuiStepStatus), + }, + { + title: i18n.translate('xpack.ml.dataframe.analytics.creation.advancedStepTitle', { + defaultMessage: 'Additional options', + }), + children: ( + + ), + status: currentStep >= ANALYTICS_STEPS.ADVANCED ? undefined : ('incomplete' as EuiStepStatus), + 'data-test-subj': 'mlAnalyticsCreateJobWizardAdvancedStep', + }, + { + title: i18n.translate('xpack.ml.dataframe.analytics.creation.detailsStepTitle', { + defaultMessage: 'Job details', + }), + children: ( + + ), + status: currentStep >= ANALYTICS_STEPS.DETAILS ? undefined : ('incomplete' as EuiStepStatus), + 'data-test-subj': 'mlAnalyticsCreateJobWizardDetailsStep', + }, + { + title: i18n.translate('xpack.ml.dataframe.analytics.creation.createStepTitle', { + defaultMessage: 'Create', + }), + children: , + status: currentStep >= ANALYTICS_STEPS.CREATE ? undefined : ('incomplete' as EuiStepStatus), + 'data-test-subj': 'mlAnalyticsCreateJobWizardCreateStep', + }, + ]; + + return ( + + + + + + + + +

+ +

+
+
+ +

+ +

+
+
+
+ {isAdvancedEditorEnabled === false && ( + + + + + {i18n.translate( + 'xpack.ml.dataframe.analytics.create.switchToJsonEditorSwitch', + { + defaultMessage: 'Switch to json editor', + } + )} + + + + + )} +
+ + {isAdvancedEditorEnabled === true && ( + + )} + {isAdvancedEditorEnabled === false && ( + + )} +
+
+
+ ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx index f95e6a93058ba..8c158c1ca14a0 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_query_bar/exploration_query_bar.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Dispatch, FC, SetStateAction, useState } from 'react'; +import React, { Dispatch, FC, SetStateAction, useEffect, useState } from 'react'; import { EuiCode, EuiInputPopover } from '@elastic/eui'; @@ -30,11 +30,15 @@ interface ErrorMessage { interface ExplorationQueryBarProps { indexPattern: IIndexPattern; setSearchQuery: Dispatch>; + includeQueryString?: boolean; + defaultQueryString?: string; } export const ExplorationQueryBar: FC = ({ indexPattern, setSearchQuery, + includeQueryString = false, + defaultQueryString, }) => { // The internal state of the input query bar updated on every key stroke. const [searchInput, setSearchInput] = useState({ @@ -44,20 +48,34 @@ export const ExplorationQueryBar: FC = ({ const [errorMessage, setErrorMessage] = useState(undefined); + useEffect(() => { + if (defaultQueryString !== undefined) { + setSearchInput({ query: defaultQueryString, language: SEARCH_QUERY_LANGUAGE.KUERY }); + } + }, []); + const searchChangeHandler = (query: Query) => setSearchInput(query); const searchSubmitHandler = (query: Query) => { try { switch (query.language) { case SEARCH_QUERY_LANGUAGE.KUERY: + const convertedKQuery = esKuery.toElasticsearchQuery( + esKuery.fromKueryExpression(query.query as string), + indexPattern + ); setSearchQuery( - esKuery.toElasticsearchQuery( - esKuery.fromKueryExpression(query.query as string), - indexPattern - ) + includeQueryString + ? { queryString: query.query, query: convertedKQuery } + : convertedKQuery ); return; case SEARCH_QUERY_LANGUAGE.LUCENE: - setSearchQuery(esQuery.luceneStringToDsl(query.query as string)); + const convertedLQuery = esQuery.luceneStringToDsl(query.query as string); + setSearchQuery( + includeQueryString + ? { queryString: query.query, query: convertedLQuery } + : convertedLQuery + ); return; } } catch (e) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx index 72514c91ff58b..295a3988e1b58 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/actions.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; -import { DeepReadonly } from '../../../../../../../common/types/common'; +// import { DeepReadonly } from '../../../../../../../common/types/common'; import { checkPermission, @@ -21,7 +21,7 @@ import { isClassificationAnalysis, } from '../../../../common/analytics'; import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -import { CloneAction } from './action_clone'; +// import { CloneAction } from './action_clone'; import { getResultsUrl, isDataFrameAnalyticsRunning, DataFrameAnalyticsListRow } from './common'; import { stopAnalytics } from '../../services/analytics_service'; @@ -106,10 +106,10 @@ export const getActions = (createAnalyticsForm: CreateAnalyticsFormProps) => { return ; }, }, - { - render: (item: DeepReadonly) => { - return ; - }, - }, + // { + // render: (item: DeepReadonly) => { + // return ; + // }, + // }, ]; }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 4a99c042b108b..bb012a2190859 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -53,6 +53,7 @@ import { CreateAnalyticsButton } from '../create_analytics_button'; import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; import { CreateAnalyticsFlyoutWrapper } from '../create_analytics_flyout_wrapper'; import { getSelectedJobIdFromUrl } from '../../../../../jobs/jobs_list/components/utils'; +import { SourceSelection } from '../source_selection'; function getItemIdToExpandedRowMap( itemIds: DataFrameAnalyticsId[], @@ -90,6 +91,7 @@ export const DataFrameAnalyticsList: FC = ({ createAnalyticsForm, }) => { const [isInitialized, setIsInitialized] = useState(false); + const [isSourceIndexModalVisible, setIsSourceIndexModalVisible] = useState(false); const [isLoading, setIsLoading] = useState(false); const [filterActive, setFilterActive] = useState(false); @@ -271,7 +273,7 @@ export const DataFrameAnalyticsList: FC = ({ !isManagementTable && createAnalyticsForm ? [ setIsSourceIndexModalVisible(true)} isDisabled={disabled} data-test-subj="mlAnalyticsCreateFirstButton" > @@ -287,6 +289,9 @@ export const DataFrameAnalyticsList: FC = ({ {!isManagementTable && createAnalyticsForm && ( )} + {isSourceIndexModalVisible === true && ( + setIsSourceIndexModalVisible(false)} /> + )} ); } @@ -402,7 +407,10 @@ export const DataFrameAnalyticsList: FC = ({ {!isManagementTable && createAnalyticsForm && ( - + )} @@ -435,6 +443,9 @@ export const DataFrameAnalyticsList: FC = ({ {!isManagementTable && createAnalyticsForm?.state.isModalVisible && ( )} + {isSourceIndexModalVisible === true && ( + setIsSourceIndexModalVisible(false)} /> + )} ); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx index 48eb95948bb12..17b905cab135b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_advanced_editor/create_analytics_advanced_editor.tsx @@ -23,11 +23,14 @@ import { XJsonMode } from '../../../../../../../shared_imports'; const xJsonMode = new XJsonMode(); import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; +import { CreateStep } from '../../../analytics_creation/components/create_step'; +import { ANALYTICS_STEPS } from '../../../analytics_creation/page'; -export const CreateAnalyticsAdvancedEditor: FC = ({ actions, state }) => { +export const CreateAnalyticsAdvancedEditor: FC = (props) => { + const { actions, state } = props; const { setAdvancedEditorRawString, setFormState } = actions; - const { advancedEditorMessages, advancedEditorRawString, isJobCreated, requestMessages } = state; + const { advancedEditorMessages, advancedEditorRawString, isJobCreated } = state; const { createIndexPattern, @@ -56,120 +59,105 @@ export const CreateAnalyticsAdvancedEditor: FC = ({ ac return ( - {requestMessages.map((requestMessage, i) => ( + + { + if (input) { + forceInput.current = input; + } + }} + disabled={isJobCreated} + placeholder="analytics job ID" + value={jobId} + onChange={(e) => setFormState({ jobId: e.target.value })} + aria-label={i18n.translate( + 'xpack.ml.dataframe.analytics.create.advancedEditor.jobIdInputAriaLabel', + { + defaultMessage: 'Choose a unique analytics job ID.', + } + )} + isInvalid={(!jobIdEmpty && !jobIdValid) || jobIdExists} + /> + + + + + + + {advancedEditorMessages.map((advancedEditorMessage, i) => ( - {requestMessage.error !== undefined ?

{requestMessage.error}

: null} + {advancedEditorMessage.message !== '' && advancedEditorMessage.error !== undefined ? ( +

{advancedEditorMessage.error}

+ ) : null}
- +
))} {!isJobCreated && ( - - { - if (input) { - forceInput.current = input; - } - }} - disabled={isJobCreated} - placeholder="analytics job ID" - value={jobId} - onChange={(e) => setFormState({ jobId: e.target.value })} - aria-label={i18n.translate( - 'xpack.ml.dataframe.analytics.create.advancedEditor.jobIdInputAriaLabel', - { - defaultMessage: 'Choose a unique analytics job ID.', - } - )} - isInvalid={(!jobIdEmpty && !jobIdValid) || jobIdExists} - /> - - - - - - - {advancedEditorMessages.map((advancedEditorMessage, i) => ( - - - {advancedEditorMessage.message !== '' && - advancedEditorMessage.error !== undefined ? ( -

{advancedEditorMessage.error}

- ) : null} -
- -
- ))} = ({ ac
)} + +
); }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/_index.scss b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/_index.scss new file mode 100644 index 0000000000000..14ff9de7ded4d --- /dev/null +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/_index.scss @@ -0,0 +1,4 @@ +.dataFrameAnalyticsCreateSearchDialog { + width: $euiSizeL * 30; + min-height: $euiSizeL * 25; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx index 10565fb4d7a93..8e9b09ef41fa8 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.test.tsx @@ -35,7 +35,9 @@ describe('Data Frame Analytics: ', () => { test('Minimal initialization', () => { const { getLastHookValue } = getMountedHook(); const props = getLastHookValue(); - const wrapper = mount(); + const wrapper = mount( + + ); expect(wrapper.find('EuiButton').text()).toBe('Create analytics job'); }); diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx index 952bd48468b0a..595a1cf73e189 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/create_analytics_button/create_analytics_button.tsx @@ -10,15 +10,26 @@ import { i18n } from '@kbn/i18n'; import { createPermissionFailureMessage } from '../../../../../capabilities/check_capabilities'; import { CreateAnalyticsFormProps } from '../../hooks/use_create_analytics_form'; -export const CreateAnalyticsButton: FC = (props) => { - const { disabled } = props.state; - const { openModal } = props.actions; +interface Props extends CreateAnalyticsFormProps { + setIsSourceIndexModalVisible: React.Dispatch>; +} + +export const CreateAnalyticsButton: FC = ({ + state, + actions, + setIsSourceIndexModalVisible, +}) => { + const { disabled } = state; + + const handleClick = () => { + setIsSourceIndexModalVisible(true); + }; const button = ( = ({ messages }) => { {messages.map((requestMessage, i) => ( void; +} + +export const SourceSelection: FC = ({ onClose }) => { + const { uiSettings, savedObjects } = useMlKibana().services; + + const onSearchSelected = (id: string, type: string) => { + window.location.href = `ml#/data_frame_analytics/new_job?${ + type === 'index-pattern' ? 'index' : 'savedSearchId' + }=${encodeURIComponent(id)}`; + }; + + return ( + <> + + + + + {' '} + /{' '} + + + + + 'search', + name: i18n.translate( + 'xpack.ml.dataFrame.analytics.create.searchSelection.savedObjectType.search', + { + defaultMessage: 'Saved search', + } + ), + }, + { + type: 'index-pattern', + getIconForSavedObject: () => 'indexPatternApp', + name: i18n.translate( + 'xpack.ml.dataFrame.analytics.create.searchSelection.savedObjectType.indexPattern', + { + defaultMessage: 'Index pattern', + } + ), + }, + ]} + fixedPageSize={fixedPageSize} + uiSettings={uiSettings} + savedObjects={savedObjects} + /> + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts index 66e4103f5bb41..c42e03b584a56 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/actions.ts @@ -72,6 +72,7 @@ export interface ActionDispatchers { closeModal: () => void; createAnalyticsJob: () => void; openModal: () => Promise; + initiateWizard: () => Promise; resetAdvancedEditorMessages: () => void; setAdvancedEditorRawString: (payload: State['advancedEditorRawString']) => void; setFormState: (payload: Partial) => void; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/index.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/index.ts index e5e56c084f7b9..8ec415bc2abb4 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/index.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/index.ts @@ -5,4 +5,9 @@ */ export { DEFAULT_NUM_TOP_FEATURE_IMPORTANCE_VALUES } from './state'; -export { useCreateAnalyticsForm, CreateAnalyticsFormProps } from './use_create_analytics_form'; +export { + AnalyticsCreationStep, + useCreateAnalyticsForm, + CreateAnalyticsFormProps, + CreateAnalyticsStepProps, +} from './use_create_analytics_form'; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts index 4ff7deab34f26..387ce89ee4120 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/state.ts @@ -11,6 +11,7 @@ import { mlNodesAvailable } from '../../../../../ml_nodes_check'; import { newJobCapsService } from '../../../../../services/new_job_capabilities_service'; import { + FieldSelectionItem, isClassificationAnalysis, isRegressionAnalysis, DataFrameAnalyticsId, @@ -26,7 +27,8 @@ export enum DEFAULT_MODEL_MEMORY_LIMIT { classification = '100mb', } -export const DEFAULT_NUM_TOP_FEATURE_IMPORTANCE_VALUES = 2; +export const DEFAULT_NUM_TOP_FEATURE_IMPORTANCE_VALUES = 0; +export const UNSET_CONFIG_ITEM = '--'; export type EsIndexName = string; export type DependentVariable = string; @@ -47,6 +49,7 @@ export interface State { advancedEditorMessages: FormMessage[]; advancedEditorRawString: string; form: { + computeFeatureInfluence: string; createIndexPattern: boolean; dependentVariable: DependentVariable; dependentVariableFetchFail: boolean; @@ -57,31 +60,47 @@ export interface State { destinationIndexNameEmpty: boolean; destinationIndexNameValid: boolean; destinationIndexPatternTitleExists: boolean; + eta: undefined | number; excludes: string[]; + excludesTableItems: FieldSelectionItem[]; excludesOptions: EuiComboBoxOptionOption[]; + featureBagFraction: undefined | number; + featureInfluenceThreshold: undefined | number; fieldOptionsFetchFail: boolean; + gamma: undefined | number; jobId: DataFrameAnalyticsId; jobIdExists: boolean; jobIdEmpty: boolean; jobIdInvalidMaxLength: boolean; jobIdValid: boolean; jobType: AnalyticsJobType; + jobConfigQuery: any; + jobConfigQueryString: string | undefined; + lambda: number | undefined; loadingDepVarOptions: boolean; loadingFieldOptions: boolean; maxDistinctValuesError: string | undefined; + maxTrees: undefined | number; + method: undefined | string; modelMemoryLimit: string | undefined; modelMemoryLimitUnitValid: boolean; modelMemoryLimitValidationResult: any; + nNeighbors: undefined | number; numTopFeatureImportanceValues: number | undefined; numTopFeatureImportanceValuesValid: boolean; + numTopClasses: number; + outlierFraction: undefined | number; + predictionFieldName: undefined | string; previousJobType: null | AnalyticsJobType; previousSourceIndex: EsIndexName | undefined; requiredFieldsError: string | undefined; + randomizeSeed: undefined | number; sourceIndex: EsIndexName; sourceIndexNameEmpty: boolean; sourceIndexNameValid: boolean; sourceIndexContainsNumericalFields: boolean; sourceIndexFieldsCheckFailed: boolean; + standardizationEnabled: undefined | string; trainingPercent: number; }; disabled: boolean; @@ -105,7 +124,8 @@ export const getInitialState = (): State => ({ advancedEditorMessages: [], advancedEditorRawString: '', form: { - createIndexPattern: false, + computeFeatureInfluence: 'true', + createIndexPattern: true, dependentVariable: '', dependentVariableFetchFail: false, dependentVariableOptions: [], @@ -115,8 +135,13 @@ export const getInitialState = (): State => ({ destinationIndexNameEmpty: true, destinationIndexNameValid: false, destinationIndexPatternTitleExists: false, + eta: undefined, excludes: [], + featureBagFraction: undefined, + featureInfluenceThreshold: undefined, fieldOptionsFetchFail: false, + gamma: undefined, + excludesTableItems: [], excludesOptions: [], jobId: '', jobIdExists: false, @@ -124,22 +149,33 @@ export const getInitialState = (): State => ({ jobIdInvalidMaxLength: false, jobIdValid: false, jobType: undefined, + jobConfigQuery: { match_all: {} }, + jobConfigQueryString: undefined, + lambda: undefined, loadingDepVarOptions: false, loadingFieldOptions: false, maxDistinctValuesError: undefined, + maxTrees: undefined, + method: undefined, modelMemoryLimit: undefined, modelMemoryLimitUnitValid: true, modelMemoryLimitValidationResult: null, + nNeighbors: undefined, numTopFeatureImportanceValues: DEFAULT_NUM_TOP_FEATURE_IMPORTANCE_VALUES, numTopFeatureImportanceValuesValid: true, + numTopClasses: 2, + outlierFraction: undefined, + predictionFieldName: undefined, previousJobType: null, previousSourceIndex: undefined, requiredFieldsError: undefined, + randomizeSeed: undefined, sourceIndex: '', sourceIndexNameEmpty: true, sourceIndexNameValid: false, sourceIndexContainsNumericalFields: true, sourceIndexFieldsCheckFailed: false, + standardizationEnabled: 'true', trainingPercent: 80, }, jobConfig: {}, @@ -222,6 +258,7 @@ export const getJobConfigFromFormState = ( index: formState.sourceIndex.includes(',') ? formState.sourceIndex.split(',').map((d) => d.trim()) : formState.sourceIndex, + query: formState.jobConfigQuery, }, dest: { index: formState.destinationIndex, @@ -239,15 +276,55 @@ export const getJobConfigFromFormState = ( formState.jobType === ANALYSIS_CONFIG_TYPE.REGRESSION || formState.jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION ) { - jobConfig.analysis = { - [formState.jobType]: { - dependent_variable: formState.dependentVariable, - num_top_feature_importance_values: formState.numTopFeatureImportanceValues, - training_percent: formState.trainingPercent, + let analysis = { + dependent_variable: formState.dependentVariable, + num_top_feature_importance_values: formState.numTopFeatureImportanceValues, + training_percent: formState.trainingPercent, + }; + + analysis = Object.assign( + analysis, + formState.predictionFieldName && { prediction_field_name: formState.predictionFieldName }, + formState.eta && { eta: formState.eta }, + formState.featureBagFraction && { + feature_bag_fraction: formState.featureBagFraction, }, + formState.gamma && { gamma: formState.gamma }, + formState.lambda && { lambda: formState.lambda }, + formState.maxTrees && { max_trees: formState.maxTrees }, + formState.randomizeSeed && { randomize_seed: formState.randomizeSeed } + ); + + jobConfig.analysis = { + [formState.jobType]: analysis, }; } + if ( + formState.jobType === ANALYSIS_CONFIG_TYPE.CLASSIFICATION && + jobConfig?.analysis?.classification !== undefined && + formState.numTopClasses !== undefined + ) { + // @ts-ignore + jobConfig.analysis.classification.num_top_classes = formState.numTopClasses; + } + + if (formState.jobType === ANALYSIS_CONFIG_TYPE.OUTLIER_DETECTION) { + const analysis = Object.assign( + {}, + formState.method && { method: formState.method }, + formState.nNeighbors && { + n_neighbors: formState.nNeighbors, + }, + formState.outlierFraction && { outlier_fraction: formState.outlierFraction }, + formState.standardizationEnabled && { + standardization_enabled: formState.standardizationEnabled, + } + ); + // @ts-ignore + jobConfig.analysis.outlier_detection = analysis; + } + return jobConfig; }; @@ -279,6 +356,11 @@ export function getCloneFormStateFromJobConfig( resultState.dependentVariable = analysisConfig.dependent_variable; resultState.numTopFeatureImportanceValues = analysisConfig.num_top_feature_importance_values; resultState.trainingPercent = analysisConfig.training_percent; + + if (isClassificationAnalysis(analyticsJobConfig.analysis)) { + // @ts-ignore + resultState.numTopClasses = analysisConfig.num_top_classes; + } } return resultState; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts index 1ec767d014a2e..c4cbe149f88bc 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/hooks/use_create_analytics_form/use_create_analytics_form.ts @@ -36,11 +36,24 @@ import { getCloneFormStateFromJobConfig, } from './state'; +import { ANALYTICS_STEPS } from '../../../analytics_creation/page'; + +export interface AnalyticsCreationStep { + number: ANALYTICS_STEPS; + completed: boolean; +} + export interface CreateAnalyticsFormProps { actions: ActionDispatchers; state: State; } +export interface CreateAnalyticsStepProps extends CreateAnalyticsFormProps { + setCurrentStep: React.Dispatch>; + step?: ANALYTICS_STEPS; + stepActivated?: boolean; +} + export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { const mlContext = useMlContext(); const [state, dispatch] = useReducer(reducer, getInitialState()); @@ -261,8 +274,12 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { dispatch({ type: ACTION.OPEN_MODAL }); }; + const initiateWizard = async () => { + await mlContext.indexPatterns.clearCache(); + await prepareFormValidation(); + }; + const startAnalyticsJob = async () => { - setIsModalButtonDisabled(true); try { const response = await ml.dataFrameAnalytics.startDataFrameAnalytics(jobId); if (response.acknowledged !== true) { @@ -278,7 +295,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { ), }); setIsJobStarted(true); - setIsModalButtonDisabled(false); refresh(); } catch (e) { addRequestMessage({ @@ -290,7 +306,6 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { } ), }); - setIsModalButtonDisabled(false); } }; @@ -331,6 +346,7 @@ export const useCreateAnalyticsForm = (): CreateAnalyticsFormProps => { closeModal, createAnalyticsJob, openModal, + initiateWizard, resetAdvancedEditorMessages, setAdvancedEditorRawString, setFormState, diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx new file mode 100644 index 0000000000000..68af9a2a49cab --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/analytics_job_creation.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { parse } from 'query-string'; +import { MlRoute, PageLoader, PageProps } from '../../router'; +import { useResolver } from '../../use_resolver'; +import { basicResolvers } from '../../resolvers'; +import { Page } from '../../../data_frame_analytics/pages/analytics_creation'; +import { ML_BREADCRUMB } from '../../breadcrumbs'; + +const breadcrumbs = [ + ML_BREADCRUMB, + { + text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.dataFrameManagementLabel', { + defaultMessage: 'Data Frame Analytics', + }), + href: '#/data_frame_analytics', + }, +]; + +export const analyticsJobsCreationRoute: MlRoute = { + path: '/data_frame_analytics/new_job', + render: (props, deps) => , + breadcrumbs, +}; + +const PageWrapper: FC = ({ location, deps }) => { + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); + const { context } = useResolver(index, savedSearchId, deps.config, basicResolvers(deps)); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/index.ts b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/index.ts index 552c15a408b65..9b6bcc25c8c7e 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/data_frame_analytics/index.ts @@ -6,3 +6,4 @@ export * from './analytics_jobs_list'; export * from './analytics_job_exploration'; +export * from './analytics_job_creation'; diff --git a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts index 0b2469c103578..e6b4e4ccf8582 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_analytics_schema.ts @@ -47,6 +47,7 @@ export const dataAnalyticsExplainSchema = schema.object({ /** Source */ source: schema.object({ index: schema.string(), + query: schema.maybe(schema.any()), }), analysis: schema.any(), analyzed_fields: schema.maybe(schema.any()), diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 4f56015227fdf..d7565fd846e70 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -60,35 +60,19 @@ export default function ({ getService }: FtrProviderContext) { await ml.navigation.navigateToDataFrameAnalytics(); }); - it('loads the job creation flyout', async () => { + it('loads the source selection modal', async () => { await ml.dataFrameAnalytics.startAnalyticsCreation(); }); + it('selects the source data and loads the job wizard page', async () => { + ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + }); + it('selects the job type', async () => { await ml.dataFrameAnalyticsCreation.assertJobTypeSelectExists(); await ml.dataFrameAnalyticsCreation.selectJobType(testData.jobType); }); - it('inputs the job id', async () => { - await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); - await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); - }); - - it('inputs the job description', async () => { - await ml.dataFrameAnalyticsCreation.assertJobDescriptionInputExists(); - await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); - }); - - it('selects the source index', async () => { - await ml.dataFrameAnalyticsCreation.assertSourceIndexInputExists(); - await ml.dataFrameAnalyticsCreation.selectSourceIndex(testData.source); - }); - - it('inputs the destination index', async () => { - await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); - await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); - }); - it('inputs the dependent variable', async () => { await ml.dataFrameAnalyticsCreation.assertDependentVariableInputExists(); await ml.dataFrameAnalyticsCreation.selectDependentVariable(testData.dependentVariable); @@ -99,11 +83,34 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.setTrainingPercent(testData.trainingPercent); }); + it('continues to the additional options step', async () => { + await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); + }); + it('inputs the model memory limit', async () => { await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); await ml.dataFrameAnalyticsCreation.setModelMemory(testData.modelMemory); }); + it('continues to the details step', async () => { + await ml.dataFrameAnalyticsCreation.continueToDetailsStep(); + }); + + it('inputs the job id', async () => { + await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); + await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); + }); + + it('inputs the job description', async () => { + await ml.dataFrameAnalyticsCreation.assertJobDescriptionInputExists(); + await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); + }); + + it('inputs the destination index', async () => { + await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); + await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); + }); + it('sets the create index pattern switch', async () => { await ml.dataFrameAnalyticsCreation.assertCreateIndexPatternSwitchExists(); await ml.dataFrameAnalyticsCreation.setCreateIndexPatternSwitchState( @@ -111,19 +118,14 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('creates the analytics job', async () => { - await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); - }); - - it('starts the analytics job', async () => { - await ml.dataFrameAnalyticsCreation.assertStartButtonExists(); - await ml.dataFrameAnalyticsCreation.startAnalyticsJob(); + it('continues to the create step', async () => { + await ml.dataFrameAnalyticsCreation.continueToCreateStep(); }); - it('closes the create job flyout', async () => { - await ml.dataFrameAnalyticsCreation.assertCloseButtonExists(); - await ml.dataFrameAnalyticsCreation.closeCreateAnalyticsJobFlyout(); + it('creates and starts the analytics job', async () => { + await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); + await ml.dataFrameAnalyticsCreation.assertStartJobCheckboxCheckState(true); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('finishes analytics processing', async () => { @@ -131,6 +133,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('displays the analytics table', async () => { + await ml.dataFrameAnalyticsCreation.navigateToJobManagementPage(); await ml.dataFrameAnalytics.assertAnalyticsTableExists(); }); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts index 7636b87033bf8..b3e47db826787 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/cloning.ts @@ -182,16 +182,6 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.createAnalyticsJob(cloneJobId); }); - it('should start the clone analytics job', async () => { - await ml.dataFrameAnalyticsCreation.assertStartButtonExists(); - await ml.dataFrameAnalyticsCreation.startAnalyticsJob(); - }); - - it('should close the create job flyout', async () => { - await ml.dataFrameAnalyticsCreation.assertCloseButtonExists(); - await ml.dataFrameAnalyticsCreation.closeCreateAnalyticsJobFlyout(); - }); - it('finishes analytics processing', async () => { await ml.dataFrameAnalytics.waitForAnalyticsCompletion(cloneJobId); }); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts index 0202c8431ce34..cff59fa42abb0 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts @@ -12,6 +12,6 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./outlier_detection_creation')); loadTestFile(require.resolve('./regression_creation')); loadTestFile(require.resolve('./classification_creation')); - loadTestFile(require.resolve('./cloning')); + // loadTestFile(require.resolve('./cloning')); }); } diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 7d10d6724d9e2..b5c2b7528437c 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -58,10 +58,14 @@ export default function ({ getService }: FtrProviderContext) { await ml.navigation.navigateToDataFrameAnalytics(); }); - it('loads the job creation flyout', async () => { + it('loads the source selection modal', async () => { await ml.dataFrameAnalytics.startAnalyticsCreation(); }); + it('selects the source data and loads the job wizard page', async () => { + ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + }); + it('selects the job type', async () => { await ml.dataFrameAnalyticsCreation.assertJobTypeSelectExists(); await ml.dataFrameAnalyticsCreation.selectJobType(testData.jobType); @@ -75,6 +79,19 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertTrainingPercentInputMissing(); }); + it('continues to the additional options step', async () => { + await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); + }); + + it('inputs the model memory limit', async () => { + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); + await ml.dataFrameAnalyticsCreation.setModelMemory(testData.modelMemory); + }); + + it('continues to the details step', async () => { + await ml.dataFrameAnalyticsCreation.continueToDetailsStep(); + }); + it('inputs the job id', async () => { await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); @@ -85,21 +102,11 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); }); - it('selects the source index', async () => { - await ml.dataFrameAnalyticsCreation.assertSourceIndexInputExists(); - await ml.dataFrameAnalyticsCreation.selectSourceIndex(testData.source); - }); - it('inputs the destination index', async () => { await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); }); - it('inputs the model memory limit', async () => { - await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); - await ml.dataFrameAnalyticsCreation.setModelMemory(testData.modelMemory); - }); - it('sets the create index pattern switch', async () => { await ml.dataFrameAnalyticsCreation.assertCreateIndexPatternSwitchExists(); await ml.dataFrameAnalyticsCreation.setCreateIndexPatternSwitchState( @@ -107,19 +114,14 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('creates the analytics job', async () => { - await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); - }); - - it('starts the analytics job', async () => { - await ml.dataFrameAnalyticsCreation.assertStartButtonExists(); - await ml.dataFrameAnalyticsCreation.startAnalyticsJob(); + it('continues to the create step', async () => { + await ml.dataFrameAnalyticsCreation.continueToCreateStep(); }); - it('closes the create job flyout', async () => { - await ml.dataFrameAnalyticsCreation.assertCloseButtonExists(); - await ml.dataFrameAnalyticsCreation.closeCreateAnalyticsJobFlyout(); + it('creates and starts the analytics job', async () => { + await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); + await ml.dataFrameAnalyticsCreation.assertStartJobCheckboxCheckState(true); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('finishes analytics processing', async () => { @@ -127,6 +129,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('displays the analytics table', async () => { + await ml.dataFrameAnalyticsCreation.navigateToJobManagementPage(); await ml.dataFrameAnalytics.assertAnalyticsTableExists(); }); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index 8322a4a1dd139..7c59664fdec35 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -60,35 +60,19 @@ export default function ({ getService }: FtrProviderContext) { await ml.navigation.navigateToDataFrameAnalytics(); }); - it('loads the job creation flyout', async () => { + it('loads the source selection modal', async () => { await ml.dataFrameAnalytics.startAnalyticsCreation(); }); + it('selects the source data and loads the job wizard page', async () => { + ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + }); + it('selects the job type', async () => { await ml.dataFrameAnalyticsCreation.assertJobTypeSelectExists(); await ml.dataFrameAnalyticsCreation.selectJobType(testData.jobType); }); - it('inputs the job id', async () => { - await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); - await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); - }); - - it('inputs the job description', async () => { - await ml.dataFrameAnalyticsCreation.assertJobDescriptionInputExists(); - await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); - }); - - it('selects the source index', async () => { - await ml.dataFrameAnalyticsCreation.assertSourceIndexInputExists(); - await ml.dataFrameAnalyticsCreation.selectSourceIndex(testData.source); - }); - - it('inputs the destination index', async () => { - await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); - await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); - }); - it('inputs the dependent variable', async () => { await ml.dataFrameAnalyticsCreation.assertDependentVariableInputExists(); await ml.dataFrameAnalyticsCreation.selectDependentVariable(testData.dependentVariable); @@ -99,11 +83,34 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.setTrainingPercent(testData.trainingPercent); }); + it('continues to the additional options step', async () => { + await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); + }); + it('inputs the model memory limit', async () => { await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); await ml.dataFrameAnalyticsCreation.setModelMemory(testData.modelMemory); }); + it('continues to the details step', async () => { + await ml.dataFrameAnalyticsCreation.continueToDetailsStep(); + }); + + it('inputs the job id', async () => { + await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); + await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); + }); + + it('inputs the job description', async () => { + await ml.dataFrameAnalyticsCreation.assertJobDescriptionInputExists(); + await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); + }); + + it('inputs the destination index', async () => { + await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); + await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); + }); + it('sets the create index pattern switch', async () => { await ml.dataFrameAnalyticsCreation.assertCreateIndexPatternSwitchExists(); await ml.dataFrameAnalyticsCreation.setCreateIndexPatternSwitchState( @@ -111,19 +118,14 @@ export default function ({ getService }: FtrProviderContext) { ); }); - it('creates the analytics job', async () => { - await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); - }); - - it('starts the analytics job', async () => { - await ml.dataFrameAnalyticsCreation.assertStartButtonExists(); - await ml.dataFrameAnalyticsCreation.startAnalyticsJob(); + it('continues to the create step', async () => { + await ml.dataFrameAnalyticsCreation.continueToCreateStep(); }); - it('closes the create job flyout', async () => { - await ml.dataFrameAnalyticsCreation.assertCloseButtonExists(); - await ml.dataFrameAnalyticsCreation.closeCreateAnalyticsJobFlyout(); + it('creates and starts the analytics job', async () => { + await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); + await ml.dataFrameAnalyticsCreation.assertStartJobCheckboxCheckState(true); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('finishes analytics processing', async () => { @@ -131,6 +133,7 @@ export default function ({ getService }: FtrProviderContext) { }); it('displays the analytics table', async () => { + await ml.dataFrameAnalyticsCreation.navigateToJobManagementPage(); await ml.dataFrameAnalytics.assertAnalyticsTableExists(); }); diff --git a/x-pack/test/functional/services/ml/api.ts b/x-pack/test/functional/services/ml/api.ts index fc2ce4bb16b99..a48159cd7515f 100644 --- a/x-pack/test/functional/services/ml/api.ts +++ b/x-pack/test/functional/services/ml/api.ts @@ -214,22 +214,60 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { ); }, - async getAnalyticsState(analyticsId: string): Promise { - log.debug(`Fetching analytics state for job ${analyticsId}`); + async getDFAJobStats(analyticsId: string): Promise { + log.debug(`Fetching data frame analytics job stats for job ${analyticsId}...`); const analyticsStats = await esSupertest .get(`/_ml/data_frame/analytics/${analyticsId}/_stats`) .expect(200) .then((res: any) => res.body); + return analyticsStats; + }, + + async getAnalyticsState(analyticsId: string): Promise { + log.debug(`Fetching analytics state for job ${analyticsId}`); + const analyticsStats = await this.getDFAJobStats(analyticsId); + expect(analyticsStats.data_frame_analytics).to.have.length( 1, `Expected dataframe analytics stats to have exactly one object (got '${analyticsStats.data_frame_analytics.length}')` ); + const state: DATA_FRAME_TASK_STATE = analyticsStats.data_frame_analytics[0].state; return state; }, + async getDFAJobTrainingRecordCount(analyticsId: string): Promise { + const analyticsStats = await this.getDFAJobStats(analyticsId); + + expect(analyticsStats.data_frame_analytics).to.have.length( + 1, + `Expected dataframe analytics stats to have exactly one object (got '${analyticsStats.data_frame_analytics.length}')` + ); + const trainingRecordCount: number = + analyticsStats.data_frame_analytics[0].data_counts.training_docs_count; + + return trainingRecordCount; + }, + + async waitForDFAJobTrainingRecordCountToBePositive(analyticsId: string) { + await retry.waitForWithTimeout( + `'${analyticsId}' to have training_docs_count > 0`, + 10 * 1000, + async () => { + const trainingRecordCount = await this.getDFAJobTrainingRecordCount(analyticsId); + if (trainingRecordCount > 0) { + return true; + } else { + throw new Error( + `expected data frame analytics job '${analyticsId}' to have training_docs_count > 0 (got ${trainingRecordCount})` + ); + } + } + ); + }, + async waitForAnalyticsState( analyticsId: string, expectedAnalyticsState: DATA_FRAME_TASK_STATE diff --git a/x-pack/test/functional/services/ml/data_frame_analytics.ts b/x-pack/test/functional/services/ml/data_frame_analytics.ts index bd7d76e34b447..634b0d4d41fca 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics.ts @@ -67,10 +67,11 @@ export function MachineLearningDataFrameAnalyticsProvider( } else { await testSubjects.click('mlAnalyticsButtonCreate'); } - await testSubjects.existOrFail('mlAnalyticsCreateJobFlyout'); + await testSubjects.existOrFail('analyticsCreateSourceIndexModal'); }, async waitForAnalyticsCompletion(analyticsId: string) { + await mlApi.waitForDFAJobTrainingRecordCountToBePositive(analyticsId); await mlApi.waitForAnalyticsState(analyticsId, DATA_FRAME_TASK_STATE.STOPPED); }, }; diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts index cff7e00eef688..081eb8775fa5b 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts @@ -42,12 +42,12 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( return { async assertJobTypeSelectExists() { - await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutJobTypeSelect'); + await testSubjects.existOrFail('mlAnalyticsCreateJobWizardJobTypeSelect'); }, async assertJobTypeSelection(expectedSelection: string) { const actualSelection = await testSubjects.getAttribute( - 'mlAnalyticsCreateJobFlyoutJobTypeSelect', + 'mlAnalyticsCreateJobWizardJobTypeSelect', 'value' ); expect(actualSelection).to.eql( @@ -57,12 +57,13 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( }, async selectJobType(jobType: string) { - await testSubjects.selectValue('mlAnalyticsCreateJobFlyoutJobTypeSelect', jobType); + await testSubjects.click('mlAnalyticsCreateJobWizardJobTypeSelect'); + await testSubjects.click(`mlAnalyticsCreation-${jobType}-option`); await this.assertJobTypeSelection(jobType); }, async assertAdvancedEditorSwitchExists() { - await testSubjects.existOrFail(`mlAnalyticsCreateJobFlyoutAdvancedEditorSwitch`, { + await testSubjects.existOrFail(`mlAnalyticsCreateJobWizardAdvancedEditorSwitch`, { allowHidden: true, }); }, @@ -70,7 +71,7 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( async assertAdvancedEditorSwitchCheckState(expectedCheckState: boolean) { const actualCheckState = (await testSubjects.getAttribute( - 'mlAnalyticsCreateJobFlyoutAdvancedEditorSwitch', + 'mlAnalyticsCreateJobWizardAdvancedEditorSwitch', 'aria-checked' )) === 'true'; expect(actualCheckState).to.eql( @@ -182,20 +183,22 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( }, async assertDependentVariableInputExists() { - await testSubjects.existOrFail( - 'mlAnalyticsCreateJobFlyoutDependentVariableSelect > comboBoxInput' - ); + await retry.tryForTime(8000, async () => { + await testSubjects.existOrFail( + 'mlAnalyticsCreateJobWizardDependentVariableSelect > comboBoxInput' + ); + }); }, async assertDependentVariableInputMissing() { await testSubjects.missingOrFail( - 'mlAnalyticsCreateJobFlyoutDependentVariableSelect > comboBoxInput' + 'mlAnalyticsCreateJobWizardDependentVariableSelect > comboBoxInput' ); }, async assertDependentVariableSelection(expectedSelection: string[]) { const actualSelection = await comboBox.getComboBoxSelectedOptions( - 'mlAnalyticsCreateJobFlyoutDependentVariableSelect > comboBoxInput' + 'mlAnalyticsCreateJobWizardDependentVariableSelect > comboBoxInput' ); expect(actualSelection).to.eql( expectedSelection, @@ -205,23 +208,23 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( async selectDependentVariable(dependentVariable: string) { await comboBox.set( - 'mlAnalyticsCreateJobFlyoutDependentVariableSelect > comboBoxInput', + 'mlAnalyticsCreateJobWizardDependentVariableSelect > comboBoxInput', dependentVariable ); await this.assertDependentVariableSelection([dependentVariable]); }, async assertTrainingPercentInputExists() { - await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutTrainingPercentSlider'); + await testSubjects.existOrFail('mlAnalyticsCreateJobWizardTrainingPercentSlider'); }, async assertTrainingPercentInputMissing() { - await testSubjects.missingOrFail('mlAnalyticsCreateJobFlyoutTrainingPercentSlider'); + await testSubjects.missingOrFail('mlAnalyticsCreateJobWizardTrainingPercentSlider'); }, async assertTrainingPercentValue(expectedValue: string) { const actualTrainingPercent = await testSubjects.getAttribute( - 'mlAnalyticsCreateJobFlyoutTrainingPercentSlider', + 'mlAnalyticsCreateJobWizardTrainingPercentSlider', 'value' ); expect(actualTrainingPercent).to.eql( @@ -231,7 +234,7 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( }, async setTrainingPercent(trainingPercent: string) { - const slider = await testSubjects.find('mlAnalyticsCreateJobFlyoutTrainingPercentSlider'); + const slider = await testSubjects.find('mlAnalyticsCreateJobWizardTrainingPercentSlider'); let currentValue = await slider.getAttribute('value'); let currentDiff = +currentValue - +trainingPercent; @@ -271,13 +274,28 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( await this.assertTrainingPercentValue(trainingPercent); }, + async continueToAdditionalOptionsStep() { + await testSubjects.click('mlAnalyticsCreateJobWizardContinueButton'); + await testSubjects.existOrFail('mlAnalyticsCreateJobWizardAdvancedStep'); + }, + + async continueToDetailsStep() { + await testSubjects.click('mlAnalyticsCreateJobWizardContinueButton'); + await testSubjects.existOrFail('mlAnalyticsCreateJobWizardDetailsStep'); + }, + + async continueToCreateStep() { + await testSubjects.click('mlAnalyticsCreateJobWizardContinueButton'); + await testSubjects.existOrFail('mlAnalyticsCreateJobWizardCreateStep'); + }, + async assertModelMemoryInputExists() { - await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutModelMemoryInput'); + await testSubjects.existOrFail('mlAnalyticsCreateJobWizardModelMemoryInput'); }, async assertModelMemoryValue(expectedValue: string) { const actualModelMemory = await testSubjects.getAttribute( - 'mlAnalyticsCreateJobFlyoutModelMemoryInput', + 'mlAnalyticsCreateJobWizardModelMemoryInput', 'value' ); expect(actualModelMemory).to.eql( @@ -289,7 +307,7 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( async setModelMemory(modelMemory: string) { await retry.tryForTime(15 * 1000, async () => { await mlCommon.setValueWithChecks( - 'mlAnalyticsCreateJobFlyoutModelMemoryInput', + 'mlAnalyticsCreateJobWizardModelMemoryInput', modelMemory, { clearWithKeyboard: true, @@ -300,14 +318,14 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( }, async assertCreateIndexPatternSwitchExists() { - await testSubjects.existOrFail(`mlAnalyticsCreateJobFlyoutCreateIndexPatternSwitch`, { + await testSubjects.existOrFail(`mlAnalyticsCreateJobWizardCreateIndexPatternSwitch`, { allowHidden: true, }); }, async getCreateIndexPatternSwitchCheckState(): Promise { const state = await testSubjects.getAttribute( - 'mlAnalyticsCreateJobFlyoutCreateIndexPatternSwitch', + 'mlAnalyticsCreateJobWizardCreateIndexPatternSwitch', 'aria-checked' ); return state === 'true'; @@ -323,58 +341,46 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( async setCreateIndexPatternSwitchState(checkState: boolean) { if ((await this.getCreateIndexPatternSwitchCheckState()) !== checkState) { - await testSubjects.click('mlAnalyticsCreateJobFlyoutCreateIndexPatternSwitch'); + await testSubjects.click('mlAnalyticsCreateJobWizardCreateIndexPatternSwitch'); } await this.assertCreateIndexPatternSwitchCheckState(checkState); }, - async assertCreateButtonExists() { - await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutCreateButton'); + async assertStartJobCheckboxExists() { + await testSubjects.existOrFail('mlAnalyticsCreateJobWizardStartJobCheckbox'); + }, + + async assertStartJobCheckboxCheckState(expectedCheckState: boolean) { + const actualCheckState = + (await testSubjects.getAttribute( + 'mlAnalyticsCreateJobWizardStartJobCheckbox', + 'checked' + )) === 'true'; + expect(actualCheckState).to.eql( + expectedCheckState, + `Start job check state should be ${expectedCheckState} (got ${actualCheckState})` + ); }, - async assertCreateButtonMissing() { - await testSubjects.missingOrFail('mlAnalyticsCreateJobFlyoutCreateButton'); + async assertCreateButtonExists() { + await testSubjects.existOrFail('mlAnalyticsCreateJobWizardCreateButton'); }, async isCreateButtonDisabled() { - const isEnabled = await testSubjects.isEnabled('mlAnalyticsCreateJobFlyoutCreateButton'); + const isEnabled = await testSubjects.isEnabled('mlAnalyticsCreateJobWizardCreateButton'); return !isEnabled; }, async createAnalyticsJob(analyticsId: string) { - await testSubjects.click('mlAnalyticsCreateJobFlyoutCreateButton'); + await testSubjects.click('mlAnalyticsCreateJobWizardCreateButton'); await retry.tryForTime(5000, async () => { - await this.assertCreateButtonMissing(); - await this.assertStartButtonExists(); + await this.assertBackToManagementCardExists(); }); await mlApi.waitForDataFrameAnalyticsJobToExist(analyticsId); }, - async assertStartButtonExists() { - await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutStartButton'); - }, - - async assertStartButtonMissing() { - await testSubjects.missingOrFail('mlAnalyticsCreateJobFlyoutStartButton'); - }, - - async startAnalyticsJob() { - await testSubjects.click('mlAnalyticsCreateJobFlyoutStartButton'); - await retry.tryForTime(5000, async () => { - await this.assertStartButtonMissing(); - await this.assertCloseButtonExists(); - }); - }, - - async assertCloseButtonExists() { - await testSubjects.existOrFail('mlAnalyticsCreateJobFlyoutCloseButton'); - }, - - async closeCreateAnalyticsJobFlyout() { - await retry.tryForTime(10 * 1000, async () => { - await testSubjects.click('mlAnalyticsCreateJobFlyoutCloseButton'); - await testSubjects.missingOrFail('mlAnalyticsCreateJobFlyout'); - }); + async assertBackToManagementCardExists() { + await testSubjects.existOrFail('analyticsWizardCardManagement'); }, async getHeaderText() { @@ -395,5 +401,19 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( await this.assertExcludedFieldsSelection(job.analyzed_fields.excludes); await this.assertModelMemoryValue(job.model_memory_limit); }, + + async assertCreationCalloutMessagesExist() { + await testSubjects.existOrFail('analyticsWizardCreationCallout_0'); + await testSubjects.existOrFail('analyticsWizardCreationCallout_1'); + await testSubjects.existOrFail('analyticsWizardCreationCallout_2'); + }, + + async navigateToJobManagementPage() { + await retry.tryForTime(5000, async () => { + await this.assertCreationCalloutMessagesExist(); + }); + await testSubjects.click('analyticsWizardCardManagement'); + await testSubjects.existOrFail('mlPageDataFrameAnalytics'); + }, }; } diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_table.ts b/x-pack/test/functional/services/ml/data_frame_analytics_table.ts index d5f4ee63f615b..60507f5ab3331 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_table.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_table.ts @@ -105,6 +105,7 @@ export function MachineLearningDataFrameAnalyticsTableProvider({ getService }: F } public async assertAnalyticsRowFields(analyticsId: string, expectedRow: object) { + await this.refreshAnalyticsTable(); const rows = await this.parseAnalyticsTable(); const analyticsRow = rows.filter((row) => row.id === analyticsId)[0]; expect(analyticsRow).to.eql( diff --git a/x-pack/test/functional/services/ml/job_source_selection.ts b/x-pack/test/functional/services/ml/job_source_selection.ts index a1857d882be3d..8da7318b642ad 100644 --- a/x-pack/test/functional/services/ml/job_source_selection.ts +++ b/x-pack/test/functional/services/ml/job_source_selection.ts @@ -34,6 +34,10 @@ export function MachineLearningJobSourceSelectionProvider({ getService }: FtrPro await this.selectSource(sourceName, 'mlPageJobTypeSelection'); }, + async selectSourceForAnalyticsJob(sourceName: string) { + await this.selectSource(sourceName, 'mlAnalyticsCreationContainer'); + }, + async selectSourceForIndexBasedDataVisualizer(sourceName: string) { await this.selectSource(sourceName, 'mlPageIndexDataVisualizer'); }, From 537d2f2de16e4e0710d44029466f8675fea40034 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Thu, 4 Jun 2020 20:42:52 +0300 Subject: [PATCH 06/13] [FIX] Allow filters without index (#68225) * Allow filters without index * Explicitly return true from isFilterApplicable id no index patterns were provided * Adjust test result --- .../data/public/ui/filter_bar/filter_item.tsx | 6 +++++- .../apps/dashboard/dashboard_filter_bar.js | 5 +++++ .../dashboard/current/kibana/data.json.gz | Bin 20510 -> 20860 bytes 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/plugins/data/public/ui/filter_bar/filter_item.tsx b/src/plugins/data/public/ui/filter_bar/filter_item.tsx index c44e1faeb8e7f..053fca7d5773b 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_item.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_item.tsx @@ -74,7 +74,8 @@ export function FilterItem(props: Props) { setIndexPatternExists(false); }); } else { - setIndexPatternExists(false); + // Allow filters without an index pattern and don't validate them. + setIndexPatternExists(true); } }, [props.filter.meta.index]); @@ -244,6 +245,9 @@ export function FilterItem(props: Props) { * This function makes this behavior explicit, but it needs to be revised. */ function isFilterApplicable() { + // Any filter is applicable if no index patterns were provided to FilterBar. + if (!props.indexPatterns.length) return true; + const ip = getIndexPatternFromFilter(filter, indexPatterns); if (ip) return true; diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.js b/test/functional/apps/dashboard/dashboard_filter_bar.js index 6bc34a8b998a4..c931e6763f483 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.js +++ b/test/functional/apps/dashboard/dashboard_filter_bar.js @@ -217,6 +217,11 @@ export default function ({ getService, getPageObjects }) { const hasWarningFieldFilter = await filterBar.hasFilter('extension', 'warn', true); expect(hasWarningFieldFilter).to.be(true); }); + + it('filter without an index pattern is rendred as a warning, if the dashboard has an index pattern', async function () { + const noIndexPatternFilter = await filterBar.hasFilter('banana', 'warn', true); + expect(noIndexPatternFilter).to.be(true); + }); }); }); } diff --git a/test/functional/fixtures/es_archiver/dashboard/current/kibana/data.json.gz b/test/functional/fixtures/es_archiver/dashboard/current/kibana/data.json.gz index a052aad9450f564426820c0354e43489a3e7e0c5..ae78761fef0d3415c8ec05ea4bfa9dcca070981a 100644 GIT binary patch delta 19344 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 TNscMkeUSejfa<-{p%DWB#o!D; From e488e831b5fc8ac9284cede176b03a05178e484b Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Thu, 4 Jun 2020 18:55:06 +0100 Subject: [PATCH 07/13] [SIEM] Update links for add-data button (#68240) * update links for add-data button * update links * rename SIEM with security * add back missing key * fixup * fixup --- .../__snapshots__/add_data.test.js.snap | 24 ++--- .../public/application/components/add_data.js | 10 +-- .../components/tutorial_directory.js | 6 +- .../security_solution/common/constants.ts | 1 + .../detection_engine_empty_page.tsx | 3 +- .../common/components/header_global/index.tsx | 3 +- .../public/hosts/pages/hosts_empty_page.tsx | 3 +- .../network/pages/network_empty_page.tsx | 3 +- .../components/overview_empty/index.tsx | 3 +- .../public/overview/pages/summary.tsx | 4 +- .../translations/translations/ja-JP.json | 60 ++++++------- .../translations/translations/zh-CN.json | 88 +++++++++---------- 12 files changed, 108 insertions(+), 100 deletions(-) diff --git a/src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap index 3b3f86e579f1a..2545bbcb5114d 100644 --- a/src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/add_data.test.js.snap @@ -202,17 +202,17 @@ exports[`apmUiEnabled 1`] = ` } textAlign="left" - title="SIEM" + title="Security" titleSize="xs" /> @@ -468,17 +468,17 @@ exports[`isNewKibanaInstance 1`] = ` } textAlign="left" - title="SIEM" + title="Security" titleSize="xs" /> @@ -765,17 +765,17 @@ exports[`mlEnabled 1`] = ` } textAlign="left" - title="SIEM" + title="Security" titleSize="xs" /> @@ -1067,17 +1067,17 @@ exports[`render 1`] = ` } textAlign="left" - title="SIEM" + title="Security" titleSize="xs" /> diff --git a/src/plugins/home/public/application/components/add_data.js b/src/plugins/home/public/application/components/add_data.js index 2f7f07a0e4549..fa1327b3fcd08 100644 --- a/src/plugins/home/public/application/components/add_data.js +++ b/src/plugins/home/public/application/components/add_data.js @@ -80,11 +80,11 @@ const AddDataUi = ({ apmUiEnabled, isNewKibanaInstance, intl, mlEnabled }) => { }; const siemData = { title: intl.formatMessage({ - id: 'home.addData.siem.nameTitle', - defaultMessage: 'SIEM', + id: 'home.addData.securitySolution.nameTitle', + defaultMessage: 'Security', }), description: intl.formatMessage({ - id: 'home.addData.siem.nameDescription', + id: 'home.addData.securitySolution.nameDescription', defaultMessage: 'Centralize security events for interactive investigation in ready-to-go visualizations.', }), @@ -221,11 +221,11 @@ const AddDataUi = ({ apmUiEnabled, isNewKibanaInstance, intl, mlEnabled }) => { footer={ diff --git a/src/plugins/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js index 4d2cec158f63e..774b23af11ac8 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.js +++ b/src/plugins/home/public/application/components/tutorial_directory.js @@ -75,10 +75,10 @@ class TutorialDirectoryUi extends React.Component { }), }, { - id: 'siem', + id: 'security', name: this.props.intl.formatMessage({ - id: 'home.tutorial.tabs.siemTitle', - defaultMessage: 'SIEM', + id: 'home.tutorial.tabs.securitySolutionTitle', + defaultMessage: 'Security', }), }, { diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index b5cd8f2dec0a3..482794804685d 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -8,6 +8,7 @@ export const APP_ID = 'securitySolution'; export const APP_NAME = 'Security'; export const APP_ICON = 'securityAnalyticsApp'; export const APP_PATH = `/app/security`; +export const ADD_DATA_PATH = `/app/home#/tutorial_directory/security`; export const DEFAULT_BYTES_FORMAT = 'format:bytes:defaultPattern'; export const DEFAULT_DATE_FORMAT = 'dateFormat'; export const DEFAULT_DATE_FORMAT_TZ = 'dateFormat:tz'; diff --git a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine_empty_page.tsx b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine_empty_page.tsx index 9632ddfeadc0d..0c58f5620964b 100644 --- a/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine_empty_page.tsx +++ b/x-pack/plugins/security_solution/public/alerts/pages/detection_engine/detection_engine_empty_page.tsx @@ -9,12 +9,13 @@ import React from 'react'; import { useKibana } from '../../../common/lib/kibana'; import { EmptyPage } from '../../../common/components/empty_page'; import * as i18n from '../../../common/translations'; +import { ADD_DATA_PATH } from '../../../../common/constants'; export const DetectionEngineEmptyPage = React.memo(() => ( css` @@ -86,7 +87,7 @@ export const HeaderGlobal = React.memo(({ hideDetectionEngine {i18n.BUTTON_ADD_DATA} diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts_empty_page.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts_empty_page.tsx index 3ab0cb1f748d6..a01e249561e5c 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts_empty_page.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts_empty_page.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { EmptyPage } from '../../common/components/empty_page'; import { useKibana } from '../../common/lib/kibana'; import * as i18n from '../../common/translations'; +import { ADD_DATA_PATH } from '../../../common/constants'; export const HostsEmptyPage = React.memo(() => { const { http, docLinks } = useKibana().services; @@ -18,7 +19,7 @@ export const HostsEmptyPage = React.memo(() => { { const { http, docLinks } = useKibana().services; @@ -18,7 +19,7 @@ export const NetworkEmptyPage = React.memo(() => { { const { http, docLinks } = useKibana().services; @@ -18,7 +19,7 @@ const OverviewEmptyComponent: React.FC = () => { { @@ -37,7 +39,7 @@ export const Summary = React.memo(() => { ), data: ( - + 機械学習ジョブの異常がこの値を超えると SIEM アプリに表示されます。

有効な値:0 ~ 100。

", + "xpack.securitySolution.uiSettings.defaultAnomalyScoreDescription": "

機械学習ジョブの異常がこの値を超えると Security アプリに表示されます。

有効な値:0 ~ 100。

", "xpack.securitySolution.uiSettings.defaultAnomalyScoreLabel": "デフォルトの異常しきい値", - "xpack.securitySolution.uiSettings.defaultIndexDescription": "

SIEM アプリがイベントを収集する Elasticsearch インデックスのコンマ区切りのリストです。

", + "xpack.securitySolution.uiSettings.defaultIndexDescription": "

Security アプリがイベントを収集する Elasticsearch インデックスのコンマ区切りのリストです。

", "xpack.securitySolution.uiSettings.defaultIndexLabel": "デフォルトのインデックス", - "xpack.securitySolution.uiSettings.defaultRefreshIntervalDescription": "

SIEM 時間フィルターのミリ単位のデフォルトの更新間隔です。

", + "xpack.securitySolution.uiSettings.defaultRefreshIntervalDescription": "

Security 時間フィルターのミリ単位のデフォルトの更新間隔です。

", "xpack.securitySolution.uiSettings.defaultRefreshIntervalLabel": "タイムピッカーの更新間隔", - "xpack.securitySolution.uiSettings.defaultTimeRangeDescription": "

SIEM 時間フィルダーのデフォルトの期間です。

", + "xpack.securitySolution.uiSettings.defaultTimeRangeDescription": "

Security 時間フィルダーのデフォルトの期間です。

", "xpack.securitySolution.uiSettings.defaultTimeRangeLabel": "デフォルトのタイムピッカー", "xpack.securitySolution.uiSettings.enableNewsFeedDescription": "

ニュースフィードを有効にします

", "xpack.securitySolution.uiSettings.enableNewsFeedLabel": "ニュースフィード", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cee6860a58b5a..86547a3729db4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -1236,9 +1236,9 @@ "home.addData.metrics.nameTitle": "指标", "home.addData.sampleDataLink": "加载数据集和 Kibana 仪表板", "home.addData.sampleDataTitle": "添加样例数据", - "home.addData.siem.addSiemEventsButtonLabel": "添加事件", - "home.addData.siem.nameDescription": "集中安全事件,以通过即用型可视化实现交互式调查。", - "home.addData.siem.nameTitle": "SIEM", + "home.addData.securitySolution.addSecurityEventsButtonLabel": "添加事件", + "home.addData.securitySolution.nameDescription": "集中安全事件,以通过即用型可视化实现交互式调查。", + "home.addData.securitySolution.nameTitle": "Security", "home.addData.title.observability": "可观测性", "home.addData.title.security": "安全", "home.addData.uploadFileLink": "导入 CSV、NDJSON 或日志文件", @@ -1370,7 +1370,7 @@ "home.tutorial.tabs.loggingTitle": "日志", "home.tutorial.tabs.metricsTitle": "指标", "home.tutorial.tabs.sampleDataTitle": "样例数据", - "home.tutorial.tabs.siemTitle": "SIEM", + "home.tutorial.tabs.securitySolutionTitle": "Security", "home.tutorial.unexpectedStatusCheckStateErrorDescription": "意外的状态检查状态 {statusCheckState}", "home.tutorial.unhandledInstructionTypeErrorDescription": "未处理的指令类型 {visibleInstructions}", "home.tutorials.activemqLogs.artifacts.dashboards.linkLabel": "ActiveMQ 应用程序事件", @@ -1393,7 +1393,7 @@ "home.tutorials.apacheMetrics.longDescription": "Metricbeat 模块 `apache` 从 Apache 2 HTTP 服务器提取内部指标。[了解详情]({learnMoreLink})。", "home.tutorials.apacheMetrics.nameTitle": "Apache 指标", "home.tutorials.apacheMetrics.shortDescription": "从 Apache 2 HTTP 服务器提取内部指标。", - "home.tutorials.auditbeat.artifacts.dashboards.linkLabel": "SIEM 应用", + "home.tutorials.auditbeat.artifacts.dashboards.linkLabel": "Security 应用", "home.tutorials.auditbeat.longDescription": "使用 Auditbeat 从主机收集审计数据。其中包括进程、用户、登录、套接字信息、文件访问等等。[了解详情]({learnMoreLink})。", "home.tutorials.auditbeat.nameTitle": "Auditbeat", "home.tutorials.auditbeat.shortDescription": "从主机收集审计数据。", @@ -1412,7 +1412,7 @@ "home.tutorials.cephMetrics.longDescription": "Metricbeat 模块 `ceph` 从 Ceph 提取内部指标。[了解详情]({learnMoreLink})。", "home.tutorials.cephMetrics.nameTitle": "Ceph 指标", "home.tutorials.cephMetrics.shortDescription": "从 Ceph 服务器提取内部指标。", - "home.tutorials.ciscoLogs.artifacts.dashboards.linkLabel": "SIEM 应用", + "home.tutorials.ciscoLogs.artifacts.dashboards.linkLabel": "Security 应用", "home.tutorials.ciscoLogs.longDescription": "这是用于 Cisco 网络设备日志的模块。当前支持“asa”文件集,该文件集用于通过 Syslog 接收或从文件读取的 Cisco ASA 防火墙日志。[了解详情]({learnMoreLink})。", "home.tutorials.ciscoLogs.nameTitle": "Cisco", "home.tutorials.ciscoLogs.shortDescription": "收集并解析从 Cisco ASA 防火墙接收的日志。", @@ -1742,7 +1742,7 @@ "home.tutorials.elasticsearchMetrics.longDescription": "Metricbeat 模块 `elasticsearch` 从 Elasticsearch 提取内部指标。[了解详情]({learnMoreLink})。", "home.tutorials.elasticsearchMetrics.nameTitle": "Elasticsearch 指标", "home.tutorials.elasticsearchMetrics.shortDescription": "从 Elasticsearch 提取内部指标。", - "home.tutorials.envoyproxyLogs.artifacts.dashboards.linkLabel": "SIEM 应用", + "home.tutorials.envoyproxyLogs.artifacts.dashboards.linkLabel": "Security 应用", "home.tutorials.envoyproxyLogs.longDescription": "这是用于 [Envoy 代理访问日志](https://www.envoyproxy.io/docs/envoy/v1.10.0/configuration/access_log)的 Filebeat 模块。其在 Kubernetes 中既支持独立部署,又支持 Envoy 代理部署。[了解详情]({learnMoreLink})。", "home.tutorials.envoyproxyLogs.nameTitle": "Envoyproxy", "home.tutorials.envoyproxyLogs.shortDescription": "收集并解析从 Envoy 代理接收的日志。", @@ -1773,7 +1773,7 @@ "home.tutorials.iisLogs.longDescription": "Filebeat 模块 `iis` 解析 IIS HTTP 服务器创建的访问和错误日志。[了解详情]({learnMoreLink})。", "home.tutorials.iisLogs.nameTitle": "IIS 日志", "home.tutorials.iisLogs.shortDescription": "收集并解析 IIS HTTP 服务器创建的访问和错误日志。", - "home.tutorials.iptablesLogs.artifacts.dashboards.linkLabel": "SIEM 应用", + "home.tutorials.iptablesLogs.artifacts.dashboards.linkLabel": "Security 应用", "home.tutorials.iptablesLogs.longDescription": "这是用于 iptables 和 ip6tables 日志的模块。其解析在网络上通过 Syslog 或从文件中接收的日志。另外,其识别某些 Ubiquiti 防火墙添加的前缀,该前缀包含规则集名称、规则编号和对流量执行的操作 (allow/deny)。[了解详情]({learnMoreLink})。", "home.tutorials.iptablesLogs.nameTitle": "Iptables / Ubiquiti", "home.tutorials.iptablesLogs.shortDescription": "从 Ubiqiti 防火墙收集并解析 iptables 和 ip6tables 日志。", @@ -1945,7 +1945,7 @@ "home.tutorials.vsphereMetrics.longDescription": "Metricbeat 模块 `vsphere` 从 vSphere 集群提取内部指标。[了解详情]({learnMoreLink})。", "home.tutorials.vsphereMetrics.nameTitle": "vSphere 指标", "home.tutorials.vsphereMetrics.shortDescription": "从 vSphere 提取内部指标。", - "home.tutorials.windowsEventLogs.artifacts.application.label": "SIEM 应用", + "home.tutorials.windowsEventLogs.artifacts.application.label": "Security 应用", "home.tutorials.windowsEventLogs.longDescription": "使用 Winlogbeat 从 Windows 事件日志收集日志。[了解详情]({learnMoreLink})。", "home.tutorials.windowsEventLogs.nameTitle": "Windows 事件日志", "home.tutorials.windowsEventLogs.shortDescription": "从 Windows 事件日志提取日志。", @@ -13447,7 +13447,7 @@ "xpack.securitySolution.andOrBadge.and": "AND", "xpack.securitySolution.andOrBadge.or": "OR", "xpack.securitySolution.anomaliesTable.table.anomaliesDescription": "异常", - "xpack.securitySolution.anomaliesTable.table.anomaliesTooltip": "异常表无法通过 SIEM 全局 KQL 搜索进行筛选。", + "xpack.securitySolution.anomaliesTable.table.anomaliesTooltip": "异常表无法通过 Security 全局 KQL 搜索进行筛选。", "xpack.securitySolution.anomaliesTable.table.showingDescription": "显示", "xpack.securitySolution.anomaliesTable.table.unit": "{totalCount, plural, =1 {个异常} other {个异常}}", "xpack.securitySolution.auditd.abortedAuditStartupDescription": "已中止审计启动", @@ -13659,7 +13659,7 @@ "xpack.securitySolution.case.caseView.editConnector": "更改外部事件管理系统", "xpack.securitySolution.case.caseView.editTagsLinkAria": "单击可编辑标记", "xpack.securitySolution.case.caseView.emailBody": "案例参考:{caseUrl}", - "xpack.securitySolution.case.caseView.emailSubject": "SIEM 案例 - {caseTitle}", + "xpack.securitySolution.case.caseView.emailSubject": "Security 案例 - {caseTitle}", "xpack.securitySolution.case.caseView.errorsPushServiceCallOutTitle": "要将案例发送到外部系统,您需要:", "xpack.securitySolution.case.caseView.fieldRequiredError": "必填字段", "xpack.securitySolution.case.caseView.goToDocumentationButton": "查看文档", @@ -13697,22 +13697,22 @@ "xpack.securitySolution.case.configure.successSaveToast": "已保存外部连接设置", "xpack.securitySolution.case.configureCases.addNewConnector": "添加新连接器", "xpack.securitySolution.case.configureCases.cancelButton": "取消", - "xpack.securitySolution.case.configureCases.caseClosureOptionsClosedIncident": "在外部系统中关闭事件时自动关闭 SIEM 案例", - "xpack.securitySolution.case.configureCases.caseClosureOptionsDesc": "定义关闭 SIEM 案例的方式。要自动关闭案例,需要与外部事件管理系统建立连接。", + "xpack.securitySolution.case.configureCases.caseClosureOptionsClosedIncident": "在外部系统中关闭事件时自动关闭 Security 案例", + "xpack.securitySolution.case.configureCases.caseClosureOptionsDesc": "定义关闭 Security 案例的方式。要自动关闭案例,需要与外部事件管理系统建立连接。", "xpack.securitySolution.case.configureCases.caseClosureOptionsLabel": "案例关闭选项", - "xpack.securitySolution.case.configureCases.caseClosureOptionsManual": "手动关闭 SIEM 案例", - "xpack.securitySolution.case.configureCases.caseClosureOptionsNewIncident": "将新事件推送到外部系统时自动关闭 SIEM 案例", + "xpack.securitySolution.case.configureCases.caseClosureOptionsManual": "手动关闭 Security 案例", + "xpack.securitySolution.case.configureCases.caseClosureOptionsNewIncident": "将新事件推送到外部系统时自动关闭 Security 案例", "xpack.securitySolution.case.configureCases.caseClosureOptionsTitle": "案例关闭", - "xpack.securitySolution.case.configureCases.fieldMappingDesc": "将数据推送到第三方时映射 SIEM 案例字段。字段映射需要与外部事件管理系统建立连接。", + "xpack.securitySolution.case.configureCases.fieldMappingDesc": "将数据推送到第三方时映射 Security 案例字段。字段映射需要与外部事件管理系统建立连接。", "xpack.securitySolution.case.configureCases.fieldMappingEditAppend": "追加", "xpack.securitySolution.case.configureCases.fieldMappingEditNothing": "无内容", "xpack.securitySolution.case.configureCases.fieldMappingEditOverwrite": "覆盖", - "xpack.securitySolution.case.configureCases.fieldMappingFirstCol": "SIEM 案例字段", + "xpack.securitySolution.case.configureCases.fieldMappingFirstCol": "Security 案例字段", "xpack.securitySolution.case.configureCases.fieldMappingSecondCol": "外部事件字段", "xpack.securitySolution.case.configureCases.fieldMappingThirdCol": "编辑和更新时", "xpack.securitySolution.case.configureCases.fieldMappingTitle": "字段映射", "xpack.securitySolution.case.configureCases.headerTitle": "配置案例", - "xpack.securitySolution.case.configureCases.incidentManagementSystemDesc": "您可能会根据需要将 SIEM 案例连接到选择的外部事件管理系统。这将允许您将案例数据作为事件推送到所选第三方系统。", + "xpack.securitySolution.case.configureCases.incidentManagementSystemDesc": "您可能会根据需要将 Security 案例连接到选择的外部事件管理系统。这将允许您将案例数据作为事件推送到所选第三方系统。", "xpack.securitySolution.case.configureCases.incidentManagementSystemLabel": "事件管理系统", "xpack.securitySolution.case.configureCases.incidentManagementSystemTitle": "连接到外部事件管理系统", "xpack.securitySolution.case.configureCases.mappingFieldComments": "注释", @@ -13746,9 +13746,9 @@ "xpack.securitySolution.case.connectors.jira.actionTypeTitle": "Jira", "xpack.securitySolution.case.connectors.jira.projectKey": "项目键", "xpack.securitySolution.case.connectors.jira.requiredProjectKeyTextField": "项目键必填。", - "xpack.securitySolution.case.connectors.jira.selectMessageText": "将 SIEM 案例数据推送或更新到 Jira 中的新问题", + "xpack.securitySolution.case.connectors.jira.selectMessageText": "将 Security 案例数据推送或更新到 Jira 中的新问题", "xpack.securitySolution.case.connectors.servicenow.actionTypeTitle": "ServiceNow", - "xpack.securitySolution.case.connectors.servicenow.selectMessageText": "将 SIEM 案例数据推送或更新到 ServiceNow 中的新事件", + "xpack.securitySolution.case.connectors.servicenow.selectMessageText": "将 Security 案例数据推送或更新到 ServiceNow 中的新事件", "xpack.securitySolution.case.createCase.descriptionFieldRequiredError": "描述必填。", "xpack.securitySolution.case.createCase.fieldTagsHelpText": "为此案例键入一个或多个定制识别标记。在每个标记后按 Enter 键可开始新的标记。", "xpack.securitySolution.case.createCase.titleFieldRequiredError": "标题必填。", @@ -13761,8 +13761,8 @@ "xpack.securitySolution.chart.allOthersGroupingLabel": "所有其他", "xpack.securitySolution.chart.dataAllValuesZerosTitle": "所有值返回零", "xpack.securitySolution.chart.dataNotAvailableTitle": "图表数据不可用", - "xpack.securitySolution.chrome.help.appName": "SIEM", - "xpack.securitySolution.chrome.helpMenu.documentation": "SIEM 文档", + "xpack.securitySolution.chrome.help.appName": "Security", + "xpack.securitySolution.chrome.helpMenu.documentation": "Security 文档", "xpack.securitySolution.chrome.helpMenu.documentation.ecs": "ECS 文档", "xpack.securitySolution.clipboard.copied": "已复制", "xpack.securitySolution.clipboard.copy": "复制", @@ -13779,7 +13779,7 @@ "xpack.securitySolution.components.embeddables.embeddedMap.serverLayerLabel": "服务器点", "xpack.securitySolution.components.embeddables.embeddedMap.sourceLayerLabel": "源点", "xpack.securitySolution.components.embeddables.indexPatternsMissingPrompt.errorButtonLabel": "配置索引模式", - "xpack.securitySolution.components.embeddables.indexPatternsMissingPrompt.errorDescription1": "要显示地图数据,必须使用匹配的全局模式定义 SIEM 索引 ({defaultIndex}) 和 Kibana 索引模式。使用 {beats} 时,可以在主机上运行 {setup} 命令,以自动创建索引模式。例如:{example}。", + "xpack.securitySolution.components.embeddables.indexPatternsMissingPrompt.errorDescription1": "要显示地图数据,必须使用匹配的全局模式定义 Security 索引 ({defaultIndex}) 和 Kibana 索引模式。使用 {beats} 时,可以在主机上运行 {setup} 命令,以自动创建索引模式。例如:{example}。", "xpack.securitySolution.components.embeddables.indexPatternsMissingPrompt.errorDescription2": "还可以在 Kibana 中配置索引模式。", "xpack.securitySolution.components.embeddables.indexPatternsMissingPrompt.errorTitle": "未配置所需的索引模式", "xpack.securitySolution.components.embeddables.mapToolTip.errorTitle": "加载地图特征时出错", @@ -13817,15 +13817,15 @@ "xpack.securitySolution.components.mlPopover.jobsTable.filters.searchFilterPlaceholder": "例如 rare_process_linux", "xpack.securitySolution.components.mlPopover.jobsTable.filters.showAllJobsLabel": "Elastic 作业", "xpack.securitySolution.components.mlPopover.jobsTable.filters.showSiemJobsLabel": "定制作业", - "xpack.securitySolution.components.mlPopup.anomalyDetectionDescription": "运行下面的任意 Machine Learning 作业以准备创建将产生已检测异常信号的信号检测规则以及查看整个 SIEM 应用程序内的异常事件。我们提供一系列常见检测作业帮助您入门。如果您希望添加自己的定制 ML 作业,请从 {machineLearning} 应用程序中创建并将它们添加到“SIEM”组。", + "xpack.securitySolution.components.mlPopup.anomalyDetectionDescription": "运行下面的任意 Machine Learning 作业以准备创建将产生已检测异常信号的信号检测规则以及查看整个 Security 应用程序内的异常事件。我们提供一系列常见检测作业帮助您入门。如果您希望添加自己的定制 ML 作业,请从 {machineLearning} 应用程序中创建并将它们添加到“SIEM”组。", "xpack.securitySolution.components.mlPopup.cloudLink": "云部署", "xpack.securitySolution.components.mlPopup.errors.createJobFailureTitle": "创建作业失败", "xpack.securitySolution.components.mlPopup.errors.startJobFailureTitle": "启动作业失败", "xpack.securitySolution.components.mlPopup.hooks.errors.indexPatternFetchFailureTitle": "索引模式提取失败", - "xpack.securitySolution.components.mlPopup.hooks.errors.siemJobFetchFailureTitle": "SIEM 作业提取失败", + "xpack.securitySolution.components.mlPopup.hooks.errors.siemJobFetchFailureTitle": "Security 作业提取失败", "xpack.securitySolution.components.mlPopup.jobsTable.createCustomJobButtonLabel": "创建定制作业", "xpack.securitySolution.components.mlPopup.jobsTable.jobNameColumn": "作业名称", - "xpack.securitySolution.components.mlPopup.jobsTable.noItemsDescription": "未找到任何 SIEM Machine Learning 作业", + "xpack.securitySolution.components.mlPopup.jobsTable.noItemsDescription": "未找到任何 Security Machine Learning 作业", "xpack.securitySolution.components.mlPopup.jobsTable.runJobColumn": "运行作业", "xpack.securitySolution.components.mlPopup.jobsTable.tagsColumn": "组", "xpack.securitySolution.components.mlPopup.licenseButtonLabel": "管理许可", @@ -13835,7 +13835,7 @@ "xpack.securitySolution.components.mlPopup.moduleNotCompatibleTitle": "{incompatibleJobCount} {incompatibleJobCount, plural, =1 {job} other {jobs}}当前不可用。", "xpack.securitySolution.components.mlPopup.showingLabel": "显示:{filterResultsLength} 个 {filterResultsLength, plural, one {作业} other {作业}}", "xpack.securitySolution.components.mlPopup.upgradeButtonLabel": "订阅选项", - "xpack.securitySolution.components.mlPopup.upgradeDescription": "要访问 SIEM 的异常检测功能,必须将您的许可更新到白金级、开始 30 天免费试用或在 AWS、GCP 或 Azurein 实施{cloudLink}。然后便可以运行 Machine Learning 作业并查看异常。", + "xpack.securitySolution.components.mlPopup.upgradeDescription": "要访问 Security 的异常检测功能,必须将您的许可更新到白金级、开始 30 天免费试用或在 AWS、GCP 或 Azurein 实施{cloudLink}。然后便可以运行 Machine Learning 作业并查看异常。", "xpack.securitySolution.components.mlPopup.upgradeTitle": "升级 Elastic 白金级", "xpack.securitySolution.components.stepDefineRule.ruleTypeField.subscriptionsLink": "白金级订阅", "xpack.securitySolution.containers.anomalies.errorFetchingAnomaliesData": "无法查询异常数据", @@ -13890,7 +13890,7 @@ "xpack.securitySolution.detectionEngine.components.importRuleModal.importRuleTitle": "导入规则", "xpack.securitySolution.detectionEngine.components.importRuleModal.initialPromptTextDescription": "选择或拖放有效的 rules_export.ndjson 文件", "xpack.securitySolution.detectionEngine.components.importRuleModal.overwriteDescription": "自动覆盖具有相同规则 ID 的已保存对象", - "xpack.securitySolution.detectionEngine.components.importRuleModal.selectRuleDescription": "选择要导入的 SIEM 规则(如从检测引擎视图导出的)", + "xpack.securitySolution.detectionEngine.components.importRuleModal.selectRuleDescription": "选择要导入的 Security 规则(如从检测引擎视图导出的)", "xpack.securitySolution.detectionEngine.components.importRuleModal.successfullyImportedRulesTitle": "已成功导入 {totalRules} 个{totalRules, plural, =1 {规则} other {规则}}", "xpack.securitySolution.detectionEngine.createRule. stepScheduleRule.completeWithActivatingTitle": "创建并激活规则", "xpack.securitySolution.detectionEngine.createRule. stepScheduleRule.completeWithoutActivatingTitle": "创建规则但不激活", @@ -13937,8 +13937,8 @@ "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.importTimelineModalTitle": "从已保存时间线导入查询", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.importTimelineQueryButton": "从已保存时间线导入查询", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.indicesCustomDescription": "提供定制的索引列表", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.indicesFromConfigDescription": "使用 SIEM 高级设置的 Elasticsearch 索引", - "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.indicesHelperDescription": "输入要运行此规则的 Elasticsearch 索引的模式。默认情况下,将包括 SIEM 高级设置中定义的索引模式。", + "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.indicesFromConfigDescription": "使用 Security 高级设置的 Elasticsearch 索引", + "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.indicesHelperDescription": "输入要运行此规则的 Elasticsearch 索引的模式。默认情况下,将包括 Security 高级设置中定义的索引模式。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.machineLearningJobIdHelpText": "我们提供若干可让您入门的常规作业。要添加自己的定制规则,在 {machineLearning} 应用程序中请将一组“siem”分配给这些作业,以使它们显示在此处。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.machineLearningJobIdRequired": "Machine Learning 作业必填。", "xpack.securitySolution.detectionEngine.createRule.stepDefineRule.mlEnableJobWarningTitle": "此 ML 作业当前未运行。在激活此规则之前请通过“ML 作业设置”设置此作业以使其运行。", @@ -13974,7 +13974,7 @@ "xpack.securitySolution.detectionEngine.editRule.saveChangeTitle": "保存更改", "xpack.securitySolution.detectionEngine.emptyActionPrimary": "查看设置说明", "xpack.securitySolution.detectionEngine.emptyActionSecondary": "前往文档", - "xpack.securitySolution.detectionEngine.emptyTitle": "似乎您没有与 SIEM 应用程序的检测引擎相关的索引", + "xpack.securitySolution.detectionEngine.emptyTitle": "似乎您没有与 Security 应用程序的检测引擎相关的索引", "xpack.securitySolution.detectionEngine.goToDocumentationButton": "查看文档", "xpack.securitySolution.detectionEngine.headerPage.pageBadgeLabel": "公测版", "xpack.securitySolution.detectionEngine.headerPage.pageBadgeTooltip": "“检测”仍为公测版。请通过在 Kibana 存储库中报告问题或错误,帮助我们改进产品。", @@ -14350,7 +14350,7 @@ "xpack.securitySolution.detectionEngine.rules.optionalFieldDescription": "可选", "xpack.securitySolution.detectionEngine.rules.pageTitle": "信号检测规则", "xpack.securitySolution.detectionEngine.rules.prePackagedRules.createOwnRuletButton": "创建自己的规则", - "xpack.securitySolution.detectionEngine.rules.prePackagedRules.emptyPromptMessage": "Elastic SIEM 提供预构建检测规则,它们运行在后台并在条件满足时创建信号。默认情况下,所有预构建规则处于禁用状态,请选择您要激活的规则。", + "xpack.securitySolution.detectionEngine.rules.prePackagedRules.emptyPromptMessage": "Elastic Security 提供预构建检测规则,它们运行在后台并在条件满足时创建信号。默认情况下,所有预构建规则处于禁用状态,请选择您要激活的规则。", "xpack.securitySolution.detectionEngine.rules.prePackagedRules.emptyPromptTitle": "加载 Elastic 预构建检测规则", "xpack.securitySolution.detectionEngine.rules.prePackagedRules.loadPreBuiltButton": "加载预构建检测规则", "xpack.securitySolution.detectionEngine.rules.releaseNotesHelp": "发行说明", @@ -14459,7 +14459,7 @@ "xpack.securitySolution.header.editableTitle.editButtonAria": "通过单击,可以编辑 {title}", "xpack.securitySolution.header.editableTitle.save": "保存", "xpack.securitySolution.headerGlobal.buttonAddData": "添加数据", - "xpack.securitySolution.headerGlobal.siem": "SIEM", + "xpack.securitySolution.headerGlobal.siem": "Security", "xpack.securitySolution.headerPage.pageSubtitle": "最后事件:{beat}", "xpack.securitySolution.hooks.useAddToTimeline.addedFieldMessage": "已将 {fieldOrValue} 添加到时间线", "xpack.securitySolution.host.details.architectureLabel": "架构", @@ -14647,7 +14647,7 @@ "xpack.securitySolution.networkTopNFlowTable.rows": "{numRows} {numRows, plural, =0 {行} =1 {行} other {行}}", "xpack.securitySolution.networkTopNFlowTable.sourceIps": "源 IP", "xpack.securitySolution.networkTopNFlowTable.unit": "{totalCount, plural, =1 {个 IP} other {个 IP}}", - "xpack.securitySolution.newsFeed.advancedSettingsLinkTitle": "SIEM 高级设置", + "xpack.securitySolution.newsFeed.advancedSettingsLinkTitle": "Security 高级设置", "xpack.securitySolution.newsFeed.noNewsMessage": "您当前的新闻源 URL 未返回最近的新闻。要更新 URL 或禁用安全新闻,您可以通过", "xpack.securitySolution.notes.addANotePlaceholder": "添加备注", "xpack.securitySolution.notes.addedANoteLabel": "已添加备注", @@ -14709,7 +14709,7 @@ "xpack.securitySolution.overview.endgameRegistryTitle": "注册表", "xpack.securitySolution.overview.endgameSecurityTitle": "安全性", "xpack.securitySolution.overview.eventsTitle": "事件计数", - "xpack.securitySolution.overview.feedbackText": "如果您对 Elastic SIEM 体验有任何建议,请随时{feedback}。", + "xpack.securitySolution.overview.feedbackText": "如果您对 Elastic Security 体验有任何建议,请随时{feedback}。", "xpack.securitySolution.overview.feedbackText.feedbackLinkText": "在线提交反馈", "xpack.securitySolution.overview.feedbackTitle": "反馈", "xpack.securitySolution.overview.filebeatCiscoTitle": "Cisco", @@ -14737,15 +14737,15 @@ "xpack.securitySolution.overview.packetBeatFlowTitle": "流", "xpack.securitySolution.overview.packetbeatTLSTitle": "TLS", "xpack.securitySolution.overview.pageSubtitle": "Elastic Stack 的安全信息和事件管理功能", - "xpack.securitySolution.overview.pageTitle": "SIEM", + "xpack.securitySolution.overview.pageTitle": "Security", "xpack.securitySolution.overview.recentCasesSidebarTitle": "最近案例", "xpack.securitySolution.overview.recentlyCreatedCasesButtonLabel": "最近创建的案例", "xpack.securitySolution.overview.recentTimelinesSidebarTitle": "最近的时间线", "xpack.securitySolution.overview.showTopTooltip": "显示热门{fieldName}", - "xpack.securitySolution.overview.startedText": "欢迎使用安全信息和事件管理 (SIEM)。首先,查看我们的 {docs} 或 {data}。有关即将推出的功能和教程,确保查看我们的{siemSolution}页。", + "xpack.securitySolution.overview.startedText": "欢迎使用安全信息和事件管理 (Security)。首先,查看我们的 {docs} 或 {data}。有关即将推出的功能和教程,确保查看我们的{siemSolution}页。", "xpack.securitySolution.overview.startedText.dataLinkText": "正在采集数据", "xpack.securitySolution.overview.startedText.docsLinkText": "文档", - "xpack.securitySolution.overview.startedText.siemSolutionLinkText": "SIEM 解决方案", + "xpack.securitySolution.overview.startedText.siemSolutionLinkText": "Security 解决方案", "xpack.securitySolution.overview.startedTitle": "入门", "xpack.securitySolution.overview.topNLabel": "热门{fieldName}", "xpack.securitySolution.overview.viewAlertsButtonLabel": "查看告警", @@ -14754,7 +14754,7 @@ "xpack.securitySolution.overview.winlogbeatSecurityTitle": "安全", "xpack.securitySolution.pages.common.emptyActionPrimary": "使用 Beats 添加数据", "xpack.securitySolution.pages.common.emptyActionSecondary": "查看入门指南", - "xpack.securitySolution.pages.common.emptyMessage": "要开始使用安全信息和事件管理 (SIEM),您将需要将 SIEM 相关数据以 Elastic Common Schema (ECS) 格式添加到 Elastic Stack。较为轻松的入门方式是安装并配置我们称作 Beats 的数据采集器。让我们现在就动手!", + "xpack.securitySolution.pages.common.emptyMessage": "要开始使用安全信息和事件管理 (Security),您将需要将 Security 相关数据以 Elastic Common Schema (ECS) 格式添加到 Elastic Stack。较为轻松的入门方式是安装并配置我们称作 Beats 的数据采集器。让我们现在就动手!", "xpack.securitySolution.pages.common.emptyTitle": "欢迎使用 SIEM。让我们教您如何入门。", "xpack.securitySolution.pages.fourohfour.noContentFoundDescription": "未找到任何内容", "xpack.securitySolution.paginatedTable.rowsButtonLabel": "每页行数", @@ -14845,7 +14845,7 @@ "xpack.securitySolution.timeline.body.renderers.endgame.usingLogonTypeDescription": "使用登录类型", "xpack.securitySolution.timeline.body.renderers.endgame.viaDescription": "通过", "xpack.securitySolution.timeline.body.renderers.endgame.withSpecialPrivilegesDescription": "使用特殊权限,", - "xpack.securitySolution.timeline.callOut.unauthorized.message.description": "您需要在 SIEM 内自动保存时间线的权限,但您可以继续使用该时间线搜索和筛选安全事件", + "xpack.securitySolution.timeline.callOut.unauthorized.message.description": "您需要在 Security 内自动保存时间线的权限,但您可以继续使用该时间线搜索和筛选安全事件", "xpack.securitySolution.timeline.categoryTooltip": "类别", "xpack.securitySolution.timeline.defaultTimelineDescription": "创建新时间线时默认提供的时间线。", "xpack.securitySolution.timeline.defaultTimelineTitle": "默认空白时间线", @@ -14914,7 +14914,7 @@ "xpack.securitySolution.timelines.components.importTimelineModal.importTitle": "导入时间线……", "xpack.securitySolution.timelines.components.importTimelineModal.initialPromptTextDescription": "选择或拖放有效的 rules_export.ndjson 文件", "xpack.securitySolution.timelines.components.importTimelineModal.overwriteDescription": "自动覆盖具有相同时间线 ID 的已保存对象", - "xpack.securitySolution.timelines.components.importTimelineModal.selectTimelineDescription": "选择要导入的 SIEM 时间线(如从“时间线”视图导出的)", + "xpack.securitySolution.timelines.components.importTimelineModal.selectTimelineDescription": "选择要导入的 Security 时间线(如从“时间线”视图导出的)", "xpack.securitySolution.timelines.components.importTimelineModal.successfullyImportedTimelinesTitle": "已成功导入 {totalCount} 条{totalCount, plural, =1 {时间线} other {时间线}}", "xpack.securitySolution.timelines.components.tabs.templatesTitle": "模板", "xpack.securitySolution.timelines.components.tabs.timelinesTitle": "时间线", @@ -14924,11 +14924,11 @@ "xpack.securitySolution.topN.rawEventsSelectLabel": "原始事件", "xpack.securitySolution.uiSettings.defaultAnomalyScoreDescription": "

在显示异常之前要超过的默认异常分数阈值。

有效值:0 到 100。

", "xpack.securitySolution.uiSettings.defaultAnomalyScoreLabel": "默认异常阈值", - "xpack.securitySolution.uiSettings.defaultIndexDescription": "

SIEM 应用要从其中搜索事件的 Elasticsearch 索引逗号分隔列表。

", + "xpack.securitySolution.uiSettings.defaultIndexDescription": "

Security 应用要从其中搜索事件的 Elasticsearch 索引逗号分隔列表。

", "xpack.securitySolution.uiSettings.defaultIndexLabel": "默认索引", - "xpack.securitySolution.uiSettings.defaultRefreshIntervalDescription": "

SIEM 时间筛选的默认刷新时间间隔(毫秒)。

", + "xpack.securitySolution.uiSettings.defaultRefreshIntervalDescription": "

Security 时间筛选的默认刷新时间间隔(毫秒)。

", "xpack.securitySolution.uiSettings.defaultRefreshIntervalLabel": "时间筛选刷新时间间隔", - "xpack.securitySolution.uiSettings.defaultTimeRangeDescription": "

SIEM 时间筛选中的默认时间期间。

", + "xpack.securitySolution.uiSettings.defaultTimeRangeDescription": "

Security 时间筛选中的默认时间期间。

", "xpack.securitySolution.uiSettings.defaultTimeRangeLabel": "时间筛选默认值", "xpack.securitySolution.uiSettings.enableNewsFeedDescription": "

启用新闻源

", "xpack.securitySolution.uiSettings.enableNewsFeedLabel": "新闻源", From 094b34c065e0c251d13bca63b4130c92bff785ef Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Thu, 4 Jun 2020 11:37:12 -0700 Subject: [PATCH 08/13] [Reporting] Adds the enabled key to schema (#68288) --- x-pack/plugins/reporting/server/config/schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts index dfabfa98f8cbf..b1234a6ddf0b6 100644 --- a/x-pack/plugins/reporting/server/config/schema.ts +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -162,6 +162,7 @@ const PollSchema = schema.object({ }); export const ConfigSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), kibanaServer: KibanaServerSchema, queue: QueueSchema, capture: CaptureSchema, From 5fd6416861b1305ba666150a2adb3bf30b3c1aba Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Thu, 4 Jun 2020 13:38:27 -0500 Subject: [PATCH 09/13] Prevent removal of actions via the UI from breaking rule AAD (#68184) This fixes #64870 _for real_. The issue ended up being caused by a conditional form field that mapped to a nested field on the rule's params: when a rule is created with an action, it has a meta.kibana_siem_app_url field. When the rule's actions were removed via the UI, that field was _also_ removed, which broke AAD and thus rule execution. This fixes the issue by making that field unconditional, and also removes the previous workaround. --- .../components/rules/step_rule_actions/index.tsx | 11 +++++------ .../rules/update_rules_notifications.ts | 3 --- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security_solution/public/alerts/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/alerts/components/rules/step_rule_actions/index.tsx index ad71059984a8b..778c6bd92bc73 100644 --- a/x-pack/plugins/security_solution/public/alerts/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/alerts/components/rules/step_rule_actions/index.tsx @@ -162,7 +162,6 @@ const StepRuleActionsComponent: FC = ({ {myStepData.throttle !== stepActionsDefaultValue.throttle ? ( <> - = ({ messageVariables: actionMessageParams, }} /> - ) : ( = ({ component={GhostFormField} /> )} + diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules_notifications.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules_notifications.ts index 8fceb8ef720b5..0dfe68f132b06 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules_notifications.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules_notifications.ts @@ -45,8 +45,5 @@ export const updateRulesNotifications = async ({ interval: ruleActions.alertThrottle, }); - // TODO: Workaround for https://github.com/elastic/kibana/issues/67290 - await alertsClient.updateApiKey({ id: ruleAlertId }); - return ruleActions; }; From 3602f0f9aad004e7a2189800626b0792531eee5b Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Thu, 4 Jun 2020 20:55:03 +0200 Subject: [PATCH 10/13] implements `extends` to `ObjectSchema` (#68067) * implements `extends` to `ObjectSchema` * add unit tests * use expectType for types assertions * allow to extends options * add comment about deep extend --- packages/kbn-config-schema/package.json | 3 +- packages/kbn-config-schema/src/index.ts | 3 +- packages/kbn-config-schema/src/types/index.ts | 2 +- .../src/types/object_type.test.ts | 140 +++++++++++++++++- .../src/types/object_type.ts | 119 ++++++++++++++- 5 files changed, 252 insertions(+), 15 deletions(-) diff --git a/packages/kbn-config-schema/package.json b/packages/kbn-config-schema/package.json index 71c0ae4bff1f9..06342127b0d89 100644 --- a/packages/kbn-config-schema/package.json +++ b/packages/kbn-config-schema/package.json @@ -10,7 +10,8 @@ "kbn:bootstrap": "yarn build" }, "devDependencies": { - "typescript": "3.7.2" + "typescript": "3.7.2", + "tsd": "^0.7.4" }, "peerDependencies": { "joi": "^13.5.2", diff --git a/packages/kbn-config-schema/src/index.ts b/packages/kbn-config-schema/src/index.ts index 5d387f327e58f..2319fe4395e3f 100644 --- a/packages/kbn-config-schema/src/index.ts +++ b/packages/kbn-config-schema/src/index.ts @@ -44,6 +44,7 @@ import { ObjectType, ObjectTypeOptions, Props, + NullableProps, RecordOfOptions, RecordOfType, StringOptions, @@ -57,7 +58,7 @@ import { StreamType, } from './types'; -export { ObjectType, TypeOf, Type }; +export { ObjectType, TypeOf, Type, Props, NullableProps }; export { ByteSizeValue } from './byte_size_value'; export { SchemaTypeError, ValidationError } from './errors'; export { isConfigSchema } from './typeguards'; diff --git a/packages/kbn-config-schema/src/types/index.ts b/packages/kbn-config-schema/src/types/index.ts index 9db79b8bf9e00..c7900e1923e78 100644 --- a/packages/kbn-config-schema/src/types/index.ts +++ b/packages/kbn-config-schema/src/types/index.ts @@ -29,7 +29,7 @@ export { LiteralType } from './literal_type'; export { MaybeType } from './maybe_type'; export { MapOfOptions, MapOfType } from './map_type'; export { NumberOptions, NumberType } from './number_type'; -export { ObjectType, ObjectTypeOptions, Props, TypeOf } from './object_type'; +export { ObjectType, ObjectTypeOptions, Props, NullableProps, TypeOf } from './object_type'; export { RecordOfOptions, RecordOfType } from './record_type'; export { StreamType } from './stream_type'; export { StringOptions, StringType } from './string_type'; diff --git a/packages/kbn-config-schema/src/types/object_type.test.ts b/packages/kbn-config-schema/src/types/object_type.test.ts index 5ab59d1c02077..334e814aa52e4 100644 --- a/packages/kbn-config-schema/src/types/object_type.test.ts +++ b/packages/kbn-config-schema/src/types/object_type.test.ts @@ -17,6 +17,7 @@ * under the License. */ +import { expectType } from 'tsd'; import { schema } from '..'; import { TypeOf } from './object_type'; @@ -360,17 +361,142 @@ test('handles optional properties', () => { type SchemaType = TypeOf; - let foo: SchemaType = { + expectType({ required: 'foo', - }; - foo = { + }); + expectType({ required: 'hello', optional: undefined, - }; - foo = { + }); + expectType({ required: 'hello', optional: 'bar', - }; + }); +}); + +describe('#extends', () => { + it('allows to extend an existing schema by adding new properties', () => { + const origin = schema.object({ + initial: schema.string(), + }); + + const extended = origin.extends({ + added: schema.number(), + }); + + expect(() => { + extended.validate({ initial: 'foo' }); + }).toThrowErrorMatchingInlineSnapshot( + `"[added]: expected value of type [number] but got [undefined]"` + ); + + expect(() => { + extended.validate({ initial: 'foo', added: 42 }); + }).not.toThrowError(); - expect(foo).toBeDefined(); + expectType>({ + added: 12, + initial: 'foo', + }); + }); + + it('allows to extend an existing schema by removing properties', () => { + const origin = schema.object({ + string: schema.string(), + number: schema.number(), + }); + + const extended = origin.extends({ number: undefined }); + + expect(() => { + extended.validate({ string: 'foo', number: 12 }); + }).toThrowErrorMatchingInlineSnapshot(`"[number]: definition for this key is missing"`); + + expect(() => { + extended.validate({ string: 'foo' }); + }).not.toThrowError(); + + expectType>({ + string: 'foo', + }); + }); + + it('allows to extend an existing schema by overriding an existing properties', () => { + const origin = schema.object({ + string: schema.string(), + mutated: schema.number(), + }); + + const extended = origin.extends({ + mutated: schema.string(), + }); + + expect(() => { + extended.validate({ string: 'foo', mutated: 12 }); + }).toThrowErrorMatchingInlineSnapshot( + `"[mutated]: expected value of type [string] but got [number]"` + ); + + expect(() => { + extended.validate({ string: 'foo', mutated: 'bar' }); + }).not.toThrowError(); + + expectType>({ + string: 'foo', + mutated: 'bar', + }); + }); + + it('properly infer the type from optional properties', () => { + const origin = schema.object({ + original: schema.maybe(schema.string()), + mutated: schema.maybe(schema.number()), + removed: schema.maybe(schema.string()), + }); + + const extended = origin.extends({ + removed: undefined, + mutated: schema.string(), + }); + + expect(() => { + extended.validate({ original: 'foo' }); + }).toThrowErrorMatchingInlineSnapshot( + `"[mutated]: expected value of type [string] but got [undefined]"` + ); + expect(() => { + extended.validate({ original: 'foo' }); + }).toThrowErrorMatchingInlineSnapshot( + `"[mutated]: expected value of type [string] but got [undefined]"` + ); + expect(() => { + extended.validate({ original: 'foo', mutated: 'bar' }); + }).not.toThrowError(); + + expectType>({ + original: 'foo', + mutated: 'bar', + }); + expectType>({ + mutated: 'bar', + }); + }); + + it(`allows to override the original schema's options`, () => { + const origin = schema.object( + { + initial: schema.string(), + }, + { defaultValue: { initial: 'foo' } } + ); + + const extended = origin.extends( + { + added: schema.number(), + }, + { defaultValue: { initial: 'bar', added: 42 } } + ); + + expect(extended.validate(undefined)).toEqual({ initial: 'bar', added: 42 }); + }); }); diff --git a/packages/kbn-config-schema/src/types/object_type.ts b/packages/kbn-config-schema/src/types/object_type.ts index fee2d02c1bfb9..431b6e905bcd4 100644 --- a/packages/kbn-config-schema/src/types/object_type.ts +++ b/packages/kbn-config-schema/src/types/object_type.ts @@ -24,6 +24,8 @@ import { ValidationError } from '../errors'; export type Props = Record>; +export type NullableProps = Record | undefined | null>; + export type TypeOf> = RT['type']; type OptionalProperties = Pick< @@ -47,6 +49,24 @@ export type ObjectResultType

= Readonly< { [K in keyof RequiredProperties

]: TypeOf } >; +type DefinedProperties = Pick< + Base, + { + [Key in keyof Base]: undefined extends Base[Key] ? never : null extends Base[Key] ? never : Key; + }[keyof Base] +>; + +type ExtendedProps

= Omit & + { [K in keyof DefinedProperties]: NP[K] }; + +type ExtendedObjectType

= ObjectType< + ExtendedProps +>; + +type ExtendedObjectTypeOptions

= ObjectTypeOptions< + ExtendedProps +>; + interface UnknownOptions { /** * Options for dealing with unknown keys: @@ -61,10 +81,13 @@ export type ObjectTypeOptions

= TypeOptions extends Type> { - private props: Record; + private props: P; + private options: ObjectTypeOptions

; + private propSchemas: Record; - constructor(props: P, { unknowns = 'forbid', ...typeOptions }: ObjectTypeOptions

= {}) { + constructor(props: P, options: ObjectTypeOptions

= {}) { const schemaKeys = {} as Record; + const { unknowns = 'forbid', ...typeOptions } = options; for (const [key, value] of Object.entries(props)) { schemaKeys[key] = value.getSchema(); } @@ -77,7 +100,93 @@ export class ObjectType

extends Type> .options({ stripUnknown: { objects: unknowns === 'ignore' } }); super(schema, typeOptions); - this.props = schemaKeys; + this.props = props; + this.propSchemas = schemaKeys; + this.options = options; + } + + /** + * Return a new `ObjectType` instance extended with given `newProps` properties. + * Original properties can be deleted from the copy by passing a `null` or `undefined` value for the key. + * + * @example + * How to add a new key to an object schema + * ```ts + * const origin = schema.object({ + * initial: schema.string(), + * }); + * + * const extended = origin.extends({ + * added: schema.number(), + * }); + * ``` + * + * How to remove an existing key from an object schema + * ```ts + * const origin = schema.object({ + * initial: schema.string(), + * toRemove: schema.number(), + * }); + * + * const extended = origin.extends({ + * toRemove: undefined, + * }); + * ``` + * + * How to override the schema's options + * ```ts + * const origin = schema.object({ + * initial: schema.string(), + * }, { defaultValue: { initial: 'foo' }}); + * + * const extended = origin.extends({ + * added: schema.number(), + * }, { defaultValue: { initial: 'foo', added: 'bar' }}); + * + * @remarks + * `extends` only support extending first-level properties. It's currently not possible to perform deep/nested extensions. + * + * ```ts + * const origin = schema.object({ + * foo: schema.string(), + * nested: schema.object({ + * a: schema.string(), + * b: schema.string(), + * }), + * }); + * + * const extended = origin.extends({ + * nested: schema.object({ + * c: schema.string(), + * }), + * }); + * + * // TypeOf is `{ foo: string; nested: { c: string } }` + * ``` + */ + public extends( + newProps: NP, + newOptions?: ExtendedObjectTypeOptions + ): ExtendedObjectType { + const extendedProps = Object.entries({ + ...this.props, + ...newProps, + }).reduce((memo, [key, value]) => { + if (value !== null && value !== undefined) { + return { + ...memo, + [key]: value, + }; + } + return memo; + }, {} as ExtendedProps); + + const extendedOptions = { + ...this.options, + ...newOptions, + } as ExtendedObjectTypeOptions; + + return new ObjectType(extendedProps, extendedOptions); } protected handleError(type: string, { reason, value }: Record) { @@ -95,10 +204,10 @@ export class ObjectType

extends Type> } validateKey(key: string, value: any) { - if (!this.props[key]) { + if (!this.propSchemas[key]) { throw new Error(`${key} is not a valid part of this schema`); } - const { value: validatedValue, error } = this.props[key].validate(value); + const { value: validatedValue, error } = this.propSchemas[key].validate(value); if (error) { throw new ValidationError(error as any, key); } From c8e6855281f9eb45020aac646578bc23285831ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20C=C3=B4t=C3=A9?= Date: Thu, 4 Jun 2020 15:58:48 -0400 Subject: [PATCH 11/13] Hide action types in action form that don't have actionParamsFields set (#68171) * Hide action types in alert flyout that don't have actionParamsFields set * Fix jest tests --- .../action_form.test.tsx | 47 +++++++++++++++++-- .../action_connector_form/action_form.tsx | 1 + 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 7ce952e9b3e0a..7db6b5145f895 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; +import React, { Fragment, lazy } from 'react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; import { ReactWrapper } from 'enzyme'; @@ -18,6 +18,13 @@ jest.mock('../../lib/action_connector_api', () => ({ const actionTypeRegistry = actionTypeRegistryMock.create(); describe('action_form', () => { let deps: any; + + const mockedActionParamsFields = lazy(async () => ({ + default() { + return ; + }, + })); + const alertType = { id: 'my-alert-type', iconClass: 'test', @@ -41,7 +48,7 @@ describe('action_form', () => { return validationResult; }, actionConnectorFields: null, - actionParamsFields: null, + actionParamsFields: mockedActionParamsFields, }; const disabledByConfigActionType = { @@ -56,7 +63,7 @@ describe('action_form', () => { return validationResult; }, actionConnectorFields: null, - actionParamsFields: null, + actionParamsFields: mockedActionParamsFields, }; const disabledByLicenseActionType = { @@ -71,7 +78,7 @@ describe('action_form', () => { return validationResult; }, actionConnectorFields: null, - actionParamsFields: null, + actionParamsFields: mockedActionParamsFields, }; const preconfiguredOnly = { @@ -86,6 +93,21 @@ describe('action_form', () => { return validationResult; }, actionConnectorFields: null, + actionParamsFields: mockedActionParamsFields, + }; + + const actionTypeWithoutParams = { + id: 'my-action-type-without-params', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, actionParamsFields: null, }; @@ -153,6 +175,7 @@ describe('action_form', () => { disabledByConfigActionType, disabledByLicenseActionType, preconfiguredOnly, + actionTypeWithoutParams, ]); actionTypeRegistry.has.mockReturnValue(true); actionTypeRegistry.get.mockReturnValue(actionType); @@ -237,6 +260,14 @@ describe('action_form', () => { enabledInLicense: false, minimumLicenseRequired: 'gold', }, + { + id: actionTypeWithoutParams.id, + name: 'Action type without params', + enabled: true, + enabledInConfig: true, + enabledInLicense: true, + minimumLicenseRequired: 'basic', + }, ]} toastNotifications={deps!.toastNotifications} docLinks={deps.docLinks} @@ -340,5 +371,13 @@ describe('action_form', () => { .exists() ).toBeTruthy(); }); + + it(`shouldn't render action types without params component`, async () => { + await setup(); + const actionOption = wrapper.find( + `[data-test-subj="${actionTypeWithoutParams.id}-ActionTypeSelectOption"]` + ); + expect(actionOption.exists()).toBeFalsy(); + }); }); }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 4c3a8d133922d..201852ddeee48 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -547,6 +547,7 @@ export const ActionForm = ({ actionTypeNodes = actionTypeRegistry .list() .filter((item) => actionTypesIndex[item.id]) + .filter((item) => !!item.actionParamsFields) .sort((a, b) => actionTypeCompare(actionTypesIndex[a.id], actionTypesIndex[b.id], preconfiguredConnectors) ) From 8fe1eb1f780a1c1a6eb5e89bccc9402d222a9d31 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Thu, 4 Jun 2020 16:10:42 -0400 Subject: [PATCH 12/13] Adding related event category stats for resolver nodes (#67909) * Allowing the categories to be specified for related events * Adding checks in the api tests for the stats * Adding more comments * Allow array or number of cateogires generation and fix up comment * Fixing type error * Renaming to byCategory Co-authored-by: Elastic Machine --- .../common/endpoint/generate_data.test.ts | 45 +++++- .../common/endpoint/generate_data.ts | 153 ++++++++++++++---- .../common/endpoint/types.ts | 21 ++- .../endpoint/routes/resolver/queries/stats.ts | 124 ++++++++++++-- .../endpoint/routes/resolver/utils/fetch.ts | 9 +- .../endpoint/routes/resolver/utils/node.ts | 5 +- .../api_integration/apis/endpoint/resolver.ts | 53 +++++- 7 files changed, 357 insertions(+), 53 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts index 3fcb00d879583..6c8c5e3f51808 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.test.ts @@ -3,7 +3,14 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EndpointDocGenerator, Event, Tree, TreeNode } from './generate_data'; +import { + EndpointDocGenerator, + Event, + Tree, + TreeNode, + RelatedEventCategory, + ECSCategory, +} from './generate_data'; interface Node { events: Event[]; @@ -106,7 +113,11 @@ describe('data generator', () => { generations, percentTerminated: 100, percentWithRelated: 100, - relatedEvents: 4, + relatedEvents: [ + { category: RelatedEventCategory.Driver, count: 1 }, + { category: RelatedEventCategory.File, count: 2 }, + { category: RelatedEventCategory.Network, count: 1 }, + ], }); }); @@ -117,6 +128,36 @@ describe('data generator', () => { return (inRelated || inLifecycle) && event.process.entity_id === node.id; }; + it('has the right related events for each node', () => { + const checkRelatedEvents = (node: TreeNode) => { + expect(node.relatedEvents.length).toEqual(4); + + const counts: Record = {}; + for (const event of node.relatedEvents) { + if (Array.isArray(event.event.category)) { + for (const cat of event.event.category) { + counts[cat] = counts[cat] + 1 || 1; + } + } else { + counts[event.event.category] = counts[event.event.category] + 1 || 1; + } + } + expect(counts[ECSCategory.Driver]).toEqual(1); + expect(counts[ECSCategory.File]).toEqual(2); + expect(counts[ECSCategory.Network]).toEqual(1); + }; + + for (const node of tree.ancestry.values()) { + checkRelatedEvents(node); + } + + for (const node of tree.children.values()) { + checkRelatedEvents(node); + } + + checkRelatedEvents(tree.origin); + }); + it('has the right number of ancestors', () => { expect(tree.ancestry.size).toEqual(ancestors); }); diff --git a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts index 57d41b6554907..bf3c27beb7eab 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -24,7 +24,7 @@ interface EventOptions { entityID?: string; parentEntityID?: string; eventType?: string; - eventCategory?: string; + eventCategory?: string | string[]; processName?: string; } @@ -75,21 +75,98 @@ const POLICIES: Array<{ name: string; id: string }> = [ const FILE_OPERATIONS: string[] = ['creation', 'open', 'rename', 'execution', 'deletion']; interface EventInfo { - category: string; + category: string | string[]; /** * This denotes the `event.type` field for when an event is created, this can be `start` or `creation` */ creationType: string; } +/** + * The valid ecs categories. + */ +export enum ECSCategory { + Driver = 'driver', + File = 'file', + Network = 'network', + /** + * Registry has not been added to ecs yet. + */ + Registry = 'registry', + Authentication = 'authentication', + Session = 'session', +} + +/** + * High level categories for related events. These specify the type of related events that should be generated. + */ +export enum RelatedEventCategory { + /** + * The Random category allows the related event categories to be chosen randomly + */ + Random = 'random', + Driver = 'driver', + File = 'file', + Network = 'network', + Registry = 'registry', + /** + * Security isn't an actual category but defines a type of related event to be created. + */ + Security = 'security', +} + +/** + * This map defines the relationship between a higher level event type defined by the RelatedEventCategory enums and + * the ECS categories that is should map to. This should only be used for tests that need to determine the exact + * ecs categories that were created based on the related event information passed to the generator. + */ +export const categoryMapping: Record = { + [RelatedEventCategory.Security]: [ECSCategory.Authentication, ECSCategory.Session], + [RelatedEventCategory.Driver]: ECSCategory.Driver, + [RelatedEventCategory.File]: ECSCategory.File, + [RelatedEventCategory.Network]: ECSCategory.Network, + [RelatedEventCategory.Registry]: ECSCategory.Registry, + /** + * Random is only used by the generator to indicate that it should randomly choose the event information when generating + * related events. It does not map to a specific ecs category. + */ + [RelatedEventCategory.Random]: '', +}; + +/** + * The related event category and number of events that should be generated. + */ +export interface RelatedEventInfo { + category: RelatedEventCategory; + count: number; +} + // These are from the v1 schemas and aren't all valid ECS event categories, still in flux -const OTHER_EVENT_CATEGORIES: EventInfo[] = [ - { category: 'driver', creationType: 'start' }, - { category: 'file', creationType: 'creation' }, - { category: 'library', creationType: 'start' }, - { category: 'network', creationType: 'start' }, - { category: 'registry', creationType: 'creation' }, -]; +const OTHER_EVENT_CATEGORIES: Record< + Exclude, + EventInfo +> = { + [RelatedEventCategory.Security]: { + category: categoryMapping[RelatedEventCategory.Security], + creationType: 'start', + }, + [RelatedEventCategory.Driver]: { + category: categoryMapping[RelatedEventCategory.Driver], + creationType: 'start', + }, + [RelatedEventCategory.File]: { + category: categoryMapping[RelatedEventCategory.File], + creationType: 'creation', + }, + [RelatedEventCategory.Network]: { + category: categoryMapping[RelatedEventCategory.Network], + creationType: 'start', + }, + [RelatedEventCategory.Registry]: { + category: categoryMapping[RelatedEventCategory.Registry], + creationType: 'creation', + }, +}; interface HostInfo { elastic: { @@ -164,7 +241,7 @@ export interface TreeOptions { ancestors?: number; generations?: number; children?: number; - relatedEvents?: number; + relatedEvents?: RelatedEventInfo[]; percentWithRelated?: number; percentTerminated?: number; alwaysGenMaxChildrenPerNode?: boolean; @@ -487,7 +564,8 @@ export class EndpointDocGenerator { * @param alertAncestors - number of ancestor generations to create relative to the alert * @param childGenerations - number of child generations to create relative to the alert * @param maxChildrenPerNode - maximum number of children for any given node in the tree - * @param relatedEventsPerNode - number of related events (file, registry, etc) to create for each process event in the tree + * @param relatedEventsPerNode - can be an array of RelatedEventInfo objects describing the related events that should be generated for each process node + * or a number which defines the number of related events and will default to random categories * @param percentNodesWithRelated - percent of nodes which should have related events * @param percentTerminated - percent of nodes which will have process termination events * @param alwaysGenMaxChildrenPerNode - flag to always return the max children per node instead of it being a random number of children @@ -496,7 +574,7 @@ export class EndpointDocGenerator { alertAncestors?: number, childGenerations?: number, maxChildrenPerNode?: number, - relatedEventsPerNode?: number, + relatedEventsPerNode?: RelatedEventInfo[] | number, percentNodesWithRelated?: number, percentTerminated?: number, alwaysGenMaxChildrenPerNode?: boolean @@ -525,13 +603,14 @@ export class EndpointDocGenerator { /** * Creates an alert event and associated process ancestry. The alert event will always be the last event in the return array. * @param alertAncestors - number of ancestor generations to create - * @param relatedEventsPerNode - number of related events to add to each process node being created + * @param relatedEventsPerNode - can be an array of RelatedEventInfo objects describing the related events that should be generated for each process node + * or a number which defines the number of related events and will default to random categories * @param pctWithRelated - percent of ancestors that will have related events * @param pctWithTerminated - percent of ancestors that will have termination events */ public createAlertEventAncestry( alertAncestors = 3, - relatedEventsPerNode = 5, + relatedEventsPerNode: RelatedEventInfo[] | number = 5, pctWithRelated = 30, pctWithTerminated = 100 ): Event[] { @@ -611,7 +690,8 @@ export class EndpointDocGenerator { * @param root - The process event to use as the root node of the tree * @param generations - number of child generations to create. The root node is not counted as a generation. * @param maxChildrenPerNode - maximum number of children for any given node in the tree - * @param relatedEventsPerNode - number of related events (file, registry, etc) to create for each process event in the tree + * @param relatedEventsPerNode - can be an array of RelatedEventInfo objects describing the related events that should be generated for each process node + * or a number which defines the number of related events and will default to random categories * @param percentNodesWithRelated - percent of nodes which should have related events * @param percentChildrenTerminated - percent of nodes which will have process termination events * @param alwaysGenMaxChildrenPerNode - flag to always return the max children per node instead of it being a random number of children @@ -620,7 +700,7 @@ export class EndpointDocGenerator { root: Event, generations = 2, maxChildrenPerNode = 2, - relatedEventsPerNode = 3, + relatedEventsPerNode: RelatedEventInfo[] | number = 3, percentNodesWithRelated = 100, percentChildrenTerminated = 100, alwaysGenMaxChildrenPerNode = false @@ -686,25 +766,40 @@ export class EndpointDocGenerator { /** * Creates related events for a process event * @param node - process event to relate events to by entityID - * @param numRelatedEvents - number of related events to generate + * @param relatedEvents - can be an array of RelatedEventInfo objects describing the related events that should be generated for each process node + * or a number which defines the number of related events and will default to random categories * @param processDuration - maximum number of seconds after process event that related event timestamp can be */ public *relatedEventsGenerator( node: Event, - numRelatedEvents = 10, + relatedEvents: RelatedEventInfo[] | number = 10, processDuration: number = 6 * 3600 ) { - for (let i = 0; i < numRelatedEvents; i++) { - const eventInfo = this.randomChoice(OTHER_EVENT_CATEGORIES); - - const ts = node['@timestamp'] + this.randomN(processDuration) * 1000; - yield this.generateEvent({ - timestamp: ts, - entityID: node.process.entity_id, - parentEntityID: node.process.parent?.entity_id, - eventCategory: eventInfo.category, - eventType: eventInfo.creationType, - }); + let relatedEventsInfo: RelatedEventInfo[]; + if (typeof relatedEvents === 'number') { + relatedEventsInfo = [{ category: RelatedEventCategory.Random, count: relatedEvents }]; + } else { + relatedEventsInfo = relatedEvents; + } + for (const event of relatedEventsInfo) { + let eventInfo: EventInfo; + + for (let i = 0; i < event.count; i++) { + if (event.category === RelatedEventCategory.Random) { + eventInfo = this.randomChoice(Object.values(OTHER_EVENT_CATEGORIES)); + } else { + eventInfo = OTHER_EVENT_CATEGORIES[event.category]; + } + + const ts = node['@timestamp'] + this.randomN(processDuration) * 1000; + yield this.generateEvent({ + timestamp: ts, + entityID: node.process.entity_id, + parentEntityID: node.process.parent?.entity_id, + eventCategory: eventInfo.category, + eventType: eventInfo.creationType, + }); + } } } diff --git a/x-pack/plugins/security_solution/common/endpoint/types.ts b/x-pack/plugins/security_solution/common/endpoint/types.ts index 45b5cf2526e12..816f9b77115ec 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types.ts @@ -41,14 +41,30 @@ type ImmutableMap = ReadonlyMap, Immutable>; type ImmutableSet = ReadonlySet>; type ImmutableObject = { readonly [K in keyof T]: Immutable }; +export interface EventStats { + /** + * The total number of related events (all events except process and alerts) that exist for a node. + */ + total: number; + /** + * A mapping of ECS event.category to the number of related events are marked with that category + * For example: + * { + * network: 5, + * file: 2 + * } + */ + byCategory: Record; +} + /** * Statistical information for a node in a resolver tree. */ export interface ResolverNodeStats { /** - * The total number of related events (all events except process and alerts) that exist for a node. + * The stats for related events (excludes alerts and process events) for a particular node in the resolver tree. */ - totalEvents: number; + events: EventStats; /** * The total number of alerts that exist for a node. */ @@ -379,6 +395,7 @@ export interface LegacyEndpointEvent { event?: { action?: string; type?: string; + category?: string | string[]; }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/stats.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/stats.ts index a1bab707879a5..359445f514b77 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/stats.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/stats.ts @@ -5,13 +5,23 @@ */ import { SearchResponse } from 'elasticsearch'; import { ResolverQuery } from './base'; -import { ResolverEvent } from '../../../../../common/endpoint/types'; +import { ResolverEvent, EventStats } from '../../../../../common/endpoint/types'; import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/public'; import { AggBucket } from '../utils/pagination'; export interface StatsResult { alerts: Record; - events: Record; + events: Record; +} + +interface CategoriesAgg extends AggBucket { + /** + * The reason categories is optional here is because if no data was returned in the query the categories aggregation + * will not be defined on the response (because it's a sub aggregation). + */ + categories?: { + buckets?: AggBucket[]; + }; } export class StatsQuery extends ResolverQuery { @@ -64,13 +74,25 @@ export class StatsQuery extends ResolverQuery { alerts: { filter: { term: { 'event.kind': 'alert' } }, aggs: { - ids: { terms: { field: 'endgame.data.alert_details.acting_process.unique_pid' } }, + ids: { + terms: { + field: 'endgame.data.alert_details.acting_process.unique_pid', + size: uniquePIDs.length, + }, + }, }, }, events: { filter: { term: { 'event.kind': 'event' } }, aggs: { - ids: { terms: { field: 'endgame.unique_pid' } }, + ids: { + terms: { field: 'endgame.unique_pid', size: uniquePIDs.length }, + aggs: { + categories: { + terms: { field: 'event.category', size: 1000 }, + }, + }, + }, }, }, }, @@ -112,34 +134,106 @@ export class StatsQuery extends ResolverQuery { alerts: { filter: { term: { 'event.kind': 'alert' } }, aggs: { - ids: { terms: { field: 'process.entity_id' } }, + ids: { terms: { field: 'process.entity_id', size: entityIDs.length } }, }, }, events: { filter: { term: { 'event.kind': 'event' } }, aggs: { - ids: { terms: { field: 'process.entity_id' } }, + ids: { + // The entityIDs array will be made up of alert and event entity_ids, so we're guaranteed that there + // won't be anymore unique process.entity_ids than the size of the array passed in + terms: { field: 'process.entity_id', size: entityIDs.length }, + aggs: { + categories: { + // Currently ECS defines a small number of valid categories (under 10 right now), as ECS grows it's possible that the + // valid categories could exceed this hardcoded limit. If that happens we might want to revisit this + // and transition it to a composite aggregation so that we can paginate through all the possible response + terms: { field: 'event.category', size: 1000 }, + }, + }, + }, }, }, }, }; } - public formatResponse(response: SearchResponse): StatsResult { - const alerts = response.aggregations.alerts.ids.buckets.reduce( - (cummulative: Record, bucket: AggBucket) => ({ - ...cummulative, - [bucket.key]: bucket.doc_count, - }), - {} - ); - const events = response.aggregations.events.ids.buckets.reduce( + private static getEventStats(catAgg: CategoriesAgg): EventStats { + const total = catAgg.doc_count; + if (!catAgg.categories?.buckets) { + return { + total, + byCategory: {}, + }; + } + + const byCategory: Record = catAgg.categories.buckets.reduce( (cummulative: Record, bucket: AggBucket) => ({ ...cummulative, [bucket.key]: bucket.doc_count, }), {} ); + return { + total, + byCategory, + }; + } + + public formatResponse(response: SearchResponse): StatsResult { + let alerts: Record = {}; + + if (response.aggregations?.alerts?.ids?.buckets) { + alerts = response.aggregations.alerts.ids.buckets.reduce( + (cummulative: Record, bucket: AggBucket) => ({ + ...cummulative, + [bucket.key]: bucket.doc_count, + }), + {} + ); + } + + /** + * The response for the events ids aggregation should look like this: + * "aggregations" : { + * "ids" : { + * "doc_count_error_upper_bound" : 0, + * "sum_other_doc_count" : 0, + * "buckets" : [ + * { + * "key" : "entity_id1", + * "doc_count" : 3, + * "categories" : { + * "doc_count_error_upper_bound" : 0, + * "sum_other_doc_count" : 0, + * "buckets" : [ + * { + * "key" : "session", + * "doc_count" : 3 + * }, + * { + * "key" : "authentication", + * "doc_count" : 2 + * } + * ] + * } + * }, + * + * Which would indicate that entity_id1 had 3 related events. 3 of the related events had category session, + * and 2 had authentication + */ + let events: Record = {}; + if (response.aggregations?.events?.ids?.buckets) { + events = response.aggregations.events.ids.buckets.reduce( + (cummulative: Record, bucket: CategoriesAgg) => ({ + ...cummulative, + [bucket.key]: StatsQuery.getEventStats(bucket), + }), + {} + ); + } + return { alerts, events, diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts index 4b14c555d49b7..4ac8e206d4f3b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/fetch.ts @@ -173,10 +173,13 @@ export class Fetcher { const statsQuery = new StatsQuery(this.indexPattern, this.endpointID); const ids = tree.ids(); const res = await statsQuery.search(this.client, ids); - const alerts = res?.alerts || {}; - const events = res?.events || {}; + const alerts = res.alerts; + const events = res.events; ids.forEach((id) => { - tree.addStats(id, { totalAlerts: alerts[id] || 0, totalEvents: events[id] || 0 }); + tree.addStats(id, { + totalAlerts: alerts[id] || 0, + events: events[id] || { total: 0, byCategory: {} }, + }); }); } } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts index ae078b5368a96..2fe7e364bb460 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/node.ts @@ -81,7 +81,10 @@ export function createTree(entityID: string): ResolverTree { }, stats: { totalAlerts: 0, - totalEvents: 0, + events: { + total: 0, + byCategory: {}, + }, }, }; } diff --git a/x-pack/test/api_integration/apis/endpoint/resolver.ts b/x-pack/test/api_integration/apis/endpoint/resolver.ts index 31e8cc9a6426c..43f42f700a4c8 100644 --- a/x-pack/test/api_integration/apis/endpoint/resolver.ts +++ b/x-pack/test/api_integration/apis/endpoint/resolver.ts @@ -14,6 +14,7 @@ import { ResolverChildren, ResolverTree, LegacyEndpointEvent, + ResolverNodeStats, } from '../../../../plugins/security_solution/common/endpoint/types'; import { parentEntityId } from '../../../../plugins/security_solution/common/endpoint/models/event'; import { FtrProviderContext } from '../../ftr_provider_context'; @@ -21,6 +22,9 @@ import { Event, Tree, TreeNode, + RelatedEventCategory, + RelatedEventInfo, + categoryMapping, } from '../../../../plugins/security_solution/common/endpoint/generate_data'; import { Options, GeneratedTrees } from '../../services/resolver'; @@ -141,16 +145,60 @@ const compareArrays = ( }); }; +/** + * Verifies that the stats received from ES for a node reflect the categories of events that the generator created. + * + * @param relatedEvents the related events received for a particular node + * @param categories the related event info used when generating the resolver tree + */ +const verifyStats = (stats: ResolverNodeStats | undefined, categories: RelatedEventInfo[]) => { + expect(stats).to.not.be(undefined); + let totalExpEvents = 0; + for (const cat of categories) { + const ecsCategories = categoryMapping[cat.category]; + if (Array.isArray(ecsCategories)) { + // if there are multiple ecs categories used to define a related event, the count for all of them should be the same + // and they should equal what is defined in the categories used to generate the related events + for (const ecsCat of ecsCategories) { + expect(stats?.events.byCategory[ecsCat]).to.be(cat.count); + } + } else { + expect(stats?.events.byCategory[ecsCategories]).to.be(cat.count); + } + + totalExpEvents += cat.count; + } + expect(stats?.events.total).to.be(totalExpEvents); +}; + +/** + * A helper function for verifying the stats information an array of nodes. + * + * @param nodes an array of lifecycle nodes that should have a stats field defined + * @param categories the related event info used when generating the resolver tree + */ +const verifyLifecycleStats = (nodes: LifecycleNode[], categories: RelatedEventInfo[]) => { + for (const node of nodes) { + verifyStats(node.stats, categories); + } +}; + export default function resolverAPIIntegrationTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); const resolver = getService('resolverGenerator'); + const relatedEventsToGen = [ + { category: RelatedEventCategory.Driver, count: 2 }, + { category: RelatedEventCategory.File, count: 1 }, + { category: RelatedEventCategory.Registry, count: 1 }, + ]; + let resolverTrees: GeneratedTrees; let tree: Tree; const treeOptions: Options = { ancestors: 5, - relatedEvents: 4, + relatedEvents: relatedEventsToGen, children: 3, generations: 2, percentTerminated: 100, @@ -563,14 +611,17 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC expect(body.children.nextChild).to.equal(null); expect(body.children.childNodes.length).to.equal(12); verifyChildren(body.children.childNodes, tree, 4, 3); + verifyLifecycleStats(body.children.childNodes, relatedEventsToGen); expect(body.ancestry.nextAncestor).to.equal(null); verifyAncestry(body.ancestry.ancestors, tree, true); + verifyLifecycleStats(body.ancestry.ancestors, relatedEventsToGen); expect(body.relatedEvents.nextEvent).to.equal(null); compareArrays(tree.origin.relatedEvents, body.relatedEvents.events, true); compareArrays(tree.origin.lifecycle, body.lifecycle, true); + verifyStats(body.stats, relatedEventsToGen); }); }); }); From f7739b5db680b95e514bf1f03e0803b1198a0914 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Thu, 4 Jun 2020 16:10:59 -0400 Subject: [PATCH 13/13] [Ingest Manager] Fix agent stream to support optionnal yaml root values (#68272) * [Ingest Manager] Fix agent stream to support optionnal yaml root values * Update x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts Co-authored-by: Jen Huang Co-authored-by: Jen Huang --- .../server/services/epm/agent/agent.test.ts | 18 ++++++++++++++++++ .../server/services/epm/agent/agent.ts | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts index 5e83a976bd7a4..635dce93f0027 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.test.ts @@ -83,4 +83,22 @@ foo: bar custom: { foo: 'bar' }, }); }); + + it('should support optional yaml values at root level', () => { + const streamTemplate = ` +input: logs +{{custom}} + `; + const vars = { + custom: { + type: 'yaml', + value: null, + }, + }; + + const output = createStream(vars, streamTemplate); + expect(output).toEqual({ + input: 'logs', + }); + }); }); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts index 61f2f95fe20a9..0bcb2464f8d74 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/agent/agent.ts @@ -94,7 +94,10 @@ function replaceRootLevelYamlVariables(yamlVariables: { [k: string]: any }, yaml let patchedTemplate = yamlTemplate; Object.entries(yamlVariables).forEach(([key, val]) => { - patchedTemplate = patchedTemplate.replace(new RegExp(`^"${key}"`, 'gm'), safeDump(val)); + patchedTemplate = patchedTemplate.replace( + new RegExp(`^"${key}"`, 'gm'), + val ? safeDump(val) : '' + ); }); return patchedTemplate;