From e5fd1c4c7fc2ca8cac34776199bfa548bc54d3ff Mon Sep 17 00:00:00 2001 From: Ahmad Bamieh Date: Wed, 29 Jul 2020 01:29:48 +0300 Subject: [PATCH 01/27] [i18n] explicit process.exit(); call for i18n_integrate cli command (#73495) Co-authored-by: Elastic Machine --- src/dev/run_i18n_integrate.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/dev/run_i18n_integrate.ts b/src/dev/run_i18n_integrate.ts index 23d66fae9f26e4..ac1e957adfc994 100644 --- a/src/dev/run_i18n_integrate.ts +++ b/src/dev/run_i18n_integrate.ts @@ -108,6 +108,7 @@ run( const reporter = new ErrorReporter(); const messages: Map = new Map(); await list.run({ messages, reporter }); + process.exitCode = 0; } catch (error) { process.exitCode = 1; if (error instanceof ErrorReporter) { @@ -117,6 +118,7 @@ run( log.error(error); } } + process.exit(); }, { flags: { From 93d45fc6ff5d5e0a8a01b81464f6f36eb6ab7883 Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 28 Jul 2020 18:49:05 -0400 Subject: [PATCH 02/27] [Ingest Manager] Update fleet instructions to run agent as a service (#73491) --- .../enrollment_instructions/manual/index.tsx | 85 ++++++++++++++----- .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 3 files changed, 65 insertions(+), 24 deletions(-) diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx index 78f4f73cf18be8..fe11c4cb08d13e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx @@ -5,7 +5,8 @@ */ import React from 'react'; -import { EuiText, EuiSpacer, EuiCode, EuiCodeBlock, EuiCopy, EuiButton } from '@elastic/eui'; +import styled from 'styled-components'; +import { EuiText, EuiSpacer, EuiCode, EuiTitle, EuiCodeBlock } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { EnrollmentAPIKey } from '../../../types'; @@ -15,42 +16,86 @@ interface Props { kibanaCASha256?: string; } +// Otherwise the copy button is over the text +const CommandCode = styled.pre({ + overflow: 'scroll', +}); + export const ManualInstructions: React.FunctionComponent = ({ kibanaUrl, apiKey, kibanaCASha256, }) => { - const command = ` -./elastic-agent enroll ${kibanaUrl} ${apiKey.api_key}${ + const enrollArgs = `${kibanaUrl} ${apiKey.api_key}${ kibanaCASha256 ? ` --ca_sha256=${kibanaCASha256}` : '' - } + }`; + const macOsLinuxTarCommand = `./elastic-agent enroll ${enrollArgs} ./elastic-agent run`; + + const linuxDebRpmCommand = `./elastic-agent enroll ${enrollArgs} +systemctl enable elastic-agent +systemctl start elastic-agent`; + + const windowsCommand = `./elastic-agent enroll ${enrollArgs} +./install-service-elastic-agent.ps1`; + return ( <> + + + +

+ +

+
+ + + {windowsCommand} + + + +

+ +

+
+ + + {linuxDebRpmCommand} + + + +

+ +

+
+ + + agent enroll, + command: ./elastic-agent run, }} /> - - -
{command}
+ + + {macOsLinuxTarCommand} - - - {(copy) => ( - - - - )} - ); }; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ee7d1e0298d001..b6aaa2065c7954 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8177,8 +8177,6 @@ "xpack.ingestManager.editPackageConfig.updatedNotificationTitle": "正常に'{packageConfigName}'を更新しました", "xpack.ingestManager.enrollemntAPIKeyList.emptyMessage": "登録トークンが見つかりません。", "xpack.ingestManager.enrollemntAPIKeyList.loadingTokensMessage": "登録トークンを読み込んでいます...", - "xpack.ingestManager.enrollmentInstructions.copyButton": "コマンドをコピー", - "xpack.ingestManager.enrollmentInstructions.descriptionText": "エージェントのディレクトリから、これらのコマンドを実行して、Elasticエージェントを登録して起動します。{enrollCommand}はエージェントの構成ファイルに書き込み、正しい設定になるようにします。このコマンドを使用すると、複数のホストでエージェントを設定できます。", "xpack.ingestManager.enrollmentStepAgentConfig.configSelectAriaLabel": "エージェント構成", "xpack.ingestManager.enrollmentStepAgentConfig.configSelectLabel": "エージェント構成", "xpack.ingestManager.enrollmentStepAgentConfig.enrollmentTokenSelectLabel": "登録トークン", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 30c932c362a4f5..dcbcda120587fa 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8182,8 +8182,6 @@ "xpack.ingestManager.editPackageConfig.updatedNotificationTitle": "已成功更新“{packageConfigName}”", "xpack.ingestManager.enrollemntAPIKeyList.emptyMessage": "未找到任何注册令牌。", "xpack.ingestManager.enrollemntAPIKeyList.loadingTokensMessage": "正在加载注册令牌......", - "xpack.ingestManager.enrollmentInstructions.copyButton": "复制命令", - "xpack.ingestManager.enrollmentInstructions.descriptionText": "从代理的目录,运行这些命令以注册并启动 Elastic 代理。{enrollCommand} 将写入代理的配置文件,以便其具有正确的设置。可以使用此命令在多个主机上设置代理。", "xpack.ingestManager.enrollmentStepAgentConfig.configSelectAriaLabel": "代理配置", "xpack.ingestManager.enrollmentStepAgentConfig.configSelectLabel": "代理配置", "xpack.ingestManager.enrollmentStepAgentConfig.enrollmentTokenSelectLabel": "注册令牌", From 7a3e800aaab50969f7d53ac77f781a2544463f06 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 28 Jul 2020 18:58:58 -0400 Subject: [PATCH 03/27] [Canvas][tech-debt] Kill Recompose:Pure - Part 1 (#73303) Co-authored-by: Elastic Machine --- .../arg_add/{arg_add.js => arg_add.tsx} | 14 +- .../components/arg_add/{index.js => index.ts} | 6 +- .../arg_add_popover/arg_add_popover.tsx | 7 +- .../components/arg_add_popover/index.ts | 5 +- .../asset_manager.stories.storyshot | 539 ---------- .../public/components/color_dot/color_dot.tsx | 12 +- .../public/components/color_dot/index.ts | 7 +- .../color_manager/color_manager.tsx | 22 +- .../public/components/color_manager/index.ts | 7 +- .../color_palette/color_palette.tsx | 8 +- .../public/components/color_palette/index.ts | 6 +- .../components/color_picker/color_picker.tsx | 5 +- .../public/components/color_picker/index.ts | 7 +- .../color_picker_popover.tsx | 9 +- .../components/color_picker_popover/index.ts | 7 +- .../datatable/{datatable.js => datatable.tsx} | 33 +- .../datatable/{index.js => index.ts} | 5 +- .../font_picker.stories.storyshot | 0 .../{ => __stories__}/font_picker.stories.tsx | 4 +- .../components/font_picker/font_picker.tsx | 14 +- .../font_picker/{index.js => index.ts} | 6 +- .../canvas/public/components/loading/index.ts | 5 +- .../public/components/loading/loading.tsx | 6 +- .../public/components/shape_picker/index.ts | 6 +- .../components/shape_picker/shape_picker.tsx | 6 +- .../components/shape_picker_popover/index.tsx | 6 +- .../shape_picker_popover.tsx | 7 +- .../public/components/shape_preview/index.ts | 6 +- .../shape_preview/shape_preview.tsx | 4 +- .../text_style_picker.stories.storyshot | 975 ++++++++++++++++++ .../__stories__/text_style_picker.stories.tsx | 21 + .../{font_sizes.js => font_sizes.ts} | 0 .../text_style_picker/{index.js => index.ts} | 5 +- ..._style_picker.js => text_style_picker.tsx} | 160 +-- .../public/components/tooltip_icon/index.ts | 6 +- .../components/tooltip_icon/tooltip_icon.tsx | 4 +- 36 files changed, 1188 insertions(+), 752 deletions(-) rename x-pack/plugins/canvas/public/components/arg_add/{arg_add.js => arg_add.tsx} (71%) rename x-pack/plugins/canvas/public/components/arg_add/{index.js => index.ts} (66%) rename x-pack/plugins/canvas/public/components/datatable/{datatable.js => datatable.tsx} (78%) rename x-pack/plugins/canvas/public/components/datatable/{index.js => index.ts} (64%) rename x-pack/plugins/canvas/public/components/font_picker/{ => __stories__}/__snapshots__/font_picker.stories.storyshot (100%) rename x-pack/plugins/canvas/public/components/font_picker/{ => __stories__}/font_picker.stories.tsx (84%) rename x-pack/plugins/canvas/public/components/font_picker/{index.js => index.ts} (64%) create mode 100644 x-pack/plugins/canvas/public/components/text_style_picker/__stories__/__snapshots__/text_style_picker.stories.storyshot create mode 100644 x-pack/plugins/canvas/public/components/text_style_picker/__stories__/text_style_picker.stories.tsx rename x-pack/plugins/canvas/public/components/text_style_picker/{font_sizes.js => font_sizes.ts} (100%) rename x-pack/plugins/canvas/public/components/text_style_picker/{index.js => index.ts} (61%) rename x-pack/plugins/canvas/public/components/text_style_picker/{text_style_picker.js => text_style_picker.tsx} (54%) diff --git a/x-pack/plugins/canvas/public/components/arg_add/arg_add.js b/x-pack/plugins/canvas/public/components/arg_add/arg_add.tsx similarity index 71% rename from x-pack/plugins/canvas/public/components/arg_add/arg_add.js rename to x-pack/plugins/canvas/public/components/arg_add/arg_add.tsx index 2d6d7d1046fddd..e85a2915a82b1c 100644 --- a/x-pack/plugins/canvas/public/components/arg_add/arg_add.js +++ b/x-pack/plugins/canvas/public/components/arg_add/arg_add.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FC, ReactEventHandler } from 'react'; import PropTypes from 'prop-types'; import { EuiDescriptionList, @@ -12,7 +12,13 @@ import { EuiDescriptionListDescription, } from '@elastic/eui'; -export const ArgAdd = ({ onValueAdd, displayName, help }) => { +interface Props { + displayName: string; + help: string; + onValueAdd?: ReactEventHandler; +} + +export const ArgAdd: FC = ({ onValueAdd = () => {}, displayName, help }) => { return ( -
-
-
- Manage workpad assets -
-
-
-
-
- -
- -
-
-
-
-
-
-
-
-
-

- Below are the image assets in this workpad. Any assets that are currently in use cannot be determined at this time. To reclaim space, delete assets. -

-
-
-
-
-
-
-
-
- Asset thumbnail -
-
-
-
-

- - airplane - -
- - - ( - 1 - kb) - - -

-
-
-
-
- - - -
-
- -
- -
-
-
-
- -
- -
-
-
-
- - - -
-
-
-
-
-
-
-
- Asset thumbnail -
-
-
-
-

- - marker - -
- - - ( - 1 - kb) - - -

-
-
-
-
- - - -
-
- -
- -
-
-
-
- -
- -
-
-
-
- - - -
-
-
-
-
-
-
-
-
-
- -
-
-
- 0% space used -
-
-
- -
-
-
-
, -
, -] -`; - exports[`Storyshots components/Assets/AssetManager two assets 1`] = ` Array [
= ({ value, children }) => { +export const ColorDot: FC = ({ value, children }) => { const tc = tinycolor(value); let style = {}; @@ -34,6 +34,6 @@ export const ColorDot: FunctionComponent = ({ value, children }) => { }; ColorDot.propTypes = { - value: PropTypes.string, children: PropTypes.node, + value: PropTypes.string, }; diff --git a/x-pack/plugins/canvas/public/components/color_dot/index.ts b/x-pack/plugins/canvas/public/components/color_dot/index.ts index aacfdf4e0cc74a..72936f6133886f 100644 --- a/x-pack/plugins/canvas/public/components/color_dot/index.ts +++ b/x-pack/plugins/canvas/public/components/color_dot/index.ts @@ -4,9 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; - -import { ColorDot as Component } from './color_dot'; - -export { Props } from './color_dot'; -export const ColorDot = pure(Component); +export { ColorDot } from './color_dot'; diff --git a/x-pack/plugins/canvas/public/components/color_manager/color_manager.tsx b/x-pack/plugins/canvas/public/components/color_manager/color_manager.tsx index 8855bffc5e771e..88bf93a3ca84a2 100644 --- a/x-pack/plugins/canvas/public/components/color_manager/color_manager.tsx +++ b/x-pack/plugins/canvas/public/components/color_manager/color_manager.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiFieldText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { FC } from 'react'; import PropTypes from 'prop-types'; -import React, { FunctionComponent } from 'react'; +import { EuiButtonIcon, EuiFieldText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import tinycolor from 'tinycolor2'; import { ColorDot } from '../color_dot/color_dot'; @@ -15,17 +15,17 @@ import { ComponentStrings } from '../../../i18n/components'; const { ColorManager: strings } = ComponentStrings; export interface Props { + /** + * Determines if the add/remove buttons are displayed. + * @default false + */ + hasButtons?: boolean; /** The function to call when the Add Color button is clicked. The button will be disabled if there is no handler. */ onAddColor?: (value: string) => void; /** The function to call when the value is changed */ onChange: (value: string) => void; /** The function to call when the Remove Color button is clicked. The button will be disabled if there is no handler. */ onRemoveColor?: (value: string) => void; - /** - * Determines if the add/remove buttons are displayed. - * @default false - */ - hasButtons?: boolean; /** * The value of the color manager. Only honors valid CSS values. * @default '' @@ -33,12 +33,12 @@ export interface Props { value?: string; } -export const ColorManager: FunctionComponent = ({ - value = '', +export const ColorManager: FC = ({ + hasButtons = false, onAddColor, - onRemoveColor, onChange, - hasButtons = false, + onRemoveColor, + value = '', }) => { const tc = tinycolor(value); const validColor = tc.isValid(); diff --git a/x-pack/plugins/canvas/public/components/color_manager/index.ts b/x-pack/plugins/canvas/public/components/color_manager/index.ts index d7f59b38a74c59..9958c17cf19419 100644 --- a/x-pack/plugins/canvas/public/components/color_manager/index.ts +++ b/x-pack/plugins/canvas/public/components/color_manager/index.ts @@ -4,9 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; - -import { ColorManager as Component } from './color_manager'; - -export { Props } from './color_manager'; -export const ColorManager = pure(Component); +export { ColorManager, Props } from './color_manager'; diff --git a/x-pack/plugins/canvas/public/components/color_palette/color_palette.tsx b/x-pack/plugins/canvas/public/components/color_palette/color_palette.tsx index 09bc08f9ae541d..d3b1936d4c3651 100644 --- a/x-pack/plugins/canvas/public/components/color_palette/color_palette.tsx +++ b/x-pack/plugins/canvas/public/components/color_palette/color_palette.tsx @@ -4,15 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon, EuiLink } from '@elastic/eui'; +import React, { FC } from 'react'; import PropTypes from 'prop-types'; -import React, { FunctionComponent } from 'react'; +import { EuiIcon, EuiLink } from '@elastic/eui'; import tinycolor from 'tinycolor2'; import { readableColor } from '../../lib/readable_color'; import { ColorDot } from '../color_dot'; import { ItemGrid } from '../item_grid'; -export interface Props { +interface Props { /** * An array of hexadecimal color values. Non-hex will be ignored. * @default [] @@ -32,7 +32,7 @@ export interface Props { value?: string; } -export const ColorPalette: FunctionComponent = ({ +export const ColorPalette: FC = ({ colors = [], colorsPerRow = 6, onChange, diff --git a/x-pack/plugins/canvas/public/components/color_palette/index.ts b/x-pack/plugins/canvas/public/components/color_palette/index.ts index fa71bc8b3b9b0e..2605868b94279c 100644 --- a/x-pack/plugins/canvas/public/components/color_palette/index.ts +++ b/x-pack/plugins/canvas/public/components/color_palette/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; -import { ColorPalette as Component } from './color_palette'; - -export { Props } from './color_palette'; -export const ColorPalette = pure(Component); +export { ColorPalette } from './color_palette'; diff --git a/x-pack/plugins/canvas/public/components/color_picker/color_picker.tsx b/x-pack/plugins/canvas/public/components/color_picker/color_picker.tsx index 2bf17301b7b387..8de3ddb3d03a41 100644 --- a/x-pack/plugins/canvas/public/components/color_picker/color_picker.tsx +++ b/x-pack/plugins/canvas/public/components/color_picker/color_picker.tsx @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import React, { FC } from 'react'; import PropTypes from 'prop-types'; -import React, { FunctionComponent } from 'react'; import tinycolor from 'tinycolor2'; + import { ColorManager, Props as ColorManagerProps } from '../color_manager'; import { ColorPalette } from '../color_palette'; @@ -18,7 +19,7 @@ export interface Props extends ColorManagerProps { colors?: string[]; } -export const ColorPicker: FunctionComponent = ({ +export const ColorPicker: FC = ({ colors = [], hasButtons = false, onAddColor, diff --git a/x-pack/plugins/canvas/public/components/color_picker/index.ts b/x-pack/plugins/canvas/public/components/color_picker/index.ts index 88968d11a665c6..35dd067ab6d394 100644 --- a/x-pack/plugins/canvas/public/components/color_picker/index.ts +++ b/x-pack/plugins/canvas/public/components/color_picker/index.ts @@ -4,9 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; - -import { ColorPicker as Component } from './color_picker'; - -export { Props } from './color_picker'; -export const ColorPicker = pure(Component); +export { ColorPicker, Props } from './color_picker'; diff --git a/x-pack/plugins/canvas/public/components/color_picker_popover/color_picker_popover.tsx b/x-pack/plugins/canvas/public/components/color_picker_popover/color_picker_popover.tsx index 9e8a6e88b649b7..143e1a7cee6acd 100644 --- a/x-pack/plugins/canvas/public/components/color_picker_popover/color_picker_popover.tsx +++ b/x-pack/plugins/canvas/public/components/color_picker_popover/color_picker_popover.tsx @@ -4,20 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiLink, PopoverAnchorPosition } from '@elastic/eui'; +import React, { FC } from 'react'; import PropTypes from 'prop-types'; -import React, { FunctionComponent } from 'react'; +import { EuiLink, PopoverAnchorPosition } from '@elastic/eui'; import tinycolor from 'tinycolor2'; + import { ColorDot } from '../color_dot'; import { ColorPicker, Props as ColorPickerProps } from '../color_picker'; import { Popover } from '../popover'; export interface Props extends ColorPickerProps { - anchorPosition: PopoverAnchorPosition; + anchorPosition?: PopoverAnchorPosition; ariaLabel?: string; } -export const ColorPickerPopover: FunctionComponent = (props: Props) => { +export const ColorPickerPopover: FC = (props: Props) => { const { value, anchorPosition, ariaLabel, ...rest } = props; const button = (handleClick: React.MouseEventHandler) => ( { +const getIcon = (type: IconType) => { if (type === null) { return; } @@ -36,19 +39,31 @@ const getIcon = (type) => { return ; }; -const getColumnName = (col) => (typeof col === 'string' ? col : col.name); +const getColumnName = (col: DatatableColumn) => (typeof col === 'string' ? col : col.name); -const getColumnType = (col) => col.type || null; +const getColumnType = (col: DatatableColumn) => col.type || null; -const getFormattedValue = (val, type) => { +const getFormattedValue = (val: any, type: any) => { if (type === 'date') { return moment(val).format(); } return String(val); }; -export const Datatable = ({ datatable, perPage, paginate, showHeader }) => ( - +interface Props { + datatable: DatatableType; + paginate?: boolean; + perPage?: number; + showHeader?: boolean; +} + +export const Datatable: FC = ({ + datatable, + paginate = false, + perPage = 10, + showHeader = false, +}) => ( + {({ rows, setPage, pageNumber, totalPages }) => (
@@ -91,7 +106,7 @@ export const Datatable = ({ datatable, perPage, paginate, showHeader }) => ( Datatable.propTypes = { datatable: PropTypes.object.isRequired, - perPage: PropTypes.number, paginate: PropTypes.bool, + perPage: PropTypes.number, showHeader: PropTypes.bool, }; diff --git a/x-pack/plugins/canvas/public/components/datatable/index.js b/x-pack/plugins/canvas/public/components/datatable/index.ts similarity index 64% rename from x-pack/plugins/canvas/public/components/datatable/index.js rename to x-pack/plugins/canvas/public/components/datatable/index.ts index c7837005368e59..e39b909c534606 100644 --- a/x-pack/plugins/canvas/public/components/datatable/index.js +++ b/x-pack/plugins/canvas/public/components/datatable/index.ts @@ -4,7 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; -import { Datatable as Component } from './datatable'; - -export const Datatable = pure(Component); +export { Datatable } from './datatable'; diff --git a/x-pack/plugins/canvas/public/components/font_picker/__snapshots__/font_picker.stories.storyshot b/x-pack/plugins/canvas/public/components/font_picker/__stories__/__snapshots__/font_picker.stories.storyshot similarity index 100% rename from x-pack/plugins/canvas/public/components/font_picker/__snapshots__/font_picker.stories.storyshot rename to x-pack/plugins/canvas/public/components/font_picker/__stories__/__snapshots__/font_picker.stories.storyshot diff --git a/x-pack/plugins/canvas/public/components/font_picker/font_picker.stories.tsx b/x-pack/plugins/canvas/public/components/font_picker/__stories__/font_picker.stories.tsx similarity index 84% rename from x-pack/plugins/canvas/public/components/font_picker/font_picker.stories.tsx rename to x-pack/plugins/canvas/public/components/font_picker/__stories__/font_picker.stories.tsx index 0ad1e01252002f..34cb3d644cccbd 100644 --- a/x-pack/plugins/canvas/public/components/font_picker/font_picker.stories.tsx +++ b/x-pack/plugins/canvas/public/components/font_picker/__stories__/font_picker.stories.tsx @@ -7,8 +7,8 @@ import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; import React from 'react'; -import { americanTypewriter } from '../../../common/lib/fonts'; -import { FontPicker } from './font_picker'; +import { americanTypewriter } from '../../../../common/lib/fonts'; +import { FontPicker } from '../font_picker'; storiesOf('components/FontPicker', module) .add('default', () => ) diff --git a/x-pack/plugins/canvas/public/components/font_picker/font_picker.tsx b/x-pack/plugins/canvas/public/components/font_picker/font_picker.tsx index 556a3c54521607..2b75841e1b7a59 100644 --- a/x-pack/plugins/canvas/public/components/font_picker/font_picker.tsx +++ b/x-pack/plugins/canvas/public/components/font_picker/font_picker.tsx @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSuperSelect } from '@elastic/eui'; +import React, { FC } from 'react'; import PropTypes from 'prop-types'; -import React, { FunctionComponent } from 'react'; +import { EuiSuperSelect } from '@elastic/eui'; import { fonts, FontValue } from '../../../common/lib/fonts'; interface DisplayedFont { - value: string; label: string; + value: string; } interface Props { @@ -19,9 +19,7 @@ interface Props { value?: FontValue; } -export const FontPicker: FunctionComponent = (props) => { - const { value, onSelect } = props; - +export const FontPicker: FC = ({ value, onSelect }) => { // While fonts are strongly-typed, we also support custom fonts someone might type in. // So let's cast the fonts and allow for additions. const displayedFonts: DisplayedFont[] = fonts; @@ -46,10 +44,10 @@ export const FontPicker: FunctionComponent = (props) => { }; FontPicker.propTypes = { - /** Initial value of the Font Picker. */ - value: PropTypes.string, /** Function to execute when a Font is selected. */ onSelect: PropTypes.func, + /** Initial value of the Font Picker. */ + value: PropTypes.string, }; FontPicker.displayName = 'FontPicker'; diff --git a/x-pack/plugins/canvas/public/components/font_picker/index.js b/x-pack/plugins/canvas/public/components/font_picker/index.ts similarity index 64% rename from x-pack/plugins/canvas/public/components/font_picker/index.js rename to x-pack/plugins/canvas/public/components/font_picker/index.ts index 5ccb7846b7a77a..339021a7e5712d 100644 --- a/x-pack/plugins/canvas/public/components/font_picker/index.js +++ b/x-pack/plugins/canvas/public/components/font_picker/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; - -import { FontPicker as Component } from './font_picker'; - -export const FontPicker = pure(Component); +export { FontPicker } from './font_picker'; diff --git a/x-pack/plugins/canvas/public/components/loading/index.ts b/x-pack/plugins/canvas/public/components/loading/index.ts index 81fedf32871843..745639955dcbaa 100644 --- a/x-pack/plugins/canvas/public/components/loading/index.ts +++ b/x-pack/plugins/canvas/public/components/loading/index.ts @@ -4,7 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; -import { Loading as Component } from './loading'; - -export const Loading = pure(Component); +export { Loading } from './loading'; diff --git a/x-pack/plugins/canvas/public/components/loading/loading.tsx b/x-pack/plugins/canvas/public/components/loading/loading.tsx index 67db16d40d4264..403be84295312e 100644 --- a/x-pack/plugins/canvas/public/components/loading/loading.tsx +++ b/x-pack/plugins/canvas/public/components/loading/loading.tsx @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiIcon, EuiLoadingSpinner, isColorDark } from '@elastic/eui'; +import React, { FC } from 'react'; import PropTypes from 'prop-types'; -import React, { FunctionComponent } from 'react'; +import { EuiIcon, EuiLoadingSpinner, isColorDark } from '@elastic/eui'; import { hexToRgb } from '../../../common/lib/hex_to_rgb'; interface Props { @@ -15,7 +15,7 @@ interface Props { text?: string; } -export const Loading: FunctionComponent = ({ +export const Loading: FC = ({ animated = false, text = '', backgroundColor = '#000000', diff --git a/x-pack/plugins/canvas/public/components/shape_picker/index.ts b/x-pack/plugins/canvas/public/components/shape_picker/index.ts index d3ed85831cbe20..3ec86e45af236e 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker/index.ts +++ b/x-pack/plugins/canvas/public/components/shape_picker/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; - -import { ShapePicker as Component } from './shape_picker'; - -export const ShapePicker = pure(Component); +export { ShapePicker } from './shape_picker'; diff --git a/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx b/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx index 56874fd3080f7a..263654522c0599 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx +++ b/x-pack/plugins/canvas/public/components/shape_picker/shape_picker.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FC } from 'react'; import PropTypes from 'prop-types'; import { EuiFlexGrid, EuiFlexItem, EuiLink } from '@elastic/eui'; import { ShapePreview } from '../shape_preview'; @@ -16,7 +16,7 @@ interface Props { onChange?: (key: string) => void; } -export const ShapePicker = ({ shapes, onChange = () => {} }: Props) => { +export const ShapePicker: FC = ({ shapes, onChange = () => {} }) => { return ( {Object.keys(shapes) @@ -33,6 +33,6 @@ export const ShapePicker = ({ shapes, onChange = () => {} }: Props) => { }; ShapePicker.propTypes = { - shapes: PropTypes.object.isRequired, onChange: PropTypes.func, + shapes: PropTypes.object.isRequired, }; diff --git a/x-pack/plugins/canvas/public/components/shape_picker_popover/index.tsx b/x-pack/plugins/canvas/public/components/shape_picker_popover/index.tsx index 1d4ae25a38fa2d..06619c0626daff 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker_popover/index.tsx +++ b/x-pack/plugins/canvas/public/components/shape_picker_popover/index.tsx @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; - -import { ShapePickerPopover as Component } from './shape_picker_popover'; - -export const ShapePickerPopover = pure(Component); +export { ShapePickerPopover } from './shape_picker_popover'; diff --git a/x-pack/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx b/x-pack/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx index d42e08d2bc8524..d61d9e47a3a78b 100644 --- a/x-pack/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx +++ b/x-pack/plugins/canvas/public/components/shape_picker_popover/shape_picker_popover.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FC } from 'react'; import PropTypes from 'prop-types'; import { EuiLink, EuiPanel } from '@elastic/eui'; import { Popover } from '../popover'; @@ -20,7 +20,7 @@ interface Props { ariaLabel?: string; } -export const ShapePickerPopover = ({ shapes, onChange, value, ariaLabel }: Props) => { +export const ShapePickerPopover: FC = ({ shapes, onChange, value, ariaLabel }) => { const button = (handleClick: React.MouseEventHandler) => ( @@ -37,7 +37,8 @@ export const ShapePickerPopover = ({ shapes, onChange, value, ariaLabel }: Props }; ShapePickerPopover.propTypes = { + ariaLabel: PropTypes.string, + onChange: PropTypes.func, shapes: PropTypes.object.isRequired, value: PropTypes.string, - onChange: PropTypes.func, }; diff --git a/x-pack/plugins/canvas/public/components/shape_preview/index.ts b/x-pack/plugins/canvas/public/components/shape_preview/index.ts index 4320a10d97a853..6027b1227a99a3 100644 --- a/x-pack/plugins/canvas/public/components/shape_preview/index.ts +++ b/x-pack/plugins/canvas/public/components/shape_preview/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; - -import { ShapePreview as Component } from './shape_preview'; - -export const ShapePreview = pure(Component); +export { ShapePreview } from './shape_preview'; diff --git a/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx b/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx index 4f67945e9ce13a..3ff18f3aa4bc42 100644 --- a/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx +++ b/x-pack/plugins/canvas/public/components/shape_preview/shape_preview.tsx @@ -4,14 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FC } from 'react'; import PropTypes from 'prop-types'; interface Props { shape?: string; } -export const ShapePreview = ({ shape }: Props) => { +export const ShapePreview: FC = ({ shape }) => { if (!shape) { return
; } diff --git a/x-pack/plugins/canvas/public/components/text_style_picker/__stories__/__snapshots__/text_style_picker.stories.storyshot b/x-pack/plugins/canvas/public/components/text_style_picker/__stories__/__snapshots__/text_style_picker.stories.storyshot new file mode 100644 index 00000000000000..ad236e701ceb01 --- /dev/null +++ b/x-pack/plugins/canvas/public/components/text_style_picker/__stories__/__snapshots__/text_style_picker.stories.storyshot @@ -0,0 +1,975 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Storyshots components/TextStylePicker default 1`] = ` +
+
+
+
+
+
+ +
+
+ + Select an option: + , is selected + +
+
+
+
+
+
+ +
+ +
+ + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+`; + +exports[`Storyshots components/TextStylePicker interactive 1`] = ` +
+
+
+
+
+
+ +
+
+ + Select an option: + , is selected + +
+
+
+
+
+
+ +
+ +
+ + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+
+`; diff --git a/x-pack/plugins/canvas/public/components/text_style_picker/__stories__/text_style_picker.stories.tsx b/x-pack/plugins/canvas/public/components/text_style_picker/__stories__/text_style_picker.stories.tsx new file mode 100644 index 00000000000000..b33a34fcd5e65f --- /dev/null +++ b/x-pack/plugins/canvas/public/components/text_style_picker/__stories__/text_style_picker.stories.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; + +import { TextStylePicker } from '../text_style_picker'; + +const Interactive = () => { + const [props, setProps] = useState({}); + return ; +}; + +storiesOf('components/TextStylePicker', module) + .addDecorator((fn) =>
{fn()}
) + .add('default', () => ) + .add('interactive', () => ); diff --git a/x-pack/plugins/canvas/public/components/text_style_picker/font_sizes.js b/x-pack/plugins/canvas/public/components/text_style_picker/font_sizes.ts similarity index 100% rename from x-pack/plugins/canvas/public/components/text_style_picker/font_sizes.js rename to x-pack/plugins/canvas/public/components/text_style_picker/font_sizes.ts diff --git a/x-pack/plugins/canvas/public/components/text_style_picker/index.js b/x-pack/plugins/canvas/public/components/text_style_picker/index.ts similarity index 61% rename from x-pack/plugins/canvas/public/components/text_style_picker/index.js rename to x-pack/plugins/canvas/public/components/text_style_picker/index.ts index 79bde95723682a..16fb39b660a0c8 100644 --- a/x-pack/plugins/canvas/public/components/text_style_picker/index.js +++ b/x-pack/plugins/canvas/public/components/text_style_picker/index.ts @@ -4,7 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; -import { TextStylePicker as Component } from './text_style_picker'; - -export const TextStylePicker = pure(Component); +export { TextStylePicker } from './text_style_picker'; diff --git a/x-pack/plugins/canvas/public/components/text_style_picker/text_style_picker.js b/x-pack/plugins/canvas/public/components/text_style_picker/text_style_picker.tsx similarity index 54% rename from x-pack/plugins/canvas/public/components/text_style_picker/text_style_picker.js rename to x-pack/plugins/canvas/public/components/text_style_picker/text_style_picker.tsx index 48d52abb031250..3dfc55919395d4 100644 --- a/x-pack/plugins/canvas/public/components/text_style_picker/text_style_picker.js +++ b/x-pack/plugins/canvas/public/components/text_style_picker/text_style_picker.tsx @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { FC, useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import { EuiFlexGroup, EuiFlexItem, EuiSelect, EuiSpacer, EuiButtonGroup } from '@elastic/eui'; +import { FontValue } from 'src/plugins/expressions'; import { ComponentStrings } from '../../../i18n'; import { FontPicker } from '../font_picker'; import { ColorPickerPopover } from '../color_picker_popover'; @@ -14,54 +15,75 @@ import { fontSizes } from './font_sizes'; const { TextStylePicker: strings } = ComponentStrings; -export const TextStylePicker = ({ - family, - size, - align, - color, - weight, - underline, - italic, - onChange, - colors, -}) => { - const alignmentButtons = [ - { - id: 'left', - label: strings.getAlignLeftOption(), - iconType: 'editorAlignLeft', - }, - { - id: 'center', - label: strings.getAlignCenterOption(), - iconType: 'editorAlignCenter', - }, - { - id: 'right', - label: strings.getAlignRightOption(), - iconType: 'editorAlignRight', - }, - ]; - - const styleButtons = [ - { - id: 'bold', - label: strings.getStyleBoldOption(), - iconType: 'editorBold', - }, - { - id: 'italic', - label: strings.getStyleItalicOption(), - iconType: 'editorItalic', - }, - { - id: 'underline', - label: strings.getStyleUnderlineOption(), - iconType: 'editorUnderline', - }, - ]; - - const stylesSelectedMap = { +interface BaseProps { + family?: FontValue; + size?: number; + align?: 'left' | 'center' | 'right'; + color?: string; + weight?: 'bold' | 'normal'; + underline?: boolean; + italic?: boolean; +} + +interface Props extends BaseProps { + colors?: string[]; + onChange: (props: BaseProps) => void; +} + +type StyleType = 'bold' | 'italic' | 'underline'; + +const alignmentButtons = [ + { + id: 'left', + label: strings.getAlignLeftOption(), + iconType: 'editorAlignLeft', + }, + { + id: 'center', + label: strings.getAlignCenterOption(), + iconType: 'editorAlignCenter', + }, + { + id: 'right', + label: strings.getAlignRightOption(), + iconType: 'editorAlignRight', + }, +]; + +const styleButtons = [ + { + id: 'bold', + label: strings.getStyleBoldOption(), + iconType: 'editorBold', + }, + { + id: 'italic', + label: strings.getStyleItalicOption(), + iconType: 'editorItalic', + }, + { + id: 'underline', + label: strings.getStyleUnderlineOption(), + iconType: 'editorUnderline', + }, +]; + +export const TextStylePicker: FC = (props) => { + const [style, setStyle] = useState(props); + + const { + align = 'left', + color, + colors, + family, + italic = false, + onChange, + size = 14, + underline = false, + weight = 'normal', + } = style; + + const stylesSelectedMap: Record = { ['bold']: weight === 'bold', ['italic']: Boolean(italic), ['underline']: Boolean(underline), @@ -72,31 +94,22 @@ export const TextStylePicker = ({ fontSizes.sort((a, b) => a - b); } - const doChange = (propName, value) => { - onChange({ - family, - size, - align, - color, - weight: weight || 'normal', - underline: underline || false, - italic: italic || false, - [propName]: value, - }); - }; + useEffect(() => onChange(style), [onChange, style]); - const onAlignmentChange = (optionId) => doChange('align', optionId); + const doChange = (propName: keyof Props, value: string | boolean | number) => { + setStyle({ ...style, [propName]: value }); + }; - const onStyleChange = (optionId) => { - let prop; + const onStyleChange = (optionId: string) => { + let prop: 'weight' | 'italic' | 'underline'; let value; if (optionId === 'bold') { prop = 'weight'; value = !stylesSelectedMap[optionId] ? 'bold' : 'normal'; } else { - prop = optionId; - value = !stylesSelectedMap[optionId]; + prop = optionId as 'italic' | 'underline'; + value = !stylesSelectedMap[prop]; } doChange(prop, value); @@ -106,14 +119,18 @@ export const TextStylePicker = ({
- doChange('family', value)} /> + {family ? ( + doChange('family', value)} /> + ) : ( + doChange('family', value)} /> + )} doChange('size', Number(e.target.value))} - options={fontSizes.map((size) => ({ text: String(size), value: size }))} + options={fontSizes.map((fontSize) => ({ text: String(fontSize), value: fontSize }))} prepend="Size" /> @@ -147,7 +164,7 @@ export const TextStylePicker = ({ buttonSize="compressed" isIconOnly idSelected={align} - onChange={onAlignmentChange} + onChange={(optionId: string) => doChange('align', optionId)} className="canvasSidebar__buttonGroup" /> @@ -159,9 +176,9 @@ export const TextStylePicker = ({ TextStylePicker.propTypes = { family: PropTypes.string, size: PropTypes.number, - align: PropTypes.string, + align: PropTypes.oneOf(['left', 'center', 'right']), color: PropTypes.string, - weight: PropTypes.string, + weight: PropTypes.oneOf(['normal', 'bold']), underline: PropTypes.bool, italic: PropTypes.bool, onChange: PropTypes.func.isRequired, @@ -171,4 +188,5 @@ TextStylePicker.propTypes = { TextStylePicker.defaultProps = { align: 'left', size: 14, + weight: 'normal', }; diff --git a/x-pack/plugins/canvas/public/components/tooltip_icon/index.ts b/x-pack/plugins/canvas/public/components/tooltip_icon/index.ts index 6e71baa3647852..55c2f7090629c8 100644 --- a/x-pack/plugins/canvas/public/components/tooltip_icon/index.ts +++ b/x-pack/plugins/canvas/public/components/tooltip_icon/index.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import { pure } from 'recompose'; -import { TooltipIcon as Component } from './tooltip_icon'; -export { IconType } from './tooltip_icon'; - -export const TooltipIcon = pure(Component); +export { TooltipIcon, IconType } from './tooltip_icon'; diff --git a/x-pack/plugins/canvas/public/components/tooltip_icon/tooltip_icon.tsx b/x-pack/plugins/canvas/public/components/tooltip_icon/tooltip_icon.tsx index 78c2b0ec53c9f0..d91bb4bc9add96 100644 --- a/x-pack/plugins/canvas/public/components/tooltip_icon/tooltip_icon.tsx +++ b/x-pack/plugins/canvas/public/components/tooltip_icon/tooltip_icon.tsx @@ -5,7 +5,7 @@ */ /* eslint react/forbid-elements: 0 */ -import React from 'react'; +import React, { FC } from 'react'; import PropTypes from 'prop-types'; import { EuiIconTip, PropsOf } from '@elastic/eui'; @@ -21,7 +21,7 @@ interface Props extends Omit { icon: IconType; } -export const TooltipIcon = ({ icon = IconType.info, ...rest }: Props) => { +export const TooltipIcon: FC = ({ icon = IconType.info, ...rest }) => { const icons = { [IconType.error]: { type: 'alert', color: 'danger' }, [IconType.warning]: { type: 'alert', color: 'warning' }, From b65ec4e07d0c47e86f930e9e72f5c3fd030aac46 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Tue, 28 Jul 2020 19:44:30 -0400 Subject: [PATCH 04/27] Get branch name from platform vs disk (#73534) https://github.com/elastic/kibana/blob/fa93a81ba67f5177024f1ab3b4ac68919a7824dc/src/core/server/plugins/types.ts#L280 & https://github.com/elastic/kibana/blob/27dbcb27964353619f686066c6ba8f25954a0881/src/core/server/config/types.ts#L25 Co-authored-by: Elastic Machine --- x-pack/plugins/ingest_manager/server/plugin.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/plugins/ingest_manager/server/plugin.ts b/x-pack/plugins/ingest_manager/server/plugin.ts index 5664a875010166..e7495df254a090 100644 --- a/x-pack/plugins/ingest_manager/server/plugin.ts +++ b/x-pack/plugins/ingest_manager/server/plugin.ts @@ -15,7 +15,6 @@ import { HttpServiceSetup, } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import packageJSON from '../../../../package.json'; import { LicensingPluginSetup, ILicense } from '../../licensing/server'; import { EncryptedSavedObjectsPluginStart, @@ -155,7 +154,7 @@ export class IngestManagerPlugin this.config$ = this.initializerContext.config.create(); this.isProductionMode = this.initializerContext.env.mode.prod; this.kibanaVersion = this.initializerContext.env.packageInfo.version; - this.kibanaBranch = packageJSON.branch; + this.kibanaBranch = this.initializerContext.env.packageInfo.branch; this.logger = this.initializerContext.logger.get(); } From b399fb03d1677bba0cd61cb5de4e0957fa1910fc Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 28 Jul 2020 17:47:41 -0600 Subject: [PATCH 05/27] [SIEM][Detection Engine][Lists] Adds the ability to change the timeout limits from 10 seconds for loads for imports (#73103) ## Summary By default the upload time limit for payloads is 10 seconds. This is really too short and we were getting internal QA bug reports that uploads are timing out on large value list importing. This PR adds the plumbing and unit tests to make the timeout configurable for routes. * Adds a single timeout option for routes and then normalizes that through Hapi for the socket, payload, and server timeouts. * Adds unit tests which test the various options * Adds integration tests which test the various options * Adds some NOTES about where there are odd behaviors/bugs within Hapi around validations and the timeouts * Adds a configurable 5 minute timeout to the large value lists route **Manual testing of the feature** You can manually test this by adding a configurable option to your chrome network throttle like so below where you throttle upload by some configurable amount. I chose to use 300 kbs/s upload Screen Shot 2020-07-23 at 11 26 01 AM And then run an import of large value lists using a large enough file that it will exceed 5 minutes: ![screen-shot-upload](https://user-images.githubusercontent.com/1151048/88318584-28ef6000-ccd8-11ea-90a1-8ca4aafabcb4.png) After 5 minutes you should see this message within your server side messages if you have configured your kibana.dev.yml to allow for these messages: ```ts server respons [10:52:31.377] [access:lists-all] POST /api/lists/items/_import?type=keyword 408 318292ms - 9.0B ``` Note that it should show you that it is trying to return a `408` after `318292ms` the timeout period. Sometimes you will get the 408 in the browser and sometimes the browser actually will not respect the 408 and continue staying in a pending state forever. This seems to be browser side issue and not a client/user land issue. If you get the browser message it will be this error toaster ![timeout-message](https://user-images.githubusercontent.com/1151048/88318760-74a20980-ccd8-11ea-9b7b-0d27f8eb6bce.png) ### Checklist - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [x] [Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) --- ...a-plugin-core-server.routeconfigoptions.md | 1 + ...-core-server.routeconfigoptions.timeout.md | 13 ++ src/core/server/http/http_server.test.ts | 127 ++++++++++++++++++ src/core/server/http/http_server.ts | 22 ++- .../http/integration_tests/router.test.ts | 124 +++++++++++++++++ src/core/server/http/router/request.ts | 2 + src/core/server/http/router/route.ts | 6 + src/core/server/server.api.md | 1 + x-pack/plugins/lists/common/constants.mock.ts | 4 +- x-pack/plugins/lists/server/config.mock.ts | 2 + x-pack/plugins/lists/server/config.test.ts | 22 +++ x-pack/plugins/lists/server/config.ts | 10 ++ .../server/routes/import_list_item_route.ts | 1 + .../server/services/lists/list_client.mock.ts | 2 + 14 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md diff --git a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md index 5a6103dfc57d4c..fee6124f8d8662 100644 --- a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.md @@ -19,5 +19,6 @@ export interface RouteConfigOptions | [authRequired](./kibana-plugin-core-server.routeconfigoptions.authrequired.md) | boolean | 'optional' | Defines authentication mode for a route: - true. A user has to have valid credentials to access a resource - false. A user can access a resource without any credentials. - 'optional'. A user can access a resource if has valid credentials or no credentials at all. Can be useful when we grant access to a resource but want to identify a user if possible.Defaults to true if an auth mechanism is registered. | | [body](./kibana-plugin-core-server.routeconfigoptions.body.md) | Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody | Additional body options [RouteConfigOptionsBody](./kibana-plugin-core-server.routeconfigoptionsbody.md). | | [tags](./kibana-plugin-core-server.routeconfigoptions.tags.md) | readonly string[] | Additional metadata tag strings to attach to the route. | +| [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) | number | Timeouts for processing durations. Response timeout is in milliseconds. Default value: 2 minutes | | [xsrfRequired](./kibana-plugin-core-server.routeconfigoptions.xsrfrequired.md) | Method extends 'get' ? never : boolean | Defines xsrf protection requirements for a route: - true. Requires an incoming POST/PUT/DELETE request to contain kbn-xsrf header. - false. Disables xsrf protection.Set to true by default | diff --git a/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md new file mode 100644 index 00000000000000..479fcf883ec4d7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.routeconfigoptions.timeout.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [RouteConfigOptions](./kibana-plugin-core-server.routeconfigoptions.md) > [timeout](./kibana-plugin-core-server.routeconfigoptions.timeout.md) + +## RouteConfigOptions.timeout property + +Timeouts for processing durations. Response timeout is in milliseconds. Default value: 2 minutes + +Signature: + +```typescript +timeout?: number; +``` diff --git a/src/core/server/http/http_server.test.ts b/src/core/server/http/http_server.test.ts index 601eba835a54e8..007d75a69b955a 100644 --- a/src/core/server/http/http_server.test.ts +++ b/src/core/server/http/http_server.test.ts @@ -992,6 +992,133 @@ describe('body options', () => { }); }); +describe('timeout options', () => { + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a POST', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.post( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).post('/').send({ test: 1 }).expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a GET', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.get( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).get('/').expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a DELETE', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.delete( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).delete('/').expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a PUT', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.put( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).put('/').expect(200, { + timeout: 300000, + }); + }); + + test('should accept a socket "timeout" which is 3 minutes in milliseconds, "300000" for a PATCH', async () => { + const { registerRouter, server: innerServer } = await server.setup(config); + + const router = new Router('', logger, enhanceWithContext); + router.patch( + { + path: '/', + validate: false, + options: { timeout: 300000 }, + }, + (context, req, res) => { + try { + return res.ok({ body: { timeout: req.route.options.timeout } }); + } catch (err) { + return res.internalError({ body: err.message }); + } + } + ); + registerRouter(router); + await server.start(); + await supertest(innerServer.listener).patch('/').expect(200, { + timeout: 300000, + }); + }); +}); + test('should return a stream in the body', async () => { const { registerRouter, server: innerServer } = await server.setup(config); diff --git a/src/core/server/http/http_server.ts b/src/core/server/http/http_server.ts index 9c16162d693348..4b70f58deba99e 100644 --- a/src/core/server/http/http_server.ts +++ b/src/core/server/http/http_server.ts @@ -161,8 +161,10 @@ export class HttpServer { this.log.debug(`registering route handler for [${route.path}]`); // Hapi does not allow payload validation to be specified for 'head' or 'get' requests const validate = isSafeMethod(route.method) ? undefined : { payload: true }; - const { authRequired, tags, body = {} } = route.options; + const { authRequired, tags, body = {}, timeout } = route.options; const { accepts: allow, maxBytes, output, parse } = body; + // Hapi does not allow timeouts on payloads to be specified for 'head' or 'get' requests + const payloadTimeout = isSafeMethod(route.method) || timeout == null ? undefined : timeout; const kibanaRouteState: KibanaRouteState = { xsrfRequired: route.options.xsrfRequired ?? !isSafeMethod(route.method), @@ -181,9 +183,23 @@ export class HttpServer { // validation applied in ./http_tools#getServerOptions // (All NP routes are already required to specify their own validation in order to access the payload) validate, - payload: [allow, maxBytes, output, parse].some((v) => typeof v !== 'undefined') - ? { allow, maxBytes, output, parse } + payload: [allow, maxBytes, output, parse, payloadTimeout].some( + (v) => typeof v !== 'undefined' + ) + ? { + allow, + maxBytes, + output, + parse, + timeout: payloadTimeout, + } : undefined, + timeout: + timeout != null + ? { + socket: timeout + 1, // Hapi server requires the socket to be greater than payload settings so we add 1 millisecond + } + : undefined, }, }); } diff --git a/src/core/server/http/integration_tests/router.test.ts b/src/core/server/http/integration_tests/router.test.ts index bb36fefa96611e..434e22e3cf6f5b 100644 --- a/src/core/server/http/integration_tests/router.test.ts +++ b/src/core/server/http/integration_tests/router.test.ts @@ -302,6 +302,130 @@ describe('Options', () => { }); }); }); + + describe('timeout', () => { + it('should timeout if configured with a small timeout value for a POST', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.post( + { path: '/a', validate: false, options: { timeout: 1000 } }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + } + ); + router.post({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + expect(supertest(innerServer.listener).post('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).post('/b').expect(200, {}); + }); + + it('should timeout if configured with a small timeout value for a PUT', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.put( + { path: '/a', validate: false, options: { timeout: 1000 } }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + } + ); + router.put({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + + expect(supertest(innerServer.listener).put('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).put('/b').expect(200, {}); + }); + + it('should timeout if configured with a small timeout value for a DELETE', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.delete( + { path: '/a', validate: false, options: { timeout: 1000 } }, + async (context, req, res) => { + await new Promise((resolve) => setTimeout(resolve, 2000)); + return res.ok({}); + } + ); + router.delete({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + expect(supertest(innerServer.listener).delete('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).delete('/b').expect(200, {}); + }); + + it('should timeout if configured with a small timeout value for a GET', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get( + // Note: There is a bug within Hapi Server where it cannot set the payload timeout for a GET call but it also cannot configure a timeout less than the payload body + // so the least amount of possible time to configure the timeout is 10 seconds. + { path: '/a', validate: false, options: { timeout: 100000 } }, + async (context, req, res) => { + // Cause a wait of 20 seconds to cause the socket hangup + await new Promise((resolve) => setTimeout(resolve, 200000)); + return res.ok({}); + } + ); + router.get({ path: '/b', validate: false }, (context, req, res) => res.ok({})); + await server.start(); + + expect(supertest(innerServer.listener).get('/a')).rejects.toThrow('socket hang up'); + await supertest(innerServer.listener).get('/b').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a POST', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.post( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + await supertest(innerServer.listener).post('/a').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a PUT', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.put( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + + await supertest(innerServer.listener).put('/a').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a DELETE', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.delete( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + await supertest(innerServer.listener).delete('/a').expect(200, {}); + }); + + it('should not timeout if configured with a 5 minute timeout value for a GET', async () => { + const { server: innerServer, createRouter } = await server.setup(setupDeps); + const router = createRouter('/'); + + router.get( + { path: '/a', validate: false, options: { timeout: 300000 } }, + async (context, req, res) => res.ok({}) + ); + await server.start(); + await supertest(innerServer.listener).get('/a').expect(200, {}); + }); + }); }); describe('Cache-Control', () => { diff --git a/src/core/server/http/router/request.ts b/src/core/server/http/router/request.ts index fefd75ad9710e9..0e73431fe7c6d7 100644 --- a/src/core/server/http/router/request.ts +++ b/src/core/server/http/router/request.ts @@ -197,12 +197,14 @@ export class KibanaRequest< private getRouteInfo(request: Request): KibanaRequestRoute { const method = request.method as Method; const { parse, maxBytes, allow, output } = request.route.settings.payload || {}; + const timeout = request.route.settings.timeout?.socket; const options = ({ authRequired: this.getAuthRequired(request), // some places in LP call KibanaRequest.from(request) manually. remove fallback to true before v8 xsrfRequired: (request.route.settings.app as KibanaRouteState)?.xsrfRequired ?? true, tags: request.route.settings.tags || [], + timeout: typeof timeout === 'number' ? timeout - 1 : undefined, // We are forced to have the timeout be 1 millisecond greater than the server and payload so we subtract one here to give the user consist settings body: isSafeMethod(method) ? undefined : { diff --git a/src/core/server/http/router/route.ts b/src/core/server/http/router/route.ts index 9789d266587afc..676c494bec522d 100644 --- a/src/core/server/http/router/route.ts +++ b/src/core/server/http/router/route.ts @@ -144,6 +144,12 @@ export interface RouteConfigOptions { * Additional body options {@link RouteConfigOptionsBody}. */ body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; + + /** + * Timeouts for processing durations. Response timeout is in milliseconds. + * Default value: 2 minutes + */ + timeout?: number; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index bb4f2f30ac18f1..c94151f8cee179 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1859,6 +1859,7 @@ export interface RouteConfigOptions { authRequired?: boolean | 'optional'; body?: Method extends 'get' | 'options' ? undefined : RouteConfigOptionsBody; tags?: readonly string[]; + timeout?: number; xsrfRequired?: Method extends 'get' ? never : boolean; } diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 22706890e20209..b7609b5a3602a5 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -3,8 +3,9 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EntriesArray } from './schemas/types'; +import moment from 'moment'; +import { EntriesArray } from './schemas/types'; export const DATE_NOW = '2020-04-20T15:25:31.830Z'; export const OLD_DATE_RELATIVE_TO_DATE_NOW = '2020-04-19T15:25:31.830Z'; export const USER = 'some user'; @@ -64,3 +65,4 @@ export const CURSOR = 'c29tZXN0cmluZ2ZvcnlvdQ=='; export const _VERSION = 'WzI5NywxXQ=='; export const VERSION = 1; export const IMMUTABLE = false; +export const IMPORT_TIMEOUT = moment.duration(5, 'minutes'); diff --git a/x-pack/plugins/lists/server/config.mock.ts b/x-pack/plugins/lists/server/config.mock.ts index 3cf5040c73675f..b272f18c4e8090 100644 --- a/x-pack/plugins/lists/server/config.mock.ts +++ b/x-pack/plugins/lists/server/config.mock.ts @@ -6,6 +6,7 @@ import { IMPORT_BUFFER_SIZE, + IMPORT_TIMEOUT, LIST_INDEX, LIST_ITEM_INDEX, MAX_IMPORT_PAYLOAD_BYTES, @@ -21,6 +22,7 @@ export const getConfigMock = (): Partial => ({ export const getConfigMockDecoded = (): ConfigType => ({ enabled: true, importBufferSize: IMPORT_BUFFER_SIZE, + importTimeout: IMPORT_TIMEOUT, listIndex: LIST_INDEX, listItemIndex: LIST_ITEM_INDEX, maxImportPayloadBytes: MAX_IMPORT_PAYLOAD_BYTES, diff --git a/x-pack/plugins/lists/server/config.test.ts b/x-pack/plugins/lists/server/config.test.ts index 60501322dcfa2f..40b04edd4c0075 100644 --- a/x-pack/plugins/lists/server/config.test.ts +++ b/x-pack/plugins/lists/server/config.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import moment from 'moment'; + import { ConfigSchema, ConfigType } from './config'; import { getConfigMock, getConfigMockDecoded } from './config.mock'; @@ -61,4 +63,24 @@ describe('config_schema', () => { '[importBufferSize]: Value must be equal to or greater than [1].' ); }); + + test('it throws if the "importTimeout" value is less than 2 minutes', () => { + const mock: ConfigType = { + ...getConfigMockDecoded(), + importTimeout: moment.duration(2, 'minutes').subtract(1, 'second'), + }; + expect(() => ConfigSchema.validate(mock)).toThrow( + '[importTimeout]: duration cannot be less than 2 minutes' + ); + }); + + test('it throws if the "importTimeout" value is greater than 1 hour', () => { + const mock: ConfigType = { + ...getConfigMockDecoded(), + importTimeout: moment.duration(1, 'hour').add(1, 'second'), + }; + expect(() => ConfigSchema.validate(mock)).toThrow( + '[importTimeout]: duration cannot be greater than 30 minutes' + ); + }); }); diff --git a/x-pack/plugins/lists/server/config.ts b/x-pack/plugins/lists/server/config.ts index 394f85ecfb6425..6e36d135abfcc3 100644 --- a/x-pack/plugins/lists/server/config.ts +++ b/x-pack/plugins/lists/server/config.ts @@ -9,6 +9,16 @@ import { TypeOf, schema } from '@kbn/config-schema'; export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), importBufferSize: schema.number({ defaultValue: 1000, min: 1 }), + importTimeout: schema.duration({ + defaultValue: '5m', + validate: (value) => { + if (value.asMinutes() < 2) { + throw new Error('duration cannot be less than 2 minutes'); + } else if (value.asMinutes() > 30) { + throw new Error('duration cannot be greater than 30 minutes'); + } + }, + }), listIndex: schema.string({ defaultValue: '.lists' }), listItemIndex: schema.string({ defaultValue: '.items' }), maxImportPayloadBytes: schema.number({ defaultValue: 9000000, min: 1 }), diff --git a/x-pack/plugins/lists/server/routes/import_list_item_route.ts b/x-pack/plugins/lists/server/routes/import_list_item_route.ts index 1003a0c52a794f..e162e7829e4562 100644 --- a/x-pack/plugins/lists/server/routes/import_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/import_list_item_route.ts @@ -27,6 +27,7 @@ export const importListItemRoute = (router: IRouter, config: ConfigType): void = parse: false, }, tags: ['access:lists-all'], + timeout: config.importTimeout.asMilliseconds(), }, path: `${LIST_ITEM_URL}/_import`, validate: { diff --git a/x-pack/plugins/lists/server/services/lists/list_client.mock.ts b/x-pack/plugins/lists/server/services/lists/list_client.mock.ts index e5036d561ddc6e..a2959a024f292e 100644 --- a/x-pack/plugins/lists/server/services/lists/list_client.mock.ts +++ b/x-pack/plugins/lists/server/services/lists/list_client.mock.ts @@ -11,6 +11,7 @@ import { getListResponseMock } from '../../../common/schemas/response/list_schem import { getCallClusterMock } from '../../../common/get_call_cluster.mock'; import { IMPORT_BUFFER_SIZE, + IMPORT_TIMEOUT, LIST_INDEX, LIST_ITEM_INDEX, MAX_IMPORT_PAYLOAD_BYTES, @@ -65,6 +66,7 @@ export const getListClientMock = (): ListClient => { config: { enabled: true, importBufferSize: IMPORT_BUFFER_SIZE, + importTimeout: IMPORT_TIMEOUT, listIndex: LIST_INDEX, listItemIndex: LIST_ITEM_INDEX, maxImportPayloadBytes: MAX_IMPORT_PAYLOAD_BYTES, From 89a392bd7cc1e6010c7518abcf948232ce855b79 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Tue, 28 Jul 2020 19:55:13 -0400 Subject: [PATCH 06/27] [Ingest Manager] API sends 404 when package config id is missing (#73212) * Add test to confirm missing config responds w/ 404 Currently failing with a 500 as in https://github.com/elastic/kibana/issues/66388 * Use after() to remove items added by test. The test initally failed with a 500 when the `after` was added. Debugging narrowed it down to a missing default config. getDefaultAgentConfigId errors if there isn't a default config. The config is added by `setupIngestManager` which _was_ always called during plugin#start but is no longer. We could add the setup call to the test/suite, but instead I changed AgentConfigService.delete to use ensureDefaultAgentConfig instead of getDefaultAgentConfigId. ensureDefaultAgentConfig adds one if it's missing. The check in delete is to make sure we don't delete the default config. We can still do that and now we add a config if it wasn't already there (which seems like A Good Thing) * Fix package config path in OpenApi spec * Return 404 if package config id is invalid/missing * Change test for error displayed text Co-authored-by: Elastic Machine --- .../common/openapi/spec_oas3.json | 4 +- .../server/routes/package_config/handlers.ts | 23 +++-- .../server/services/agent_config.ts | 2 +- .../apis/index.js | 1 + .../apis/package_config/get.ts | 89 +++++++++++++++++++ .../apps/endpoint/policy_details.ts | 2 +- 6 files changed, 108 insertions(+), 13 deletions(-) create mode 100644 x-pack/test/ingest_manager_api_integration/apis/package_config/get.ts diff --git a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json index e16edac5ddb7a4..cfae2c450c824e 100644 --- a/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json +++ b/x-pack/plugins/ingest_manager/common/openapi/spec_oas3.json @@ -922,7 +922,7 @@ }, "parameters": [] }, - "/packageConfigs": { + "/package_configs": { "get": { "summary": "PackageConfigs - List", "tags": [], @@ -1237,7 +1237,7 @@ ] } }, - "/packageConfigs/{packageConfigId}": { + "/package_configs/{packageConfigId}": { "get": { "summary": "PackageConfigs - Info", "tags": [], diff --git a/x-pack/plugins/ingest_manager/server/routes/package_config/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/package_config/handlers.ts index 6b0c2fe9c2ff7c..d2820cdbeb6c8a 100644 --- a/x-pack/plugins/ingest_manager/server/routes/package_config/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/package_config/handlers.ts @@ -5,7 +5,7 @@ */ import { TypeOf } from '@kbn/config-schema'; import Boom from 'boom'; -import { RequestHandler } from 'src/core/server'; +import { RequestHandler, SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; import { appContextService, packageConfigService } from '../../services'; import { getPackageInfo } from '../../services/epm/packages'; import { @@ -49,8 +49,12 @@ export const getOnePackageConfigHandler: RequestHandler> = async (context, request, response) => { const soClient = context.core.savedObjects.client; + const { packageConfigId } = request.params; + const notFoundResponse = () => + response.notFound({ body: { message: `Package config ${packageConfigId} not found` } }); + try { - const packageConfig = await packageConfigService.get(soClient, request.params.packageConfigId); + const packageConfig = await packageConfigService.get(soClient, packageConfigId); if (packageConfig) { return response.ok({ body: { @@ -58,17 +62,18 @@ export const getOnePackageConfigHandler: RequestHandler {} here + // because `this` has to point to the Mocha context + // see https://mochajs.org/#arrow-functions + + describe('Package Config - get by id', async function () { + skipIfNoDockerRegistry(providerContext); + let agentConfigId: string; + let packageConfigId: string; + + before(async function () { + if (!server.enabled) { + return; + } + const { body: agentConfigResponse } = await supertest + .post(`/api/ingest_manager/agent_configs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Test config', + namespace: 'default', + }); + agentConfigId = agentConfigResponse.item.id; + + const { body: packageConfigResponse } = await supertest + .post(`/api/ingest_manager/package_configs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'filetest-1', + description: '', + namespace: 'default', + config_id: agentConfigId, + enabled: true, + output_id: '', + inputs: [], + package: { + name: 'filetest', + title: 'For File Tests', + version: '0.1.0', + }, + }); + packageConfigId = packageConfigResponse.item.id; + }); + + after(async function () { + if (!server.enabled) { + return; + } + + await supertest + .post(`/api/ingest_manager/agent_configs/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ agentConfigId }) + .expect(200); + + await supertest + .post(`/api/ingest_manager/package_configs/delete`) + .set('kbn-xsrf', 'xxxx') + .send({ packageConfigIds: [packageConfigId] }) + .expect(200); + }); + + it('should succeed with a valid id', async function () { + const { body: apiResponse } = await supertest + .get(`/api/ingest_manager/package_configs/${packageConfigId}`) + .expect(200); + + expect(apiResponse.success).to.be(true); + }); + + it('should return a 404 with an invalid id', async function () { + await supertest.get(`/api/ingest_manager/package_configs/IS_NOT_PRESENT`).expect(404); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index cf76f297d83be1..8b197a6c69a305 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -27,7 +27,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.policy.navigateToPolicyDetails('invalid-id'); await testSubjects.existOrFail('policyDetailsIdNotFoundMessage'); expect(await testSubjects.getVisibleText('policyDetailsIdNotFoundMessage')).to.equal( - 'Saved object [ingest-package-configs/invalid-id] not found' + 'Package config invalid-id not found' ); }); }); From 14b2cbb15575be3d488ce36dac25244629b180a6 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Tue, 28 Jul 2020 19:58:26 -0400 Subject: [PATCH 07/27] [Security Solution][Resolver] Handle iso time strings (#73551) Co-authored-by: Elastic Machine --- .../public/resolver/lib/date.test.ts | 56 ++++++++++++++----- .../public/resolver/lib/date.ts | 29 ++++++++-- 2 files changed, 66 insertions(+), 19 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts index 7a48245fcfc414..5555578e44f7b5 100644 --- a/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.test.ts @@ -3,38 +3,64 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { getFriendlyElapsedTime } from './date'; +import { getFriendlyElapsedTime, getUnixTime } from './date'; describe('date', () => { - describe('getFriendlyElapsedTime', () => { - const second = 1000; - const minute = second * 60; - const hour = minute * 60; - const day = hour * 24; - const week = day * 7; - const month = day * 30; - const year = day * 365; + const second = 1000; + const minute = second * 60; + const hour = minute * 60; + const day = hour * 24; + const week = day * 7; + const month = day * 30; + const year = day * 365; - const initialTime = new Date('6/1/2020').getTime(); + describe('getUnixTime', () => { + const unixTime = new Date('6/1/2020').getTime(); + const unixStringTime = String(unixTime); + const isoTime = new Date('6/1/2020').toISOString(); + const notATime = 'imLate'; + + it('should return the time if already a unix timestamp', () => { + expect(getUnixTime(unixTime)).toEqual(unixTime); + }); + + it('should properly convert a unix timestamp string to a number', () => { + expect(getUnixTime(unixStringTime)).toEqual(unixTime); + }); + + it('should properly convert an ISO string to a unix timestamp', () => { + expect(getUnixTime(isoTime)).toEqual(unixTime); + }); - const oneMillisecond = new Date(initialTime + 1).getTime(); + it('should return NaN if an invalid time is provided', () => { + expect(getUnixTime(notATime)).toBeNaN(); + }); + }); + + describe('getFriendlyElapsedTime', () => { + const initialTime = new Date('6/1/2020').getTime(); + const oneMillisecond = new Date(initialTime + 1).toISOString(); const oneSecond = new Date(initialTime + 1 * second).getTime(); const oneMinute = new Date(initialTime + 1 * minute).getTime(); - const oneHour = new Date(initialTime + 1 * hour).getTime(); + const oneHour = new Date(initialTime + 1 * hour).toISOString(); const oneDay = new Date(initialTime + 1 * day).getTime(); - const oneWeek = new Date(initialTime + 1 * week).getTime(); + const oneWeek = `${new Date(initialTime + 1 * week).getTime()}`; const oneMonth = new Date(initialTime + 1 * month).getTime(); const oneYear = new Date(initialTime + 1 * year).getTime(); const almostASecond = new Date(initialTime + 999).getTime(); - const almostAMinute = new Date(initialTime + 59.9 * second).getTime(); + const almostAMinute = new Date(initialTime + 59.9 * second).toISOString(); const almostAnHour = new Date(initialTime + 59.9 * minute).getTime(); const almostADay = new Date(initialTime + 23.9 * hour).getTime(); - const almostAWeek = new Date(initialTime + 6.9 * day).getTime(); + const almostAWeek = new Date(initialTime + 6.9 * day).toISOString(); const almostAMonth = new Date(initialTime + 3.9 * week).getTime(); const almostAYear = new Date(initialTime + 11.9 * month).getTime(); const threeYears = new Date(initialTime + 3 * year).getTime(); + it('should return null if invalid times are given', () => { + expect(getFriendlyElapsedTime(initialTime, 'ImTimeless')).toEqual(null); + }); + it('should return the correct singular relative time', () => { expect(getFriendlyElapsedTime(initialTime, initialTime)).toEqual({ duration: '<1', diff --git a/x-pack/plugins/security_solution/public/resolver/lib/date.ts b/x-pack/plugins/security_solution/public/resolver/lib/date.ts index a5e07e6a02a885..3cd0c910f46f31 100644 --- a/x-pack/plugins/security_solution/public/resolver/lib/date.ts +++ b/x-pack/plugins/security_solution/public/resolver/lib/date.ts @@ -6,6 +6,26 @@ import { DurationDetails, DurationTypes } from '../types'; +/** + * Given a time, it will convert it to a unix timestamp if not one already. If it is unable to do so, it will return NaN + */ +export const getUnixTime = (time: number | string): number | typeof NaN => { + if (!time) { + return NaN; + } + if (typeof time === 'number') { + return time; + } + // If it's a date string just get the time in MS + let unixTime = Date.parse(time); + if (Number.isNaN(unixTime)) { + // If not an ISO date string, last check will be if it's a unix timestamp string + unixTime = parseInt(time, 10); + } + + return unixTime; +}; + /* * Given two unix timestamps, it will return an object containing the time difference and properly pluralized friendly version of the time difference. * i.e. a time difference of 1000ms will yield => { duration: 1, durationType: 'second' } and 10000ms will yield => { duration: 10, durationType: 'seconds' } @@ -15,12 +35,13 @@ export const getFriendlyElapsedTime = ( from: number | string, to: number | string ): DurationDetails | null => { - const startTime = typeof from === 'number' ? from : parseInt(from, 10); - const endTime = typeof to === 'number' ? to : parseInt(to, 10); - const elapsedTimeInMs = endTime - startTime; - if (Number.isNaN(elapsedTimeInMs)) { + const startTime = getUnixTime(from); + const endTime = getUnixTime(to); + + if (Number.isNaN(startTime) || Number.isNaN(endTime)) { return null; } + const elapsedTimeInMs = endTime - startTime; const second = 1000; const minute = second * 60; From 17ec168c287733253345854eff2e75f9b151a932 Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Tue, 28 Jul 2020 19:58:58 -0400 Subject: [PATCH 08/27] [Security Solution][Resolver] Undo origin panel update (#73501) Co-authored-by: Elastic Machine --- .../public/resolver/view/map.tsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 19c403f1257bec..0ca71c5bf60cef 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -8,7 +8,7 @@ /* eslint-disable react/display-name */ -import React, { useContext, useEffect } from 'react'; +import React, { useContext } from 'react'; import { useSelector } from 'react-redux'; import { useEffectOnce } from 'react-use'; import { EuiLoadingSpinner } from '@elastic/eui'; @@ -68,25 +68,12 @@ export const ResolverMap = React.memo(function ({ const hasError = useSelector(selectors.hasError); const activeDescendantId = useSelector(selectors.ariaActiveDescendant); const { colorMap } = useResolverTheme(); - const { - cleanUpQueryParams, - queryParams: { crumbId }, - pushToQueryParams, - } = useResolverQueryParams(); + const { cleanUpQueryParams } = useResolverQueryParams(); useEffectOnce(() => { return () => cleanUpQueryParams(); }); - useEffect(() => { - // When you refresh the page after selecting a process in the table view (not the timeline view) - // The old crumbId still exists in the query string even though a resolver is no longer visible - // This just makes sure the activeDescendant and crumbId are in sync on load for that view as well as the timeline - if (activeDescendantId && crumbId !== activeDescendantId) { - pushToQueryParams({ crumbId: activeDescendantId, crumbEvent: '' }); - } - }, [crumbId, activeDescendantId, pushToQueryParams]); - return ( {isLoading ? ( From 774d3591c083e14bdd56339f2a93eb27e1d531f5 Mon Sep 17 00:00:00 2001 From: Angela Chuang <6295984+angorayc@users.noreply.github.com> Date: Wed, 29 Jul 2020 01:03:45 +0100 Subject: [PATCH 09/27] [Security Solution] Update security overview splash (#73050) ## Summary https://github.com/elastic/endpoint-app-team/issues/591 How to verify: 1. go to: x-pack/test/security_solution_cypress/runner.ts 2. comment line 20 (await esArchiver.load('auditbeat');) 3. in line 25 change cypress:run for cypress:open 4. then in our directory run yarn cypress:run-as-ci when the cypress is open, 5. you can access the Kibana instance in port 5620 with username elastic and password changeme Screenshot 2020-07-23 at 14 48 34 ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [ ] ~[Documentation](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#writing-documentation) was added for features that require explanation or tutorials~ - [ ] ~[Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios~ - [ ] ~This was checked for [keyboard-only and screenreader accessibility](https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Accessibility#Accessibility_testing_checklist)~ - [ ] ~This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)~ - [ ] ~This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers)~ ### For maintainers - [ ] ~This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process)~ --- .../public/doc_links/doc_links_service.ts | 2 +- .../pages/saved_object_no_permissions.tsx | 18 +- .../__snapshots__/index.test.tsx.snap | 24 ++- .../components/empty_page/index.test.tsx | 14 +- .../common/components/empty_page/index.tsx | 157 +++++++++++------- .../public/common/translations.ts | 39 ++++- .../detection_engine.test.tsx | 3 + .../detection_engine_no_signal_index.tsx | 19 ++- .../detection_engine_user_unauthenticated.tsx | 19 ++- .../pages/detection_engine/translations.ts | 4 +- .../components/overview_empty/index.test.tsx | 83 +++++++++ .../components/overview_empty/index.tsx | 53 ++++-- .../public/overview/pages/overview.test.tsx | 4 +- .../public/overview/pages/overview.tsx | 2 +- .../translations/translations/ja-JP.json | 4 +- .../translations/translations/zh-CN.json | 4 +- 16 files changed, 331 insertions(+), 118 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/overview/components/overview_empty/index.test.tsx diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 70b25cb78787ae..eb54983d0be135 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -112,7 +112,7 @@ export class DocLinksService { kibana: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/index.html`, siem: { guide: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/index.html`, - gettingStarted: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/install-siem.html`, + gettingStarted: `${ELASTIC_WEBSITE_URL}guide/en/security/${DOC_LINK_VERSION}/index.html`, }, query: { luceneQuerySyntax: `${ELASTICSEARCH_DOCS}query-dsl-query-string-query.html#query-string-syntax`, diff --git a/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx b/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx index 7129aa04bdf696..c61ff6d18caab8 100644 --- a/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx +++ b/x-pack/plugins/security_solution/public/cases/pages/saved_object_no_permissions.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EmptyPage } from '../../common/components/empty_page'; import * as i18n from './translations'; @@ -12,13 +12,21 @@ import { useKibana } from '../../common/lib/kibana'; export const CaseSavedObjectNoPermissions = React.memo(() => { const docLinks = useKibana().services.docLinks; + const actions = useMemo( + () => ({ + savedObject: { + icon: 'documents', + label: i18n.GO_TO_DOCUMENTATION, + url: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/security/${docLinks.DOC_LINK_VERSION}s`, + target: '_blank', + }, + }), + [docLinks] + ); return ( - - Do Something - + + Do Something + + } + title={false} + /> } diff --git a/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx index 6a14c12cee0f85..8e025faefeabe3 100644 --- a/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx @@ -10,12 +10,12 @@ import React from 'react'; import { EmptyPage } from './index'; test('renders correctly', () => { - const EmptyComponent = shallow( - - ); + const actions = { + actions: { + label: 'Do Something', + url: 'my/url/from/nowwhere', + }, + }; + const EmptyComponent = shallow(); expect(EmptyComponent).toMatchSnapshot(); }); diff --git a/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx index f6d6752729b6d9..89f4b125e930cd 100644 --- a/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx @@ -4,84 +4,123 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, IconType } from '@elastic/eui'; -import React, { MouseEventHandler, ReactNode } from 'react'; +import { + EuiButton, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + IconType, + EuiCard, +} from '@elastic/eui'; +import React, { MouseEventHandler, ReactNode, useMemo } from 'react'; import styled from 'styled-components'; const EmptyPrompt = styled(EuiEmptyPrompt)` align-self: center; /* Corrects horizontal centering in IE11 */ + max-width: 60em; `; EmptyPrompt.displayName = 'EmptyPrompt'; +interface EmptyPageActions { + icon?: IconType; + label: string; + target?: string; + url: string; + descriptionTitle?: string; + description?: string; + fill?: boolean; + onClick?: MouseEventHandler; +} + +export type EmptyPageActionsProps = Record; + interface EmptyPageProps { - actionPrimaryIcon?: IconType; - actionPrimaryLabel: string; - actionPrimaryTarget?: string; - actionPrimaryUrl: string; - actionPrimaryFill?: boolean; - actionSecondaryIcon?: IconType; - actionSecondaryLabel?: string; - actionSecondaryTarget?: string; - actionSecondaryUrl?: string; - actionSecondaryOnClick?: MouseEventHandler; + actions: EmptyPageActionsProps; 'data-test-subj'?: string; message?: ReactNode; title: string; } -export const EmptyPage = React.memo( - ({ - actionPrimaryIcon, - actionPrimaryLabel, - actionPrimaryTarget, - actionPrimaryUrl, - actionPrimaryFill = true, - actionSecondaryIcon, - actionSecondaryLabel, - actionSecondaryTarget, - actionSecondaryUrl, - actionSecondaryOnClick, - message, - title, - ...rest - }) => ( +const EmptyPageComponent = React.memo(({ actions, message, title, ...rest }) => { + const titles = Object.keys(actions); + const maxItemWidth = 283; + const renderActions = useMemo( + () => + Object.values(actions) + .filter((a) => a.label && a.url) + .map( + ( + { + icon, + label, + target, + url, + descriptionTitle = false, + description = false, + onClick, + fill = true, + }, + idx + ) => + descriptionTitle != null || description != null ? ( + + + {label} + + } + /> + + ) : ( + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {label} + + + ) + ), + [actions, titles] + ); + + return ( {title}} body={message &&

{message}

} - actions={ - - - - {actionPrimaryLabel} - - - - {actionSecondaryLabel && actionSecondaryUrl && ( - - {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} - - {actionSecondaryLabel} - - - )} - - } + actions={{renderActions}} {...rest} /> - ) -); + ); +}); + +EmptyPageComponent.displayName = 'EmptyPageComponent'; +export const EmptyPage = React.memo(EmptyPageComponent); EmptyPage.displayName = 'EmptyPage'; diff --git a/x-pack/plugins/security_solution/public/common/translations.ts b/x-pack/plugins/security_solution/public/common/translations.ts index 413119fb40f141..3b94ac8959496f 100644 --- a/x-pack/plugins/security_solution/public/common/translations.ts +++ b/x-pack/plugins/security_solution/public/common/translations.ts @@ -7,16 +7,39 @@ import { i18n } from '@kbn/i18n'; export const EMPTY_TITLE = i18n.translate('xpack.securitySolution.pages.common.emptyTitle', { - defaultMessage: 'Welcome to Security Solution. Let’s get you started.', + defaultMessage: 'Welcome to Elastic Security. Let’s get you started.', }); -export const EMPTY_ACTION_PRIMARY = i18n.translate( - 'xpack.securitySolution.pages.common.emptyActionPrimary', +export const EMPTY_ACTION_ELASTIC_AGENT = i18n.translate( + 'xpack.securitySolution.pages.common.emptyActionElasticAgent', + { + defaultMessage: 'Add data with Elastic Agent', + } +); + +export const EMPTY_ACTION_ELASTIC_AGENT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.pages.common.emptyActionElasticAgentDescription', + { + defaultMessage: + 'The Elastic Agent provides a simple, unified way to add monitoring to your hosts.', + } +); + +export const EMPTY_ACTION_BEATS = i18n.translate( + 'xpack.securitySolution.pages.common.emptyActionBeats', { defaultMessage: 'Add data with Beats', } ); +export const EMPTY_ACTION_BEATS_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.pages.common.emptyActionBeatsDescription', + { + defaultMessage: + 'Lightweight Beats can send data from hundreds or thousands of machines and systems', + } +); + export const EMPTY_ACTION_SECONDARY = i18n.translate( 'xpack.securitySolution.pages.common.emptyActionSecondary', { @@ -27,6 +50,14 @@ export const EMPTY_ACTION_SECONDARY = i18n.translate( export const EMPTY_ACTION_ENDPOINT = i18n.translate( 'xpack.securitySolution.pages.common.emptyActionEndpoint', { - defaultMessage: 'Add data with Elastic Agent (Beta)', + defaultMessage: 'Add Elastic Endpoint Security', + } +); + +export const EMPTY_ACTION_ENDPOINT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.pages.common.emptyActionEndpointDescription', + { + defaultMessage: + 'Protect your hosts with threat prevention, detection, and deep security data visibility.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx index 110620fad7eba8..982712cbe97975 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.test.tsx @@ -53,6 +53,9 @@ jest.mock('react-router-dom', () => { useHistory: jest.fn(), }; }); +jest.mock('../../components/alerts_info', () => ({ + useAlertInfo: jest.fn().mockReturnValue([]), +})); const state: State = { ...mockGlobalState, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx index 32ae585aec1910..c315361b294c78 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { EmptyPage } from '../../../common/components/empty_page'; import * as i18n from './translations'; @@ -12,12 +12,21 @@ import { useKibana } from '../../../common/lib/kibana'; export const DetectionEngineNoIndex = React.memo(() => { const docLinks = useKibana().services.docLinks; + const actions = useMemo( + () => ({ + detections: { + icon: 'documents', + label: i18n.GO_TO_DOCUMENTATION, + url: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/security/${docLinks.DOC_LINK_VERSION}/detection-engine-overview.html#detections-permissions`, + target: '_blank', + }, + }), + [docLinks] + ); + return ( { const docLinks = useKibana().services.docLinks; - + const actions = useMemo( + () => ({ + detectionUnauthenticated: { + icon: 'documents', + label: i18n.GO_TO_DOCUMENTATION, + url: `${docLinks.ELASTIC_WEBSITE_URL}guide/en/security/${docLinks.DOC_LINK_VERSION}/detection-engine-overview.html#detections-permissions`, + target: '_blank', + }, + }), + [docLinks] + ); return ( ({ + useIngestUrl: jest + .fn() + .mockReturnValue({ appId: 'ingestAppId', appPath: 'ingestPath', url: 'ingestUrl' }), +})); + +jest.mock('../../../common/hooks/endpoint/ingest_enabled', () => ({ + useIngestEnabledCheck: jest.fn().mockReturnValue({ allEnabled: true }), +})); + +jest.mock('../../../common/hooks/endpoint/use_navigate_to_app_event_handler', () => ({ + useNavigateToAppEventHandler: jest.fn(), +})); + +describe('OverviewEmpty', () => { + describe('When isIngestEnabled = true', () => { + let wrapper: ShallowWrapper; + beforeAll(() => { + wrapper = shallow(); + }); + + afterAll(() => { + (useIngestEnabledCheck as jest.Mock).mockReset(); + }); + + test('render with correct actions ', () => { + expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ + beats: { + description: + 'Lightweight Beats can send data from hundreds or thousands of machines and systems', + fill: false, + label: 'Add data with Beats', + url: '/app/home#/tutorial_directory/security', + }, + elasticAgent: { + description: + 'The Elastic Agent provides a simple, unified way to add monitoring to your hosts.', + fill: false, + label: 'Add data with Elastic Agent', + url: 'ingestUrl', + }, + endpoint: { + description: + 'Protect your hosts with threat prevention, detection, and deep security data visibility.', + fill: false, + label: 'Add Elastic Endpoint Security', + onClick: undefined, + url: '/app/home#/tutorial_directory/security', + }, + }); + }); + }); + + describe('When isIngestEnabled = false', () => { + let wrapper: ShallowWrapper; + beforeAll(() => { + (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: false }); + wrapper = shallow(); + }); + + test('render with correct actions ', () => { + expect(wrapper.find('[data-test-subj="empty-page"]').prop('actions')).toEqual({ + beats: { + description: + 'Lightweight Beats can send data from hundreds or thousands of machines and systems', + fill: false, + label: 'Add data with Beats', + url: '/app/home#/tutorial_directory/security', + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx index 33413be10079e5..1d2c6889213f13 100644 --- a/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/overview_empty/index.tsx @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; +import { omit } from 'lodash/fp'; + import { FormattedMessage } from '@kbn/i18n/react'; import { EuiLink } from '@elastic/eui'; import * as i18nCommon from '../../../common/translations'; -import { EmptyPage } from '../../../common/components/empty_page'; +import { EmptyPage, EmptyPageActionsProps } from '../../../common/components/empty_page'; import { useKibana } from '../../../common/lib/kibana'; import { ADD_DATA_PATH } from '../../../../common/constants'; import { useIngestUrl } from '../../../management/pages/endpoint_hosts/view/hooks'; @@ -23,23 +25,45 @@ const OverviewEmptyComponent: React.FC = () => { ); const handleOnClick = useNavigateToAppEventHandler(ingestAppId, { path: ingestPath }); const { allEnabled: isIngestEnabled } = useIngestEnabledCheck(); + const emptyPageActions: EmptyPageActionsProps = useMemo( + () => ({ + elasticAgent: { + label: i18nCommon.EMPTY_ACTION_ELASTIC_AGENT, + url: ingestUrl, + description: i18nCommon.EMPTY_ACTION_ELASTIC_AGENT_DESCRIPTION, + fill: false, + }, + beats: { + label: i18nCommon.EMPTY_ACTION_BEATS, + url: `${basePath}${ADD_DATA_PATH}`, + description: i18nCommon.EMPTY_ACTION_BEATS_DESCRIPTION, + fill: false, + }, + endpoint: { + label: i18nCommon.EMPTY_ACTION_ENDPOINT, + url: `${basePath}${ADD_DATA_PATH}`, + description: i18nCommon.EMPTY_ACTION_ENDPOINT_DESCRIPTION, + onClick: handleOnClick, + fill: false, + }, + }), + [basePath, ingestUrl, handleOnClick] + ); + + const emptyPageIngestDisabledActions = useMemo( + () => omit(['elasticAgent', 'endpoint'], emptyPageActions), + [emptyPageActions] + ); return isIngestEnabled === true ? ( {i18nCommon.EMPTY_ACTION_SECONDARY} @@ -50,16 +74,13 @@ const OverviewEmptyComponent: React.FC = () => { /> ) : ( {i18nCommon.EMPTY_ACTION_SECONDARY} diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx index 286cc870378e16..74225c4e4f8232 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.test.tsx @@ -83,7 +83,7 @@ describe('Overview', () => { ); - expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(false); + expect(wrapper.find('[data-test-subj="empty-page-endpoint-action"]').exists()).toBe(false); }); it('shows Endpoint get ready button when ingest is enabled', () => { @@ -95,7 +95,7 @@ describe('Overview', () => { ); - expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(true); + expect(wrapper.find('[data-test-subj="empty-page-endpoint-action"]').exists()).toBe(true); }); }); diff --git a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx index 423aa597a0129b..1b743c259555ac 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/overview.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/overview.tsx @@ -17,6 +17,7 @@ import { SiemSearchBar } from '../../common/components/search_bar'; import { WrapperPage } from '../../common/components/wrapper_page'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { useWithSource } from '../../common/containers/source'; + import { EventsByDataset } from '../components/events_by_dataset'; import { EventCounts } from '../components/event_counts'; import { OverviewEmpty } from '../components/overview_empty'; @@ -66,7 +67,6 @@ const OverviewComponent: React.FC = ({ addMessage('management', 'dismissEndpointNotice'); }, [addMessage]); const { allEnabled: isIngestEnabled } = useIngestEnabledCheck(); - return ( <> {indicesExist ? ( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index b6aaa2065c7954..8fbcb3f1122cc1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13572,7 +13572,7 @@ "xpack.securitySolution.detectionEngine.editRule.errorMsgDescription": "申し訳ありません", "xpack.securitySolution.detectionEngine.editRule.pageTitle": "ルール設定の編集", "xpack.securitySolution.detectionEngine.editRule.saveChangeTitle": "変更を保存", - "xpack.securitySolution.detectionEngine.emptyActionPrimary": "セットアップの手順を表示", + "xpack.securitySolution.detectionEngine.emptyActionBeats": "セットアップの手順を表示", "xpack.securitySolution.detectionEngine.emptyActionSecondary": "ドキュメントに移動", "xpack.securitySolution.detectionEngine.emptyTitle": "Securityアプリケーションの検出エンジンに関連したインデックスがないようです", "xpack.securitySolution.detectionEngine.goToDocumentationButton": "ドキュメンテーションを表示", @@ -14349,7 +14349,7 @@ "xpack.securitySolution.overview.viewEventsButtonLabel": "イベントを表示", "xpack.securitySolution.overview.winlogbeatMWSysmonOperational": "Microsoft-Windows-Sysmon/Operational", "xpack.securitySolution.overview.winlogbeatSecurityTitle": "セキュリティ", - "xpack.securitySolution.pages.common.emptyActionPrimary": "Beatsでデータを表示", + "xpack.securitySolution.pages.common.emptyActionBeats": "Beatsでデータを表示", "xpack.securitySolution.pages.common.emptyActionSecondary": "入門ガイドを表示", "xpack.securitySolution.pages.common.emptyTitle": "SIEMへようこそ。始めましょう。", "xpack.securitySolution.pages.fourohfour.noContentFoundDescription": "コンテンツがありません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index dcbcda120587fa..b69763449a06f6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13578,7 +13578,7 @@ "xpack.securitySolution.detectionEngine.editRule.errorMsgDescription": "抱歉", "xpack.securitySolution.detectionEngine.editRule.pageTitle": "编辑规则设置", "xpack.securitySolution.detectionEngine.editRule.saveChangeTitle": "保存更改", - "xpack.securitySolution.detectionEngine.emptyActionPrimary": "查看设置说明", + "xpack.securitySolution.detectionEngine.emptyActionBeats": "查看设置说明", "xpack.securitySolution.detectionEngine.emptyActionSecondary": "前往文档", "xpack.securitySolution.detectionEngine.emptyTitle": "似乎您没有与 Security 应用程序的检测引擎相关的索引", "xpack.securitySolution.detectionEngine.goToDocumentationButton": "查看文档", @@ -14355,7 +14355,7 @@ "xpack.securitySolution.overview.viewEventsButtonLabel": "查看事件", "xpack.securitySolution.overview.winlogbeatMWSysmonOperational": "Microsoft-Windows-Sysmon/Operational", "xpack.securitySolution.overview.winlogbeatSecurityTitle": "安全", - "xpack.securitySolution.pages.common.emptyActionPrimary": "使用 Beats 添加数据", + "xpack.securitySolution.pages.common.emptyActionBeats": "使用 Beats 添加数据", "xpack.securitySolution.pages.common.emptyActionSecondary": "查看入门指南", "xpack.securitySolution.pages.common.emptyTitle": "欢迎使用 SIEM。让我们教您如何入门。", "xpack.securitySolution.pages.fourohfour.noContentFoundDescription": "未找到任何内容", From f1c08939ead12341ccb2dabfe2bb9f8681de4e65 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Tue, 28 Jul 2020 20:21:11 -0400 Subject: [PATCH 10/27] [SECURITY_SOLUTION][Administration] Task/remove policy tab (#73352) --- .../components/management_empty_state.tsx | 63 ++----------------- .../components/management_page_view.tsx | 45 +------------ .../view/details/host_details.tsx | 6 +- .../endpoint_hosts/view/details/index.tsx | 4 +- .../pages/endpoint_hosts/view/index.test.tsx | 4 +- .../pages/endpoint_hosts/view/index.tsx | 4 +- .../public/management/pages/policy/index.tsx | 8 +-- .../configure_package_config.tsx | 58 +++++++---------- .../pages/policy/view/policy_details.test.tsx | 14 ++--- .../pages/policy/view/policy_details.tsx | 19 +++--- .../pages/policy/view/policy_list.test.tsx | 3 +- .../endpoint_overview/translations.ts | 4 +- .../apps/endpoint/endpoint_list.ts | 10 +-- .../apps/endpoint/index.ts | 1 - .../apps/endpoint/policy_details.ts | 4 +- 15 files changed, 68 insertions(+), 179 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx index fb9f97f3f7570e..a4518d1a1f4937 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_empty_state.tsx @@ -77,57 +77,6 @@ const PolicyEmptyState = React.memo<{ /> - - - - - - - - -

- -

-
-
-
- - - - -
- - - - - - - -

- -

-
-
-
- - - - -
-
- [ { title: i18n.translate('xpack.securitySolution.endpoint.hostList.stepOneTitle', { - defaultMessage: 'Select the policy you want to use to protect your hosts', + defaultMessage: 'Select the integration you want to use', }), children: ( <> @@ -203,7 +152,7 @@ const HostsEmptyState = React.memo<{ ) : selectionOptions.length ? ( @@ -211,7 +160,7 @@ const HostsEmptyState = React.memo<{ ) : ( ); }} @@ -263,13 +212,13 @@ const HostsEmptyState = React.memo<{ headerComponent={ } bodyComponent={ } /> diff --git a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx index 42341b524362df..aa562b9a202017 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx @@ -4,52 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useMemo } from 'react'; -import { i18n } from '@kbn/i18n'; -import { useParams } from 'react-router-dom'; +import React, { memo } from 'react'; import { PageView, PageViewProps } from '../../common/components/endpoint/page_view'; -import { AdministrationSubTab } from '../types'; -import { SecurityPageName } from '../../app/types'; -import { useFormatUrl } from '../../common/components/link_to'; -import { getHostListPath, getPoliciesPath } from '../common/routing'; -import { useNavigateByRouterEventHandler } from '../../common/hooks/endpoint/use_navigate_by_router_event_handler'; export const ManagementPageView = memo>((options) => { - const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); - const { tabName } = useParams<{ tabName: AdministrationSubTab }>(); - - const goToEndpoint = useNavigateByRouterEventHandler( - getHostListPath({ name: 'hostList' }, search) - ); - - const goToPolicies = useNavigateByRouterEventHandler(getPoliciesPath(search)); - - const tabs = useMemo((): PageViewProps['tabs'] | undefined => { - if (options.viewType === 'details') { - return undefined; - } - return [ - { - name: i18n.translate('xpack.securitySolution.managementTabs.hosts', { - defaultMessage: 'Hosts', - }), - id: AdministrationSubTab.hosts, - isSelected: tabName === AdministrationSubTab.hosts, - href: formatUrl(getHostListPath({ name: 'hostList' })), - onClick: goToEndpoint, - }, - { - name: i18n.translate('xpack.securitySolution.managementTabs.policies', { - defaultMessage: 'Policies', - }), - id: AdministrationSubTab.policies, - isSelected: tabName === AdministrationSubTab.policies, - href: formatUrl(getPoliciesPath()), - onClick: goToPolicies, - }, - ]; - }, [formatUrl, goToEndpoint, goToPolicies, options.viewType, tabName]); - return ; + return ; }); ManagementPageView.displayName = 'ManagementPageView'; diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx index cea66acbef8cad..109392cb7a9297 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx @@ -121,7 +121,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { return [ { title: i18n.translate('xpack.securitySolution.endpoint.host.details.policy', { - defaultMessage: 'Policy', + defaultMessage: 'Integration', }), description: ( <> @@ -136,7 +136,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { }, { title: i18n.translate('xpack.securitySolution.endpoint.host.details.policyStatus', { - defaultMessage: 'Policy Status', + defaultMessage: 'Configuration response', }), description: ( { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index 71b38853085581..212c8977a88526 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -158,7 +158,7 @@ const PolicyResponseFlyoutPanel = memo<{

@@ -167,7 +167,7 @@ const PolicyResponseFlyoutPanel = memo<{ title={ } /> diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx index 47227244b70660..9d49c8705affe2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.test.tsx @@ -431,7 +431,7 @@ describe('when on the hosts page', () => { const renderResult = render(); const linkToReassign = await renderResult.findByTestId('hostDetailsLinkToIngest'); expect(linkToReassign).not.toBeNull(); - expect(linkToReassign.textContent).toEqual('Reassign Policy'); + expect(linkToReassign.textContent).toEqual('Reassign Configuration'); expect(linkToReassign.getAttribute('href')).toEqual( `/app/ingestManager#/fleet/agents/${agentId}/activity?openReassignFlyout=true` ); @@ -492,7 +492,7 @@ describe('when on the hosts page', () => { it('should include the sub-panel title', async () => { expect( (await renderResult.findByTestId('hostDetailsPolicyResponseFlyoutTitle')).textContent - ).toBe('Policy Response'); + ).toBe('Configuration Response'); }); it('should show a configuration section for each protection', async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index e38ef1bd5fe864..2692f7791b7c05 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -237,7 +237,7 @@ export const HostList = () => { { field: 'metadata.Endpoint.policy.applied', name: i18n.translate('xpack.securitySolution.endpointList.policy', { - defaultMessage: 'Policy', + defaultMessage: 'Integration', }), truncateText: true, // eslint-disable-next-line react/display-name @@ -256,7 +256,7 @@ export const HostList = () => { { field: 'metadata.Endpoint.policy.applied', name: i18n.translate('xpack.securitySolution.endpointList.policyStatus', { - defaultMessage: 'Policy Status', + defaultMessage: 'Configuration Status', }), // eslint-disable-next-line react/display-name render: (policy: HostInfo['metadata']['Endpoint']['policy']['applied'], item: HostInfo) => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx index 5122bbcd5d55d5..681f1f1430926b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/index.tsx @@ -6,17 +6,13 @@ import React, { memo } from 'react'; import { Route, Switch } from 'react-router-dom'; -import { PolicyDetails, PolicyList } from './view'; -import { - MANAGEMENT_ROUTING_POLICIES_PATH, - MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, -} from '../../common/constants'; +import { PolicyDetails } from './view'; +import { MANAGEMENT_ROUTING_POLICY_DETAILS_PATH } from '../../common/constants'; import { NotFoundPage } from '../../../app/404'; export const PolicyContainer = memo(() => { return ( - diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_package_config.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_package_config.tsx index 67f24977406c69..2b08bfd2b282b1 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_package_config.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/ingest_manager_integration/configure_package_config.tsx @@ -6,8 +6,7 @@ import React, { memo, useMemo } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiCallOut, EuiText, EuiTitle, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiCallOut, EuiText, EuiSpacer } from '@elastic/eui'; import { LinkToApp } from '../../../../../common/components/endpoint/link_to_app'; import { CustomConfigurePackageConfigContent, @@ -50,52 +49,37 @@ export const ConfigureEndpointPackageConfig = memo - -

- -

-

{from === 'edit' ? ( - <> - - - - - - + + + + ), + }} + /> ) : ( )}

diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index 4f7c14735fe210..6ed4e06ee51c5c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -10,7 +10,7 @@ import { mount } from 'enzyme'; import { PolicyDetails } from './policy_details'; import { EndpointDocGenerator } from '../../../../../common/endpoint/generate_data'; import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; -import { getPolicyDetailPath, getPoliciesPath } from '../../../common/routing'; +import { getPolicyDetailPath, getHostListPath } from '../../../common/routing'; import { apiPathMockResponseProviders } from '../store/policy_list/test_mock_utils'; jest.mock('../../../../common/components/link_to'); @@ -19,7 +19,7 @@ describe('Policy Details', () => { type FindReactWrapperResponse = ReturnType['find']>; const policyDetailsPathUrl = getPolicyDetailPath('1'); - const policyListPathUrl = getPoliciesPath(); + const hostListPath = getHostListPath({ name: 'hostList' }); const sleep = (ms = 100) => new Promise((wakeup) => setTimeout(wakeup, ms)); const generator = new EndpointDocGenerator(); let history: AppContextTestRender['history']; @@ -127,8 +127,8 @@ describe('Policy Details', () => { const backToListButton = pageHeaderLeft.find('EuiButtonEmpty'); expect(backToListButton.prop('iconType')).toBe('arrowLeft'); - expect(backToListButton.prop('href')).toBe(policyListPathUrl); - expect(backToListButton.text()).toBe('Back to policy list'); + expect(backToListButton.prop('href')).toBe(hostListPath); + expect(backToListButton.text()).toBe('Back to endpoint hosts'); const pageTitle = pageHeaderLeft.find('[data-test-subj="pageViewHeaderLeftTitle"]'); expect(pageTitle).toHaveLength(1); @@ -141,7 +141,7 @@ describe('Policy Details', () => { ); expect(history.location.pathname).toEqual(policyDetailsPathUrl); backToListButton.simulate('click', { button: 0 }); - expect(history.location.pathname).toEqual(policyListPathUrl); + expect(history.location.pathname).toEqual(hostListPath); }); it('should display agent stats', async () => { await asyncActions; @@ -173,7 +173,7 @@ describe('Policy Details', () => { const navigateToAppMockedCalls = coreStart.application.navigateToApp.mock.calls; expect(navigateToAppMockedCalls[navigateToAppMockedCalls.length - 1]).toEqual([ 'securitySolution:administration', - { path: policyListPathUrl }, + { path: hostListPath }, ]); }); it('should display save button', async () => { @@ -232,7 +232,7 @@ describe('Policy Details', () => { ); expect(warningCallout).toHaveLength(1); expect(warningCallout.text()).toEqual( - 'This action will update 5 hostsSaving these changes will apply updates to all endpoints assigned to this policy' + 'This action will update 5 hostsSaving these changes will apply updates to all endpoints assigned to this agent configuration.' ); }); it('should close dialog if cancel button is clicked', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index 288bc484c23b51..cd63991dbac93f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -42,7 +42,7 @@ import { PageViewHeaderTitle } from '../../../../common/components/endpoint/page import { ManagementPageView } from '../../../components/management_page_view'; import { SpyRoute } from '../../../../common/utils/route/spy_routes'; import { SecurityPageName } from '../../../../app/types'; -import { getPoliciesPath } from '../../../common/routing'; +import { getHostListPath } from '../../../common/routing'; import { useFormatUrl } from '../../../../common/components/link_to'; import { useNavigateToAppEventHandler } from '../../../../common/hooks/endpoint/use_navigate_to_app_event_handler'; import { MANAGEMENT_APP_ID } from '../../../common/constants'; @@ -56,7 +56,7 @@ export const PolicyDetails = React.memo(() => { application: { navigateToApp }, }, } = useKibana(); - const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); + const { formatUrl } = useFormatUrl(SecurityPageName.administration); const { state: locationRouteState } = useLocation(); // Store values @@ -70,6 +70,7 @@ export const PolicyDetails = React.memo(() => { const [showConfirm, setShowConfirm] = useState(false); const [routeState, setRouteState] = useState(); const policyName = policyItem?.name ?? ''; + const hostListRouterPath = getHostListPath({ name: 'hostList' }); // Handle showing update statuses useEffect(() => { @@ -87,7 +88,7 @@ export const PolicyDetails = React.memo(() => { @@ -109,11 +110,11 @@ export const PolicyDetails = React.memo(() => { } }, [navigateToApp, notifications.toasts, policyName, policyUpdateStatus, routeState]); - const handleBackToListOnClick = useNavigateByRouterEventHandler(getPoliciesPath()); + const handleBackToListOnClick = useNavigateByRouterEventHandler(hostListRouterPath); const navigateToAppArguments = useMemo((): Parameters => { - return routeState?.onCancelNavigateTo ?? [MANAGEMENT_APP_ID, { path: getPoliciesPath() }]; - }, [routeState?.onCancelNavigateTo]); + return routeState?.onCancelNavigateTo ?? [MANAGEMENT_APP_ID, { path: hostListRouterPath }]; + }, [hostListRouterPath, routeState?.onCancelNavigateTo]); const handleCancelOnClick = useNavigateToAppEventHandler(...navigateToAppArguments); const handleSaveOnClick = useCallback(() => { @@ -162,11 +163,11 @@ export const PolicyDetails = React.memo(() => { iconType="arrowLeft" contentProps={{ style: { paddingLeft: '0' } }} onClick={handleBackToListOnClick} - href={formatUrl(getPoliciesPath(search))} + href={formatUrl(hostListRouterPath)} > {policyItem.name} @@ -306,7 +307,7 @@ const ConfirmUpdate = React.memo<{ >
diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx index 047aa6918736e0..e35c97698f5cbf 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.test.tsx @@ -14,7 +14,8 @@ import { AppAction } from '../../../../common/store/actions'; jest.mock('../../../../common/components/link_to'); -describe('when on the policies page', () => { +// Skipping these test now that the Policy List has been hidden +describe.skip('when on the policies page', () => { let render: () => ReturnType; let history: AppContextTestRender['history']; let store: AppContextTestRender['store']; diff --git a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts index 34e3347b5ff9a3..a7f1be3debb830 100644 --- a/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts +++ b/x-pack/plugins/security_solution/public/overview/components/host_overview/endpoint_overview/translations.ts @@ -9,14 +9,14 @@ import { i18n } from '@kbn/i18n'; export const ENDPOINT_POLICY = i18n.translate( 'xpack.securitySolution.host.details.endpoint.endpointPolicy', { - defaultMessage: 'Endpoint policy', + defaultMessage: 'Integration', } ); export const POLICY_STATUS = i18n.translate( 'xpack.securitySolution.host.details.endpoint.policyStatus', { - defaultMessage: 'Policy status', + defaultMessage: 'Configuration Status', } ); diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index 07667a140d0902..85d0e56231643e 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -36,8 +36,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { [ 'Hostname', 'Host Status', - 'Policy', - 'Policy Status', + 'Integration', + 'Configuration Status', 'Operating System', 'IP Address', 'Version', @@ -119,7 +119,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); // The integration does not work properly yet. Skipping this test for now. - it.skip('navigates to ingest fleet when the Reassign Policy link is clicked', async () => { + it.skip('navigates to ingest fleet when the Reassign Configuration link is clicked', async () => { await (await testSubjects.find('hostnameCellLink')).click(); await (await testSubjects.find('hostDetailsLinkToIngest')).click(); await testSubjects.existOrFail('fleetAgentListTable'); @@ -145,8 +145,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { 'OS', 'Last Seen', 'Alerts', - 'Policy', - 'Policy Status', + 'Integration', + 'Configuration Status', 'IP Address', 'Hostname', 'Sensor Version', diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts index eec3da4ce1c5ea..7962ec60ff57e8 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts @@ -29,7 +29,6 @@ export default function (providerContext: FtrProviderContext) { await ingestManager.setup(); }); loadTestFile(require.resolve('./endpoint_list')); - loadTestFile(require.resolve('./policy_list')); loadTestFile(require.resolve('./policy_details')); }); } diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index 8b197a6c69a305..ca84384390a291 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -73,7 +73,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await testSubjects.existOrFail('policyDetailsSuccessMessage'); expect(await testSubjects.getVisibleText('policyDetailsSuccessMessage')).to.equal( - `Policy ${policyInfo.packageConfig.name} has been updated.` + `Integration ${policyInfo.packageConfig.name} has been updated.` ); }); it('should persist update on the screen', async () => { @@ -81,7 +81,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await pageObjects.policy.confirmAndSave(); await testSubjects.existOrFail('policyDetailsSuccessMessage'); - await pageObjects.policy.navigateToPolicyList(); + await pageObjects.endpoint.navigateToHostList(); await pageObjects.policy.navigateToPolicyDetails(policyInfo.packageConfig.id); expect(await (await testSubjects.find('policyWindowsEvent_process')).isSelected()).to.equal( From 78aa24dbd6e61c05559d7432d4357af1c2ad81b3 Mon Sep 17 00:00:00 2001 From: Phillip Burch Date: Tue, 28 Jul 2020 19:30:42 -0500 Subject: [PATCH 11/27] Make button appear clickable, change state of empty text (#73496) Co-authored-by: Elastic Machine --- .../public/components/saved_views/toolbar_control.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx index db9f92532645fe..2e06ee55189d9e 100644 --- a/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx +++ b/x-pack/plugins/infra/public/components/saved_views/toolbar_control.tsx @@ -161,7 +161,11 @@ export function SavedViewsToolbarControls(props: Props) { /> - + (props: Props) { {currentView ? currentView.name : i18n.translate('xpack.infra.savedView.unknownView', { - defaultMessage: 'Unknown', + defaultMessage: 'No view seleted', })} From fea3bfcebc2af2b8ff22660e015a044e32280cd0 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Tue, 28 Jul 2020 20:59:45 -0400 Subject: [PATCH 12/27] [Resolver] simulator and click through tests (#73310) Write a few jest tests for resolver's react code. --- .../resolver/data_access_layer/factory.ts | 70 ++++ .../mocks/one_ancestor_two_children.ts | 96 +++++ .../public/resolver/models/process_event.ts | 16 +- .../public/resolver/store/index.ts | 8 +- .../public/resolver/store/middleware/index.ts | 27 +- .../store/middleware/resolver_tree_fetcher.ts | 31 +- .../resolver/store/mocks/endpoint_event.ts | 2 +- .../connect_enzyme_wrapper_and_store.ts | 20 + .../resolver/test_utilities/extend_jest.ts | 88 +++++ .../resolver/test_utilities/react_wrapper.ts | 17 + .../test_utilities/simulator/index.tsx | 290 ++++++++++++++ .../simulator/mock_resolver.tsx | 109 ++++++ .../test_utilities/spy_middleware_factory.ts | 58 +++ .../public/resolver/types.ts | 136 ++++++- .../resolver/view/clickthrough.test.tsx | 99 +++++ .../public/resolver/view/index.tsx | 45 +-- .../panels/panel_content_process_detail.tsx | 368 +++++++++--------- .../resolver/view/process_event_dot.tsx | 13 +- .../view/resolver_without_providers.tsx | 139 +++++++ ...or.ts => side_effect_simulator_factory.ts} | 5 +- .../public/resolver/view/use_camera.test.tsx | 9 +- .../view/use_resolver_query_params.ts | 8 +- 22 files changed, 1347 insertions(+), 307 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/react_wrapper.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx create mode 100644 x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx create mode 100644 x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx rename x-pack/plugins/security_solution/public/resolver/view/{side_effect_simulator.ts => side_effect_simulator_factory.ts} (95%) diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts new file mode 100644 index 00000000000000..016ebfa0faee40 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaReactContextValue } from '../../../../../../src/plugins/kibana_react/public'; +import { StartServices } from '../../types'; +import { DataAccessLayer } from '../types'; +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../../common/endpoint/types'; +import { DEFAULT_INDEX_KEY as defaultIndexKey } from '../../../common/constants'; + +/** + * The data access layer for resolver. All communication with the Kibana server is done through this object. This object is provided to Resolver. In tests, a mock data access layer can be used instead. + */ +export function dataAccessLayerFactory( + context: KibanaReactContextValue +): DataAccessLayer { + const dataAccessLayer: DataAccessLayer = { + /** + * Used to get non-process related events for a node. + */ + async relatedEvents(entityID: string): Promise { + return context.services.http.get(`/api/endpoint/resolver/${entityID}/events`, { + query: { events: 100 }, + }); + }, + /** + * Used to get descendant and ancestor process events for a node. + */ + async resolverTree(entityID: string, signal: AbortSignal): Promise { + return context.services.http.get(`/api/endpoint/resolver/${entityID}`, { + signal, + }); + }, + + /** + * Used to get the default index pattern from the SIEM application. + */ + indexPatterns(): string[] { + return context.services.uiSettings.get(defaultIndexKey); + }, + + /** + * Used to get the entity_id for an _id. + */ + async entities({ + _id, + indices, + signal, + }: { + _id: string; + indices: string[]; + signal: AbortSignal; + }): Promise { + return context.services.http.get('/api/endpoint/resolver/entity', { + signal, + query: { + _id, + indices, + }, + }); + }, + }; + return dataAccessLayer; +} diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts new file mode 100644 index 00000000000000..be0bc1b812a0b4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/one_ancestor_two_children.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../../../common/endpoint/types'; +import { mockEndpointEvent } from '../../store/mocks/endpoint_event'; +import { mockTreeWithNoAncestorsAnd2Children } from '../../store/mocks/resolver_tree'; +import { DataAccessLayer } from '../../types'; + +interface Metadata { + /** + * The `_id` of the document being analyzed. + */ + databaseDocumentID: string; + /** + * A record of entityIDs to be used in tests assertions. + */ + entityIDs: { + /** + * The entityID of the node related to the document being analyzed. + */ + origin: 'origin'; + /** + * The entityID of the first child of the origin. + */ + firstChild: 'firstChild'; + /** + * The entityID of the second child of the origin. + */ + secondChild: 'secondChild'; + }; +} + +/** + * A simple mock dataAccessLayer possible that returns a tree with 0 ancestors and 2 direct children. 1 related event is returned. The parameter to `entities` is ignored. + */ +export function oneAncestorTwoChildren(): { dataAccessLayer: DataAccessLayer; metadata: Metadata } { + const metadata: Metadata = { + databaseDocumentID: '_id', + entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' }, + }; + return { + metadata, + dataAccessLayer: { + /** + * Fetch related events for an entity ID + */ + relatedEvents(entityID: string): Promise { + return Promise.resolve({ + entityID, + events: [ + mockEndpointEvent({ + entityID, + name: 'event', + timestamp: 0, + }), + ], + nextEvent: null, + }); + }, + + /** + * Fetch a ResolverTree for a entityID + */ + resolverTree(): Promise { + return Promise.resolve( + mockTreeWithNoAncestorsAnd2Children({ + originID: metadata.entityIDs.origin, + firstChildID: metadata.entityIDs.firstChild, + secondChildID: metadata.entityIDs.secondChild, + }) + ); + }, + + /** + * Get an array of index patterns that contain events. + */ + indexPatterns(): string[] { + return ['index pattern']; + }, + + /** + * Get entities matching a document. + */ + entities(): Promise { + return Promise.resolve([{ entity_id: metadata.entityIDs.origin }]); + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts index 4f8df87b3ac0b6..1a5c67f6a6f2ff 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.ts @@ -29,7 +29,7 @@ export function isTerminatedProcess(passedEvent: ResolverEvent) { } /** - * ms since unix epoc, based on timestamp. + * ms since Unix epoc, based on timestamp. * may return NaN if the timestamp wasn't present or was invalid. */ export function datetime(passedEvent: ResolverEvent): number | null { @@ -85,7 +85,7 @@ export function eventType(passedEvent: ResolverEvent): ResolverProcessType { } /** - * Returns the process event's pid + * Returns the process event's PID */ export function uniquePidForProcess(passedEvent: ResolverEvent): string { if (event.isLegacyEvent(passedEvent)) { @@ -96,7 +96,7 @@ export function uniquePidForProcess(passedEvent: ResolverEvent): string { } /** - * Returns the pid for the process on the host + * Returns the PID for the process on the host */ export function processPid(passedEvent: ResolverEvent): number | undefined { if (event.isLegacyEvent(passedEvent)) { @@ -107,7 +107,7 @@ export function processPid(passedEvent: ResolverEvent): number | undefined { } /** - * Returns the process event's parent pid + * Returns the process event's parent PID */ export function uniqueParentPidForProcess(passedEvent: ResolverEvent): string | undefined { if (event.isLegacyEvent(passedEvent)) { @@ -118,7 +118,7 @@ export function uniqueParentPidForProcess(passedEvent: ResolverEvent): string | } /** - * Returns the process event's parent pid + * Returns the process event's parent PID */ export function processParentPid(passedEvent: ResolverEvent): number | undefined { if (event.isLegacyEvent(passedEvent)) { @@ -144,12 +144,12 @@ export function processPath(passedEvent: ResolverEvent): string | undefined { */ export function userInfoForProcess( passedEvent: ResolverEvent -): { user?: string; domain?: string } | undefined { +): { name?: string; domain?: string } | undefined { return passedEvent.user; } /** - * Returns the MD5 hash for the `passedEvent` param, or undefined if it can't be located + * Returns the MD5 hash for the `passedEvent` parameter, or undefined if it can't be located * @param {ResolverEvent} passedEvent The `ResolverEvent` to get the MD5 value for * @returns {string | undefined} The MD5 string for the event */ @@ -164,7 +164,7 @@ export function md5HashForProcess(passedEvent: ResolverEvent): string | undefine /** * Returns the command line path and arguments used to run the `passedEvent` if any * - * @param {ResolverEvent} passedEvent The `ResolverEvent` to get the arguemnts value for + * @param {ResolverEvent} passedEvent The `ResolverEvent` to get the arguments value for * @returns {string | undefined} The arguments (including the path) used to run the process */ export function argsForProcess(passedEvent: ResolverEvent): string | undefined { diff --git a/x-pack/plugins/security_solution/public/resolver/store/index.ts b/x-pack/plugins/security_solution/public/resolver/store/index.ts index d9e750241ced1f..950a61db33f177 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/index.ts @@ -6,22 +6,20 @@ import { createStore, applyMiddleware, Store } from 'redux'; import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly'; -import { KibanaReactContextValue } from '../../../../../../src/plugins/kibana_react/public'; -import { ResolverState } from '../types'; -import { StartServices } from '../../types'; +import { ResolverState, DataAccessLayer } from '../types'; import { resolverReducer } from './reducer'; import { resolverMiddlewareFactory } from './middleware'; import { ResolverAction } from './actions'; export const storeFactory = ( - context?: KibanaReactContextValue + dataAccessLayer: DataAccessLayer ): Store => { const actionsDenylist: Array = ['userMovedPointer']; const composeEnhancers = composeWithDevTools({ name: 'Resolver', actionsBlacklist: actionsDenylist, }); - const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(context)); + const middlewareEnhancer = applyMiddleware(resolverMiddlewareFactory(dataAccessLayer)); return createStore(resolverReducer, composeEnhancers(middlewareEnhancer)); }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts index 398e855a1f5d40..ef6b1f5eb3c6f7 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/index.ts @@ -5,34 +5,26 @@ */ import { Dispatch, MiddlewareAPI } from 'redux'; -import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; -import { StartServices } from '../../../types'; -import { ResolverState } from '../../types'; +import { ResolverState, DataAccessLayer } from '../../types'; import { ResolverRelatedEvents } from '../../../../common/endpoint/types'; import { ResolverTreeFetcher } from './resolver_tree_fetcher'; import { ResolverAction } from '../actions'; type MiddlewareFactory = ( - context?: KibanaReactContextValue + dataAccessLayer: DataAccessLayer ) => ( api: MiddlewareAPI, S> ) => (next: Dispatch) => (action: ResolverAction) => unknown; /** - * The redux middleware that the app uses to trigger side effects. + * The `redux` middleware that the application uses to trigger side effects. * All data fetching should be done here. - * For actions that the app triggers directly, use `app` as a prefix for the type. + * For actions that the application triggers directly, use `app` as a prefix for the type. * For actions that are triggered as a result of server interaction, use `server` as a prefix for the type. */ -export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { +export const resolverMiddlewareFactory: MiddlewareFactory = (dataAccessLayer: DataAccessLayer) => { return (api) => (next) => { - // This cannot work w/o `context`. - if (!context) { - return async (action: ResolverAction) => { - next(action); - }; - } - const resolverTreeFetcher = ResolverTreeFetcher(context, api); + const resolverTreeFetcher = ResolverTreeFetcher(dataAccessLayer, api); return async (action: ResolverAction) => { next(action); @@ -45,12 +37,7 @@ export const resolverMiddlewareFactory: MiddlewareFactory = (context) => { const entityIdToFetchFor = action.payload; let result: ResolverRelatedEvents | undefined; try { - result = await context.services.http.get( - `/api/endpoint/resolver/${entityIdToFetchFor}/events`, - { - query: { events: 100 }, - } - ); + result = await dataAccessLayer.relatedEvents(entityIdToFetchFor); } catch { api.dispatch({ type: 'serverFailedToReturnRelatedEventData', diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts index 7d16dc251e6fc9..2c98059d420e8f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts @@ -9,11 +9,8 @@ import { Dispatch, MiddlewareAPI } from 'redux'; import { ResolverTree, ResolverEntityIndex } from '../../../../common/endpoint/types'; -import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; -import { ResolverState } from '../../types'; +import { ResolverState, DataAccessLayer } from '../../types'; import * as selectors from '../selectors'; -import { StartServices } from '../../../types'; -import { DEFAULT_INDEX_KEY as defaultIndexKey } from '../../../../common/constants'; import { ResolverAction } from '../actions'; /** * A function that handles syncing ResolverTree data w/ the current entity ID. @@ -23,7 +20,7 @@ import { ResolverAction } from '../actions'; * This is a factory because it is stateful and keeps that state in closure. */ export function ResolverTreeFetcher( - context: KibanaReactContextValue, + dataAccessLayer: DataAccessLayer, api: MiddlewareAPI, ResolverState> ): () => void { let lastRequestAbortController: AbortController | undefined; @@ -48,17 +45,12 @@ export function ResolverTreeFetcher( payload: databaseDocumentIDToFetch, }); try { - const indices: string[] = context.services.uiSettings.get(defaultIndexKey); - const matchingEntities: ResolverEntityIndex = await context.services.http.get( - '/api/endpoint/resolver/entity', - { - signal: lastRequestAbortController.signal, - query: { - _id: databaseDocumentIDToFetch, - indices, - }, - } - ); + const indices: string[] = dataAccessLayer.indexPatterns(); + const matchingEntities: ResolverEntityIndex = await dataAccessLayer.entities({ + _id: databaseDocumentIDToFetch, + indices, + signal: lastRequestAbortController.signal, + }); if (matchingEntities.length < 1) { // If no entity_id could be found for the _id, bail out with a failure. api.dispatch({ @@ -68,9 +60,10 @@ export function ResolverTreeFetcher( return; } const entityIDToFetch = matchingEntities[0].entity_id; - result = await context.services.http.get(`/api/endpoint/resolver/${entityIDToFetch}`, { - signal: lastRequestAbortController.signal, - }); + result = await dataAccessLayer.resolverTree( + entityIDToFetch, + lastRequestAbortController.signal + ); } catch (error) { // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError if (error instanceof DOMException && error.name === 'AbortError') { diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts index 8f2e0ad3a6d858..709f2faf13b006 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/endpoint_event.ts @@ -18,7 +18,7 @@ export function mockEndpointEvent({ }: { entityID: string; name: string; - parentEntityId: string | undefined; + parentEntityId?: string; timestamp: number; lifecycleType?: string; }): EndpointEvent { diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts new file mode 100644 index 00000000000000..3a4a1f7d634d11 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/connect_enzyme_wrapper_and_store.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Store } from 'redux'; +import { ReactWrapper } from 'enzyme'; + +/** + * We use the full-DOM emulation mode of `enzyme` via `mount`. Even though we use `react-redux`, `enzyme` + * does not update the DOM after state transitions. This subscribes to the `redux` store and after any state + * transition it asks `enzyme` to update the DOM to match the React state. + */ +export function connectEnzymeWrapperAndStore(store: Store, wrapper: ReactWrapper): void { + store.subscribe(() => { + // See https://enzymejs.github.io/enzyme/docs/api/ReactWrapper/update.html + return wrapper.update(); + }); +} diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts new file mode 100644 index 00000000000000..9fc7af38beb42a --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/extend_jest.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Typescript won't allow global namespace stuff unless you're in a module. + * This wouldn't otherwise be a module. The code runs as soon as it's imported. + * This is done this way because the `declare` will be active on import, so in + * order to be correct, the code that the `declare` declares needs to be available on import as well. + */ +export {}; + +declare global { + /* eslint-disable @typescript-eslint/no-namespace */ + namespace jest { + interface Matchers { + toYieldEqualTo(expectedYield: T extends AsyncIterable ? E : never): Promise; + } + } +} + +expect.extend({ + /** + * A custom matcher that takes an async generator and compares each value it yields to an expected value. + * If any yielded value deep-equals the expected value, the matcher will pass. + * If the generator ends with none of the yielded values matching, it will fail. + */ + async toYieldEqualTo( + this: jest.MatcherContext, + receivedIterable: AsyncIterable, + expected: T + ): Promise<{ pass: boolean; message: () => string }> { + // Used in printing out the pass or fail message + const matcherName = 'toSometimesYieldEqualTo'; + const options: jest.MatcherHintOptions = { + comment: 'deep equality with any yielded value', + isNot: this.isNot, + promise: this.promise, + }; + // The last value received: Used in printing the message + const received: T[] = []; + + // Set to true if the test passes. + let pass: boolean = false; + + // Async iterate over the iterable + for await (const next of receivedIterable) { + // keep track of all received values. Used in pass and fail messages + received.push(next); + // Use deep equals to compare the value to the expected value + if (this.equals(next, expected)) { + // If the value is equal, break + pass = true; + break; + } + } + + // Use `pass` as set in the above loop (or initialized to `false`) + // See https://jestjs.io/docs/en/expect#custom-matchers-api and https://jestjs.io/docs/en/expect#thisutils + const message = pass + ? () => + `${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\n` + + `Expected: not ${this.utils.printExpected(expected)}\n${ + this.utils.stringify(expected) !== this.utils.stringify(received[received.length - 1]!) + ? `Received: ${this.utils.printReceived(received[received.length - 1])}` + : '' + }` + : () => + `${this.utils.matcherHint(matcherName, undefined, undefined, options)}\n\nCompared ${ + received.length + } yields.\n\n${received + .map( + (next, index) => + `yield ${index + 1}:\n\n${this.utils.printDiffOrStringify( + expected, + next, + 'Expected', + 'Received', + this.expand + )}` + ) + .join(`\n\n`)}`; + + return { message, pass }; + }, +}); diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/react_wrapper.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/react_wrapper.ts new file mode 100644 index 00000000000000..40267d83c30f84 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/react_wrapper.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReactWrapper } from 'enzyme'; + +/** + * Return a collection of attribute 'entries'. + * The 'entries' are attributeName-attributeValue tuples. + */ +export function attributeEntries(wrapper: ReactWrapper): Array<[string, string]> { + return Array.prototype.slice + .call(wrapper.getDOMNode().attributes) + .map(({ name, value }) => [name, value]); +} diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx new file mode 100644 index 00000000000000..7a61427c56a3ba --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -0,0 +1,290 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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 { Store, createStore, applyMiddleware } from 'redux'; +import { mount, ReactWrapper } from 'enzyme'; +import { createMemoryHistory, History as HistoryPackageHistoryInterface } from 'history'; +import { CoreStart } from '../../../../../../../src/core/public'; +import { coreMock } from '../../../../../../../src/core/public/mocks'; +import { connectEnzymeWrapperAndStore } from '../connect_enzyme_wrapper_and_store'; +import { spyMiddlewareFactory } from '../spy_middleware_factory'; +import { resolverMiddlewareFactory } from '../../store/middleware'; +import { resolverReducer } from '../../store/reducer'; +import { MockResolver } from './mock_resolver'; +import { ResolverState, DataAccessLayer, SpyMiddleware } from '../../types'; +import { ResolverAction } from '../../store/actions'; + +/** + * Test a Resolver instance using jest, enzyme, and a mock data layer. + */ +export class Simulator { + /** + * A string that uniquely identifies this Resolver instance among others mounted in the DOM. + */ + private readonly resolverComponentInstanceID: string; + /** + * The redux store, creating in the constructor using the `dataAccessLayer`. + * This code subscribes to state transitions. + */ + private readonly store: Store; + /** + * A fake 'History' API used with `react-router` to simulate a browser history. + */ + private readonly history: HistoryPackageHistoryInterface; + /** + * The 'wrapper' returned by `enzyme` that contains the rendered Resolver react code. + */ + private readonly wrapper: ReactWrapper; + /** + * A `redux` middleware that exposes all actions dispatched (along with the state at that point.) + * This is used by `debugActions`. + */ + private readonly spyMiddleware: SpyMiddleware; + constructor({ + dataAccessLayer, + resolverComponentInstanceID, + databaseDocumentID, + }: { + /** + * A (mock) data access layer that will be used to create the Resolver store. + */ + dataAccessLayer: DataAccessLayer; + /** + * A string that uniquely identifies this Resolver instance among others mounted in the DOM. + */ + resolverComponentInstanceID: string; + /** + * a databaseDocumentID to pass to Resolver. Resolver will use this in requests to the mock data layer. + */ + databaseDocumentID?: string; + }) { + this.resolverComponentInstanceID = resolverComponentInstanceID; + // create the spy middleware (for debugging tests) + this.spyMiddleware = spyMiddlewareFactory(); + + /** + * Create the real resolver middleware with a fake data access layer. + * By providing different data access layers, you can simulate different data and server environments. + */ + const middlewareEnhancer = applyMiddleware( + resolverMiddlewareFactory(dataAccessLayer), + // install the spyMiddleware + this.spyMiddleware.middleware + ); + + // Create a redux store w/ the top level Resolver reducer and the enhancer that includes the Resolver middleware and the `spyMiddleware` + this.store = createStore(resolverReducer, middlewareEnhancer); + + // Create a fake 'history' instance that Resolver will use to read and write query string values + this.history = createMemoryHistory(); + + // Used for `KibanaContextProvider` + const coreStart: CoreStart = coreMock.createStart(); + + // Render Resolver via the `MockResolver` component, using `enzyme`. + this.wrapper = mount( + + ); + + // Update the enzyme wrapper after each state transition + connectEnzymeWrapperAndStore(this.store, this.wrapper); + } + + /** + * Call this to console.log actions (and state). Use this to debug your tests. + * State and actions aren't exposed otherwise because the tests using this simulator should + * assert stuff about the DOM instead of internal state. Use selector/middleware/reducer + * unit tests to test that stuff. + */ + public debugActions(): /** + * Optionally call this to stop debugging actions. + */ () => void { + return this.spyMiddleware.debugActions(); + } + + /** + * Return a promise that resolves after the `store`'s next state transition. + * Used by `mapStateTransitions` + */ + private stateTransitioned(): Promise { + // keep track of the resolve function of the promise that has been returned. + let resolveState: (() => void) | null = null; + + const promise: Promise = new Promise((resolve) => { + // Immediately expose the resolve function in the outer scope. It will be resolved when the next state transition occurs. + resolveState = resolve; + }); + + // Subscribe to the store + const unsubscribe = this.store.subscribe(() => { + // Once a state transition occurs, unsubscribe. + unsubscribe(); + // Resolve the promise. The null assertion is safe here as Promise initializers run immediately (according to spec and node/browser implementations.) + // NB: the state is not resolved here. Code using the simulator should not rely on state or selectors of state. + resolveState!(); + }); + + // Return the promise that will be resolved on the next state transition, allowing code to `await` for the next state transition. + return promise; + } + + /** + * This will yield the return value of `mapper` after each state transition. If no state transition occurs for 10 event loops in a row, this will give up. + */ + public async *mapStateTransitions(mapper: () => R): AsyncIterable { + // Yield the value before any state transitions have occurred. + yield mapper(); + + /** Increment this each time an event loop completes without a state transition. + * If this value hits `10`, end the loop. + * + * Code will test assertions after each state transition. If the assertion hasn't passed and no further state transitions occur, + * then the jest timeout will happen. The timeout doesn't give a useful message about the assertion. + * By short-circuiting this function, code that uses it can short circuit the test timeout and print a useful error message. + * + * NB: the logic to short-circuit the loop is here because knowledge of state is a concern of the simulator, not tests. + */ + let timeoutCount = 0; + while (true) { + /** + * `await` a race between the next state transition and a timeout that happens after `0`ms. + * If the timeout wins, no `dispatch` call caused a state transition in the last loop. + * If this keeps happening, assume that Resolver isn't going to do anything else. + * + * If Resolver adds intentional delay logic (e.g. waiting before making a request), this code might have to change. + * In that case, Resolver should use the side effect context to schedule future work. This code could then subscribe to some event published by the side effect context. That way, this code will be aware of Resolver's intention to do work. + */ + const timedOut: boolean = await Promise.race([ + (async (): Promise => { + await this.stateTransitioned(); + // If a state transition occurs, return false for `timedOut` + return false; + })(), + new Promise((resolve) => { + setTimeout(() => { + // If a timeout occurs, resolve `timedOut` as true + return resolve(true); + }, 0); + }), + ]); + + if (timedOut) { + // If a timout occurred, note it. + timeoutCount++; + if (timeoutCount === 10) { + // if 10 timeouts happen in a row, end the loop early + return; + } + } else { + // If a state transition occurs, reset the timeout count and yield the value + timeoutCount = 0; + yield mapper(); + } + } + } + + /** + * Find a process node element. Takes options supported by `resolverNodeSelector`. + * returns a `ReactWrapper` even if nothing is found, as that is how `enzyme` does things. + */ + public processNodeElements(options: ProcessNodeElementSelectorOptions = {}): ReactWrapper { + return this.findInDOM(processNodeElementSelector(options)); + } + + /** + * true if a process node element is found for the entityID and if it has an [aria-selected] attribute. + */ + public processNodeElementLooksSelected(entityID: string): boolean { + return this.processNodeElements({ entityID, selected: true }).length === 1; + } + + /** + * true if a process node element is found for the entityID and if it *does not have* an [aria-selected] attribute. + */ + public processNodeElementLooksUnselected(entityID: string): boolean { + // find the process node, then exclude it if its selected. + return ( + this.processNodeElements({ entityID }).not( + processNodeElementSelector({ entityID, selected: true }) + ).length === 1 + ); + } + + /** + * Return the selected node query string values. + */ + public queryStringValues(): { selectedNode: string[] } { + const urlSearchParams = new URLSearchParams(this.history.location.search); + return { + selectedNode: urlSearchParams.getAll(`resolver-${this.resolverComponentInstanceID}-id`), + }; + } + + /** + * The element that shows when Resolver is waiting for the graph data. + */ + public graphLoadingElement(): ReactWrapper { + return this.findInDOM('[data-test-subj="resolver:graph:loading"]'); + } + + /** + * The element that shows if Resolver couldn't draw the graph. + */ + public graphErrorElement(): ReactWrapper { + return this.findInDOM('[data-test-subj="resolver:graph:error"]'); + } + + /** + * The element where nodes get drawn. + */ + public graphElement(): ReactWrapper { + return this.findInDOM('[data-test-subj="resolver:graph"]'); + } + + /** + * Like `this.wrapper.find` but only returns DOM nodes. + */ + private findInDOM(selector: string): ReactWrapper { + return this.wrapper.find(selector).filterWhere((wrapper) => typeof wrapper.type() === 'string'); + } +} + +const baseResolverSelector = '[data-test-subj="resolver:node"]'; + +interface ProcessNodeElementSelectorOptions { + /** + * Entity ID of the node. If passed, will be used to create an data-attribute CSS selector that should only get the related node element. + */ + entityID?: string; + /** + * If true, only get nodes with an `[aria-selected="true"]` attribute. + */ + selected?: boolean; +} + +/** + * An `enzyme` supported CSS selector for process node elements. + */ +function processNodeElementSelector({ + entityID, + selected = false, +}: ProcessNodeElementSelectorOptions = {}): string { + let selector: string = baseResolverSelector; + if (entityID !== undefined) { + selector += `[data-test-resolver-node-id="${entityID}"]`; + } + if (selected) { + selector += '[aria-selected="true"]'; + } + return selector; +} diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx new file mode 100644 index 00000000000000..36bb2a5ffc9fe9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-duplicate-imports */ +/* eslint-disable react/display-name */ + +import React, { useMemo, useEffect, useState, useCallback } from 'react'; +import { Router } from 'react-router-dom'; +import { I18nProvider } from '@kbn/i18n/react'; +import { Provider } from 'react-redux'; +import { Store } from 'redux'; +import { KibanaContextProvider } from '../../../../../../../src/plugins/kibana_react/public'; +import { CoreStart } from '../../../../../../../src/core/public'; +import { ResolverState, SideEffectSimulator, ResolverProps } from '../../types'; +import { ResolverAction } from '../../store/actions'; +import { ResolverWithoutProviders } from '../../view/resolver_without_providers'; +import { SideEffectContext } from '../../view/side_effect_context'; +import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_factory'; + +type MockResolverProps = { + /** + * Used to simulate a raster width. Defaults to 1600. + */ + rasterWidth?: number; + /** + * Used to simulate a raster height. Defaults to 1200. + */ + rasterHeight?: number; + /** + * Used for the `KibanaContextProvider` + */ + coreStart: CoreStart; + /** + * Used for `react-router`. + */ + history: React.ComponentProps['history']; + /** Pass a resolver store. See `storeFactory` and `mockDataAccessLayer` */ + store: Store; + /** + * All the props from `ResolverWithoutStore` can be passed. These aren't defaulted to anything (you might want to test what happens when they aren't present.) + */ +} & ResolverProps; + +/** + * This is a mock Resolver component. It is intended to be used with `enzyme` tests via the `Simulator` class. It wraps Resolver in the required providers: + * * `i18n` + * * `Router` using a provided `History` + * * `SideEffectContext.Provider` using side effect simulator it creates + * * `KibanaContextProvider` using a provided `CoreStart` instance + * * `react-redux`'s `Provider` using a provided `Store`. + * + * Resolver needs to measure its size in the DOM. The `SideEffectSimulator` instance can fake the size of an element. + * However in tests, React doesn't have good DOM reconciliation and the root element is often swapped out. When the + * element is replaced, the fake dimensions stop being applied. In order to get around this issue, this component will + * trigger a simulated resize on the root node reference any time it changes. This simulates the layout process a real + * browser would do when an element is attached to the DOM. + */ +export const MockResolver = React.memo((props: MockResolverProps) => { + const [resolverElement, setResolverElement] = useState(null); + + // Get a ref to the underlying Resolver element so we can resize. + // Use a callback function because the underlying DOM node can change. In fact, `enzyme` seems to change it a lot. + const resolverRef = useCallback((element: HTMLDivElement | null) => { + setResolverElement(element); + }, []); + + const simulator: SideEffectSimulator = useMemo(() => sideEffectSimulatorFactory(), []); + + // Resize the Resolver element to match the passed in props. Resolver is size dependent. + useEffect(() => { + if (resolverElement) { + const size: DOMRect = { + width: props.rasterWidth ?? 1600, + height: props.rasterHeight ?? 1200, + x: 0, + y: 0, + bottom: 0, + left: 0, + top: 0, + right: 0, + toJSON() { + return this; + }, + }; + simulator.controls.simulateElementResize(resolverElement, size); + } + }, [props.rasterWidth, props.rasterHeight, simulator.controls, resolverElement]); + + return ( + + + + + + + + + + + + ); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts b/x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts new file mode 100644 index 00000000000000..45730531cf4672 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/spy_middleware_factory.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ResolverAction } from '../store/actions'; +import { SpyMiddleware, SpyMiddlewareStateActionPair } from '../types'; + +/** + * Return a `SpyMiddleware` to be used in testing. Use `debugActions` to console.log actions and the state they produced. + * For reducer/middleware tests, you can use `actions` to get access to each dispatched action along with the state it produced. + */ +export const spyMiddlewareFactory: () => SpyMiddleware = () => { + const resolvers: Set<(stateActionPair: SpyMiddlewareStateActionPair) => void> = new Set(); + + const actions = async function* actions() { + while (true) { + const promise: Promise = new Promise((resolve) => { + resolvers.add(resolve); + }); + yield await promise; + } + }; + + return { + middleware: (api) => (next) => (action: ResolverAction) => { + // handle the action first so we get the state after the reducer + next(action); + + const state = api.getState(); + + // Resolving these promises may cause code to await the next result. That will add more resolve functions to `resolvers`. + // For this reason, copy all the existing resolvers to an array and clear the set. + const oldResolvers = [...resolvers]; + resolvers.clear(); + for (const resolve of oldResolvers) { + resolve({ action, state }); + } + }, + actions, + debugActions() { + let stop: boolean = false; + (async () => { + for await (const actionStatePair of actions()) { + if (stop) { + break; + } + // eslint-disable-next-line no-console + console.log('action', actionStatePair.action, 'state', actionStatePair.state); + } + })(); + return () => { + stop = true; + }; + }, + }; +}; diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 02a890ca13ee8e..38e0cd04835592 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -4,10 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable no-duplicate-imports */ + import { Store } from 'redux'; +import { Middleware, Dispatch } from 'redux'; import { BBox } from 'rbush'; import { ResolverAction } from './store/actions'; -import { ResolverEvent, ResolverRelatedEvents, ResolverTree } from '../../common/endpoint/types'; +import { + ResolverEvent, + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../common/endpoint/types'; /** * Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`. @@ -30,21 +38,21 @@ export interface ResolverState { } /** - * Piece of redux state that models an animation for the camera. + * Piece of `redux` state that models an animation for the camera. */ export interface ResolverUIState { /** - * The nodeID for the process that is selected (in the aria-activedescendent sense of being selected.) + * The `nodeID` for the process that is selected (in the `aria-activedescendent` sense of being selected.) */ readonly ariaActiveDescendant: string | null; /** - * nodeID of the selected node + * `nodeID` of the selected node */ readonly selectedNode: string | null; } /** - * Piece of redux state that models an animation for the camera. + * Piece of `redux` state that models an animation for the camera. */ export interface CameraAnimationState { /** @@ -68,7 +76,7 @@ export interface CameraAnimationState { } /** - * The redux state for the `useCamera` hook. + * The `redux` state for the `useCamera` hook. */ export type CameraState = { /** @@ -88,7 +96,7 @@ export type CameraState = { readonly translationNotCountingCurrentPanning: Vector2; /** - * The world coordinates that the pointing device was last over. This is used during mousewheel zoom. + * The world coordinates that the pointing device was last over. This is used during mouse-wheel zoom. */ readonly latestFocusedWorldCoordinates: Vector2 | null; } & ( @@ -135,7 +143,7 @@ export type CameraState = { export type IndexedEntity = IndexedEdgeLineSegment | IndexedProcessNode; /** - * The entity stored in rbush for resolver edge lines. + * The entity stored in `rbush` for resolver edge lines. */ export interface IndexedEdgeLineSegment extends BBox { type: 'edgeLine'; @@ -143,7 +151,7 @@ export interface IndexedEdgeLineSegment extends BBox { } /** - * The entity store in rbush for resolver process nodes. + * The entity store in `rbush` for resolver process nodes. */ export interface IndexedProcessNode extends BBox { type: 'processNode'; @@ -160,7 +168,7 @@ export interface VisibleEntites { } /** - * State for `data` reducer which handles receiving Resolver data from the backend. + * State for `data` reducer which handles receiving Resolver data from the back-end. */ export interface DataState { readonly relatedEvents: Map; @@ -213,11 +221,11 @@ export type Vector2 = readonly [number, number]; */ export interface AABB { /** - * Vector who's `x` component is the _left_ side of the AABB and who's `y` component is the _bottom_ side of the AABB. + * Vector who's `x` component is the _left_ side of the `AABB` and who's `y` component is the _bottom_ side of the `AABB`. **/ readonly minimum: Vector2; /** - * Vector who's `x` component is the _right_ side of the AABB and who's `y` component is the _bottom_ side of the AABB. + * Vector who's `x` component is the _right_ side of the `AABB` and who's `y` component is the _bottom_ side of the `AABB`. **/ readonly maximum: Vector2; } @@ -266,7 +274,7 @@ export interface ProcessEvent { } /** - * A represention of a process tree with indices for O(1) access to children and values by id. + * A representation of a process tree with indices for O(1) access to children and values by id. */ export interface IndexedProcessTree { /** @@ -280,7 +288,7 @@ export interface IndexedProcessTree { } /** - * A map of ProcessEvents (representing process nodes) to the 'width' of their subtrees as calculated by `widthsOfProcessSubtrees` + * A map of `ProcessEvents` (representing process nodes) to the 'width' of their subtrees as calculated by `widthsOfProcessSubtrees` */ export type ProcessWidths = Map; /** @@ -318,16 +326,16 @@ export interface DurationDetails { */ export interface EdgeLineMetadata { elapsedTime?: DurationDetails; - // A string of the two joined process nodes concatted together. + // A string of the two joined process nodes concatenated together. uniqueId: string; } /** - * A tuple of 2 vector2 points forming a polyline. Used to connect process nodes in the graph. + * A tuple of 2 vector2 points forming a poly-line. Used to connect process nodes in the graph. */ export type EdgeLinePoints = Vector2[]; /** - * Edge line components including the points joining the edgeline and any optional associated metadata + * Edge line components including the points joining the edge-line and any optional associated metadata */ export interface EdgeLineSegment { points: EdgeLinePoints; @@ -335,7 +343,7 @@ export interface EdgeLineSegment { } /** - * Used to provide precalculated info from `widthsOfProcessSubtrees`. These 'width' values are used in the layout of the graph. + * Used to provide pre-calculated info from `widthsOfProcessSubtrees`. These 'width' values are used in the layout of the graph. */ export type ProcessWithWidthMetadata = { process: ResolverEvent; @@ -423,11 +431,11 @@ export type ResolverStore = Store; */ export interface IsometricTaxiLayout { /** - * A map of events to position. each event represents its own node. + * A map of events to position. Each event represents its own node. */ processNodePositions: Map; /** - * A map of edgline segments, which graphically connect nodes. + * A map of edge-line segments, which graphically connect nodes. */ edgeLineSegments: EdgeLineSegment[]; @@ -436,3 +444,91 @@ export interface IsometricTaxiLayout { */ ariaLevels: Map; } + +/** + * An object with methods that can be used to access data from the Kibana server. + * This is injected into Resolver. + * This allows tests to provide a mock data access layer. + * In the future, other implementations of Resolver could provide different data access layers. + */ +export interface DataAccessLayer { + /** + * Fetch related events for an entity ID + */ + relatedEvents: (entityID: string) => Promise; + + /** + * Fetch a ResolverTree for a entityID + */ + resolverTree: (entityID: string, signal: AbortSignal) => Promise; + + /** + * Get an array of index patterns that contain events. + */ + indexPatterns: () => string[]; + + /** + * Get entities matching a document. + */ + entities: (parameters: { + /** _id of the document to find an entity in. */ + _id: string; + /** indices to search in */ + indices: string[]; + /** signal to abort the request */ + signal: AbortSignal; + }) => Promise; +} + +/** + * The externally provided React props. + */ +export interface ResolverProps { + /** + * Used by `styled-components`. + */ + className?: string; + /** + * The `_id` value of an event in ES. + * Used as the origin of the Resolver graph. + */ + databaseDocumentID?: string; + /** + * A string literal describing where in the application resolver is located. + * Used to prevent collisions in things like query parameters. + */ + resolverComponentInstanceID: string; +} + +/** + * Used by `SpyMiddleware`. + */ +export interface SpyMiddlewareStateActionPair { + /** An action dispatched, `state` is the state that the reducer returned when handling this action. + */ + action: ResolverAction; + /** + * A resolver state that was returned by the reducer when handling `action`. + */ + state: ResolverState; +} + +/** + * A wrapper object that has a middleware along with an async generator that returns the actions dispatched to the store (along with state.) + */ +export interface SpyMiddleware { + /** + * A middleware to use with `applyMiddleware`. + */ + middleware: Middleware<{}, ResolverState, Dispatch>; + /** + * A generator that returns all state and action pairs that pass through the middleware. + */ + actions: () => AsyncGenerator; + + /** + * Prints actions to the console. + * Call the returned function to stop debugging. + */ + debugActions: () => () => void; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx new file mode 100644 index 00000000000000..9cb900736677e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.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 { oneAncestorTwoChildren } from '../data_access_layer/mocks/one_ancestor_two_children'; +import { Simulator } from '../test_utilities/simulator'; +// Extend jest with a custom matcher +import '../test_utilities/extend_jest'; + +describe('Resolver, when analyzing a tree that has 1 ancestor and 2 children', () => { + let simulator: Simulator; + let databaseDocumentID: string; + let entityIDs: { origin: string; firstChild: string; secondChild: string }; + + // the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances + const resolverComponentInstanceID = 'resolverComponentInstanceID'; + + beforeEach(async () => { + // create a mock data access layer + const { metadata: dataAccessLayerMetadata, dataAccessLayer } = oneAncestorTwoChildren(); + + // save a reference to the entity IDs exposed by the mock data layer + entityIDs = dataAccessLayerMetadata.entityIDs; + + // save a reference to the `_id` supported by the mock data layer + databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + }); + + describe('when it has loaded', () => { + beforeEach(async () => { + await expect( + /** + * It's important that all of these are done in a single `expect`. + * If you do them concurrently with each other, you'll have incorrect results. + * + * For example, there might be no loading element at one point, and 1 graph element at one point, but never a single time when there is both 1 graph element and 0 loading elements. + */ + simulator.mapStateTransitions(() => ({ + graphElements: simulator.graphElement().length, + graphLoadingElements: simulator.graphLoadingElement().length, + graphErrorElements: simulator.graphErrorElement().length, + })) + ).toYieldEqualTo({ + // it should have 1 graph element, an no error or loading elements. + graphElements: 1, + graphLoadingElements: 0, + graphErrorElements: 0, + }); + }); + + // Combining assertions here for performance. Unfortunately, Enzyme + jsdom + React is slow. + it(`should have 3 nodes, with the entityID's 'origin', 'firstChild', and 'secondChild'. 'origin' should be selected.`, async () => { + expect(simulator.processNodeElementLooksSelected(entityIDs.origin)).toBe(true); + + expect(simulator.processNodeElementLooksUnselected(entityIDs.firstChild)).toBe(true); + expect(simulator.processNodeElementLooksUnselected(entityIDs.secondChild)).toBe(true); + + expect(simulator.processNodeElements().length).toBe(3); + }); + + describe("when the second child node's first button has been clicked", () => { + beforeEach(() => { + // Click the first button under the second child element. + simulator + .processNodeElements({ entityID: entityIDs.secondChild }) + .find('button') + .simulate('click'); + }); + it('should render the second child node as selected, and the first child not as not selected, and the query string should indicate that the second child is selected', async () => { + await expect( + simulator.mapStateTransitions(function value() { + return { + // the query string has a key showing that the second child is selected + queryStringSelectedNode: simulator.queryStringValues().selectedNode, + // the second child is rendered in the DOM, and shows up as selected + secondChildLooksSelected: simulator.processNodeElementLooksSelected( + entityIDs.secondChild + ), + // the origin is in the DOM, but shows up as unselected + originLooksUnselected: simulator.processNodeElementLooksUnselected(entityIDs.origin), + }; + }) + ).toYieldEqualTo({ + // Just the second child should be marked as selected in the query string + queryStringSelectedNode: [entityIDs.secondChild], + // The second child is rendered and has `[aria-selected]` + secondChildLooksSelected: true, + // The origin child is rendered and doesn't have `[aria-selected]` + originLooksUnselected: true, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index c1ffa42d02abbc..d9a0bf291d0e43 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -7,50 +7,29 @@ import React, { useMemo } from 'react'; import { Provider } from 'react-redux'; -import { ResolverMap } from './map'; import { storeFactory } from '../store'; import { StartServices } from '../../types'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { DataAccessLayer, ResolverProps } from '../types'; +import { dataAccessLayerFactory } from '../data_access_layer/factory'; +import { ResolverWithoutProviders } from './resolver_without_providers'; /** - * The top level, unconnected, Resolver component. + * The `Resolver` component to use. This sets up the DataAccessLayer provider. Use `ResolverWithoutProviders` in tests or in other scenarios where you want to provide a different (or fake) data access layer. */ -export const Resolver = React.memo(function ({ - className, - databaseDocumentID, - resolverComponentInstanceID, -}: { - /** - * Used by `styled-components`. - */ - className?: string; - /** - * The `_id` value of an event in ES. - * Used as the origin of the Resolver graph. - */ - databaseDocumentID?: string; - /** - * A string literal describing where in the app resolver is located, - * used to prevent collisions in things like query params - */ - resolverComponentInstanceID: string; -}) { +export const Resolver = React.memo((props: ResolverProps) => { const context = useKibana(); + const dataAccessLayer: DataAccessLayer = useMemo(() => dataAccessLayerFactory(context), [ + context, + ]); + const store = useMemo(() => { - return storeFactory(context); - }, [context]); + return storeFactory(dataAccessLayer); + }, [dataAccessLayer]); - /** - * Setup the store and use `Provider` here. This allows the ResolverMap component to - * dispatch actions and read from state. - */ return ( - + ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx index 29c7676d2167de..7b5eb13359dbb3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_process_detail.tsx @@ -1,184 +1,184 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * 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, { memo, useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { i18n } from '@kbn/i18n'; -import { - htmlIdGenerator, - EuiSpacer, - EuiTitle, - EuiText, - EuiTextColor, - EuiDescriptionList, -} from '@elastic/eui'; -import styled from 'styled-components'; -import { FormattedMessage } from 'react-intl'; -import * as selectors from '../../store/selectors'; -import * as event from '../../../../common/endpoint/models/event'; -import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities'; -import { - processPath, - processPid, - userInfoForProcess, - processParentPid, - md5HashForProcess, - argsForProcess, -} from '../../models/process_event'; -import { CubeForProcess } from './process_cube_icon'; -import { ResolverEvent } from '../../../../common/endpoint/types'; -import { useResolverTheme } from '../assets'; - -const StyledDescriptionList = styled(EuiDescriptionList)` - &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { - max-width: 10em; - } -`; - -/** - * A description list view of all the Metadata that goes with a particular process event, like: - * Created, Pid, User/Domain, etc. - */ -export const ProcessDetails = memo(function ProcessDetails({ - processEvent, - pushToQueryParams, -}: { - processEvent: ResolverEvent; - pushToQueryParams: (queryStringKeyValuePair: CrumbInfo) => unknown; -}) { - const processName = event.eventName(processEvent); - const entityId = event.entityId(processEvent); - const isProcessTerminated = useSelector(selectors.isProcessTerminated)(entityId); - const processInfoEntry = useMemo(() => { - const eventTime = event.eventTimestamp(processEvent); - const dateTime = eventTime ? formatDate(eventTime) : ''; - - const createdEntry = { - title: '@timestamp', - description: dateTime, - }; - - const pathEntry = { - title: 'process.executable', - description: processPath(processEvent), - }; - - const pidEntry = { - title: 'process.pid', - description: processPid(processEvent), - }; - - const userEntry = { - title: 'user.name', - description: (userInfoForProcess(processEvent) as { name: string }).name, - }; - - const domainEntry = { - title: 'user.domain', - description: (userInfoForProcess(processEvent) as { domain: string }).domain, - }; - - const parentPidEntry = { - title: 'process.parent.pid', - description: processParentPid(processEvent), - }; - - const md5Entry = { - title: 'process.hash.md5', - description: md5HashForProcess(processEvent), - }; - - const commandLineEntry = { - title: 'process.args', - description: argsForProcess(processEvent), - }; - - // This is the data in {title, description} form for the EUIDescriptionList to display - const processDescriptionListData = [ - createdEntry, - pathEntry, - pidEntry, - userEntry, - domainEntry, - parentPidEntry, - md5Entry, - commandLineEntry, - ] - .filter((entry) => { - return entry.description; - }) - .map((entry) => { - return { - ...entry, - description: String(entry.description), - }; - }); - - return processDescriptionListData; - }, [processEvent]); - - const crumbs = useMemo(() => { - return [ - { - text: i18n.translate( - 'xpack.securitySolution.endpoint.resolver.panel.processDescList.events', - { - defaultMessage: 'Events', - } - ), - onClick: () => { - pushToQueryParams({ crumbId: '', crumbEvent: '' }); - }, - }, - { - text: ( - <> - - - ), - onClick: () => {}, - }, - ]; - }, [processName, pushToQueryParams]); - const { cubeAssetsForNode } = useResolverTheme(); - const { descriptionText } = useMemo(() => { - if (!processEvent) { - return { descriptionText: '' }; - } - return cubeAssetsForNode(isProcessTerminated, false); - }, [processEvent, cubeAssetsForNode, isProcessTerminated]); - - const titleId = useMemo(() => htmlIdGenerator('resolverTable')(), []); - return ( - <> - - - -

- - {processName} -

-
- - - {descriptionText} - - - - - - ); -}); -ProcessDetails.displayName = 'ProcessDetails'; +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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, { memo, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { i18n } from '@kbn/i18n'; +import { + htmlIdGenerator, + EuiSpacer, + EuiTitle, + EuiText, + EuiTextColor, + EuiDescriptionList, +} from '@elastic/eui'; +import styled from 'styled-components'; +import { FormattedMessage } from 'react-intl'; +import * as selectors from '../../store/selectors'; +import * as event from '../../../../common/endpoint/models/event'; +import { CrumbInfo, formatDate, StyledBreadcrumbs } from './panel_content_utilities'; +import { + processPath, + processPid, + userInfoForProcess, + processParentPid, + md5HashForProcess, + argsForProcess, +} from '../../models/process_event'; +import { CubeForProcess } from './process_cube_icon'; +import { ResolverEvent } from '../../../../common/endpoint/types'; +import { useResolverTheme } from '../assets'; + +const StyledDescriptionList = styled(EuiDescriptionList)` + &.euiDescriptionList.euiDescriptionList--column dt.euiDescriptionList__title.desc-title { + max-width: 10em; + } +`; + +/** + * A description list view of all the Metadata that goes with a particular process event, like: + * Created, PID, User/Domain, etc. + */ +export const ProcessDetails = memo(function ProcessDetails({ + processEvent, + pushToQueryParams, +}: { + processEvent: ResolverEvent; + pushToQueryParams: (queryStringKeyValuePair: CrumbInfo) => unknown; +}) { + const processName = event.eventName(processEvent); + const entityId = event.entityId(processEvent); + const isProcessTerminated = useSelector(selectors.isProcessTerminated)(entityId); + const processInfoEntry = useMemo(() => { + const eventTime = event.eventTimestamp(processEvent); + const dateTime = eventTime ? formatDate(eventTime) : ''; + + const createdEntry = { + title: '@timestamp', + description: dateTime, + }; + + const pathEntry = { + title: 'process.executable', + description: processPath(processEvent), + }; + + const pidEntry = { + title: 'process.pid', + description: processPid(processEvent), + }; + + const userEntry = { + title: 'user.name', + description: userInfoForProcess(processEvent)?.name, + }; + + const domainEntry = { + title: 'user.domain', + description: userInfoForProcess(processEvent)?.domain, + }; + + const parentPidEntry = { + title: 'process.parent.pid', + description: processParentPid(processEvent), + }; + + const md5Entry = { + title: 'process.hash.md5', + description: md5HashForProcess(processEvent), + }; + + const commandLineEntry = { + title: 'process.args', + description: argsForProcess(processEvent), + }; + + // This is the data in {title, description} form for the EUIDescriptionList to display + const processDescriptionListData = [ + createdEntry, + pathEntry, + pidEntry, + userEntry, + domainEntry, + parentPidEntry, + md5Entry, + commandLineEntry, + ] + .filter((entry) => { + return entry.description; + }) + .map((entry) => { + return { + ...entry, + description: String(entry.description), + }; + }); + + return processDescriptionListData; + }, [processEvent]); + + const crumbs = useMemo(() => { + return [ + { + text: i18n.translate( + 'xpack.securitySolution.endpoint.resolver.panel.processDescList.events', + { + defaultMessage: 'Events', + } + ), + onClick: () => { + pushToQueryParams({ crumbId: '', crumbEvent: '' }); + }, + }, + { + text: ( + <> + + + ), + onClick: () => {}, + }, + ]; + }, [processName, pushToQueryParams]); + const { cubeAssetsForNode } = useResolverTheme(); + const { descriptionText } = useMemo(() => { + if (!processEvent) { + return { descriptionText: '' }; + } + return cubeAssetsForNode(isProcessTerminated, false); + }, [processEvent, cubeAssetsForNode, isProcessTerminated]); + + const titleId = useMemo(() => htmlIdGenerator('resolverTable')(), []); + return ( + <> + + + +

+ + {processName} +

+
+ + + {descriptionText} + + + + + + ); +}); +ProcessDetails.displayName = 'ProcessDetails'; diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index aed292e4a39d16..24de45ee894dcb 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -195,7 +195,7 @@ const UnstyledProcessEventDot = React.memo( * `beginElement` is by [w3](https://www.w3.org/TR/SVG11/animate.html#__smil__ElementTimeControl__beginElement) * but missing in [TSJS-lib-generator](https://github.com/microsoft/TSJS-lib-generator/blob/15a4678e0ef6de308e79451503e444e9949ee849/inputfiles/addedTypes.json#L1819) */ - beginElement: () => void; + beginElement?: () => void; }) | null; } = React.createRef(); @@ -238,10 +238,8 @@ const UnstyledProcessEventDot = React.memo( const { pushToQueryParams } = useResolverQueryParams(); const handleClick = useCallback(() => { - if (animationTarget.current !== null) { - // This works but the types are missing in the typescript DOM lib - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (animationTarget.current as any).beginElement(); + if (animationTarget.current?.beginElement) { + animationTarget.current.beginElement(); } dispatch({ type: 'userSelectedResolverNode', @@ -297,7 +295,8 @@ const UnstyledProcessEventDot = React.memo( */ return (
{ handleFocus(); handleClick(); - } /* a11y note: this is strictly an alternate to the button, so no tabindex is necessary*/ + } /* a11y note: this is strictly an alternate to the button, so no tabindex is necessary*/ } role="img" aria-labelledby={labelHTMLID} diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx new file mode 100644 index 00000000000000..f444d5a25e1ef9 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-duplicate-imports */ + +/* eslint-disable react/display-name */ + +import React, { useContext, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { useEffectOnce } from 'react-use'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import * as selectors from '../store/selectors'; +import { EdgeLine } from './edge_line'; +import { GraphControls } from './graph_controls'; +import { ProcessEventDot } from './process_event_dot'; +import { useCamera } from './use_camera'; +import { SymbolDefinitions, useResolverTheme } from './assets'; +import { useStateSyncingActions } from './use_state_syncing_actions'; +import { useResolverQueryParams } from './use_resolver_query_params'; +import { StyledMapContainer, StyledPanel, GraphContainer } from './styles'; +import { entityId } from '../../../common/endpoint/models/event'; +import { SideEffectContext } from './side_effect_context'; +import { ResolverProps } from '../types'; + +/** + * The highest level connected Resolver component. Needs a `Provider` in its ancestry to work. + */ +export const ResolverWithoutProviders = React.memo( + /** + * Use `forwardRef` so that the `Simulator` used in testing can access the top level DOM element. + */ + React.forwardRef(function ( + { className, databaseDocumentID, resolverComponentInstanceID }: ResolverProps, + refToForward + ) { + /** + * This is responsible for dispatching actions that include any external data. + * `databaseDocumentID` + */ + useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); + + const { timestamp } = useContext(SideEffectContext); + + // use this for the entire render in order to keep things in sync + const timeAtRender = timestamp(); + + const { processNodePositions, connectingEdgeLineSegments } = useSelector( + selectors.visibleNodesAndEdgeLines + )(timeAtRender); + const terminatedProcesses = useSelector(selectors.terminatedProcesses); + const { projectionMatrix, ref: cameraRef, onMouseDown } = useCamera(); + + const ref = useCallback( + (element: HTMLDivElement | null) => { + // Supply `useCamera` with the ref + cameraRef(element); + + // If a ref is being forwarded, populate that as well. + if (typeof refToForward === 'function') { + refToForward(element); + } else if (refToForward !== null) { + refToForward.current = element; + } + }, + [cameraRef, refToForward] + ); + const isLoading = useSelector(selectors.isLoading); + const hasError = useSelector(selectors.hasError); + const activeDescendantId = useSelector(selectors.ariaActiveDescendant); + const { colorMap } = useResolverTheme(); + const { cleanUpQueryParams } = useResolverQueryParams(); + + useEffectOnce(() => { + return () => cleanUpQueryParams(); + }); + + return ( + + {isLoading ? ( +
+ +
+ ) : hasError ? ( +
+
+ {' '} + +
+
+ ) : ( + + {connectingEdgeLineSegments.map( + ({ points: [startPosition, endPosition], metadata }) => ( + + ) + )} + {[...processNodePositions].map(([processEvent, position]) => { + const processEntityId = entityId(processEvent); + return ( + + ); + })} + + )} + + + +
+ ); + }) +); diff --git a/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator.ts b/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator_factory.ts similarity index 95% rename from x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator.ts rename to x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator_factory.ts index 5e9073ba2d3c9a..25be222e2fe4a7 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/side_effect_simulator_factory.ts @@ -11,12 +11,13 @@ import { SideEffectSimulator } from '../types'; * Create mock `SideEffectors` for `SideEffectContext.Provider`. The `control` * object is used to control the mocks. */ -export const sideEffectSimulator: () => SideEffectSimulator = () => { +export const sideEffectSimulatorFactory: () => SideEffectSimulator = () => { // The set of mock `ResizeObserver` instances that currently exist const resizeObserverInstances: Set = new Set(); // A map of `Element`s to their fake `DOMRect`s - const contentRects: Map = new Map(); + // Use a `WeakMap` since elements can be removed from the DOM. + const contentRects: WeakMap = new Map(); /** * Simulate an element's size changing. This will trigger any `ResizeObserverCallback`s which diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index a27f157bc93643..b32d63283b547d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -10,15 +10,16 @@ import { renderHook, act as hooksAct } from '@testing-library/react-hooks'; import { useCamera, useAutoUpdatingClientRect } from './use_camera'; import { Provider } from 'react-redux'; import * as selectors from '../store/selectors'; -import { storeFactory } from '../store'; import { Matrix3, ResolverStore, SideEffectSimulator } from '../types'; import { ResolverEvent } from '../../../common/endpoint/types'; import { SideEffectContext } from './side_effect_context'; import { applyMatrix3 } from '../models/vector2'; -import { sideEffectSimulator } from './side_effect_simulator'; +import { sideEffectSimulatorFactory } from './side_effect_simulator_factory'; import { mockProcessEvent } from '../models/process_event_test_helpers'; import { mock as mockResolverTree } from '../models/resolver_tree'; import { ResolverAction } from '../store/actions'; +import { createStore } from 'redux'; +import { resolverReducer } from '../store/reducer'; describe('useCamera on an unpainted element', () => { let element: HTMLElement; @@ -29,7 +30,7 @@ describe('useCamera on an unpainted element', () => { let simulator: SideEffectSimulator; beforeEach(async () => { - store = storeFactory(); + store = createStore(resolverReducer); const Test = function Test() { const camera = useCamera(); @@ -38,7 +39,7 @@ describe('useCamera on an unpainted element', () => { return
; }; - simulator = sideEffectSimulator(); + simulator = sideEffectSimulatorFactory(); reactRenderResult = render( diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts index 84d954de6ef274..ed514a61d4e068 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -20,12 +20,12 @@ export function useResolverQueryParams() { const history = useHistory(); const urlSearch = useLocation().search; const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); - const uniqueCrumbIdKey: string = `resolver-id:${resolverComponentInstanceID}`; - const uniqueCrumbEventKey: string = `resolver-event:${resolverComponentInstanceID}`; + const uniqueCrumbIdKey: string = `resolver-${resolverComponentInstanceID}-id`; + const uniqueCrumbEventKey: string = `resolver-${resolverComponentInstanceID}-event`; const pushToQueryParams = useCallback( (newCrumbs: CrumbInfo) => { - // Construct a new set of params from the current set (minus empty params) - // by assigning the new set of params provided in `newCrumbs` + // Construct a new set of parameters from the current set (minus empty parameters) + // by assigning the new set of parameters provided in `newCrumbs` const crumbsToPass = { ...querystring.parse(urlSearch.slice(1)), [uniqueCrumbIdKey]: newCrumbs.crumbId, From a6a09370625240eb1891613b8b39a18485d7ded3 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Tue, 28 Jul 2020 21:24:04 -0400 Subject: [PATCH 13/27] [Security Solution] Validate exception list size when adding new items (#73399) * Validate exception list size when adding new items * Update comment * Extract list size validation and apply to endpoint route also Co-authored-by: Elastic Machine --- x-pack/plugins/lists/common/constants.ts | 2 + .../routes/create_endpoint_list_item_route.ts | 15 ++++- .../create_exception_list_item_route.ts | 13 +++++ .../plugins/lists/server/routes/validate.ts | 56 +++++++++++++++++++ .../delete_exception_list_item.ts | 16 ++++++ .../exception_lists/exception_list_client.ts | 15 ++++- .../exception_list_client_types.ts | 6 ++ .../lib/detection_engine/signals/utils.ts | 3 +- 8 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 x-pack/plugins/lists/server/routes/validate.ts diff --git a/x-pack/plugins/lists/common/constants.ts b/x-pack/plugins/lists/common/constants.ts index df16085b53405b..6c73dc16563022 100644 --- a/x-pack/plugins/lists/common/constants.ts +++ b/x-pack/plugins/lists/common/constants.ts @@ -48,3 +48,5 @@ export const ENDPOINT_LIST_NAME = 'Elastic Endpoint Security Exception List'; /** The description of the single global space agnostic endpoint list */ export const ENDPOINT_LIST_DESCRIPTION = 'Elastic Endpoint Security Exception List'; + +export const MAX_EXCEPTION_LIST_SIZE = 10000; diff --git a/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts index 5ff2a9d9df9f47..22aa1fb59858b4 100644 --- a/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts @@ -6,7 +6,7 @@ import { IRouter } from 'kibana/server'; -import { ENDPOINT_LIST_ITEM_URL } from '../../common/constants'; +import { ENDPOINT_LIST_ID, ENDPOINT_LIST_ITEM_URL } from '../../common/constants'; import { buildRouteValidation, buildSiemResponse, transformError } from '../siem_server_deps'; import { validate } from '../../common/siem_common_deps'; import { @@ -16,6 +16,7 @@ import { } from '../../common/schemas'; import { getExceptionListClient } from './utils/get_exception_list_client'; +import { validateExceptionListSize } from './validate'; export const createEndpointListItemRoute = (router: IRouter): void => { router.post( @@ -71,6 +72,18 @@ export const createEndpointListItemRoute = (router: IRouter): void => { if (errors != null) { return siemResponse.error({ body: errors, statusCode: 500 }); } else { + const listSizeError = await validateExceptionListSize( + exceptionLists, + ENDPOINT_LIST_ID, + 'agnostic' + ); + if (listSizeError != null) { + await exceptionLists.deleteExceptionListItemById({ + id: createdList.id, + namespaceType: 'agnostic', + }); + return siemResponse.error(listSizeError); + } return response.ok({ body: validated ?? {} }); } } diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts index e4885c7393bd4a..ed58621dae973b 100644 --- a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts @@ -17,6 +17,7 @@ import { import { getExceptionListClient } from './utils/get_exception_list_client'; import { endpointDisallowedFields } from './endpoint_disallowed_fields'; +import { validateExceptionListSize } from './validate'; export const createExceptionListItemRoute = (router: IRouter): void => { router.post( @@ -104,6 +105,18 @@ export const createExceptionListItemRoute = (router: IRouter): void => { if (errors != null) { return siemResponse.error({ body: errors, statusCode: 500 }); } else { + const listSizeError = await validateExceptionListSize( + exceptionLists, + listId, + namespaceType + ); + if (listSizeError != null) { + await exceptionLists.deleteExceptionListItemById({ + id: createdList.id, + namespaceType, + }); + return siemResponse.error(listSizeError); + } return response.ok({ body: validated ?? {} }); } } diff --git a/x-pack/plugins/lists/server/routes/validate.ts b/x-pack/plugins/lists/server/routes/validate.ts new file mode 100644 index 00000000000000..bbd4b0eaf0e33d --- /dev/null +++ b/x-pack/plugins/lists/server/routes/validate.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExceptionListClient } from '../services/exception_lists/exception_list_client'; +import { MAX_EXCEPTION_LIST_SIZE } from '../../common/constants'; +import { foundExceptionListItemSchema } from '../../common/schemas'; +import { NamespaceType } from '../../common/schemas/types'; +import { validate } from '../../common/siem_common_deps'; + +export const validateExceptionListSize = async ( + exceptionLists: ExceptionListClient, + listId: string, + namespaceType: NamespaceType +): Promise<{ body: string; statusCode: number } | null> => { + const exceptionListItems = await exceptionLists.findExceptionListItem({ + filter: undefined, + listId, + namespaceType, + page: undefined, + perPage: undefined, + sortField: undefined, + sortOrder: undefined, + }); + if (exceptionListItems == null) { + // If exceptionListItems is null then we couldn't find the list so it may have been deleted + return { + body: `Unable to find list id: ${listId} to verify max exception list size`, + statusCode: 500, + }; + } + const [validatedItems, err] = validate(exceptionListItems, foundExceptionListItemSchema); + if (err != null) { + return { + body: err, + statusCode: 500, + }; + } + // Unnecessary since validatedItems comes from exceptionListItems which is already + // checked for null, but typescript fails to detect that + if (validatedItems == null) { + return { + body: `Unable to find list id: ${listId} to verify max exception list size`, + statusCode: 500, + }; + } + if (validatedItems.total > MAX_EXCEPTION_LIST_SIZE) { + return { + body: `Failed to add exception item, exception list would exceed max size of ${MAX_EXCEPTION_LIST_SIZE}`, + statusCode: 400, + }; + } + return null; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts index 8dce1f1f79e358..ee85cf36a48b5c 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/delete_exception_list_item.ts @@ -8,6 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { ExceptionListItemSchema, + Id, IdOrUndefined, ItemIdOrUndefined, NamespaceType, @@ -23,6 +24,12 @@ interface DeleteExceptionListItemOptions { savedObjectsClient: SavedObjectsClientContract; } +interface DeleteExceptionListItemByIdOptions { + id: Id; + namespaceType: NamespaceType; + savedObjectsClient: SavedObjectsClientContract; +} + export const deleteExceptionListItem = async ({ itemId, id, @@ -43,3 +50,12 @@ export const deleteExceptionListItem = async ({ return exceptionListItem; } }; + +export const deleteExceptionListItemById = async ({ + id, + namespaceType, + savedObjectsClient, +}: DeleteExceptionListItemByIdOptions): Promise => { + const savedObjectType = getSavedObjectType({ namespaceType }); + await savedObjectsClient.delete(savedObjectType, id); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 11302e64b35387..83b44ababf9dec 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -20,6 +20,7 @@ import { CreateExceptionListItemOptions, CreateExceptionListOptions, DeleteEndpointListItemOptions, + DeleteExceptionListItemByIdOptions, DeleteExceptionListItemOptions, DeleteExceptionListOptions, FindEndpointListItemOptions, @@ -40,7 +41,7 @@ import { createExceptionListItem } from './create_exception_list_item'; import { updateExceptionList } from './update_exception_list'; import { updateExceptionListItem } from './update_exception_list_item'; import { deleteExceptionList } from './delete_exception_list'; -import { deleteExceptionListItem } from './delete_exception_list_item'; +import { deleteExceptionListItem, deleteExceptionListItemById } from './delete_exception_list_item'; import { findExceptionListItem } from './find_exception_list_item'; import { findExceptionList } from './find_exception_list'; import { findExceptionListsItem } from './find_exception_list_items'; @@ -326,6 +327,18 @@ export class ExceptionListClient { }); }; + public deleteExceptionListItemById = async ({ + id, + namespaceType, + }: DeleteExceptionListItemByIdOptions): Promise => { + const { savedObjectsClient } = this; + return deleteExceptionListItemById({ + id, + namespaceType, + savedObjectsClient, + }); + }; + /** * This is the same as "deleteExceptionListItem" except it applies specifically to the endpoint list. */ diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 555b9c5e95a77d..963716b55ea771 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -19,6 +19,7 @@ import { ExceptionListType, ExceptionListTypeOrUndefined, FilterOrUndefined, + Id, IdOrUndefined, Immutable, ItemId, @@ -93,6 +94,11 @@ export interface DeleteExceptionListItemOptions { namespaceType: NamespaceType; } +export interface DeleteExceptionListItemByIdOptions { + id: Id; + namespaceType: NamespaceType; +} + export interface DeleteEndpointListItemOptions { id: IdOrUndefined; itemId: ItemIdOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 90373ee6761215..ae4274f31e1455 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -15,6 +15,7 @@ import { ListArrayOrUndefined } from '../../../../common/detection_engine/schema import { BulkResponse, BulkResponseErrorAggregation, isValidUnit } from './types'; import { BuildRuleMessage } from './rule_messages'; import { hasLargeValueList } from '../../../../common/detection_engine/utils'; +import { MAX_EXCEPTION_LIST_SIZE } from '../../../../../lists/common/constants'; interface SortExceptionsReturn { exceptionsWithValueLists: ExceptionListItemSchema[]; @@ -183,7 +184,7 @@ export const getExceptions = async ({ listId: foundList.list_id, namespaceType, page: 1, - perPage: 10000, + perPage: MAX_EXCEPTION_LIST_SIZE, filter: undefined, sortOrder: undefined, sortField: undefined, From 9c08978cc9861263178a54a757cf1e008e6f3afb Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Tue, 28 Jul 2020 21:48:38 -0400 Subject: [PATCH 14/27] [Resolver] Remove useless check that breaks when tree has no nodes (#73583) --- .../resolver/store/data/selectors.test.ts | 23 ++++++++++++++ .../public/resolver/store/data/selectors.ts | 6 ++-- .../resolver/store/mocks/resolver_tree.ts | 30 +++++++++++++++++++ 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index 0826391a106881..6786a93f1d9cac 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -14,6 +14,7 @@ import { mockTreeWith2AncestorsAndNoChildren, mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents, mockTreeWithAllProcessesTerminated, + mockTreeWithNoProcessEvents, } from '../mocks/resolver_tree'; import { uniquePidForProcess } from '../../models/process_event'; import { EndpointEvent } from '../../../../common/endpoint/types'; @@ -408,4 +409,26 @@ describe('data state', () => { expect(selectors.graphableProcesses(state()).length).toBe(4); }); }); + describe('with a tree with no process events', () => { + beforeEach(() => { + const tree = mockTreeWithNoProcessEvents(); + actions.push({ + type: 'serverReturnedResolverData', + payload: { + result: tree, + // this value doesn't matter + databaseDocumentID: '', + }, + }); + }); + it('should return an empty layout', () => { + expect(selectors.layout(state())).toMatchInlineSnapshot(` + Object { + "ariaLevels": Map {}, + "edgeLineSegments": Array [], + "processNodePositions": Map {}, + } + `); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index ea0cb8663d11d0..10ace895b32671 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -374,9 +374,9 @@ export const layout = createSelector( // find the origin node const originNode = indexedProcessTreeModel.processEvent(indexedProcessTree, originID); - if (!originNode) { - // this should only happen if the `ResolverTree` from the server has an entity ID with no matching lifecycle events. - throw new Error('Origin node not found in ResolverTree'); + if (originNode === null) { + // If a tree is returned that has no process events for the origin, this can happen. + return taxiLayout; } // Find the position of the origin, we'll center the map on it intrinsically diff --git a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts index ae43955f4c47c7..6a8ab61ccf9b64 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/mocks/resolver_tree.ts @@ -226,3 +226,33 @@ export function mockTreeWith1AncestorAnd2ChildrenAndAllNodesHave2GraphableEvents lifecycle: [origin, originClone], } as unknown) as ResolverTree; } + +export function mockTreeWithNoProcessEvents(): ResolverTree { + return { + entityID: 'entityID', + children: { + childNodes: [], + nextChild: null, + }, + relatedEvents: { + events: [], + nextEvent: null, + }, + relatedAlerts: { + alerts: [], + nextAlert: null, + }, + lifecycle: [], + ancestry: { + ancestors: [], + nextAncestor: null, + }, + stats: { + totalAlerts: 0, + events: { + total: 0, + byCategory: {}, + }, + }, + }; +} From e105bc514d5040a8d8980abd497d64ae51d42e46 Mon Sep 17 00:00:00 2001 From: spalger Date: Tue, 28 Jul 2020 18:53:31 -0700 Subject: [PATCH 15/27] skip flaky suite (#72339) --- .../cypress/integration/timeline_local_storage.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts b/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts index 383ebe22205859..2fb265c55e3ad2 100644 --- a/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/timeline_local_storage.spec.ts @@ -13,7 +13,8 @@ import { TABLE_COLUMN_EVENTS_MESSAGE } from '../screens/hosts/external_events'; import { waitsForEventsToBeLoaded, openEventsViewerFieldsBrowser } from '../tasks/hosts/events'; import { removeColumn, resetFields } from '../tasks/timeline'; -describe('persistent timeline', () => { +// FLAKY: https://github.com/elastic/kibana/issues/72339 +describe.skip('persistent timeline', () => { before(() => { loginAndWaitForPage(HOSTS_URL); openEvents(); From a0366aa32b1e3c7d50728fb5a585522b2e67c591 Mon Sep 17 00:00:00 2001 From: Davis Plumlee <56367316+dplumlee@users.noreply.github.com> Date: Tue, 28 Jul 2020 22:36:18 -0400 Subject: [PATCH 16/27] [Security Solution][Detections] Exception Modal UI improvements (#73546) --- .../exceptions/add_exception_modal/index.tsx | 22 ++++++++++------- .../add_exception_modal/translations.ts | 12 ++++++++-- .../exceptions/builder/builder_entry_item.tsx | 1 + .../exceptions/edit_exception_modal/index.tsx | 24 ++++++++++++------- .../edit_exception_modal/translations.ts | 12 ++++++++-- .../exceptions/exceptionable_fields.json | 20 ++++++++++++++++ .../common/components/exceptions/helpers.tsx | 2 +- 7 files changed, 70 insertions(+), 23 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 0f7e5b24ed8f96..c15cad53229aec 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -67,7 +67,8 @@ export interface AddExceptionModalProps extends AddExceptionModalBaseProps { const Modal = styled(EuiModal)` ${({ theme }) => css` - width: ${theme.eui.euiBreakpoints.m}; + width: ${theme.eui.euiBreakpoints.l}; + max-width: ${theme.eui.euiBreakpoints.l}; `} `; @@ -285,7 +286,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({ - {i18n.ADD_EXCEPTION} + + {exceptionListType === 'endpoint' ? i18n.ADD_ENDPOINT_EXCEPTION : i18n.ADD_EXCEPTION} + {ruleName} @@ -330,13 +333,6 @@ export const AddExceptionModal = memo(function AddExceptionModal({ - {exceptionListType === 'endpoint' && ( - <> - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - + {exceptionListType === 'endpoint' && ( + <> + + + {i18n.ENDPOINT_QUARANTINE_TEXT} + + + )} )} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts index 81db1b10f70213..abc296e9c0e1a0 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/translations.ts @@ -17,6 +17,13 @@ export const ADD_EXCEPTION = i18n.translate( } ); +export const ADD_ENDPOINT_EXCEPTION = i18n.translate( + 'xpack.securitySolution.exceptions.addException.addEndpointException', + { + defaultMessage: 'Add Endpoint Exception', + } +); + export const ADD_EXCEPTION_ERROR = i18n.translate( 'xpack.securitySolution.exceptions.addException.error', { @@ -49,14 +56,15 @@ export const ENDPOINT_QUARANTINE_TEXT = i18n.translate( 'xpack.securitySolution.exceptions.addException.endpointQuarantineText', { defaultMessage: - 'Any file in quarantine on any endpoint that matches the attribute(s) selected will automatically be restored to its original location', + 'Any file in quarantine on any endpoint that matches the attribute(s) selected will automatically be restored to its original location. This exception will apply to any rule that is linked to the Global Endpoint Exception List.', } ); export const BULK_CLOSE_LABEL = i18n.translate( 'xpack.securitySolution.exceptions.addException.bulkCloseLabel', { - defaultMessage: 'Close all alerts that match attributes in this exception', + defaultMessage: + 'Close all alerts that match this exception, including alerts generated by other rules', } ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx index dcc8a0e4fb1ba3..5939a5a1b576eb 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/builder_entry_item.tsx @@ -110,6 +110,7 @@ export const BuilderEntryItem: React.FC = ({ isDisabled={indexPattern == null} onChange={handleFieldChange} data-test-subj="exceptionBuilderEntryField" + fieldInputWidth={275} /> ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 7ae4fe4ea79702..341d2f2bab37a5 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -54,7 +54,8 @@ interface EditExceptionModalProps { const Modal = styled(EuiModal)` ${({ theme }) => css` - width: ${theme.eui.euiBreakpoints.m}; + width: ${theme.eui.euiBreakpoints.l}; + max-width: ${theme.eui.euiBreakpoints.l}; `} `; @@ -211,7 +212,11 @@ export const EditExceptionModal = memo(function EditExceptionModal({ - {i18n.EDIT_EXCEPTION_TITLE} + + {exceptionListType === 'endpoint' + ? i18n.EDIT_ENDPOINT_EXCEPTION_TITLE + : i18n.EDIT_EXCEPTION_TITLE} + {ruleName} @@ -243,13 +248,6 @@ export const EditExceptionModal = memo(function EditExceptionModal({ - {exceptionListType === 'endpoint' && ( - <> - {i18n.ENDPOINT_QUARANTINE_TEXT} - - - )} - + {exceptionListType === 'endpoint' && ( + <> + + + {i18n.ENDPOINT_QUARANTINE_TEXT} + + + )} )} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts index d09f0158b2e1db..c5b6fc8a6a9ae3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts @@ -24,6 +24,13 @@ export const EDIT_EXCEPTION_TITLE = i18n.translate( } ); +export const EDIT_ENDPOINT_EXCEPTION_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.editException.editEndpointExceptionTitle', + { + defaultMessage: 'Edit Endpoint Exception', + } +); + export const EDIT_EXCEPTION_ERROR = i18n.translate( 'xpack.securitySolution.exceptions.editException.error', { @@ -41,7 +48,8 @@ export const EDIT_EXCEPTION_SUCCESS = i18n.translate( export const BULK_CLOSE_LABEL = i18n.translate( 'xpack.securitySolution.exceptions.editException.bulkCloseLabel', { - defaultMessage: 'Close all alerts that match attributes in this exception', + defaultMessage: + 'Close all alerts that match this exception, including alerts generated by other rules', } ); @@ -57,7 +65,7 @@ export const ENDPOINT_QUARANTINE_TEXT = i18n.translate( 'xpack.securitySolution.exceptions.editException.endpointQuarantineText', { defaultMessage: - 'Any file in quarantine on any endpoint that matches the attribute(s) selected will automatically be restored to its original location', + 'Any file in quarantine on any endpoint that matches the attribute(s) selected will automatically be restored to its original location. This exception will apply to any rule that is linked to the Global Endpoint Exception List.', } ); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json index 18257b0de0a17c..fdf0ea60ecf6a8 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json @@ -7,25 +7,32 @@ "Target.process.Ext.services", "Target.process.Ext.user", "Target.process.command_line", + "Target.process.command_line.text", "Target.process.executable", + "Target.process.executable.text", "Target.process.hash.md5", "Target.process.hash.sha1", "Target.process.hash.sha256", "Target.process.hash.sha512", "Target.process.name", + "Target.process.name.text", "Target.process.parent.Ext.code_signature.status", "Target.process.parent.Ext.code_signature.subject_name", "Target.process.parent.Ext.code_signature.trusted", "Target.process.parent.Ext.code_signature.valid", "Target.process.parent.command_line", + "Target.process.parent.command_line.text", "Target.process.parent.executable", + "Target.process.parent.executable.text", "Target.process.parent.hash.md5", "Target.process.parent.hash.sha1", "Target.process.parent.hash.sha256", "Target.process.parent.hash.sha512", "Target.process.parent.name", + "Target.process.parent.name.text", "Target.process.parent.pgid", "Target.process.parent.working_directory", + "Target.process.parent.working_directory.text", "Target.process.pe.company", "Target.process.pe.description", "Target.process.pe.file_version", @@ -33,6 +40,7 @@ "Target.process.pe.product", "Target.process.pgid", "Target.process.working_directory", + "Target.process.working_directory.text", "agent.id", "agent.type", "agent.version", @@ -67,6 +75,7 @@ "file.name", "file.owner", "file.path", + "file.path.text", "file.pe.company", "file.pe.description", "file.pe.file_version", @@ -74,6 +83,7 @@ "file.pe.product", "file.size", "file.target_path", + "file.target_path.text", "file.type", "file.uid", "group.Ext.real.id", @@ -85,8 +95,10 @@ "host.os.Ext.variant", "host.os.family", "host.os.full", + "host.os.full.text", "host.os.kernel", "host.os.name", + "host.os.name.text", "host.os.platform", "host.os.version", "host.type", @@ -97,25 +109,32 @@ "process.Ext.services", "process.Ext.user", "process.command_line", + "process.command_line.text", "process.executable", + "process.executable.text", "process.hash.md5", "process.hash.sha1", "process.hash.sha256", "process.hash.sha512", "process.name", + "process.name.text", "process.parent.Ext.code_signature.status", "process.parent.Ext.code_signature.subject_name", "process.parent.Ext.code_signature.trusted", "process.parent.Ext.code_signature.valid", "process.parent.command_line", + "process.parent.command_line.text", "process.parent.executable", + "process.parent.executable.text", "process.parent.hash.md5", "process.parent.hash.sha1", "process.parent.hash.sha256", "process.parent.hash.sha512", "process.parent.name", + "process.parent.name.text", "process.parent.pgid", "process.parent.working_directory", + "process.parent.working_directory.text", "process.pe.company", "process.pe.description", "process.pe.file_version", @@ -123,5 +142,6 @@ "process.pe.product", "process.pgid", "process.working_directory", + "process.working_directory.text", "rule.uuid" ] \ No newline at end of file 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 index ee45f9b5de1fa8..3abb788312ff43 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -440,7 +440,7 @@ export const defaultEndpointExceptionItems = ( ], }, { - field: 'file.path', + field: 'file.path.text', operator: 'included', type: 'match', value: filePath ?? '', From 3cc2293836fe0d48374c351e9dfaf9828f1bc94c Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Tue, 28 Jul 2020 21:38:47 -0500 Subject: [PATCH 17/27] [Security Solution][Detections] Adds additional context to the "needs index permissions" prompt (#73547) * Adds conditional context to the "needs index permissions" prompt In an effort to assist the user in their configuration, this adds additional context to this configuration prompt. We now distinguish which indexes need configuration: signals, lists, or both. * Use latin pluralization consistently * Rename component file to be more accurate * Refactor message construction to separate function * Remove unused translations Co-authored-by: Elastic Machine --- .../detection_engine/detection_engine.tsx | 7 +++-- ...tsx => detection_engine_no_index.test.tsx} | 6 ++-- ...ndex.tsx => detection_engine_no_index.tsx} | 24 +++++++++++++--- .../pages/detection_engine/translations.ts | 28 ++++++++++++++++--- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 6 files changed, 53 insertions(+), 14 deletions(-) rename x-pack/plugins/security_solution/public/detections/pages/detection_engine/{detection_engine_no_signal_index.test.tsx => detection_engine_no_index.test.tsx} (72%) rename x-pack/plugins/security_solution/public/detections/pages/detection_engine/{detection_engine_no_signal_index.tsx => detection_engine_no_index.tsx} (54%) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 8385fcc71682d6..c114e4519df10a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -36,7 +36,7 @@ import { alertsHistogramOptions } from '../../components/alerts_histogram_panel/ import { useUserInfo } from '../../components/user_info'; import { EVENTS_VIEWER_HEADER_HEIGHT } from '../../../common/components/events_viewer/events_viewer'; import { OverviewEmpty } from '../../../overview/components/overview_empty'; -import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; +import { DetectionEngineNoIndex } from './detection_engine_no_index'; import { DetectionEngineHeaderPage } from '../../components/detection_engine_header_page'; import { useListsConfig } from '../../containers/detection_engine/lists/use_lists_config'; import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unauthenticated'; @@ -144,7 +144,10 @@ export const DetectionEnginePageComponent: React.FC = ({ return ( - + ); } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_index.test.tsx similarity index 72% rename from x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.test.tsx rename to x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_index.test.tsx index 34d55908c9ba16..82d8ee9c638622 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_index.test.tsx @@ -7,12 +7,14 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { DetectionEngineNoIndex } from './detection_engine_no_signal_index'; +import { DetectionEngineNoIndex } from './detection_engine_no_index'; jest.mock('../../../common/lib/kibana'); describe('DetectionEngineNoIndex', () => { it('renders correctly', () => { - const wrapper = shallow(); + const wrapper = shallow( + + ); expect(wrapper.find('EmptyPage')).toHaveLength(1); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_index.tsx similarity index 54% rename from x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx rename to x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_index.tsx index c315361b294c78..648a9405606efd 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_signal_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine_no_index.tsx @@ -10,7 +10,22 @@ import { EmptyPage } from '../../../common/components/empty_page'; import * as i18n from './translations'; import { useKibana } from '../../../common/lib/kibana'; -export const DetectionEngineNoIndex = React.memo(() => { +const buildMessage = (needsListsIndex: boolean, needsSignalsIndex: boolean): string => { + if (needsSignalsIndex && needsListsIndex) { + return i18n.NEEDS_INDEX_PERMISSIONS(i18n.NEEDS_SIGNALS_AND_LISTS_INDEXES); + } else if (needsSignalsIndex) { + return i18n.NEEDS_INDEX_PERMISSIONS(i18n.NEEDS_SIGNALS_INDEX); + } else if (needsListsIndex) { + return i18n.NEEDS_INDEX_PERMISSIONS(i18n.NEEDS_LISTS_INDEXES); + } else { + return i18n.NEEDS_INDEX_PERMISSIONS(''); + } +}; + +const DetectionEngineNoIndexComponent: React.FC<{ + needsListsIndex: boolean; + needsSignalsIndex: boolean; +}> = ({ needsListsIndex, needsSignalsIndex }) => { const docLinks = useKibana().services.docLinks; const actions = useMemo( () => ({ @@ -23,15 +38,16 @@ export const DetectionEngineNoIndex = React.memo(() => { }), [docLinks] ); + const message = buildMessage(needsListsIndex, needsSignalsIndex); return ( ); -}); +}; -DetectionEngineNoIndex.displayName = 'DetectionEngineNoIndex'; +export const DetectionEngineNoIndex = React.memo(DetectionEngineNoIndexComponent); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts index d8b2930b77f0f1..dd86d9fd2dfc62 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/translations.ts @@ -79,14 +79,34 @@ export const NO_INDEX_TITLE = i18n.translate( } ); -export const NO_INDEX_MSG_BODY = i18n.translate( - 'xpack.securitySolution.detectionEngine.noIndexMsgBody', +export const NEEDS_SIGNALS_AND_LISTS_INDEXES = i18n.translate( + 'xpack.securitySolution.detectionEngine.needsSignalsAndListsIndexesMessage', { - defaultMessage: - 'To use the detection engine, a user with the required cluster and index privileges must first access this page. For more help, contact your administrator.', + defaultMessage: 'You need permissions for the signals and lists indices.', + } +); + +export const NEEDS_SIGNALS_INDEX = i18n.translate( + 'xpack.securitySolution.detectionEngine.needsSignalsIndexMessage', + { + defaultMessage: 'You need permissions for the signals index.', + } +); + +export const NEEDS_LISTS_INDEXES = i18n.translate( + 'xpack.securitySolution.detectionEngine.needsListsIndexesMessage', + { + defaultMessage: 'You need permissions for the lists indices.', } ); +export const NEEDS_INDEX_PERMISSIONS = (additionalContext: string) => + i18n.translate('xpack.securitySolution.detectionEngine.needsIndexPermissionsMessage', { + values: { additionalContext }, + defaultMessage: + 'To use the detection engine, a user with the required cluster and index privileges must first access this page. {additionalContext} For more help, contact your Elastic Stack administrator.', + }); + export const GO_TO_DOCUMENTATION = i18n.translate( 'xpack.securitySolution.detectionEngine.goToDocumentationButton', { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 8fbcb3f1122cc1..a87e92bb33d501 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13865,7 +13865,6 @@ "xpack.securitySolution.detectionEngine.mlUnavailableTitle": "{totalRules} {totalRules, plural, =1 {個のルール} other {個のルール}}で機械学習を有効にする必要があります。", "xpack.securitySolution.detectionEngine.noApiIntegrationKeyCallOutMsg": "Kibanaを起動するごとに保存されたオブジェクトの新しい暗号化キーを作成します。永続キーがないと、Kibanaの再起動後にルールを削除または修正することができません。永続キーを設定するには、kibana.ymlファイルに32文字以上のテキスト値を付けてxpack.encryptedSavedObjects.encryptionKey設定を追加してください。", "xpack.securitySolution.detectionEngine.noApiIntegrationKeyCallOutTitle": "API統合キーが必要です", - "xpack.securitySolution.detectionEngine.noIndexMsgBody": "検出エンジンを使用するには、必要なクラスターとインデックス権限のユーザーが最初にこのページにアクセスする必要があります。ヘルプについては、管理者にお問い合わせください。", "xpack.securitySolution.detectionEngine.noIndexTitle": "検出エンジンを設定しましょう", "xpack.securitySolution.detectionEngine.pageTitle": "検出エンジン", "xpack.securitySolution.detectionEngine.panelSubtitleShowing": "表示中", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b69763449a06f6..250bf78fe8db20 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13871,7 +13871,6 @@ "xpack.securitySolution.detectionEngine.mlUnavailableTitle": "{totalRules} 个 {totalRules, plural, =1 {规则需要} other {规则需要}}启用 Machine Learning。", "xpack.securitySolution.detectionEngine.noApiIntegrationKeyCallOutMsg": "每次启动 Kibana,都会为已保存对象生成新的加密密钥。没有持久性密钥,在 Kibana 重新启动后,将无法删除或修改规则。要设置持久性密钥,请将文本值为 32 个或更多任意字符的 xpack.encryptedSavedObjects.encryptionKey 设置添加到 kibana.yml 文件。", "xpack.securitySolution.detectionEngine.noApiIntegrationKeyCallOutTitle": "需要 API 集成密钥", - "xpack.securitySolution.detectionEngine.noIndexMsgBody": "要使用检测引擎,具有所需集群和索引权限的用户必须首先访问此页面。若需要更多帮助,请联系您的管理员。", "xpack.securitySolution.detectionEngine.noIndexTitle": "让我们来设置您的检测引擎", "xpack.securitySolution.detectionEngine.pageTitle": "检测引擎", "xpack.securitySolution.detectionEngine.panelSubtitleShowing": "正在显示", From 5a049098197b3de121aaf404aa10064b4127a631 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Tue, 28 Jul 2020 22:43:40 -0400 Subject: [PATCH 18/27] [Security Solution][Exceptions] Use semantic version for manifest version + Scaling Tweaks (#73388) * Manifest version is semantic version * Configurable task interval * Use task interval over scheduled when provided * Fix crash on download of large artifact * Don't need to generate linux artifacts * Configurable artifact validation * Test fixes * Test fixes * Type/test fixes * Final tweaks * Remove linux endpoint exception generation from UI * Fix paging so that we stop before 10k * Fix pagination * Fix pagination test Co-authored-by: Elastic Machine --- .../common/endpoint/generate_data.ts | 2 +- .../common/endpoint/schema/common.ts | 5 +- .../common/endpoint/schema/manifest.ts | 4 +- .../exceptions/add_exception_modal/index.tsx | 2 +- .../security_solution/server/config.ts | 6 ++ .../server/endpoint/config.ts | 6 ++ .../endpoint/ingest_integration.test.ts | 25 +---- .../server/endpoint/ingest_integration.ts | 10 +- .../server/endpoint/lib/artifacts/common.ts | 4 +- .../endpoint/lib/artifacts/lists.test.ts | 12 ++- .../server/endpoint/lib/artifacts/lists.ts | 9 +- .../endpoint/lib/artifacts/manifest.test.ts | 49 +++------ .../server/endpoint/lib/artifacts/manifest.ts | 100 +++++++----------- .../server/endpoint/lib/artifacts/mocks.ts | 32 ++---- .../lib/artifacts/saved_object_mappings.ts | 8 +- .../endpoint/lib/artifacts/task.test.ts | 2 +- .../server/endpoint/lib/artifacts/task.ts | 31 ++++-- .../artifacts/download_exception_list.ts | 10 +- .../endpoint/schemas/artifacts/manifest.ts | 23 ++++ .../schemas/artifacts/saved_objects.mock.ts | 2 + .../schemas/artifacts/saved_objects.ts | 4 + .../services/artifacts/manifest_client.ts | 1 - .../manifest_manager/manifest_manager.test.ts | 82 ++++---------- .../manifest_manager/manifest_manager.ts | 62 +++++------ .../routes/__mocks__/index.ts | 2 + .../endpoint/artifacts/api_feature/data.json | 28 +---- .../apps/endpoint/policy_details.ts | 12 --- .../apis/artifacts/index.ts | 78 ++++++++++++-- 28 files changed, 284 insertions(+), 327 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/manifest.ts 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 97ac5c9030a3db..9a92270fc9c14d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/generate_data.ts +++ b/x-pack/plugins/security_solution/common/endpoint/generate_data.ts @@ -1041,7 +1041,7 @@ export class EndpointDocGenerator { config: { artifact_manifest: { value: { - manifest_version: 'WzAsMF0=', + manifest_version: '1.0.0', schema_version: 'v1', artifacts: {}, }, diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/common.ts b/x-pack/plugins/security_solution/common/endpoint/schema/common.ts index 8f2ea1f8a64522..1c910927a7afa2 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/common.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/common.ts @@ -23,8 +23,6 @@ export const encryptionAlgorithm = t.keyof({ export const identifier = t.string; -export const manifestVersion = t.string; - export const manifestSchemaVersion = t.keyof({ v1: null, }); @@ -34,4 +32,7 @@ export const relativeUrl = t.string; export const sha256 = t.string; +export const semanticVersion = t.string; +export type SemanticVersion = t.TypeOf; + export const size = t.number; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/manifest.ts b/x-pack/plugins/security_solution/common/endpoint/schema/manifest.ts index f8bb8b70f2d5b3..f03db881837d5f 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/manifest.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/manifest.ts @@ -11,9 +11,9 @@ import { encryptionAlgorithm, identifier, manifestSchemaVersion, - manifestVersion, relativeUrl, sha256, + semanticVersion, size, } from './common'; @@ -50,7 +50,7 @@ export type ManifestEntryDispatchSchema = t.TypeOf { - const osDefaults = ['windows', 'macos', 'linux']; + const osDefaults = ['windows', 'macos']; if (alertData) { const osTypes = getMappedNonEcsValue({ data: alertData.nonEcsData, diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts index f7d961ae3ec5cc..e2c06ae9f931f1 100644 --- a/x-pack/plugins/security_solution/server/config.ts +++ b/x-pack/plugins/security_solution/server/config.ts @@ -29,6 +29,12 @@ export const configSchema = schema.object({ from: schema.string({ defaultValue: 'now-15m' }), to: schema.string({ defaultValue: 'now' }), }), + + /** + * Artifacts Configuration + */ + packagerTaskInterval: schema.string({ defaultValue: '60s' }), + validateArtifactDownloads: schema.boolean({ defaultValue: true }), }); export const createConfig$ = (context: PluginInitializerContext) => diff --git a/x-pack/plugins/security_solution/server/endpoint/config.ts b/x-pack/plugins/security_solution/server/endpoint/config.ts index 908e14468c5c7c..6a3644f7aaf71f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/config.ts +++ b/x-pack/plugins/security_solution/server/endpoint/config.ts @@ -27,6 +27,12 @@ export const EndpointConfigSchema = schema.object({ from: schema.string({ defaultValue: 'now-15m' }), to: schema.string({ defaultValue: 'now' }), }), + + /** + * Artifacts Configuration + */ + packagerTaskInterval: schema.string({ defaultValue: '60s' }), + validateArtifactDownloads: schema.boolean({ defaultValue: true }), }); export function createConfig$(context: PluginInitializerContext) { diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts index be749b2ebd25a3..03999715dfc710 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.test.ts @@ -12,7 +12,6 @@ import { ManifestManagerMockType, } from './services/artifacts/manifest_manager/manifest_manager.mock'; import { getPackageConfigCreateCallback } from './ingest_integration'; -import { ManifestConstants } from './lib/artifacts'; describe('ingest_integration tests ', () => { describe('ingest_integration sanity checks', () => { @@ -30,16 +29,6 @@ describe('ingest_integration tests ', () => { expect(newPolicyConfig.inputs[0]!.config!.policy.value).toEqual(policyConfigFactory()); expect(newPolicyConfig.inputs[0]!.config!.artifact_manifest.value).toEqual({ artifacts: { - 'endpoint-exceptionlist-linux-v1': { - compression_algorithm: 'zlib', - decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - decoded_size: 14, - encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - encoded_size: 22, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - }, 'endpoint-exceptionlist-macos-v1': { compression_algorithm: 'zlib', decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', @@ -61,7 +50,7 @@ describe('ingest_integration tests ', () => { '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', }, }, - manifest_version: 'a9b7ef358a363f327f479e31efc4f228b2277a7fb4d1914ca9b4e7ca9ffcf537', + manifest_version: '1.0.0', schema_version: 'v1', }); }); @@ -70,9 +59,7 @@ describe('ingest_integration tests ', () => { const logger = loggingSystemMock.create().get('ingest_integration.test'); const manifestManager = getManifestManagerMock(); manifestManager.pushArtifacts = jest.fn().mockResolvedValue([new Error('error updating')]); - const lastComputed = await manifestManager.getLastComputedManifest( - ManifestConstants.SCHEMA_VERSION - ); + const lastComputed = await manifestManager.getLastComputedManifest(); const callback = getPackageConfigCreateCallback(logger, manifestManager); const policyConfig = createNewPackageConfigMock(); @@ -90,9 +77,7 @@ describe('ingest_integration tests ', () => { const manifestManager = getManifestManagerMock({ mockType: ManifestManagerMockType.InitialSystemState, }); - const lastComputed = await manifestManager.getLastComputedManifest( - ManifestConstants.SCHEMA_VERSION - ); + const lastComputed = await manifestManager.getLastComputedManifest(); expect(lastComputed).toEqual(null); manifestManager.buildNewManifest = jest.fn().mockRejectedValue(new Error('abcd')); @@ -107,9 +92,7 @@ describe('ingest_integration tests ', () => { test('subsequent policy creations succeed', async () => { const logger = loggingSystemMock.create().get('ingest_integration.test'); const manifestManager = getManifestManagerMock(); - const lastComputed = await manifestManager.getLastComputedManifest( - ManifestConstants.SCHEMA_VERSION - ); + const lastComputed = await manifestManager.getLastComputedManifest(); manifestManager.buildNewManifest = jest.fn().mockResolvedValue(lastComputed); // no diffs const callback = getPackageConfigCreateCallback(logger, manifestManager); diff --git a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts index 11d4b12d0b76ab..695267f3228579 100644 --- a/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts +++ b/x-pack/plugins/security_solution/server/endpoint/ingest_integration.ts @@ -10,7 +10,7 @@ import { factory as policyConfigFactory } from '../../common/endpoint/models/pol import { NewPolicyData } from '../../common/endpoint/types'; import { ManifestManager } from './services/artifacts'; import { Manifest } from './lib/artifacts'; -import { reportErrors, ManifestConstants } from './lib/artifacts/common'; +import { reportErrors } from './lib/artifacts/common'; import { InternalArtifactCompleteSchema } from './schemas/artifacts'; import { manifestDispatchSchema } from '../../common/endpoint/schema/manifest'; @@ -18,14 +18,14 @@ const getManifest = async (logger: Logger, manifestManager: ManifestManager): Pr let manifest: Manifest | null = null; try { - manifest = await manifestManager.getLastComputedManifest(ManifestConstants.SCHEMA_VERSION); + manifest = await manifestManager.getLastComputedManifest(); // If we have not yet computed a manifest, then we have to do so now. This should only happen // once. if (manifest == null) { // New computed manifest based on current state of exception list - const newManifest = await manifestManager.buildNewManifest(ManifestConstants.SCHEMA_VERSION); - const diffs = newManifest.diff(Manifest.getDefault(ManifestConstants.SCHEMA_VERSION)); + const newManifest = await manifestManager.buildNewManifest(); + const diffs = newManifest.diff(Manifest.getDefault()); // Compress new artifacts const adds = diffs.filter((diff) => diff.type === 'add').map((diff) => diff.id); @@ -63,7 +63,7 @@ const getManifest = async (logger: Logger, manifestManager: ManifestManager): Pr logger.error(err); } - return manifest ?? Manifest.getDefault(ManifestConstants.SCHEMA_VERSION); + return manifest ?? Manifest.getDefault(); }; /** diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts index 7298a9bfa72a66..7f90aa7b910632 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/common.ts @@ -13,13 +13,11 @@ import { export const ArtifactConstants = { GLOBAL_ALLOWLIST_NAME: 'endpoint-exceptionlist', SAVED_OBJECT_TYPE: 'endpoint:user-artifact', - SUPPORTED_OPERATING_SYSTEMS: ['linux', 'macos', 'windows'], - SCHEMA_VERSION: 'v1', + SUPPORTED_OPERATING_SYSTEMS: ['macos', 'windows'], }; export const ManifestConstants = { SAVED_OBJECT_TYPE: 'endpoint:user-artifact-manifest', - SCHEMA_VERSION: 'v1', }; export const getArtifactId = (artifact: InternalArtifactSchema) => { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index bb8b4fb3d5ce72..fea3b2b9a45266 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -314,21 +314,23 @@ describe('buildEventTypeSignal', () => { test('it should convert the exception lists response to the proper endpoint format while paging', async () => { // The first call returns two exceptions const first = getFoundExceptionListItemSchemaMock(); + first.per_page = 2; + first.total = 4; first.data.push(getExceptionListItemSchemaMock()); // The second call returns two exceptions const second = getFoundExceptionListItemSchemaMock(); + second.per_page = 2; + second.total = 4; second.data.push(getExceptionListItemSchemaMock()); - // The third call returns no exceptions, paging stops - const third = getFoundExceptionListItemSchemaMock(); - third.data = []; mockExceptionClient.findExceptionListItem = jest .fn() .mockReturnValueOnce(first) - .mockReturnValueOnce(second) - .mockReturnValueOnce(third); + .mockReturnValueOnce(second); + const resp = await getFullEndpointExceptionList(mockExceptionClient, 'linux', 'v1'); + // Expect 2 exceptions, the first two calls returned the same exception list items expect(resp.entries.length).toEqual(2); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 5998a88527f2f9..e41781dd605a07 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -79,10 +79,10 @@ export async function getFullEndpointExceptionList( schemaVersion: string ): Promise { const exceptions: WrappedTranslatedExceptionList = { entries: [] }; - let numResponses = 0; let page = 1; + let paging = true; - do { + while (paging) { const response = await eClient.findExceptionListItem({ listId: ENDPOINT_LIST_ID, namespaceType: 'agnostic', @@ -94,17 +94,16 @@ export async function getFullEndpointExceptionList( }); if (response?.data !== undefined) { - numResponses = response.data.length; - exceptions.entries = exceptions.entries.concat( translateToEndpointExceptions(response, schemaVersion) ); + paging = (page - 1) * 100 + response.data.length < response.total; page++; } else { break; } - } while (numResponses > 0); + } const [validated, errors] = validate(exceptions, wrappedTranslatedExceptionList); if (errors != null) { diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts index 95587c6fc105d5..3d70f7266277f6 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.test.ts @@ -6,7 +6,7 @@ import { ManifestSchemaVersion } from '../../../../common/endpoint/schema/common'; import { InternalArtifactCompleteSchema } from '../../schemas'; -import { ManifestConstants, getArtifactId } from './common'; +import { getArtifactId } from './common'; import { Manifest } from './manifest'; import { getMockArtifacts, @@ -30,29 +30,21 @@ describe('manifest', () => { }); test('Can create manifest with valid schema version', () => { - const manifest = new Manifest('v1'); + const manifest = new Manifest(); expect(manifest).toBeInstanceOf(Manifest); }); test('Cannot create manifest with invalid schema version', () => { expect(() => { - new Manifest('abcd' as ManifestSchemaVersion); + new Manifest({ + schemaVersion: 'abcd' as ManifestSchemaVersion, + }); }).toThrow(); }); test('Empty manifest transforms correctly to expected endpoint format', async () => { expect(emptyManifest.toEndpointFormat()).toStrictEqual({ artifacts: { - 'endpoint-exceptionlist-linux-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - decoded_size: 14, - encoded_size: 22, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - }, 'endpoint-exceptionlist-macos-v1': { compression_algorithm: 'zlib', encryption_algorithm: 'none', @@ -74,7 +66,7 @@ describe('manifest', () => { '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', }, }, - manifest_version: 'a9b7ef358a363f327f479e31efc4f228b2277a7fb4d1914ca9b4e7ca9ffcf537', + manifest_version: '1.0.0', schema_version: 'v1', }); }); @@ -82,16 +74,6 @@ describe('manifest', () => { test('Manifest transforms correctly to expected endpoint format', async () => { expect(manifest1.toEndpointFormat()).toStrictEqual({ artifacts: { - 'endpoint-exceptionlist-linux-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', - decoded_size: 432, - encoded_size: 147, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - }, 'endpoint-exceptionlist-macos-v1': { compression_algorithm: 'zlib', encryption_algorithm: 'none', @@ -113,15 +95,16 @@ describe('manifest', () => { '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', }, }, - manifest_version: 'a7f4760bfa2662e85e30fe4fb8c01b4c4a20938c76ab21d3c5a3e781e547cce7', + manifest_version: '1.0.0', schema_version: 'v1', }); }); test('Manifest transforms correctly to expected saved object format', async () => { expect(manifest1.toSavedObject()).toStrictEqual({ + schemaVersion: 'v1', + semanticVersion: '1.0.0', ids: [ - 'endpoint-exceptionlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', 'endpoint-exceptionlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', ], @@ -133,12 +116,12 @@ describe('manifest', () => { expect(diffs).toEqual([ { id: - 'endpoint-exceptionlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', + 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', type: 'delete', }, { id: - 'endpoint-exceptionlist-linux-v1-0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', + 'endpoint-exceptionlist-macos-v1-0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', type: 'add', }, ]); @@ -154,7 +137,6 @@ describe('manifest', () => { const entries = manifest1.getEntries(); const keys = Object.keys(entries); expect(keys).toEqual([ - 'endpoint-exceptionlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', 'endpoint-exceptionlist-windows-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', ]); @@ -168,13 +150,8 @@ describe('manifest', () => { }); test('Manifest can be created from list of artifacts', async () => { - const oldManifest = new Manifest(ManifestConstants.SCHEMA_VERSION); - const manifest = Manifest.fromArtifacts(artifacts, 'v1', oldManifest); - expect( - manifest.contains( - 'endpoint-exceptionlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3' - ) - ).toEqual(true); + const oldManifest = new Manifest(); + const manifest = Manifest.fromArtifacts(artifacts, oldManifest); expect( manifest.contains( 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3' diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts index 6ece2bf0f48e80..9e0e940ea9a1d4 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/manifest.ts @@ -3,8 +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 { createHash } from 'crypto'; +import semver from 'semver'; import { validate } from '../../../../common/validate'; import { InternalArtifactSchema, @@ -13,13 +12,15 @@ import { InternalArtifactCompleteSchema, } from '../../schemas/artifacts'; import { - manifestSchemaVersion, ManifestSchemaVersion, + SemanticVersion, + semanticVersion, } from '../../../../common/endpoint/schema/common'; -import { ManifestSchema, manifestSchema } from '../../../../common/endpoint/schema/manifest'; +import { manifestSchema, ManifestSchema } from '../../../../common/endpoint/schema/manifest'; import { ManifestEntry } from './manifest_entry'; import { maybeCompressArtifact, isCompressed } from './lists'; import { getArtifactId } from './common'; +import { ManifestVersion, manifestVersion } from '../../schemas/artifacts/manifest'; export interface ManifestDiff { type: string; @@ -28,37 +29,39 @@ export interface ManifestDiff { export class Manifest { private entries: Record; - private schemaVersion: ManifestSchemaVersion; - - // For concurrency control - private version: string | undefined; + private version: ManifestVersion; - constructor(schemaVersion: string, version?: string) { + constructor(version?: Partial) { this.entries = {}; - this.version = version; - const [validated, errors] = validate( - (schemaVersion as unknown) as object, - manifestSchemaVersion - ); + const decodedVersion = { + schemaVersion: version?.schemaVersion ?? 'v1', + semanticVersion: version?.semanticVersion ?? '1.0.0', + soVersion: version?.soVersion, + }; + const [validated, errors] = validate(decodedVersion, manifestVersion); if (errors != null || validated === null) { - throw new Error(`Invalid manifest schema version: ${schemaVersion}`); + throw new Error(errors ?? 'Invalid version format.'); } - this.schemaVersion = validated; + this.version = validated; } - public static getDefault(schemaVersion: string) { - return new Manifest(schemaVersion); + public static getDefault(schemaVersion?: ManifestSchemaVersion) { + return new Manifest({ schemaVersion, semanticVersion: '1.0.0' }); } public static fromArtifacts( artifacts: InternalArtifactCompleteSchema[], - schemaVersion: string, - oldManifest: Manifest + oldManifest: Manifest, + schemaVersion?: ManifestSchemaVersion ): Manifest { - const manifest = new Manifest(schemaVersion, oldManifest.getSoVersion()); + const manifest = new Manifest({ + schemaVersion, + semanticVersion: oldManifest.getSemanticVersion(), + soVersion: oldManifest.getSavedObjectVersion(), + }); artifacts.forEach((artifact) => { const id = getArtifactId(artifact); const existingArtifact = oldManifest.getArtifact(id); @@ -71,25 +74,12 @@ export class Manifest { return manifest; } - public static fromPkgConfig(manifestPkgConfig: ManifestSchema): Manifest | null { - if (manifestSchema.is(manifestPkgConfig)) { - const manifest = new Manifest(manifestPkgConfig.schema_version); - for (const [identifier, artifactRecord] of Object.entries(manifestPkgConfig.artifacts)) { - const artifact = { - identifier, - compressionAlgorithm: artifactRecord.compression_algorithm, - encryptionAlgorithm: artifactRecord.encryption_algorithm, - decodedSha256: artifactRecord.decoded_sha256, - decodedSize: artifactRecord.decoded_size, - encodedSha256: artifactRecord.encoded_sha256, - encodedSize: artifactRecord.encoded_size, - }; - manifest.addEntry(artifact); - } - return manifest; - } else { - return null; + public bumpSemanticVersion() { + const newSemanticVersion = semver.inc(this.getSemanticVersion(), 'patch'); + if (!semanticVersion.is(newSemanticVersion)) { + throw new Error(`Invalid semver: ${newSemanticVersion}`); } + this.version.semanticVersion = newSemanticVersion; } public async compressArtifact(id: string): Promise { @@ -112,30 +102,16 @@ export class Manifest { return null; } - public equals(manifest: Manifest): boolean { - return this.getSha256() === manifest.getSha256(); - } - - public getSha256(): string { - let sha256 = createHash('sha256'); - Object.keys(this.entries) - .sort() - .forEach((docId) => { - sha256 = sha256.update(docId); - }); - return sha256.digest('hex'); - } - public getSchemaVersion(): ManifestSchemaVersion { - return this.schemaVersion; + return this.version.schemaVersion; } - public getSoVersion(): string | undefined { - return this.version; + public getSavedObjectVersion(): string | undefined { + return this.version.soVersion; } - public setSoVersion(version: string) { - this.version = version; + public getSemanticVersion(): SemanticVersion { + return this.version.semanticVersion; } public addEntry(artifact: InternalArtifactSchema) { @@ -179,8 +155,8 @@ export class Manifest { public toEndpointFormat(): ManifestSchema { const manifestObj: ManifestSchema = { - manifest_version: this.getSha256(), - schema_version: this.schemaVersion, + manifest_version: this.getSemanticVersion(), + schema_version: this.getSchemaVersion(), artifacts: {}, }; @@ -198,7 +174,9 @@ export class Manifest { public toSavedObject(): InternalManifestSchema { return { - ids: Object.keys(this.entries), + ids: Object.keys(this.getEntries()), + schemaVersion: this.getSchemaVersion(), + semanticVersion: this.getSemanticVersion(), }; } } diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/mocks.ts index 0ec6cb2bd61b3f..62fff4715b562a 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/mocks.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/mocks.ts @@ -29,7 +29,7 @@ export const getMockArtifactsWithDiff = async (opts?: { compress: boolean }) => return Promise.all( ArtifactConstants.SUPPORTED_OPERATING_SYSTEMS.map>( async (os) => { - if (os === 'linux') { + if (os === 'macos') { return getInternalArtifactMockWithDiffs(os, 'v1'); } return getInternalArtifactMock(os, 'v1', opts); @@ -49,21 +49,21 @@ export const getEmptyMockArtifacts = async (opts?: { compress: boolean }) => { }; export const getMockManifest = async (opts?: { compress: boolean }) => { - const manifest = new Manifest('v1'); + const manifest = new Manifest(); const artifacts = await getMockArtifacts(opts); artifacts.forEach((artifact) => manifest.addEntry(artifact)); return manifest; }; export const getMockManifestWithDiffs = async (opts?: { compress: boolean }) => { - const manifest = new Manifest('v1'); + const manifest = new Manifest(); const artifacts = await getMockArtifactsWithDiff(opts); artifacts.forEach((artifact) => manifest.addEntry(artifact)); return manifest; }; export const getEmptyMockManifest = async (opts?: { compress: boolean }) => { - const manifest = new Manifest('v1'); + const manifest = new Manifest(); const artifacts = await getEmptyMockArtifacts(opts); artifacts.forEach((artifact) => manifest.addEntry(artifact)); return manifest; @@ -74,16 +74,6 @@ export const createPackageConfigWithInitialManifestMock = (): PackageConfig => { packageConfig.inputs[0].config!.artifact_manifest = { value: { artifacts: { - 'endpoint-exceptionlist-linux-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - encoded_sha256: 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - decoded_size: 14, - encoded_size: 22, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - }, 'endpoint-exceptionlist-macos-v1': { compression_algorithm: 'zlib', encryption_algorithm: 'none', @@ -105,7 +95,7 @@ export const createPackageConfigWithInitialManifestMock = (): PackageConfig => { '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', }, }, - manifest_version: 'a9b7ef358a363f327f479e31efc4f228b2277a7fb4d1914ca9b4e7ca9ffcf537', + manifest_version: '1.0.0', schema_version: 'v1', }, }; @@ -117,16 +107,6 @@ export const createPackageConfigWithManifestMock = (): PackageConfig => { packageConfig.inputs[0].config!.artifact_manifest = { value: { artifacts: { - 'endpoint-exceptionlist-linux-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: '0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', - encoded_sha256: '57941169bb2c5416f9bd7224776c8462cb9a2be0fe8b87e6213e77a1d29be824', - decoded_size: 292, - encoded_size: 131, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', - }, 'endpoint-exceptionlist-macos-v1': { compression_algorithm: 'zlib', encryption_algorithm: 'none', @@ -148,7 +128,7 @@ export const createPackageConfigWithManifestMock = (): PackageConfig => { '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', }, }, - manifest_version: '520f6cf88b3f36a065c6ca81058d5f8690aadadf6fe857f8dec4cc37589e7283', + manifest_version: '1.0.1', schema_version: 'v1', }, }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts index 0fb433df95de3d..734304516e37e5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/saved_object_mappings.ts @@ -55,7 +55,13 @@ export const manifestSavedObjectMappings: SavedObjectsType['mappings'] = { type: 'date', index: false, }, - // array of doc ids + schemaVersion: { + type: 'keyword', + }, + semanticVersion: { + type: 'keyword', + index: false, + }, ids: { type: 'keyword', index: false, diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.test.ts index daa8a7dd83ee03..32d58da5c3b784 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.test.ts @@ -38,7 +38,7 @@ describe('task', () => { taskManager: mockTaskManagerSetup, }); const mockTaskManagerStart = taskManagerMock.createStart(); - manifestTask.start({ taskManager: mockTaskManagerStart }); + await manifestTask.start({ taskManager: mockTaskManagerStart }); expect(mockTaskManagerStart.ensureScheduled).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts index ba164059866ea5..4f2dbdf7644e2b 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/task.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { Logger } from 'src/core/server'; import { ConcreteTaskInstance, @@ -11,7 +10,7 @@ import { TaskManagerStartContract, } from '../../../../../task_manager/server'; import { EndpointAppContext } from '../../types'; -import { reportErrors, ManifestConstants } from './common'; +import { reportErrors } from './common'; import { InternalArtifactCompleteSchema } from '../../schemas/artifacts'; export const ManifestTaskConstants = { @@ -45,7 +44,23 @@ export class ManifestTask { createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { return { run: async () => { + const taskInterval = (await this.endpointAppContext.config()).packagerTaskInterval; await this.runTask(taskInstance.id); + const nextRun = new Date(); + if (taskInterval.endsWith('s')) { + const seconds = parseInt(taskInterval.slice(0, -1), 10); + nextRun.setSeconds(nextRun.getSeconds() + seconds); + } else if (taskInterval.endsWith('m')) { + const minutes = parseInt(taskInterval.slice(0, -1), 10); + nextRun.setMinutes(nextRun.getMinutes() + minutes); + } else { + this.logger.error(`Invalid task interval: ${taskInterval}`); + return; + } + return { + state: {}, + runAt: nextRun, + }; }, cancel: async () => {}, }; @@ -61,7 +76,7 @@ export class ManifestTask { taskType: ManifestTaskConstants.TYPE, scope: ['securitySolution'], schedule: { - interval: '60s', + interval: (await this.endpointAppContext.config()).packagerTaskInterval, }, state: {}, params: { version: ManifestTaskConstants.VERSION }, @@ -92,19 +107,14 @@ export class ManifestTask { try { // Last manifest we computed, which was saved to ES - const oldManifest = await manifestManager.getLastComputedManifest( - ManifestConstants.SCHEMA_VERSION - ); + const oldManifest = await manifestManager.getLastComputedManifest(); if (oldManifest == null) { this.logger.debug('User manifest not available yet.'); return; } // New computed manifest based on current state of exception list - const newManifest = await manifestManager.buildNewManifest( - ManifestConstants.SCHEMA_VERSION, - oldManifest - ); + const newManifest = await manifestManager.buildNewManifest(oldManifest); const diffs = newManifest.diff(oldManifest); // Compress new artifacts @@ -131,6 +141,7 @@ export class ManifestTask { // Commit latest manifest state, if different if (diffs.length) { + newManifest.bumpSemanticVersion(); const error = await manifestManager.commit(newManifest); if (error) { throw error; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts index 38e900c4d50154..d825841f1e2576 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/artifacts/download_exception_list.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { IRouter, SavedObjectsClientContract, @@ -14,7 +13,6 @@ import { import LRU from 'lru-cache'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { authenticateAgentWithAccessToken } from '../../../../../ingest_manager/server/services/agents/authenticate'; -import { validate } from '../../../../common/validate'; import { LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG } from '../../../../common/endpoint/constants'; import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; import { ArtifactConstants } from '../../lib/artifacts'; @@ -63,6 +61,7 @@ export function registerDownloadExceptionListRoute( } } + const validateDownload = (await endpointContext.config()).validateArtifactDownloads; const buildAndValidateResponse = (artName: string, body: Buffer): IKibanaResponse => { const artifact: HttpResponseOptions = { body, @@ -72,11 +71,10 @@ export function registerDownloadExceptionListRoute( }, }; - const [validated, errors] = validate(artifact, downloadArtifactResponseSchema); - if (errors !== null || validated === null) { - return res.internalError({ body: errors! }); + if (validateDownload && !downloadArtifactResponseSchema.is(artifact)) { + return res.internalError({ body: 'Artifact failed to validate.' }); } else { - return res.ok((validated as unknown) as HttpResponseOptions); + return res.ok(artifact); } }; diff --git a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/manifest.ts b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/manifest.ts new file mode 100644 index 00000000000000..707d4c1374fe2d --- /dev/null +++ b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/manifest.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { manifestSchemaVersion, semanticVersion } from '../../../../common/endpoint/schema/common'; + +const optionalVersions = t.partial({ + soVersion: t.string, +}); + +export const manifestVersion = t.intersection([ + optionalVersions, + t.exact( + t.type({ + schemaVersion: manifestSchemaVersion, + semanticVersion, + }) + ), +]); +export type ManifestVersion = t.TypeOf; diff --git a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts index d95627601a183b..ae565f785c399d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts +++ b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.mock.ts @@ -53,4 +53,6 @@ export const getInternalArtifactMockWithDiffs = async ( export const getInternalManifestMock = (): InternalManifestSchema => ({ ids: [], + schemaVersion: 'v1', + semanticVersion: '1.0.0', }); diff --git a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts index 4dea916dcb4369..56f247b65d802f 100644 --- a/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts +++ b/x-pack/plugins/security_solution/server/endpoint/schemas/artifacts/saved_objects.ts @@ -9,8 +9,10 @@ import { compressionAlgorithm, encryptionAlgorithm, identifier, + semanticVersion, sha256, size, + manifestSchemaVersion, } from '../../../../common/endpoint/schema/common'; import { created } from './common'; @@ -58,6 +60,8 @@ export type InternalArtifactCreateSchema = t.TypeOf; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_client.ts index 385f115e6301a5..e55243f0650a56 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_client.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_client.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { SavedObject, SavedObjectsClientContract, diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts index c838f772fb66b0..d99d6a959d7aac 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.test.ts @@ -16,22 +16,17 @@ describe('manifest_manager', () => { describe('ManifestManager sanity checks', () => { test('ManifestManager can retrieve and diff manifests', async () => { const manifestManager = getManifestManagerMock(); - const oldManifest = await manifestManager.getLastComputedManifest( - ManifestConstants.SCHEMA_VERSION - ); - const newManifest = await manifestManager.buildNewManifest( - ManifestConstants.SCHEMA_VERSION, - oldManifest! - ); + const oldManifest = await manifestManager.getLastComputedManifest(); + const newManifest = await manifestManager.buildNewManifest(oldManifest!); expect(newManifest.diff(oldManifest!)).toEqual([ { id: - 'endpoint-exceptionlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', + 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', type: 'delete', }, { id: - 'endpoint-exceptionlist-linux-v1-0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', + 'endpoint-exceptionlist-macos-v1-0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', type: 'add', }, ]); @@ -40,23 +35,18 @@ describe('manifest_manager', () => { test('ManifestManager populates cache properly', async () => { const cache = new LRU({ max: 10, maxAge: 1000 * 60 * 60 }); const manifestManager = getManifestManagerMock({ cache }); - const oldManifest = await manifestManager.getLastComputedManifest( - ManifestConstants.SCHEMA_VERSION - ); - const newManifest = await manifestManager.buildNewManifest( - ManifestConstants.SCHEMA_VERSION, - oldManifest! - ); + const oldManifest = await manifestManager.getLastComputedManifest(); + const newManifest = await manifestManager.buildNewManifest(oldManifest!); const diffs = newManifest.diff(oldManifest!); expect(diffs).toEqual([ { id: - 'endpoint-exceptionlist-linux-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', + 'endpoint-exceptionlist-macos-v1-96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', type: 'delete', }, { id: - 'endpoint-exceptionlist-linux-v1-0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', + 'endpoint-exceptionlist-macos-v1-0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', type: 'add', }, ]); @@ -104,13 +94,8 @@ describe('manifest_manager', () => { test('ManifestManager cannot dispatch incomplete (uncompressed) artifact', async () => { const packageConfigService = createPackageConfigServiceMock(); const manifestManager = getManifestManagerMock({ packageConfigService }); - const oldManifest = await manifestManager.getLastComputedManifest( - ManifestConstants.SCHEMA_VERSION - ); - const newManifest = await manifestManager.buildNewManifest( - ManifestConstants.SCHEMA_VERSION, - oldManifest! - ); + const oldManifest = await manifestManager.getLastComputedManifest(); + const newManifest = await manifestManager.buildNewManifest(oldManifest!); const dispatchErrors = await manifestManager.tryDispatch(newManifest); expect(dispatchErrors.length).toEqual(1); expect(dispatchErrors[0].message).toEqual('Invalid manifest'); @@ -119,17 +104,14 @@ describe('manifest_manager', () => { test('ManifestManager can dispatch manifest', async () => { const packageConfigService = createPackageConfigServiceMock(); const manifestManager = getManifestManagerMock({ packageConfigService }); - const oldManifest = await manifestManager.getLastComputedManifest( - ManifestConstants.SCHEMA_VERSION - ); - const newManifest = await manifestManager.buildNewManifest( - ManifestConstants.SCHEMA_VERSION, - oldManifest! - ); + const oldManifest = await manifestManager.getLastComputedManifest(); + const newManifest = await manifestManager.buildNewManifest(oldManifest!); const diffs = newManifest.diff(oldManifest!); const newArtifactId = diffs[1].id; await newManifest.compressArtifact(newArtifactId); + newManifest.bumpSemanticVersion(); + const dispatchErrors = await manifestManager.tryDispatch(newManifest); expect(dispatchErrors).toEqual([]); @@ -140,10 +122,10 @@ describe('manifest_manager', () => { expect( packageConfigService.update.mock.calls[0][2].inputs[0].config!.artifact_manifest.value ).toEqual({ - manifest_version: '520f6cf88b3f36a065c6ca81058d5f8690aadadf6fe857f8dec4cc37589e7283', + manifest_version: '1.0.1', schema_version: 'v1', artifacts: { - 'endpoint-exceptionlist-linux-v1': { + 'endpoint-exceptionlist-macos-v1': { compression_algorithm: 'zlib', encryption_algorithm: 'none', decoded_sha256: '0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', @@ -151,17 +133,7 @@ describe('manifest_manager', () => { decoded_size: 292, encoded_size: 131, relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', - }, - 'endpoint-exceptionlist-macos-v1': { - compression_algorithm: 'zlib', - encryption_algorithm: 'none', - decoded_sha256: '96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', - encoded_sha256: '975382ab55d019cbab0bbac207a54e2a7d489fad6e8f6de34fc6402e5ef37b1e', - decoded_size: 432, - encoded_size: 147, - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/96b76a1a911662053a1562ac14c4ff1e87c2ff550d6fe52e1e0b3790526597d3', + '/api/endpoint/artifacts/download/endpoint-exceptionlist-macos-v1/0a5a2013a79f9e60682472284a1be45ab1ff68b9b43426d00d665016612c15c8', }, 'endpoint-exceptionlist-windows-v1': { compression_algorithm: 'zlib', @@ -180,17 +152,14 @@ describe('manifest_manager', () => { test('ManifestManager fails to dispatch on conflict', async () => { const packageConfigService = createPackageConfigServiceMock(); const manifestManager = getManifestManagerMock({ packageConfigService }); - const oldManifest = await manifestManager.getLastComputedManifest( - ManifestConstants.SCHEMA_VERSION - ); - const newManifest = await manifestManager.buildNewManifest( - ManifestConstants.SCHEMA_VERSION, - oldManifest! - ); + const oldManifest = await manifestManager.getLastComputedManifest(); + const newManifest = await manifestManager.buildNewManifest(oldManifest!); const diffs = newManifest.diff(oldManifest!); const newArtifactId = diffs[1].id; await newManifest.compressArtifact(newArtifactId); + newManifest.bumpSemanticVersion(); + packageConfigService.update.mockRejectedValueOnce({ status: 409 }); const dispatchErrors = await manifestManager.tryDispatch(newManifest); expect(dispatchErrors).toEqual([{ status: 409 }]); @@ -202,13 +171,8 @@ describe('manifest_manager', () => { savedObjectsClient, }); - const oldManifest = await manifestManager.getLastComputedManifest( - ManifestConstants.SCHEMA_VERSION - ); - const newManifest = await manifestManager.buildNewManifest( - ManifestConstants.SCHEMA_VERSION, - oldManifest! - ); + const oldManifest = await manifestManager.getLastComputedManifest(); + const newManifest = await manifestManager.buildNewManifest(oldManifest!); const diffs = newManifest.diff(oldManifest!); const oldArtifactId = diffs[0].id; const newArtifactId = diffs[1].id; diff --git a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts index b52c51ba789af2..217fd6de2ba689 100644 --- a/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts +++ b/x-pack/plugins/security_solution/server/endpoint/services/artifacts/manifest_manager/manifest_manager.ts @@ -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 semver from 'semver'; import { Logger, SavedObjectsClientContract } from 'src/core/server'; import LRU from 'lru-cache'; import { PackageConfigServiceInterface } from '../../../../../../ingest_manager/server'; @@ -13,7 +13,6 @@ import { manifestDispatchSchema } from '../../../../../common/endpoint/schema/ma import { ArtifactConstants, - ManifestConstants, Manifest, buildArtifact, getFullEndpointExceptionList, @@ -52,6 +51,7 @@ export class ManifestManager { protected savedObjectsClient: SavedObjectsClientContract; protected logger: Logger; protected cache: LRU; + protected schemaVersion: ManifestSchemaVersion; constructor(context: ManifestManagerContext) { this.artifactClient = context.artifactClient; @@ -60,28 +60,27 @@ export class ManifestManager { this.savedObjectsClient = context.savedObjectsClient; this.logger = context.logger; this.cache = context.cache; + this.schemaVersion = 'v1'; } /** - * Gets a ManifestClient for the provided schemaVersion. + * Gets a ManifestClient for this manager's schemaVersion. * - * @param schemaVersion The schema version of the manifest. - * @returns {ManifestClient} A ManifestClient scoped to the provided schemaVersion. + * @returns {ManifestClient} A ManifestClient scoped to the appropriate schemaVersion. */ - protected getManifestClient(schemaVersion: string): ManifestClient { - return new ManifestClient(this.savedObjectsClient, schemaVersion as ManifestSchemaVersion); + protected getManifestClient(): ManifestClient { + return new ManifestClient(this.savedObjectsClient, this.schemaVersion); } /** * Builds an array of artifacts (one per supported OS) based on the current * state of exception-list-agnostic SOs. * - * @param schemaVersion The schema version of the artifact * @returns {Promise} An array of uncompressed artifacts built from exception-list-agnostic SOs. * @throws Throws/rejects if there are errors building the list. */ protected async buildExceptionListArtifacts( - schemaVersion: string + artifactSchemaVersion?: string ): Promise { return ArtifactConstants.SUPPORTED_OPERATING_SYSTEMS.reduce< Promise @@ -89,10 +88,10 @@ export class ManifestManager { const exceptionList = await getFullEndpointExceptionList( this.exceptionListClient, os, - schemaVersion + artifactSchemaVersion ?? 'v1' ); const artifacts = await acc; - const artifact = await buildArtifact(exceptionList, os, schemaVersion); + const artifact = await buildArtifact(exceptionList, os, artifactSchemaVersion ?? 'v1'); return Promise.resolve([...artifacts, artifact]); }, Promise.resolve([])); } @@ -168,20 +167,23 @@ export class ManifestManager { * Returns the last computed manifest based on the state of the * user-artifact-manifest SO. * - * @param schemaVersion The schema version of the manifest. * @returns {Promise} The last computed manifest, or null if does not exist. * @throws Throws/rejects if there is an unexpected error retrieving the manifest. */ - public async getLastComputedManifest(schemaVersion: string): Promise { + public async getLastComputedManifest(): Promise { try { - const manifestClient = this.getManifestClient(schemaVersion); + const manifestClient = this.getManifestClient(); const manifestSo = await manifestClient.getManifest(); if (manifestSo.version === undefined) { throw new Error('No version returned for manifest.'); } - const manifest = new Manifest(schemaVersion, manifestSo.version); + const manifest = new Manifest({ + schemaVersion: this.schemaVersion, + semanticVersion: manifestSo.attributes.semanticVersion, + soVersion: manifestSo.version, + }); for (const id of manifestSo.attributes.ids) { const artifactSo = await this.artifactClient.getArtifact(id); @@ -199,22 +201,17 @@ export class ManifestManager { /** * Builds a new manifest based on the current user exception list. * - * @param schemaVersion The schema version of the manifest. * @param baselineManifest A baseline manifest to use for initializing pre-existing artifacts. * @returns {Promise} A new Manifest object reprenting the current exception list. */ - public async buildNewManifest( - schemaVersion: string, - baselineManifest?: Manifest - ): Promise { + public async buildNewManifest(baselineManifest?: Manifest): Promise { // Build new exception list artifacts - const artifacts = await this.buildExceptionListArtifacts(ArtifactConstants.SCHEMA_VERSION); + const artifacts = await this.buildExceptionListArtifacts(); // Build new manifest const manifest = Manifest.fromArtifacts( artifacts, - ManifestConstants.SCHEMA_VERSION, - baselineManifest ?? Manifest.getDefault(schemaVersion) + baselineManifest ?? Manifest.getDefault(this.schemaVersion) ); return manifest; @@ -247,14 +244,12 @@ export class ManifestManager { for (const packageConfig of items) { const { id, revision, updated_at, updated_by, ...newPackageConfig } = packageConfig; if (newPackageConfig.inputs.length > 0 && newPackageConfig.inputs[0].config !== undefined) { - const artifactManifest = newPackageConfig.inputs[0].config.artifact_manifest ?? { + const oldManifest = newPackageConfig.inputs[0].config.artifact_manifest ?? { value: {}, }; - const oldManifest = - Manifest.fromPkgConfig(artifactManifest.value) ?? - Manifest.getDefault(ManifestConstants.SCHEMA_VERSION); - if (!manifest.equals(oldManifest)) { + const newManifestVersion = manifest.getSemanticVersion(); + if (semver.gt(newManifestVersion, oldManifest.value.manifest_version)) { newPackageConfig.inputs[0].config.artifact_manifest = { value: serializedManifest, }; @@ -262,7 +257,7 @@ export class ManifestManager { try { await this.packageConfigService.update(this.savedObjectsClient, id, newPackageConfig); this.logger.debug( - `Updated package config ${id} with manifest version ${manifest.getSha256()}` + `Updated package config ${id} with manifest version ${manifest.getSemanticVersion()}` ); } catch (err) { errors.push(err); @@ -274,8 +269,7 @@ export class ManifestManager { errors.push(new Error(`Package config ${id} has no config.`)); } } - - paging = page * items.length < total; + paging = (page - 1) * 20 + items.length < total; page++; } @@ -290,11 +284,11 @@ export class ManifestManager { */ public async commit(manifest: Manifest): Promise { try { - const manifestClient = this.getManifestClient(manifest.getSchemaVersion()); + const manifestClient = this.getManifestClient(); // Commit the new manifest const manifestSo = manifest.toSavedObject(); - const version = manifest.getSoVersion(); + const version = manifest.getSavedObjectVersion(); if (version == null) { await manifestClient.createManifest(manifestSo); @@ -304,7 +298,7 @@ export class ManifestManager { }); } - this.logger.info(`Committed manifest ${manifest.getSha256()}`); + this.logger.info(`Committed manifest ${manifest.getSemanticVersion()}`); } catch (err) { return err; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts index bfaab096a50135..196d816b6b2570 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts @@ -25,6 +25,8 @@ export const createMockConfig = () => ({ from: 'now-15m', to: 'now', }, + packagerTaskInterval: '60s', + validateArtifactDownloads: true, }); export const mockGetCurrentUser = { diff --git a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json index 47390f0428742e..3af10705976716 100644 --- a/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json +++ b/x-pack/test/functional/es_archives/endpoint/artifacts/api_feature/data.json @@ -1,28 +1,3 @@ -{ - "type": "doc", - "value": { - "id": "endpoint:user-artifact:endpoint-exceptionlist-linux-v1-d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", - "index": ".kibana", - "source": { - "references": [ - ], - "endpoint:user-artifact": { - "body": "eJylkM8KwjAMxl9Fci59gN29iicvMqR02QjUbiSpKGPvbiw6ETwpuX1/fh9kBszKhALNcQa9TQgNCJ2nhOA+vJ4wdWaGqJSHPY8RRXxPCb3QkJEtP07IQUe2GOWYSoedqU8qXq16ikGqeAmpPNRtCqIU3WbnDx4WN38d/WvhQqmCXzDlIlojP9CsjLC0bqWtHwhaGN/1jHVkae3u+6N6Sg==", - "created": 1593016187465, - "compressionAlgorithm": "zlib", - "encryptionAlgorithm": "none", - "identifier": "endpoint-exceptionlist-linux-v1", - "encodedSha256": "5caaeabcb7864d47157fc7c28d5a7398b4f6bbaaa565d789c02ee809253b7613", - "encodedSize": 160, - "decodedSha256": "d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", - "decodedSize": 358 - }, - "type": "endpoint:user-artifact", - "updated_at": "2020-06-24T16:29:47.584Z" - } - } -} - { "type": "doc", "value": { @@ -83,8 +58,9 @@ ], "endpoint:user-artifact-manifest": { "created": 1593183699663, + "schemaVersion": "v1", + "semanticVersion": "1.0.1", "ids": [ - "endpoint-exceptionlist-linux-v1-d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f", "endpoint-exceptionlist-macos-v1-d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658", "endpoint-exceptionlist-windows-v1-8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e" ] diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts index ca84384390a291..d4947222a6cc0d 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/policy_details.ts @@ -118,18 +118,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, artifact_manifest: { artifacts: { - 'endpoint-exceptionlist-linux-v1': { - compression_algorithm: 'zlib', - decoded_sha256: - 'd801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - decoded_size: 14, - encoded_sha256: - 'f8e6afa1d5662f5b37f83337af774b5785b5b7f1daee08b7b00c2d6813874cda', - encoded_size: 22, - encryption_algorithm: 'none', - relative_url: - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d801aa1fb7ddcc330a5e3173372ea6af4a3d08ec58074478e85aa5603e926658', - }, 'endpoint-exceptionlist-macos-v1': { compression_algorithm: 'zlib', decoded_sha256: diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts index a4a8de418157f4..d5106f55499241 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/artifacts/index.ts @@ -83,24 +83,24 @@ export default function (providerContext: FtrProviderContext) { it('should download an artifact with list items', async () => { await supertestWithoutAuth .get( - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' ) .set('kbn-xsrf', 'xxx') .set('authorization', `ApiKey ${agentAccessAPIKey}`) .send() .expect(200) .expect((response) => { - expect(response.body.byteLength).to.equal(160); + expect(response.body.byteLength).to.equal(191); const encodedHash = createHash('sha256').update(response.body).digest('hex'); expect(encodedHash).to.equal( - '5caaeabcb7864d47157fc7c28d5a7398b4f6bbaaa565d789c02ee809253b7613' + '73015ee5131dabd1b48aa4776d3e766d836f8dd8c9fa8999c9b931f60027f07f' ); const decodedBody = inflateSync(response.body); const decodedHash = createHash('sha256').update(decodedBody).digest('hex'); expect(decodedHash).to.equal( - 'd2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + '8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' ); - expect(decodedBody.byteLength).to.equal(358); + expect(decodedBody.byteLength).to.equal(704); const artifactJson = JSON.parse(decodedBody.toString()); expect(artifactJson).to.eql({ entries: [ @@ -113,6 +113,35 @@ export default function (providerContext: FtrProviderContext) { type: 'exact_cased', value: 'Elastic, N.V.', }, + { + entries: [ + { + field: 'signer', + operator: 'included', + type: 'exact_cased', + value: '😈', + }, + { + field: 'trusted', + operator: 'included', + type: 'exact_cased', + value: 'true', + }, + ], + field: 'file.signature', + type: 'nested', + }, + ], + }, + { + type: 'simple', + entries: [ + { + field: 'actingProcess.file.signer', + operator: 'included', + type: 'exact_cased', + value: 'Another signer', + }, { entries: [ { @@ -280,7 +309,7 @@ export default function (providerContext: FtrProviderContext) { it('should download an artifact from cache', async () => { await supertestWithoutAuth .get( - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' ) .set('kbn-xsrf', 'xxx') .set('authorization', `ApiKey ${agentAccessAPIKey}`) @@ -292,22 +321,24 @@ export default function (providerContext: FtrProviderContext) { .then(async () => { await supertestWithoutAuth .get( - '/api/endpoint/artifacts/download/endpoint-exceptionlist-linux-v1/d2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + '/api/endpoint/artifacts/download/endpoint-exceptionlist-windows-v1/8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' ) .set('kbn-xsrf', 'xxx') .set('authorization', `ApiKey ${agentAccessAPIKey}`) .send() .expect(200) .expect((response) => { + expect(response.body.byteLength).to.equal(191); const encodedHash = createHash('sha256').update(response.body).digest('hex'); expect(encodedHash).to.equal( - '5caaeabcb7864d47157fc7c28d5a7398b4f6bbaaa565d789c02ee809253b7613' + '73015ee5131dabd1b48aa4776d3e766d836f8dd8c9fa8999c9b931f60027f07f' ); const decodedBody = inflateSync(response.body); const decodedHash = createHash('sha256').update(decodedBody).digest('hex'); expect(decodedHash).to.equal( - 'd2a9c760005b08d43394e59a8701ae75c80881934ccf15a006944452b80f7f9f' + '8d2bcc37e82fad5d06e2c9e4bd96793ea8905ace1d528a57d0d0579ecc8c647e' ); + expect(decodedBody.byteLength).to.equal(704); const artifactJson = JSON.parse(decodedBody.toString()); expect(artifactJson).to.eql({ entries: [ @@ -320,6 +351,35 @@ export default function (providerContext: FtrProviderContext) { type: 'exact_cased', value: 'Elastic, N.V.', }, + { + entries: [ + { + field: 'signer', + operator: 'included', + type: 'exact_cased', + value: '😈', + }, + { + field: 'trusted', + operator: 'included', + type: 'exact_cased', + value: 'true', + }, + ], + field: 'file.signature', + type: 'nested', + }, + ], + }, + { + type: 'simple', + entries: [ + { + field: 'actingProcess.file.signer', + operator: 'included', + type: 'exact_cased', + value: 'Another signer', + }, { entries: [ { From 7314c1ba8fe896473de9b1a081ee8c3133eb7075 Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Tue, 28 Jul 2020 23:05:52 -0400 Subject: [PATCH 19/27] [SECURITY_SOLUTION] Task/add detections rule text (#73596) --- .../view/policy_forms/protections/malware.tsx | 29 ++++++++++++++++++- .../components/endpoint_notice/index.tsx | 4 +-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index dee1e27782e691..84d4bf5355cd98 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -7,9 +7,18 @@ import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { EuiRadio, EuiSwitch, EuiTitle, EuiSpacer, htmlIdGenerator } from '@elastic/eui'; +import { + EuiRadio, + EuiSwitch, + EuiTitle, + EuiSpacer, + htmlIdGenerator, + EuiCallOut, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { APP_ID } from '../../../../../../../common/constants'; +import { SecurityPageName } from '../../../../../../app/types'; import { Immutable, ProtectionModes } from '../../../../../../../common/endpoint/types'; import { OS, MalwareProtectionOSes } from '../../../types'; @@ -17,6 +26,7 @@ import { ConfigForm } from '../config_form'; import { policyConfig } from '../../../store/policy_details/selectors'; import { usePolicyDetailsSelector } from '../../policy_hooks'; import { clone } from '../../../models/policy_details_config'; +import { LinkToApp } from '../../../../../../common/components/endpoint/link_to_app'; const ProtectionRadioGroup = styled.div` display: flex; @@ -177,6 +187,23 @@ export const MalwareProtections = React.memo(() => { rightCorner={protectionSwitch} > {radioButtons} + + + + + + ), + }} + /> + ); }); diff --git a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx index 7170412cb55ad2..1d726a7dbd9017 100644 --- a/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx +++ b/x-pack/plugins/security_solution/public/overview/components/endpoint_notice/index.tsx @@ -33,7 +33,7 @@ export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) => } @@ -49,7 +49,7 @@ export const EndpointNotice = memo<{ onDismiss: () => void }>(({ onDismiss }) => From 7059270ce975e2b142a2f6238857dd24cc9446b8 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 28 Jul 2020 22:09:04 -0500 Subject: [PATCH 20/27] [deb/rpm] fix config folder path (#73001) Co-authored-by: Elastic Machine Co-authored-by: Tyler Smalley --- docs/setup/production.asciidoc | 2 +- .../config/deprecation/core_deprecations.test.ts | 2 +- .../server/config/deprecation/core_deprecations.ts | 2 +- src/core/server/path/index.ts | 10 +++++++--- src/dev/build/tasks/bin/scripts/kibana-keystore | 2 +- .../build/tasks/bin/scripts/kibana-keystore.bat | 4 ++-- src/dev/build/tasks/bin/scripts/kibana-plugin | 2 +- src/dev/build/tasks/bin/scripts/kibana-plugin.bat | 4 ++-- src/dev/build/tasks/bin/scripts/kibana.bat | 4 ++-- .../os_packages/package_scripts/post_install.sh | 14 ++++++++++++++ .../service_templates/sysv/etc/default/kibana | 2 +- 11 files changed, 33 insertions(+), 15 deletions(-) diff --git a/docs/setup/production.asciidoc b/docs/setup/production.asciidoc index afb4b37df6a289..23dbb3346b7ef2 100644 --- a/docs/setup/production.asciidoc +++ b/docs/setup/production.asciidoc @@ -167,7 +167,7 @@ These can be used to automatically update the list of hosts as a cluster is resi Kibana has a default maximum memory limit of 1.4 GB, and in most cases, we recommend leaving this unconfigured. In some scenarios, such as large reporting jobs, it may make sense to tweak limits to meet more specific requirements. -You can modify this limit by setting `--max-old-space-size` in the `node.options` config file that can be found inside `kibana/config` folder or any other configured with the environment variable `KIBANA_PATH_CONF` (for example in debian based system would be `/etc/kibana`). +You can modify this limit by setting `--max-old-space-size` in the `node.options` config file that can be found inside `kibana/config` folder or any other configured with the environment variable `KBN_PATH_CONF` (for example in debian based system would be `/etc/kibana`). The option accepts a limit in MB: -------- diff --git a/src/core/server/config/deprecation/core_deprecations.test.ts b/src/core/server/config/deprecation/core_deprecations.test.ts index ebdb6f1c88b16f..adf0f523393660 100644 --- a/src/core/server/config/deprecation/core_deprecations.test.ts +++ b/src/core/server/config/deprecation/core_deprecations.test.ts @@ -51,7 +51,7 @@ describe('core deprecations', () => { const { messages } = applyCoreDeprecations(); expect(messages).toMatchInlineSnapshot(` Array [ - "Environment variable CONFIG_PATH is deprecated. It has been replaced with KIBANA_PATH_CONF pointing to a config folder", + "Environment variable CONFIG_PATH is deprecated. It has been replaced with KBN_PATH_CONF pointing to a config folder", ] `); }); diff --git a/src/core/server/config/deprecation/core_deprecations.ts b/src/core/server/config/deprecation/core_deprecations.ts index 715f5b883139f9..6cc0e5ef138d54 100644 --- a/src/core/server/config/deprecation/core_deprecations.ts +++ b/src/core/server/config/deprecation/core_deprecations.ts @@ -23,7 +23,7 @@ import { ConfigDeprecationProvider, ConfigDeprecation } from './types'; const configPathDeprecation: ConfigDeprecation = (settings, fromPath, log) => { if (has(process.env, 'CONFIG_PATH')) { log( - `Environment variable CONFIG_PATH is deprecated. It has been replaced with KIBANA_PATH_CONF pointing to a config folder` + `Environment variable CONFIG_PATH is deprecated. It has been replaced with KBN_PATH_CONF pointing to a config folder` ); } return settings; diff --git a/src/core/server/path/index.ts b/src/core/server/path/index.ts index 1bb650518c47aa..7c1a81643fbc81 100644 --- a/src/core/server/path/index.ts +++ b/src/core/server/path/index.ts @@ -25,14 +25,18 @@ import { fromRoot } from '../utils'; const isString = (v: any): v is string => typeof v === 'string'; const CONFIG_PATHS = [ + process.env.KBN_PATH_CONF && join(process.env.KBN_PATH_CONF, 'kibana.yml'), process.env.KIBANA_PATH_CONF && join(process.env.KIBANA_PATH_CONF, 'kibana.yml'), process.env.CONFIG_PATH, // deprecated fromRoot('config/kibana.yml'), ].filter(isString); -const CONFIG_DIRECTORIES = [process.env.KIBANA_PATH_CONF, fromRoot('config'), '/etc/kibana'].filter( - isString -); +const CONFIG_DIRECTORIES = [ + process.env.KBN_PATH_CONF, + process.env.KIBANA_PATH_CONF, + fromRoot('config'), + '/etc/kibana', +].filter(isString); const DATA_PATHS = [ process.env.DATA_PATH, // deprecated diff --git a/src/dev/build/tasks/bin/scripts/kibana-keystore b/src/dev/build/tasks/bin/scripts/kibana-keystore index f83df118d24e87..d811e70095548c 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-keystore +++ b/src/dev/build/tasks/bin/scripts/kibana-keystore @@ -14,7 +14,7 @@ while [ -h "$SCRIPT" ] ; do done DIR="$(dirname "${SCRIPT}")/.." -CONFIG_DIR=${KIBANA_PATH_CONF:-"$DIR/config"} +CONFIG_DIR=${KBN_PATH_CONF:-"$DIR/config"} NODE="${DIR}/node/bin/node" test -x "$NODE" if [ ! -x "$NODE" ]; then diff --git a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat index 389eb5bf488e46..7e227141c8ba36 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-keystore.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-keystore.bat @@ -12,8 +12,8 @@ If Not Exist "%NODE%" ( Exit /B 1 ) -set CONFIG_DIR=%KIBANA_PATH_CONF% -If [%KIBANA_PATH_CONF%] == [] ( +set CONFIG_DIR=%KBN_PATH_CONF% +If [%KBN_PATH_CONF%] == [] ( set CONFIG_DIR=%DIR%\config ) diff --git a/src/dev/build/tasks/bin/scripts/kibana-plugin b/src/dev/build/tasks/bin/scripts/kibana-plugin index f1102e1ef5a320..f4486e9cf85fbb 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-plugin +++ b/src/dev/build/tasks/bin/scripts/kibana-plugin @@ -14,7 +14,7 @@ while [ -h "$SCRIPT" ] ; do done DIR="$(dirname "${SCRIPT}")/.." -CONFIG_DIR=${KIBANA_PATH_CONF:-"$DIR/config"} +CONFIG_DIR=${KBN_PATH_CONF:-"$DIR/config"} NODE="${DIR}/node/bin/node" test -x "$NODE" if [ ! -x "$NODE" ]; then diff --git a/src/dev/build/tasks/bin/scripts/kibana-plugin.bat b/src/dev/build/tasks/bin/scripts/kibana-plugin.bat index 6815b1b9eab8ca..4fb30977fda06e 100755 --- a/src/dev/build/tasks/bin/scripts/kibana-plugin.bat +++ b/src/dev/build/tasks/bin/scripts/kibana-plugin.bat @@ -13,8 +13,8 @@ If Not Exist "%NODE%" ( Exit /B 1 ) -set CONFIG_DIR=%KIBANA_PATH_CONF% -If [%KIBANA_PATH_CONF%] == [] ( +set CONFIG_DIR=%KBN_PATH_CONF% +If [%KBN_PATH_CONF%] == [] ( set CONFIG_DIR=%DIR%\config ) diff --git a/src/dev/build/tasks/bin/scripts/kibana.bat b/src/dev/build/tasks/bin/scripts/kibana.bat index d3edc92f110a53..98dd9ec05a48cb 100755 --- a/src/dev/build/tasks/bin/scripts/kibana.bat +++ b/src/dev/build/tasks/bin/scripts/kibana.bat @@ -14,8 +14,8 @@ If Not Exist "%NODE%" ( Exit /B 1 ) -set CONFIG_DIR=%KIBANA_PATH_CONF% -If [%KIBANA_PATH_CONF%] == [] ( +set CONFIG_DIR=%KBN_PATH_CONF% +If [%KBN_PATH_CONF%] == [] ( set CONFIG_DIR=%DIR%\config ) diff --git a/src/dev/build/tasks/os_packages/package_scripts/post_install.sh b/src/dev/build/tasks/os_packages/package_scripts/post_install.sh index c49b291d1a0c9d..1c679bdb40b59c 100644 --- a/src/dev/build/tasks/os_packages/package_scripts/post_install.sh +++ b/src/dev/build/tasks/os_packages/package_scripts/post_install.sh @@ -31,6 +31,10 @@ case $1 in --ingroup "<%= group %>" --shell /bin/false "<%= user %>" fi + if [ -n "$2" ]; then + IS_UPGRADE=true + fi + set_access ;; abort-deconfigure|abort-upgrade|abort-remove) @@ -47,6 +51,10 @@ case $1 in -c "kibana service user" "<%= user %>" fi + if [ "$1" = "2" ]; then + IS_UPGRADE=true + fi + set_access ;; @@ -55,3 +63,9 @@ case $1 in exit 1 ;; esac + +if [ "$IS_UPGRADE" = "true" ]; then + if command -v systemctl >/dev/null; then + systemctl daemon-reload + fi +fi diff --git a/src/dev/build/tasks/os_packages/service_templates/sysv/etc/default/kibana b/src/dev/build/tasks/os_packages/service_templates/sysv/etc/default/kibana index 092dc6482fa1d4..ee019d348ed97f 100644 --- a/src/dev/build/tasks/os_packages/service_templates/sysv/etc/default/kibana +++ b/src/dev/build/tasks/os_packages/service_templates/sysv/etc/default/kibana @@ -12,4 +12,4 @@ KILL_ON_STOP_TIMEOUT=0 BABEL_CACHE_PATH="/var/lib/kibana/optimize/.babel_register_cache.json" -KIBANA_PATH_CONF="/etc/kibana" +KBN_PATH_CONF="/etc/kibana" From e64573231905158121187f07f8aadc26ba4ac287 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Tue, 28 Jul 2020 23:27:14 -0400 Subject: [PATCH 21/27] [Security Solution][Exceptions] - Update rule.exceptions_list to include exception list list_id (#73349) ## Summary This PR addresses the following: - Adds `list_id` to `rule.exceptions_list` - this is needed in a number of features - Updated `getExceptions` in `x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts` to use the latest exception item find endpoint that accepts an array of lists (previously was looping through lists and conducting a `find` for each) - Updated prepackaged rule that makes reference to global endpoint list to include `list_id` - Updates `formatAboutStepData` in `x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts` to include exception list `list_id` --- .../hooks/use_exception_list.test.ts | 28 +++++++--- .../plugins/lists/public/exceptions/types.ts | 1 + .../add_prepackged_rules_schema.test.ts | 5 +- .../request/create_rules_schema.test.ts | 5 +- .../request/import_rules_schema.test.ts | 5 +- .../request/patch_rules_schema.test.ts | 5 +- .../request/update_rules_schema.test.ts | 5 +- .../schemas/types/lists.mock.ts | 9 ++- .../schemas/types/lists.test.ts | 10 ++-- .../detection_engine/schemas/types/lists.ts | 5 +- ...se_fetch_or_create_rule_exception_list.tsx | 1 + .../exceptions/viewer/index.test.tsx | 2 + .../components/exceptions/viewer/index.tsx | 2 - .../rules/all/__mocks__/mock.ts | 7 --- .../rules/create/helpers.test.ts | 31 ++++------- .../detection_engine/rules/create/helpers.ts | 7 ++- .../detection_engine/rules/details/index.tsx | 4 +- .../prepackaged_rules/elastic_endpoint.json | 14 ++--- .../prepackaged_rules/external_alerts.json | 9 +-- .../detection_engine/signals/utils.test.ts | 55 ++++++++----------- .../lib/detection_engine/signals/utils.ts | 49 +++++------------ 21 files changed, 122 insertions(+), 137 deletions(-) diff --git a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts index 918397d01ce2c4..f678ed4faeeda0 100644 --- a/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts +++ b/x-pack/plugins/lists/public/exceptions/hooks/use_exception_list.test.ts @@ -41,7 +41,9 @@ describe('useExceptionList', () => { useExceptionList({ filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, - lists: [{ id: 'myListId', namespaceType: 'single', type: 'detection' }], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], onError: onErrorMock, pagination: { page: 1, @@ -76,7 +78,9 @@ describe('useExceptionList', () => { useExceptionList({ filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, - lists: [{ id: 'myListId', namespaceType: 'single', type: 'detection' }], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], onError: onErrorMock, onSuccess: onSuccessMock, pagination: { @@ -131,7 +135,9 @@ describe('useExceptionList', () => { initialProps: { filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, - lists: [{ id: 'myListId', namespaceType: 'single', type: 'detection' }], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], onError: onErrorMock, onSuccess: onSuccessMock, pagination: { @@ -146,7 +152,9 @@ describe('useExceptionList', () => { rerender({ filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, - lists: [{ id: 'newListId', namespaceType: 'single', type: 'detection' }], + lists: [ + { id: 'newListId', listId: 'new_list_id', namespaceType: 'single', type: 'detection' }, + ], onError: onErrorMock, onSuccess: onSuccessMock, pagination: { @@ -173,7 +181,9 @@ describe('useExceptionList', () => { useExceptionList({ filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, - lists: [{ id: 'myListId', namespaceType: 'single', type: 'detection' }], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], onError: onErrorMock, pagination: { page: 1, @@ -210,7 +220,9 @@ describe('useExceptionList', () => { useExceptionList({ filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, - lists: [{ id: 'myListId', namespaceType: 'single', type: 'detection' }], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], onError: onErrorMock, pagination: { page: 1, @@ -238,7 +250,9 @@ describe('useExceptionList', () => { useExceptionList({ filterOptions: { filter: '', tags: [] }, http: mockKibanaHttpService, - lists: [{ id: 'myListId', namespaceType: 'single', type: 'detection' }], + lists: [ + { id: 'myListId', listId: 'list_id', namespaceType: 'single', type: 'detection' }, + ], onError: onErrorMock, pagination: { page: 1, diff --git a/x-pack/plugins/lists/public/exceptions/types.ts b/x-pack/plugins/lists/public/exceptions/types.ts index f99323b3847814..c0ec72e1c19eb2 100644 --- a/x-pack/plugins/lists/public/exceptions/types.ts +++ b/x-pack/plugins/lists/public/exceptions/types.ts @@ -60,6 +60,7 @@ export interface UseExceptionListProps { export interface ExceptionIdentifiers { id: string; + listId: string; namespaceType: NamespaceType; type: ExceptionListType; } diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts index 5fd2c3dbbf8944..3cad48ec18fc1d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackged_rules_schema.test.ts @@ -1446,11 +1446,13 @@ describe('add prepackaged rules schema', () => { exceptions_list: [ { id: 'some_uuid', + list_id: 'list_id_single', namespace_type: 'single', type: 'detection', }, { - id: 'some_uuid', + id: 'endpoint_list', + list_id: 'endpoint_list', namespace_type: 'agnostic', type: 'endpoint', }, @@ -1535,6 +1537,7 @@ describe('add prepackaged rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "exceptions_list,list_id"', 'Invalid value "undefined" supplied to "exceptions_list,type"', 'Invalid value "not a namespace type" supplied to "exceptions_list,namespace_type"', ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts index 71f39649562491..c2c2f4784f2b5a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.test.ts @@ -1513,11 +1513,13 @@ describe('create rules schema', () => { exceptions_list: [ { id: 'some_uuid', + list_id: 'list_id_single', namespace_type: 'single', type: 'detection', }, { - id: 'some_uuid', + id: 'endpoint_list', + list_id: 'endpoint_list', namespace_type: 'agnostic', type: 'endpoint', }, @@ -1600,6 +1602,7 @@ describe('create rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "exceptions_list,list_id"', 'Invalid value "undefined" supplied to "exceptions_list,type"', 'Invalid value "not a namespace type" supplied to "exceptions_list,namespace_type"', ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts index 828626ef26d6fe..00a3f2126f44a0 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.test.ts @@ -1642,11 +1642,13 @@ describe('import rules schema', () => { exceptions_list: [ { id: 'some_uuid', + list_id: 'list_id_single', namespace_type: 'single', type: 'detection', }, { - id: 'some_uuid', + id: 'endpoint_list', + list_id: 'endpoint_list', namespace_type: 'agnostic', type: 'endpoint', }, @@ -1730,6 +1732,7 @@ describe('import rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "exceptions_list,list_id"', 'Invalid value "undefined" supplied to "exceptions_list,type"', 'Invalid value "not a namespace type" supplied to "exceptions_list,namespace_type"', ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts index e75aff1abe3e96..e4fc53b934f589 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.test.ts @@ -1176,11 +1176,13 @@ describe('patch_rules_schema', () => { exceptions_list: [ { id: 'some_uuid', + list_id: 'list_id_single', namespace_type: 'single', type: 'detection', }, { - id: 'some_uuid', + id: 'endpoint_list', + list_id: 'endpoint_list', namespace_type: 'agnostic', type: 'endpoint', }, @@ -1251,6 +1253,7 @@ describe('patch_rules_schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "exceptions_list,list_id"', 'Invalid value "undefined" supplied to "exceptions_list,type"', 'Invalid value "not a namespace type" supplied to "exceptions_list,namespace_type"', 'Invalid value "[{"id":"uuid_here","namespace_type":"not a namespace type"}]" supplied to "exceptions_list"', diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts index d18d2d91b963ca..024198d7830483 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.test.ts @@ -1448,11 +1448,13 @@ describe('update rules schema', () => { exceptions_list: [ { id: 'some_uuid', + list_id: 'list_id_single', namespace_type: 'single', type: 'detection', }, { - id: 'some_uuid', + id: 'endpoint_list', + list_id: 'endpoint_list', namespace_type: 'agnostic', type: 'endpoint', }, @@ -1534,6 +1536,7 @@ describe('update rules schema', () => { const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "exceptions_list,list_id"', 'Invalid value "undefined" supplied to "exceptions_list,type"', 'Invalid value "not a namespace type" supplied to "exceptions_list,namespace_type"', ]); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.mock.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.mock.ts index 0c7853bc3c08a1..fec75488118204 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.mock.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.mock.ts @@ -4,17 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ import { List, ListArray } from './lists'; +import { ENDPOINT_LIST_ID } from '../../../shared_imports'; export const getListMock = (): List => ({ id: 'some_uuid', + list_id: 'list_id_single', namespace_type: 'single', type: 'detection', }); -export const getListAgnosticMock = (): List => ({ - id: 'some_uuid', +export const getEndpointListMock = (): List => ({ + id: ENDPOINT_LIST_ID, + list_id: ENDPOINT_LIST_ID, namespace_type: 'agnostic', type: 'endpoint', }); -export const getListArrayMock = (): ListArray => [getListMock(), getListAgnosticMock()]; +export const getListArrayMock = (): ListArray => [getListMock(), getEndpointListMock()]; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts index 56ee4630996fdc..7a2c167bfd8554 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.test.ts @@ -9,7 +9,7 @@ import { left } from 'fp-ts/lib/Either'; import { foldLeftRight, getPaths } from '../../../test_utils'; -import { getListAgnosticMock, getListMock, getListArrayMock } from './lists.mock'; +import { getEndpointListMock, getListMock, getListArrayMock } from './lists.mock'; import { List, ListArray, @@ -31,7 +31,7 @@ describe('Lists', () => { }); test('it should validate a list with "namespace_type" of "agnostic"', () => { - const payload = getListAgnosticMock(); + const payload = getEndpointListMock(); const decoded = list.decode(payload); const message = pipe(decoded, foldLeftRight); @@ -91,7 +91,7 @@ describe('Lists', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "1" supplied to "Array<{| id: string, type: "detection" | "endpoint", namespace_type: "agnostic" | "single" |}>"', + 'Invalid value "1" supplied to "Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint", namespace_type: "agnostic" | "single" |}>"', ]); expect(message.schema).toEqual({}); }); @@ -122,8 +122,8 @@ describe('Lists', () => { const message = pipe(decoded, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'Invalid value "1" supplied to "(Array<{| id: string, type: "detection" | "endpoint", namespace_type: "agnostic" | "single" |}> | undefined)"', - 'Invalid value "[1]" supplied to "(Array<{| id: string, type: "detection" | "endpoint", namespace_type: "agnostic" | "single" |}> | undefined)"', + 'Invalid value "1" supplied to "(Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint", namespace_type: "agnostic" | "single" |}> | undefined)"', + 'Invalid value "[1]" supplied to "(Array<{| id: NonEmptyString, list_id: NonEmptyString, type: "detection" | "endpoint", namespace_type: "agnostic" | "single" |}> | undefined)"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts index e5aaee6d3ec74e..fecdd0761aadee 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/lists.ts @@ -8,9 +8,12 @@ import * as t from 'io-ts'; import { exceptionListType, namespaceType } from '../../../shared_imports'; +import { NonEmptyString } from './non_empty_string'; + export const list = t.exact( t.type({ - id: t.string, + id: NonEmptyString, + list_id: NonEmptyString, type: exceptionListType, namespace_type: namespaceType, }) diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx index 2a5ef7b21b5196..0d367e03a799f3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx @@ -102,6 +102,7 @@ export const useFetchOrCreateRuleExceptionList = ({ const newExceptionListReference = { id: newExceptionList.id, + list_id: newExceptionList.list_id, type: newExceptionList.type, namespace_type: newExceptionList.namespace_type, }; 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 index 986f27f6495ec5..84613d1c73536e 100644 --- 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 @@ -72,6 +72,7 @@ describe('ExceptionsViewer', () => { exceptionListsMeta={[ { id: '5b543420', + listId: 'list_id', type: 'endpoint', namespaceType: 'single', }, @@ -124,6 +125,7 @@ describe('ExceptionsViewer', () => { exceptionListsMeta={[ { id: '5b543420', + listId: 'list_id', type: 'endpoint', namespaceType: 'single', }, 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 index 16eaef4136983e..7f4d8252764060 100644 --- 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 @@ -176,8 +176,6 @@ const ExceptionsViewerComponent = ({ const handleEditException = useCallback( (exception: ExceptionListItemSchema): void => { - // TODO: Added this just for testing. Update - // modal state logic as needed once ready dispatch({ type: 'updateExceptionToEdit', exception, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 7a6b61f0dfd893..8c6e91254314eb 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -6,7 +6,6 @@ import { esFilters } from '../../../../../../../../../../src/plugins/data/public'; import { Rule, RuleError } from '../../../../../containers/detection_engine/rules'; -import { List } from '../../../../../../../common/detection_engine/schemas/types'; import { AboutStepRule, ActionsStepRule, DefineStepRule, ScheduleStepRule } from '../../types'; import { FieldValueQueryBar } from '../../../../../components/rules/query_bar'; import { fillEmptySeverityMappings } from '../../helpers'; @@ -242,9 +241,3 @@ export const mockRules: Rule[] = [ mockRule('abe6c564-050d-45a5-aaf0-386c37dd1f61'), mockRule('63f06f34-c181-4b2d-af35-f2ace572a1ee'), ]; - -export const mockExceptionsList: List = { - namespace_type: 'single', - id: '75cd4380-cc5e-11ea-9101-5b34f44aeb44', - type: 'detection', -}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts index 6458d2faa24680..4f2055424ca61d 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts @@ -5,9 +5,11 @@ */ import { List } from '../../../../../../common/detection_engine/schemas/types'; -import { ENDPOINT_LIST_ID } from '../../../../../shared_imports'; import { NewRule } from '../../../../containers/detection_engine/rules'; - +import { + getListMock, + getEndpointListMock, +} from '../../../../../../common/detection_engine/schemas/types/lists.mock'; import { DefineStepRuleJson, ScheduleStepRuleJson, @@ -29,19 +31,12 @@ import { } from './helpers'; import { mockDefineStepRule, - mockExceptionsList, mockQueryBar, mockScheduleStepRule, mockAboutStepRule, mockActionsStepRule, } from '../all/__mocks__/mock'; -const ENDPOINT_LIST = { - id: ENDPOINT_LIST_ID, - namespace_type: 'agnostic', - type: 'endpoint', -} as List; - describe('helpers', () => { describe('getTimeTypeValue', () => { test('returns timeObj with value 0 if no time value found', () => { @@ -391,14 +386,12 @@ describe('helpers', () => { }, [] ); - expect(result.exceptions_list).toEqual([ - { id: ENDPOINT_LIST_ID, namespace_type: 'agnostic', type: 'endpoint' }, - ]); + expect(result.exceptions_list).toEqual([getEndpointListMock()]); }); test('returns formatted object with detections exceptions_list', () => { - const result: AboutStepRuleJson = formatAboutStepData(mockData, [mockExceptionsList]); - expect(result.exceptions_list).toEqual([mockExceptionsList]); + const result: AboutStepRuleJson = formatAboutStepData(mockData, [getListMock()]); + expect(result.exceptions_list).toEqual([getListMock()]); }); test('returns formatted object with both exceptions_lists', () => { @@ -407,13 +400,13 @@ describe('helpers', () => { ...mockData, isAssociatedToEndpointList: true, }, - [mockExceptionsList] + [getListMock()] ); - expect(result.exceptions_list).toEqual([ENDPOINT_LIST, mockExceptionsList]); + expect(result.exceptions_list).toEqual([getEndpointListMock(), getListMock()]); }); test('returns formatted object with pre-existing exceptions lists', () => { - const exceptionsLists: List[] = [ENDPOINT_LIST, mockExceptionsList]; + const exceptionsLists: List[] = [getEndpointListMock(), getListMock()]; const result: AboutStepRuleJson = formatAboutStepData( { ...mockData, @@ -425,9 +418,9 @@ describe('helpers', () => { }); test('returns formatted object with pre-existing endpoint exceptions list disabled', () => { - const exceptionsLists: List[] = [ENDPOINT_LIST, mockExceptionsList]; + const exceptionsLists: List[] = [getEndpointListMock(), getListMock()]; const result: AboutStepRuleJson = formatAboutStepData(mockData, exceptionsLists); - expect(result.exceptions_list).toEqual([mockExceptionsList]); + expect(result.exceptions_list).toEqual([getListMock()]); }); test('returns formatted object with empty falsePositive and references filtered out', () => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 8b03f62fc82bde..434a33ac377228 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -177,7 +177,12 @@ export const formatAboutStepData = ( ...(isAssociatedToEndpointList ? { exceptions_list: [ - { id: ENDPOINT_LIST_ID, namespace_type: 'agnostic', type: 'endpoint' }, + { + id: ENDPOINT_LIST_ID, + list_id: ENDPOINT_LIST_ID, + namespace_type: 'agnostic', + type: 'endpoint', + }, ...detectionExceptionLists, ] as AboutStepRuleJson['exceptions_list'], } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 90424e1fb9dd08..789469e981fb45 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -328,13 +328,13 @@ export const RuleDetailsPageComponent: FC = ({ lists: ExceptionIdentifiers[]; allowedExceptionListTypes: ExceptionListTypeEnum[]; }>( - (acc, { id, namespace_type, type }) => { + (acc, { id, list_id, namespace_type, type }) => { const { allowedExceptionListTypes, lists } = acc; const shouldAddEndpoint = type === ExceptionListTypeEnum.ENDPOINT && !allowedExceptionListTypes.includes(ExceptionListTypeEnum.ENDPOINT); return { - lists: [...lists, { id, namespaceType: namespace_type, type }], + lists: [...lists, { id, listId: list_id, namespaceType: namespace_type, type }], allowedExceptionListTypes: shouldAddEndpoint ? [...allowedExceptionListTypes, ExceptionListTypeEnum.ENDPOINT] : allowedExceptionListTypes, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json index e6a517d85db813..05601ec8ffb4c1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/elastic_endpoint.json @@ -1,20 +1,17 @@ { - "author": [ - "Elastic" - ], + "author": ["Elastic"], "description": "Generates a detection alert each time an Elastic Endpoint Security alert is received. Enabling this rule allows you to immediately begin investigating your Elastic Endpoint alerts.", "enabled": true, "exceptions_list": [ { "id": "endpoint_list", + "list_id": "endpoint_list", "namespace_type": "agnostic", "type": "endpoint" } ], "from": "now-10m", - "index": [ - "logs-endpoint.alerts-*" - ], + "index": ["logs-endpoint.alerts-*"], "language": "kuery", "license": "Elastic License", "max_signals": 10000, @@ -57,10 +54,7 @@ "value": "99" } ], - "tags": [ - "Elastic", - "Endpoint" - ], + "tags": ["Elastic", "Endpoint"], "timestamp_override": "event.ingested", "type": "query", "version": 1 diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json index 678ad9eb03b50e..8b627c48d29047 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_rules/external_alerts.json @@ -1,12 +1,9 @@ { - "author": [ - "Elastic" - ], + "author": ["Elastic"], "description": "Generates a detection alert for each external alert written to the configured indices. Enabling this rule allows you to immediately begin investigating external alerts in the app.", "index": [ "apm-*-transaction*", "auditbeat-*", - "endgame-*", "filebeat-*", "logs-*", "packetbeat-*", @@ -54,9 +51,7 @@ "value": "99" } ], - "tags": [ - "Elastic" - ], + "tags": ["Elastic"], "timestamp_override": "event.ingested", "type": "query", "version": 1 diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index a610970907bf8f..3c41f29625a51e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -716,26 +716,31 @@ describe('utils', () => { describe('#getExceptions', () => { test('it successfully returns array of exception list items', async () => { + listMock.getExceptionListClient = () => + (({ + findExceptionListsItem: jest.fn().mockResolvedValue({ + data: [getExceptionListItemSchemaMock()], + page: 1, + per_page: 10000, + total: 1, + }), + } as unknown) as ExceptionListClient); const client = listMock.getExceptionListClient(); const exceptions = await getExceptions({ client, lists: getListArrayMock(), }); - expect(client.getExceptionList).toHaveBeenNthCalledWith(1, { - id: 'some_uuid', - listId: undefined, - namespaceType: 'single', - }); - expect(client.getExceptionList).toHaveBeenNthCalledWith(2, { - id: 'some_uuid', - listId: undefined, - namespaceType: 'agnostic', + expect(client.findExceptionListsItem).toHaveBeenCalledWith({ + listId: ['list_id_single', 'endpoint_list'], + namespaceType: ['single', 'agnostic'], + page: 1, + perPage: 10000, + filter: [], + sortOrder: undefined, + sortField: undefined, }); - expect(exceptions).toEqual([ - getExceptionListItemSchemaMock(), - getExceptionListItemSchemaMock(), - ]); + expect(exceptions).toEqual([getExceptionListItemSchemaMock()]); }); test('it throws if "client" is undefined', async () => { @@ -747,7 +752,7 @@ describe('utils', () => { ).rejects.toThrowError('lists plugin unavailable during rule execution'); }); - test('it returns empty array if no "lists" is undefined', async () => { + test('it returns empty array if "lists" is undefined', async () => { const exceptions = await getExceptions({ client: listMock.getExceptionListClient(), lists: undefined, @@ -771,11 +776,11 @@ describe('utils', () => { ).rejects.toThrowError('unable to fetch exception list items'); }); - test('it throws if "findExceptionListItem" fails', async () => { + test('it throws if "findExceptionListsItem" fails', async () => { const err = new Error('error fetching list'); listMock.getExceptionListClient = () => (({ - findExceptionListItem: jest.fn().mockRejectedValue(err), + findExceptionListsItem: jest.fn().mockRejectedValue(err), } as unknown) as ExceptionListClient); await expect(() => @@ -786,24 +791,10 @@ describe('utils', () => { ).rejects.toThrowError('unable to fetch exception list items'); }); - test('it returns empty array if "getExceptionList" returns null', async () => { - listMock.getExceptionListClient = () => - (({ - getExceptionList: jest.fn().mockResolvedValue(null), - } as unknown) as ExceptionListClient); - - const exceptions = await getExceptions({ - client: listMock.getExceptionListClient(), - lists: undefined, - }); - - expect(exceptions).toEqual([]); - }); - - test('it returns empty array if "findExceptionListItem" returns null', async () => { + test('it returns empty array if "findExceptionListsItem" returns null', async () => { listMock.getExceptionListClient = () => (({ - findExceptionListItem: jest.fn().mockResolvedValue(null), + findExceptionListsItem: jest.fn().mockResolvedValue(null), } as unknown) as ExceptionListClient); const exceptions = await getExceptions({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index ae4274f31e1455..9519720d0bbecd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -161,43 +161,20 @@ export const getExceptions = async ({ throw new Error('lists plugin unavailable during rule execution'); } - if (lists != null) { + if (lists != null && lists.length > 0) { try { - // Gather all exception items of all exception lists linked to rule - const exceptions = await Promise.all( - lists - .map(async (list) => { - const { id, namespace_type: namespaceType } = list; - try { - // TODO update once exceptions client `findExceptionListItem` - // accepts an array of list ids - const foundList = await client.getExceptionList({ - id, - namespaceType, - listId: undefined, - }); - - if (foundList == null) { - return []; - } else { - const items = await client.findExceptionListItem({ - listId: foundList.list_id, - namespaceType, - page: 1, - perPage: MAX_EXCEPTION_LIST_SIZE, - filter: undefined, - sortOrder: undefined, - sortField: undefined, - }); - return items != null ? items.data : []; - } - } catch { - throw new Error('unable to fetch exception list items'); - } - }) - .flat() - ); - return exceptions.flat(); + const listIds = lists.map(({ list_id: listId }) => listId); + const namespaceTypes = lists.map(({ namespace_type: namespaceType }) => namespaceType); + const items = await client.findExceptionListsItem({ + listId: listIds, + namespaceType: namespaceTypes, + page: 1, + perPage: MAX_EXCEPTION_LIST_SIZE, + filter: [], + sortOrder: undefined, + sortField: undefined, + }); + return items != null ? items.data : []; } catch { throw new Error('unable to fetch exception list items'); } From 0b708e00d37341800a1e84afe32c4310e91f7b26 Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Tue, 28 Jul 2020 23:33:30 -0400 Subject: [PATCH 22/27] [Security_Solution][Bug] Fix user name/domain to ECS structure (#73530) * The changes this tests are being ported separately to 7.9 --- .../resolver/models/process_event.test.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts b/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts index 7eb692851bc9bd..4b1d555d0a7c38 100644 --- a/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/models/process_event.test.ts @@ -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 { eventType, orderByTime } from './process_event'; +import { eventType, orderByTime, userInfoForProcess } from './process_event'; import { mockProcessEvent } from './process_event_test_helpers'; import { LegacyEndpointEvent, ResolverEvent } from '../../../common/endpoint/types'; @@ -24,6 +24,22 @@ describe('process event', () => { expect(eventType(event)).toEqual('processCreated'); }); }); + describe('userInfoForProcess', () => { + let event: LegacyEndpointEvent; + beforeEach(() => { + event = mockProcessEvent({ + user: { + name: 'aaa', + domain: 'bbb', + }, + }); + }); + it('returns the right user info for the process', () => { + const { name, domain } = userInfoForProcess(event)!; + expect(name).toEqual('aaa'); + expect(domain).toEqual('bbb'); + }); + }); describe('orderByTime', () => { let mock: (time: number, eventID: string) => ResolverEvent; let events: ResolverEvent[]; From 41c2967e08ba6bf7f6fe8d1f59f894c091529240 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Wed, 29 Jul 2020 00:01:33 -0400 Subject: [PATCH 23/27] [Security Solution][Resolver] Handle disabled process collection (#73592) * Handling entity ids of empty string * Tests for entity id being empty * More comments * entity test * Renaming interface * Removing unneeded test Co-authored-by: Elastic Machine --- .../common/endpoint/schema/resolver.ts | 20 +-- .../components/timeline/body/helpers.test.ts | 58 ++++++- .../components/timeline/body/helpers.ts | 3 +- .../server/endpoint/routes/resolver/entity.ts | 7 + .../endpoint/routes/resolver/queries/base.ts | 3 +- .../routes/resolver/queries/children.ts | 12 ++ .../apis/index.ts | 3 +- .../apis/resolver/entity_id.ts | 156 ++++++++++++++++++ .../apis/{resolver.ts => resolver/tree.ts} | 14 +- .../services/resolver.ts | 65 +++++--- 10 files changed, 300 insertions(+), 41 deletions(-) create mode 100644 x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts rename x-pack/test/security_solution_endpoint_api_int/apis/{resolver.ts => resolver/tree.ts} (98%) diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts index c67ad3665d004f..f3e67f84b2880f 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/resolver.ts @@ -10,7 +10,7 @@ import { schema } from '@kbn/config-schema'; * Used to validate GET requests for a complete resolver tree. */ export const validateTree = { - params: schema.object({ id: schema.string() }), + params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ children: schema.number({ defaultValue: 200, min: 0, max: 10000 }), ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), @@ -19,7 +19,7 @@ export const validateTree = { afterEvent: schema.maybe(schema.string()), afterAlert: schema.maybe(schema.string()), afterChild: schema.maybe(schema.string()), - legacyEndpointID: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), }; @@ -27,11 +27,11 @@ export const validateTree = { * Used to validate GET requests for non process events for a specific event. */ export const validateEvents = { - params: schema.object({ id: schema.string() }), + params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ events: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterEvent: schema.maybe(schema.string()), - legacyEndpointID: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), }; @@ -39,11 +39,11 @@ export const validateEvents = { * Used to validate GET requests for alerts for a specific process. */ export const validateAlerts = { - params: schema.object({ id: schema.string() }), + params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ alerts: schema.number({ defaultValue: 1000, min: 1, max: 10000 }), afterAlert: schema.maybe(schema.string()), - legacyEndpointID: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), }; @@ -51,10 +51,10 @@ export const validateAlerts = { * Used to validate GET requests for the ancestors of a process event. */ export const validateAncestry = { - params: schema.object({ id: schema.string() }), + params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ ancestors: schema.number({ defaultValue: 200, min: 0, max: 10000 }), - legacyEndpointID: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), }; @@ -62,11 +62,11 @@ export const validateAncestry = { * Used to validate GET requests for children of a specified process event. */ export const validateChildren = { - params: schema.object({ id: schema.string() }), + params: schema.object({ id: schema.string({ minLength: 1 }) }), query: schema.object({ children: schema.number({ defaultValue: 200, min: 1, max: 10000 }), afterChild: schema.maybe(schema.string()), - legacyEndpointID: schema.maybe(schema.string()), + legacyEndpointID: schema.maybe(schema.string({ minLength: 1 })), }), }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts index 8ba1a999e2b2ae..c8adaa891610ad 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.test.ts @@ -6,7 +6,13 @@ import { Ecs } from '../../../../graphql/types'; -import { eventHasNotes, eventIsPinned, getPinTooltip, stringifyEvent } from './helpers'; +import { + eventHasNotes, + eventIsPinned, + getPinTooltip, + stringifyEvent, + isInvestigateInResolverActionEnabled, +} from './helpers'; import { TimelineType } from '../../../../../common/types/timeline'; describe('helpers', () => { @@ -242,4 +248,54 @@ describe('helpers', () => { expect(eventIsPinned({ eventId, pinnedEventIds })).toEqual(false); }); }); + + describe('isInvestigateInResolverActionEnabled', () => { + it('returns false if agent.type does not equal endpoint', () => { + const data: Ecs = { _id: '1', agent: { type: ['blah'] } }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); + }); + + it('returns false if agent.type does not have endpoint in first array index', () => { + const data: Ecs = { _id: '1', agent: { type: ['blah', 'endpoint'] } }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); + }); + + it('returns false if process.entity_id is not defined', () => { + const data: Ecs = { _id: '1', agent: { type: ['endpoint'] } }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); + }); + + it('returns true if agent.type has endpoint in first array index', () => { + const data: Ecs = { + _id: '1', + agent: { type: ['endpoint', 'blah'] }, + process: { entity_id: ['5'] }, + }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeTruthy(); + }); + + it('returns false if multiple entity_ids', () => { + const data: Ecs = { + _id: '1', + agent: { type: ['endpoint', 'blah'] }, + process: { entity_id: ['5', '10'] }, + }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); + }); + + it('returns false if entity_id is an empty string', () => { + const data: Ecs = { + _id: '1', + agent: { type: ['endpoint', 'blah'] }, + process: { entity_id: [''] }, + }; + + expect(isInvestigateInResolverActionEnabled(data)).toBeFalsy(); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts index 067cea175c99bd..6a5e25632c29ba 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/helpers.ts @@ -106,7 +106,8 @@ export const getEventType = (event: Ecs): Omit => { export const isInvestigateInResolverActionEnabled = (ecsData?: Ecs) => { return ( get(['agent', 'type', 0], ecsData) === 'endpoint' && - get(['process', 'entity_id'], ecsData)?.length > 0 + get(['process', 'entity_id'], ecsData)?.length === 1 && + get(['process', 'entity_id', 0], ecsData) !== '' ); }; diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts index ae91201646103f..c79bcda71de9b5 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts @@ -70,6 +70,13 @@ export function handleEntities(): RequestHandler implements MSearchQuer } private buildQuery(ids: string | string[]): { query: JsonObject; index: string | string[] } { - const idsArray = ResolverQuery.createIdsArray(ids); + // only accept queries for entity_ids that are not an empty string + const idsArray = ResolverQuery.createIdsArray(ids).filter((id) => id !== ''); if (this.endpointID) { return { query: this.legacyQuery(this.endpointID, idsArray), index: legacyEventIndexPattern }; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts index 7fd3808662baa7..d99533e23f2c27 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts @@ -74,6 +74,18 @@ export class ChildrenQuery extends ResolverQuery { ], }, }, + { + exists: { + field: 'process.entity_id', + }, + }, + { + bool: { + must_not: { + term: { 'process.entity_id': '' }, + }, + }, + }, { term: { 'event.category': 'process' }, }, diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts index fb11a7c52fd354..56adc2382e2340 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/index.ts @@ -26,7 +26,8 @@ export default function endpointAPIIntegrationTests(providerContext: FtrProvider before(async () => { await ingestManager.setup(); }); - loadTestFile(require.resolve('./resolver')); + loadTestFile(require.resolve('./resolver/entity_id')); + loadTestFile(require.resolve('./resolver/tree')); loadTestFile(require.resolve('./metadata')); loadTestFile(require.resolve('./policy')); loadTestFile(require.resolve('./artifacts')); diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts new file mode 100644 index 00000000000000..4f2a8013772043 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity_id.ts @@ -0,0 +1,156 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { SearchResponse } from 'elasticsearch'; +import { eventsIndexPattern } from '../../../../plugins/security_solution/common/endpoint/constants'; +import { + ResolverTree, + ResolverEntityIndex, +} from '../../../../plugins/security_solution/common/endpoint/types'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { + EndpointDocGenerator, + Event, +} from '../../../../plugins/security_solution/common/endpoint/generate_data'; +import { InsertedEvents } from '../../services/resolver'; + +export default function resolverAPIIntegrationTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const resolver = getService('resolverGenerator'); + const es = getService('es'); + const generator = new EndpointDocGenerator('resolver'); + + describe('Resolver handling of entity ids', () => { + describe('entity api', () => { + let origin: Event; + let genData: InsertedEvents; + before(async () => { + origin = generator.generateEvent({ parentEntityID: 'a' }); + origin.process.entity_id = ''; + genData = await resolver.insertEvents([origin]); + }); + + after(async () => { + await resolver.deleteData(genData); + }); + + it('excludes events that have an empty entity_id field', async () => { + // first lets get the _id of the document using the parent.process.entity_id + // then we'll use the API to search for that specific document + const res = await es.search>({ + index: genData.indices[0], + body: { + query: { + bool: { + filter: [ + { + term: { 'process.parent.entity_id': origin.process.parent!.entity_id }, + }, + ], + }, + }, + }, + }); + const { body }: { body: ResolverEntityIndex } = await supertest.get( + // using the same indices value here twice to force the query parameter to be an array + // for some reason using supertest's query() function doesn't construct a parsable array + `/api/endpoint/resolver/entity?_id=${res.body.hits.hits[0]._id}&indices=${eventsIndexPattern}&indices=${eventsIndexPattern}` + ); + expect(body).to.be.empty(); + }); + }); + + describe('children', () => { + let origin: Event; + let childNoEntityID: Event; + let childWithEntityID: Event; + let events: Event[]; + let genData: InsertedEvents; + + before(async () => { + // construct a tree with an origin and two direct children. One child will not have an entity_id. That child + // should not be returned by the backend. + origin = generator.generateEvent({ entityID: 'a' }); + childNoEntityID = generator.generateEvent({ + parentEntityID: origin.process.entity_id, + ancestry: [origin.process.entity_id], + }); + // force it to be empty + childNoEntityID.process.entity_id = ''; + + childWithEntityID = generator.generateEvent({ + entityID: 'b', + parentEntityID: origin.process.entity_id, + ancestry: [origin.process.entity_id], + }); + events = [origin, childNoEntityID, childWithEntityID]; + genData = await resolver.insertEvents(events); + }); + + after(async () => { + await resolver.deleteData(genData); + }); + + it('does not find children without a process entity_id', async () => { + const { body }: { body: ResolverTree } = await supertest + .get(`/api/endpoint/resolver/${origin.process.entity_id}`) + .expect(200); + expect(body.children.childNodes.length).to.be(1); + expect(body.children.childNodes[0].entityID).to.be(childWithEntityID.process.entity_id); + }); + }); + + describe('ancestors', () => { + let origin: Event; + let ancestor1: Event; + let ancestor2: Event; + let ancestorNoEntityID: Event; + let events: Event[]; + let genData: InsertedEvents; + + before(async () => { + // construct a tree with an origin that has two ancestors. The origin will have an empty string as one of the + // entity_ids in the ancestry array. This is to make sure that the backend will not query for that event. + ancestor2 = generator.generateEvent({ + entityID: '2', + }); + ancestor1 = generator.generateEvent({ + entityID: '1', + parentEntityID: ancestor2.process.entity_id, + ancestry: [ancestor2.process.entity_id], + }); + + // we'll insert an event that doesn't have an entity id so if the backend does search for it, it should be + // returned and our test should fail + ancestorNoEntityID = generator.generateEvent({ + ancestry: [ancestor2.process.entity_id], + }); + ancestorNoEntityID.process.entity_id = ''; + + origin = generator.generateEvent({ + entityID: 'a', + parentEntityID: ancestor1.process.entity_id, + ancestry: ['', ancestor2.process.entity_id], + }); + + events = [origin, ancestor1, ancestor2, ancestorNoEntityID]; + genData = await resolver.insertEvents(events); + }); + + after(async () => { + await resolver.deleteData(genData); + }); + + it('does not query for ancestors that have an empty string for the entity_id', async () => { + const { body }: { body: ResolverTree } = await supertest + .get(`/api/endpoint/resolver/${origin.process.entity_id}`) + .expect(200); + expect(body.ancestry.ancestors.length).to.be(1); + expect(body.ancestry.ancestors[0].entityID).to.be(ancestor2.process.entity_id); + }); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts similarity index 98% rename from x-pack/test/security_solution_endpoint_api_int/apis/resolver.ts rename to x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts index 3b515f86c6761d..3527e7e575c996 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/tree.ts @@ -16,12 +16,12 @@ import { LegacyEndpointEvent, ResolverNodeStats, ResolverRelatedAlerts, -} from '../../../plugins/security_solution/common/endpoint/types'; +} from '../../../../plugins/security_solution/common/endpoint/types'; import { parentEntityId, eventId, -} from '../../../plugins/security_solution/common/endpoint/models/event'; -import { FtrProviderContext } from '../ftr_provider_context'; +} from '../../../../plugins/security_solution/common/endpoint/models/event'; +import { FtrProviderContext } from '../../ftr_provider_context'; import { Event, Tree, @@ -29,8 +29,8 @@ import { RelatedEventCategory, RelatedEventInfo, categoryMapping, -} from '../../../plugins/security_solution/common/endpoint/generate_data'; -import { Options, GeneratedTrees } from '../services/resolver'; +} from '../../../../plugins/security_solution/common/endpoint/generate_data'; +import { Options, GeneratedTrees } from '../../services/resolver'; /** * Check that the given lifecycle is in the resolver tree's corresponding map @@ -256,7 +256,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC ancestryArraySize: 2, }; - describe('Resolver', () => { + describe('Resolver tree', () => { before(async () => { await esArchiver.load('endpoint/resolver/api_feature'); resolverTrees = await resolver.createTrees(treeOptions); @@ -264,7 +264,7 @@ export default function resolverAPIIntegrationTests({ getService }: FtrProviderC tree = resolverTrees.trees[0]; }); after(async () => { - await resolver.deleteTrees(resolverTrees); + await resolver.deleteData(resolverTrees); // this unload is for an endgame-* index so it does not use data streams await esArchiver.unload('endpoint/resolver/api_feature'); }); diff --git a/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts b/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts index 7f568a2b003140..335689b804d5ba 100644 --- a/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts +++ b/x-pack/test/security_solution_endpoint_api_int/services/resolver.ts @@ -7,9 +7,12 @@ import { TreeOptions, Tree, EndpointDocGenerator, + Event, } from '../../../plugins/security_solution/common/endpoint/generate_data'; import { FtrProviderContext } from '../ftr_provider_context'; +const processIndex = 'logs-endpoint.events.process-default'; + /** * Options for build a resolver tree */ @@ -26,17 +29,41 @@ export interface Options extends TreeOptions { */ export interface GeneratedTrees { trees: Tree[]; - eventsIndex: string; - alertsIndex: string; + indices: string[]; +} + +/** + * Structure containing the events inserted into ES and the index they live in + */ +export interface InsertedEvents { + events: Event[]; + indices: string[]; +} + +interface BulkCreateHeader { + create: { + _index: string; + }; } export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { const client = getService('es'); return { + async insertEvents( + events: Event[], + eventsIndex: string = processIndex + ): Promise { + const body = events.reduce((array: Array, doc) => { + array.push({ create: { _index: eventsIndex } }, doc); + return array; + }, []); + await client.bulk({ body, refresh: true }); + return { events, indices: [eventsIndex] }; + }, async createTrees( options: Options, - eventsIndex: string = 'logs-endpoint.events.process-default', + eventsIndex: string = processIndex, alertsIndex: string = 'logs-endpoint.alerts-default' ): Promise { const seed = options.seed || 'resolver-seed'; @@ -45,7 +72,7 @@ export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { const numTrees = options.numTrees ?? 1; for (let j = 0; j < numTrees; j++) { const tree = generator.generateTree(options); - const body = tree.allEvents.reduce((array: Array>, doc) => { + const body = tree.allEvents.reduce((array: Array, doc) => { let index = eventsIndex; if (doc.event.kind === 'alert') { index = alertsIndex; @@ -60,23 +87,21 @@ export function ResolverGeneratorProvider({ getService }: FtrProviderContext) { await client.bulk({ body, refresh: true }); allTrees.push(tree); } - return { trees: allTrees, eventsIndex, alertsIndex }; + return { trees: allTrees, indices: [eventsIndex, alertsIndex] }; }, - async deleteTrees(trees: GeneratedTrees) { - /** - * The ingest manager handles creating the template for the endpoint's indices. It is using a V2 template - * with data streams. Data streams aren't included in the javascript elasticsearch client in kibana yet so we - * need to do raw requests here. Delete a data stream is slightly different than that of a regular index which - * is why we're using _data_stream here. - */ - await client.transport.request({ - method: 'DELETE', - path: `_data_stream/${trees.eventsIndex}`, - }); - await client.transport.request({ - method: 'DELETE', - path: `_data_stream/${trees.alertsIndex}`, - }); + async deleteData(genData: { indices: string[] }) { + for (const index of genData.indices) { + /** + * The ingest manager handles creating the template for the endpoint's indices. It is using a V2 template + * with data streams. Data streams aren't included in the javascript elasticsearch client in kibana yet so we + * need to do raw requests here. Delete a data stream is slightly different than that of a regular index which + * is why we're using _data_stream here. + */ + await client.transport.request({ + method: 'DELETE', + path: `_data_stream/${index}`, + }); + } }, }; } From 36d5391acc098719bdf3ef3e8636ba35000e74af Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Tue, 28 Jul 2020 23:19:36 -0500 Subject: [PATCH 24/27] [Security Solution][Detections] Value Lists Modal supports multiple exports (#73532) * Remove need for ValueListsTable Modifying columns has revealed that they should be exposed as props, at which point we have no real need for the table component. * Unroll the ActionButton component I thought this was useful when I wrote it! * Handle multiple simultaneous exports on value lists modal Instead of passing our export function to GenericDownloader, we now manage the multiple exports ourselves, and when successful we pass the blob to GenericDownloader. * tracks a list of exporting IDs instead of single ID * chains onto the export promise to set local state * Port useful table tests over to modal tests These verify that we've wired up our table actions to our API calls. A little brittle/tied to implementation, but I'd rather have them than not. * WIP: Simpler version of GenericDownloader * Replace use of GenericDownloader with simpler AutoDownload This component takes a blob and downloads it in a cross-browser-compatible manner. * Handle error when uploading value lists Converts to the try/catch/finally form as well. * Fix failing cypress test We lost this test subj during our refactor, oops * More explicit setting of global DOM function Our component fails due to this method being undefined, so we mock it out for these tests. We do not need to reset the mock as it is assigned fresh on every test. * Fixes jest failures on CI Defines a global static method in a more portable way, as the regular assignment was failing on CI as the property was readonly. * Simplify our export/delete clicks in jest tests The less we assume about the UI, the more robust these'll be. Co-authored-by: Elastic Machine --- .../auto_download.test.tsx | 35 +++++ .../auto_download.tsx | 42 ++++++ .../modal.test.tsx | 74 ++++++++++- .../value_lists_management_modal/modal.tsx | 67 ++++++---- .../table.test.tsx | 125 ------------------ .../value_lists_management_modal/table.tsx | 53 -------- .../table_helpers.tsx | 66 ++++----- .../translations.ts | 4 + 8 files changed, 220 insertions(+), 246 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx delete mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.test.tsx new file mode 100644 index 00000000000000..53dcf986d395c8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { globalNode } from '../../../common/mock'; +import { AutoDownload } from './auto_download'; + +describe('AutoDownload', () => { + beforeEach(() => { + // our DOM environment lacks this function that our component needs + Object.defineProperty(globalNode.window.URL, 'revokeObjectURL', { + writable: true, + value: jest.fn(), + }); + }); + + it('calls onDownload once if a blob is provided', () => { + const onDownload = jest.fn(); + mount(); + + expect(onDownload).toHaveBeenCalledTimes(1); + }); + + it('does not call onDownload if no blob is provided', () => { + const onDownload = jest.fn(); + mount(); + + expect(onDownload).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.tsx new file mode 100644 index 00000000000000..9c8280bebe4fd3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/auto_download.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useRef } from 'react'; +import styled from 'styled-components'; + +const InvisibleAnchor = styled.a` + display: none; +`; + +interface AutoDownloadProps { + blob: Blob | undefined; + name?: string; + onDownload?: () => void; +} + +export const AutoDownload: React.FC = ({ blob, name, onDownload }) => { + const anchorRef = useRef(null); + + useEffect(() => { + if (blob && anchorRef?.current) { + if (typeof window.navigator.msSaveOrOpenBlob === 'function') { + window.navigator.msSaveBlob(blob); + } else { + const objectURL = window.URL.createObjectURL(blob); + anchorRef.current.href = objectURL; + anchorRef.current.download = name ?? 'download.txt'; + anchorRef.current.click(); + window.URL.revokeObjectURL(objectURL); + } + + if (onDownload) { + onDownload(); + } + } + }, [blob, name, onDownload]); + + return ; +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx index 175882de551cb7..ff743d1d5090a7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx @@ -6,11 +6,38 @@ import React from 'react'; import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { exportList, useDeleteList, useFindLists, ListSchema } from '../../../shared_imports'; import { TestProviders } from '../../../common/mock'; import { ValueListsModal } from './modal'; +jest.mock('../../../shared_imports', () => { + const actual = jest.requireActual('../../../shared_imports'); + + return { + ...actual, + exportList: jest.fn(), + useDeleteList: jest.fn(), + useFindLists: jest.fn(), + }; +}); + describe('ValueListsModal', () => { + beforeEach(() => { + // Do not resolve the export in tests as it causes unexpected state updates + (exportList as jest.Mock).mockImplementation(() => new Promise(() => {})); + (useFindLists as jest.Mock).mockReturnValue({ + start: jest.fn(), + result: { data: Array(3).fill(getListResponseMock()), total: 3 }, + }); + (useDeleteList as jest.Mock).mockReturnValue({ + start: jest.fn(), + result: getListResponseMock(), + }); + }); + it('renders nothing if showModal is false', () => { const container = mount( @@ -47,7 +74,7 @@ describe('ValueListsModal', () => { container.unmount(); }); - it('renders ValueListsForm and ValueListsTable', () => { + it('renders ValueListsForm and an EuiTable', () => { const container = mount( @@ -55,7 +82,50 @@ describe('ValueListsModal', () => { ); expect(container.find('ValueListsForm')).toHaveLength(1); - expect(container.find('ValueListsTable')).toHaveLength(1); + expect(container.find('EuiBasicTable')).toHaveLength(1); container.unmount(); }); + + describe('modal table actions', () => { + it('calls exportList when export is clicked', () => { + const container = mount( + + + + ); + + act(() => { + container + .find('button[data-test-subj="action-export-value-list"]') + .first() + .simulate('click'); + container.unmount(); + }); + + expect(exportList).toHaveBeenCalledWith(expect.objectContaining({ listId: 'some-list-id' })); + }); + + it('calls deleteList when delete is clicked', () => { + const deleteListMock = jest.fn(); + (useDeleteList as jest.Mock).mockReturnValue({ + start: deleteListMock, + result: getListResponseMock(), + }); + const container = mount( + + + + ); + + act(() => { + container + .find('button[data-test-subj="action-delete-value-list"]') + .first() + .simulate('click'); + container.unmount(); + }); + + expect(deleteListMock).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' })); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx index dc722604390903..4921a98b38bd1b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx @@ -6,6 +6,7 @@ import React, { useCallback, useEffect, useState } from 'react'; import { + EuiBasicTable, EuiButton, EuiModal, EuiModalBody, @@ -13,7 +14,9 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiOverlayMask, + EuiPanel, EuiSpacer, + EuiText, } from '@elastic/eui'; import { @@ -25,10 +28,10 @@ import { } from '../../../shared_imports'; import { useKibana } from '../../../common/lib/kibana'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; -import { GenericDownloader } from '../../../common/components/generic_downloader'; import * as i18n from './translations'; -import { ValueListsTable } from './table'; +import { buildColumns } from './table_helpers'; import { ValueListsForm } from './form'; +import { AutoDownload } from './auto_download'; interface ValueListsModalProps { onClose: () => void; @@ -45,8 +48,9 @@ export const ValueListsModalComponent: React.FC = ({ const { http } = useKibana().services; const { start: findLists, ...lists } = useFindLists(); const { start: deleteList, result: deleteResult } = useDeleteList(); - const [exportListId, setExportListId] = useState(); const [deletingListIds, setDeletingListIds] = useState([]); + const [exportingListIds, setExportingListIds] = useState([]); + const [exportDownload, setExportDownload] = useState<{ name?: string; blob?: Blob }>({}); const { addError, addSuccess } = useAppToasts(); const fetchLists = useCallback(() => { @@ -62,19 +66,26 @@ export const ValueListsModalComponent: React.FC = ({ ); useEffect(() => { - if (deleteResult != null && deletingListIds.length > 0) { - setDeletingListIds([...deletingListIds.filter((id) => id !== deleteResult.id)]); + if (deleteResult != null) { + setDeletingListIds((ids) => [...ids.filter((id) => id !== deleteResult.id)]); fetchLists(); } - }, [deleteResult, deletingListIds, fetchLists]); + }, [deleteResult, fetchLists]); const handleExport = useCallback( - async ({ ids }: { ids: string[] }) => - exportList({ http, listId: ids[0], signal: new AbortController().signal }), - [http] + async ({ id }: { id: string }) => { + try { + setExportingListIds((ids) => [...ids, id]); + const blob = await exportList({ http, listId: id, signal: new AbortController().signal }); + setExportDownload({ name: id, blob }); + } catch (error) { + addError(error, { title: i18n.EXPORT_ERROR }); + } finally { + setExportingListIds((ids) => [...ids.filter((_id) => _id !== id)]); + } + }, + [addError, http] ); - const handleExportClick = useCallback(({ id }: { id: string }) => setExportListId(id), []); - const handleExportComplete = useCallback(() => setExportListId(undefined), []); const handleTableChange = useCallback( ({ page: { index, size } }: { page: { index: number; size: number } }) => { @@ -121,8 +132,8 @@ export const ValueListsModalComponent: React.FC = ({ const tableItems = (lists.result?.data ?? []).map((item) => ({ ...item, - isExporting: item.id === exportListId, isDeleting: deletingListIds.includes(item.id), + isExporting: exportingListIds.includes(item.id), })); const pagination = { @@ -131,6 +142,7 @@ export const ValueListsModalComponent: React.FC = ({ totalItemCount: lists.result?.total ?? 0, hidePerPageOptions: true, }; + const columns = buildColumns(handleExport, handleDelete); return ( @@ -141,14 +153,19 @@ export const ValueListsModalComponent: React.FC = ({ - + + +

{i18n.TABLE_TITLE}

+
+ +
@@ -156,12 +173,10 @@ export const ValueListsModalComponent: React.FC = ({ - setExportDownload({})} />
); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx deleted file mode 100644 index 2724c0a0696b6b..00000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; - -import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; -import { ListSchema } from '../../../../../lists/common/schemas/response'; -import { TestProviders } from '../../../common/mock'; -import { ValueListsTable } from './table'; -import { TableItem } from './types'; - -const buildItems = (lists: ListSchema[]): TableItem[] => - lists.map((list) => ({ - ...list, - isDeleting: false, - isExporting: false, - })); - -describe('ValueListsTable', () => { - it('renders a row for each list', () => { - const lists = Array(3).fill(getListResponseMock()); - const items = buildItems(lists); - const container = mount( - - - - ); - - expect(container.find('tbody tr')).toHaveLength(3); - }); - - it('calls onChange when pagination is modified', () => { - const lists = Array(6).fill(getListResponseMock()); - const items = buildItems(lists); - const onChange = jest.fn(); - const container = mount( - - - - ); - - act(() => { - container.find('a[data-test-subj="pagination-button-next"]').simulate('click'); - }); - - expect(onChange).toHaveBeenCalledWith( - expect.objectContaining({ page: expect.objectContaining({ index: 1 }) }) - ); - }); - - it('calls onExport when export is clicked', () => { - const lists = Array(3).fill(getListResponseMock()); - const items = buildItems(lists); - const onExport = jest.fn(); - const container = mount( - - - - ); - - act(() => { - container - .find('tbody tr') - .first() - .find('button[data-test-subj="action-export-value-list"]') - .simulate('click'); - }); - - expect(onExport).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' })); - }); - - it('calls onDelete when delete is clicked', () => { - const lists = Array(3).fill(getListResponseMock()); - const items = buildItems(lists); - const onDelete = jest.fn(); - const container = mount( - - - - ); - - act(() => { - container - .find('tbody tr') - .first() - .find('button[data-test-subj="action-delete-value-list"]') - .simulate('click'); - }); - - expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' })); - }); -}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx deleted file mode 100644 index a2e3b73a0abf0a..00000000000000 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiBasicTable, EuiText, EuiPanel } from '@elastic/eui'; - -import * as i18n from './translations'; -import { buildColumns } from './table_helpers'; -import { TableProps, TableItemCallback } from './types'; - -export interface ValueListsTableProps { - items: TableProps['items']; - loading: boolean; - onChange: TableProps['onChange']; - onExport: TableItemCallback; - onDelete: TableItemCallback; - pagination: Exclude; -} - -export const ValueListsTableComponent: React.FC = ({ - items, - loading, - onChange, - onExport, - onDelete, - pagination, -}) => { - const columns = buildColumns(onExport, onDelete); - return ( - - -

{i18n.TABLE_TITLE}

-
- -
- ); -}; - -ValueListsTableComponent.displayName = 'ValueListsTableComponent'; - -export const ValueListsTable = React.memo(ValueListsTableComponent); - -ValueListsTable.displayName = 'ValueListsTable'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx index e90d106636e632..bb3a97749a11a8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table_helpers.tsx @@ -8,40 +8,18 @@ import React from 'react'; import styled from 'styled-components'; -import { EuiButtonIcon, IconType, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; +import { EuiButtonIcon, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui'; import { ListSchema } from '../../../../../lists/common/schemas/response'; import { FormattedDate } from '../../../common/components/formatted_date'; import * as i18n from './translations'; -import { TableItem, TableItemCallback, TableProps } from './types'; +import { TableItemCallback, TableProps } from './types'; const AlignedSpinner = styled(EuiLoadingSpinner)` margin: ${({ theme }) => theme.eui.euiSizeXS}; vertical-align: middle; `; -const ActionButton: React.FC<{ - content: string; - dataTestSubj: string; - icon: IconType; - isLoading: boolean; - item: TableItem; - onClick: TableItemCallback; -}> = ({ content, dataTestSubj, icon, item, onClick, isLoading }) => ( - - {isLoading ? ( - - ) : ( - onClick(item)} - /> - )} - -); - export const buildColumns = ( onExport: TableItemCallback, onDelete: TableItemCallback @@ -70,26 +48,34 @@ export const buildColumns = ( actions: [ { render: (item) => ( - + + {item.isExporting ? ( + + ) : ( + onExport(item)} + /> + )} + ), }, { render: (item) => ( - + + {item.isDeleting ? ( + + ) : ( + onDelete(item)} + /> + )} + ), }, ], diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts index b7b2cae7b0ad6e..7063dca2341ca6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts @@ -65,6 +65,10 @@ export const uploadSuccessMessage = (fileName: string) => values: { fileName }, }); +export const EXPORT_ERROR = i18n.translate('xpack.securitySolution.lists.valueListsExportError', { + defaultMessage: 'There was an error exporting the value list.', +}); + export const COLUMN_FILE_NAME = i18n.translate( 'xpack.securitySolution.lists.valueListsTable.fileNameColumn', { From 4cc87e31a2af8f40d8c6b11fb177d73120142eec Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Tue, 28 Jul 2020 23:58:47 -0500 Subject: [PATCH 25/27] Do not render descriptionless actions within an EuiCard (#73611) This updates the logic of EmptyPage to better handle these cases. Adds snapshot tests to verify. --- .../__snapshots__/index.test.tsx.snap | 36 +++++++++++++++++-- .../components/empty_page/index.test.tsx | 32 ++++++++++++----- .../common/components/empty_page/index.tsx | 15 ++------ 3 files changed, 60 insertions(+), 23 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/empty_page/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/common/components/empty_page/__snapshots__/index.test.tsx.snap index 09dcb8dc5d84e1..9bf3be7b5dfa47 100644 --- a/x-pack/plugins/security_solution/public/common/components/empty_page/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/common/components/empty_page/__snapshots__/index.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`renders correctly 1`] = ` +exports[`EmptyPage component renders actions with descriptions 1`] = ` `; + +exports[`EmptyPage component renders actions without descriptions 1`] = ` + + + + Do Something + + + + } + iconType="logoSecurity" + title={ +

+ My Super Title +

+ } +/> +`; diff --git a/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx index 8e025faefeabe3..28e01eaa3eead9 100644 --- a/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/empty_page/index.test.tsx @@ -9,13 +9,27 @@ import React from 'react'; import { EmptyPage } from './index'; -test('renders correctly', () => { - const actions = { - actions: { - label: 'Do Something', - url: 'my/url/from/nowwhere', - }, - }; - const EmptyComponent = shallow(); - expect(EmptyComponent).toMatchSnapshot(); +describe('EmptyPage component', () => { + it('renders actions without descriptions', () => { + const actions = { + actions: { + label: 'Do Something', + url: 'my/url/from/nowwhere', + }, + }; + const EmptyComponent = shallow(); + expect(EmptyComponent).toMatchSnapshot(); + }); + + it('renders actions with descriptions', () => { + const actions = { + actions: { + description: 'My Description', + label: 'Do Something', + url: 'my/url/from/nowwhere', + }, + }; + const EmptyComponent = shallow(); + expect(EmptyComponent).toMatchSnapshot(); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx index 89f4b125e930cd..e0db1e90374ad2 100644 --- a/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/empty_page/index.tsx @@ -51,16 +51,7 @@ const EmptyPageComponent = React.memo(({ actions, message, title .filter((a) => a.label && a.url) .map( ( - { - icon, - label, - target, - url, - descriptionTitle = false, - description = false, - onClick, - fill = true, - }, + { icon, label, target, url, descriptionTitle, description, onClick, fill = true }, idx ) => descriptionTitle != null || description != null ? ( @@ -70,8 +61,8 @@ const EmptyPageComponent = React.memo(({ actions, message, title key={`empty-page-${titles[idx]}-action`} > Date: Wed, 29 Jul 2020 09:35:37 +0300 Subject: [PATCH 26/27] [Security Solutions] Add tooltips (#73436) ## Summary This PR adds three tooltips. The first two are tooltips for the `attaching to a case` buttons. The third tooltip is for the `Upload value lists` button in the `Detections` page. **Timeline:** Screenshot 2020-07-28 at 3 08 31 PM Screenshot 2020-07-28 at 3 08 38 PM **Detections:** Screenshot 2020-07-28 at 3 19 53 PM ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/master/packages/kbn-i18n/README.md) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) ### For maintainers - [x] This was checked for breaking API changes and was [labeled appropriately](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#release-notes-process) --- .../pages/detection_engine/rules/index.tsx | 20 ++++---- .../detection_engine/rules/translations.ts | 8 +++ .../timeline/properties/helpers.tsx | 50 +++++++++++++------ .../timeline/properties/translations.ts | 7 +++ 4 files changed, 60 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index f49ee8246024a5..b6f58ef7045f8a 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButton, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; import React, { useCallback, useRef, useState } from 'react'; import { useHistory } from 'react-router-dom'; @@ -204,14 +204,16 @@ const RulesPageComponent: React.FC = () => { )} - - {i18n.UPLOAD_VALUE_LISTS} - + + + {i18n.UPLOAD_VALUE_LISTS} + + ( timelineTitle, ]); - return ( - - {buttonText} - + const button = useMemo( + () => ( + + {buttonText} + + ), + [compact, timelineStatus, handleClick, buttonText] + ); + return timelineStatus === TimelineStatus.draft ? ( + + {button} + + ) : ( + button ); } ); @@ -225,8 +235,8 @@ export const ExistingCase = React.memo( ? i18n.ATTACH_TO_EXISTING_CASE : i18n.ATTACH_TIMELINE_TO_EXISTING_CASE; - return ( - <> + const button = useMemo( + () => ( ( > {buttonText} - + ), + [buttonText, handleClick, timelineStatus, compact] + ); + return timelineStatus === TimelineStatus.draft ? ( + + {button} + + ) : ( + button ); } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts index 34681d5ed68094..1fc3b7b00f8475 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/translations.ts @@ -151,6 +151,13 @@ export const ATTACH_TO_EXISTING_CASE = i18n.translate( } ); +export const ATTACH_TIMELINE_TO_CASE_TOOLTIP = i18n.translate( + 'xpack.securitySolution.timeline.properties.attachTimelineToCaseTooltip', + { + defaultMessage: 'Please provide a title for your timeline in order to attach it to a case', + } +); + export const STREAM_LIVE = i18n.translate( 'xpack.securitySolution.timeline.properties.streamLiveButtonLabel', { From 14144156f6069303bae882b820f0e9dcc9a608a1 Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Wed, 29 Jul 2020 10:48:23 +0300 Subject: [PATCH 27/27] [Data] Query Input String manager (#72093) * improve test stability * query string input manager (needed for search demo) * docs * dashboard * Fix jest * mock fix * Allow restoring a saved query * sync url * Luke's fix to test * cleanup * lens jest tests * docs * use queryStringManager.getDefaultQuery Don't sync query to global state * Update app.test.tsx lens mock * jest fix * jest * use new api in the example * Rename state param to query to match url state * Apply changes to discover * Update src/plugins/data/public/query/query_string/index.ts Co-authored-by: Anton Dosov * Improve query string state manager * Cleanup dashboard code * Handle refresh button * Set initial dashboard state * visualize state * remove unused * docs * fix example * fix jest * fix filter app state in discover * fix maps test * jest Co-authored-by: Anton Dosov Co-authored-by: Anton Dosov Co-authored-by: Elastic Machine --- ...plugins-data-public.connecttoquerystate.md | 3 +- ...a-plugin-plugins-data-public.querystate.md | 1 + ...in-plugins-data-public.querystate.query.md | 11 ++ ...ugins-data-public.syncquerystatewithurl.md | 2 +- .../with_data_services/components/app.tsx | 15 +-- .../public/application/dashboard_app.tsx | 5 +- .../application/dashboard_app_controller.tsx | 103 +++++++----------- src/plugins/data/public/public.api.md | 102 ++++++++--------- src/plugins/data/public/query/mocks.ts | 3 + .../data/public/query/query_service.ts | 7 ++ .../data/public/query/query_string/index.ts | 20 ++++ .../query_string/query_string_manager.mock.ts | 37 +++++++ .../query_string/query_string_manager.ts | 90 +++++++++++++++ .../state_sync/connect_to_query_state.test.ts | 2 + .../state_sync/connect_to_query_state.ts | 23 +++- .../create_global_query_observable.ts | 8 ++ .../state_sync/sync_state_with_url.test.ts | 2 + .../query/state_sync/sync_state_with_url.ts | 2 +- .../data/public/query/state_sync/types.ts | 3 +- .../ui/search_bar/create_search_bar.tsx | 45 +++----- .../search_bar/lib/clear_saved_query.test.ts | 16 +-- .../ui/search_bar/lib/clear_saved_query.ts | 11 +- .../populate_state_from_saved_query.test.ts | 17 ++- .../lib/populate_state_from_saved_query.ts | 9 +- .../lib/use_query_string_manager.ts | 51 +++++++++ .../ui/search_bar/lib/use_saved_query.ts | 13 +-- .../public/application/angular/discover.html | 3 +- .../public/application/angular/discover.js | 42 ++----- .../components/scripting_help/test_script.tsx | 12 +- .../public/components/controls/filters.tsx | 4 +- .../components/visualize_top_nav.tsx | 15 +-- .../utils/use/use_editor_updates.test.ts | 6 + .../utils/use/use_editor_updates.ts | 19 +--- .../utils/use/use_visualize_app_state.test.ts | 1 + .../utils/use/use_visualize_app_state.tsx | 29 ++++- .../public/application/utils/utils.ts | 11 +- test/functional/apps/visualize/_area_chart.js | 4 + .../lens/public/app_plugin/app.test.tsx | 9 ++ x-pack/plugins/lens/public/app_plugin/app.tsx | 14 +-- .../filter_editor/filter_editor.js | 10 +- .../join_editor/resources/where_expression.js | 9 +- .../routing/bootstrap/get_initial_query.js | 12 +- .../top_nav_menu/top_nav_menu.js | 8 +- .../routing/routes/maps_app/maps_app_view.js | 5 +- .../public/routing/state_syncing/app_sync.js | 1 + 45 files changed, 478 insertions(+), 337 deletions(-) create mode 100644 docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystate.query.md create mode 100644 src/plugins/data/public/query/query_string/index.ts create mode 100644 src/plugins/data/public/query/query_string/query_string_manager.mock.ts create mode 100644 src/plugins/data/public/query/query_string/query_string_manager.ts create mode 100644 src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.connecttoquerystate.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.connecttoquerystate.md index a6731e5ef8de15..7c937b39cda87d 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.connecttoquerystate.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.connecttoquerystate.md @@ -9,9 +9,10 @@ Helper to setup two-way syncing of global data and a state container Signature: ```typescript -connectToQueryState: ({ timefilter: { timefilter }, filterManager, state$, }: Pick, stateContainer: BaseStateContainer, syncConfig: { +connectToQueryState: ({ timefilter: { timefilter }, filterManager, queryString, state$, }: Pick, stateContainer: BaseStateContainer, syncConfig: { time?: boolean; refreshInterval?: boolean; filters?: FilterStateStore | boolean; + query?: boolean; }) => () => void ``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystate.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystate.md index cc489a0cb03676..021d808afecb57 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystate.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystate.md @@ -17,6 +17,7 @@ export interface QueryState | Property | Type | Description | | --- | --- | --- | | [filters](./kibana-plugin-plugins-data-public.querystate.filters.md) | Filter[] | | +| [query](./kibana-plugin-plugins-data-public.querystate.query.md) | Query | | | [refreshInterval](./kibana-plugin-plugins-data-public.querystate.refreshinterval.md) | RefreshInterval | | | [time](./kibana-plugin-plugins-data-public.querystate.time.md) | TimeRange | | diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystate.query.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystate.query.md new file mode 100644 index 00000000000000..b0ac376a358dc4 --- /dev/null +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.querystate.query.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-data-public](./kibana-plugin-plugins-data-public.md) > [QueryState](./kibana-plugin-plugins-data-public.querystate.md) > [query](./kibana-plugin-plugins-data-public.querystate.query.md) + +## QueryState.query property + +Signature: + +```typescript +query?: Query; +``` diff --git a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.syncquerystatewithurl.md b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.syncquerystatewithurl.md index f6f8bed8cb9143..1aafa022f96904 100644 --- a/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.syncquerystatewithurl.md +++ b/docs/development/plugins/data/public/kibana-plugin-plugins-data-public.syncquerystatewithurl.md @@ -9,7 +9,7 @@ Helper to setup syncing of global data with the URL Signature: ```typescript -syncQueryStateWithUrl: (query: Pick, kbnUrlStateStorage: IKbnUrlStateStorage) => { +syncQueryStateWithUrl: (query: Pick, kbnUrlStateStorage: IKbnUrlStateStorage) => { stop: () => void; hasInheritedQueryFromUrl: boolean; } diff --git a/examples/state_containers_examples/public/with_data_services/components/app.tsx b/examples/state_containers_examples/public/with_data_services/components/app.tsx index 04bdb53efa502e..d007cfd97edca6 100644 --- a/examples/state_containers_examples/public/with_data_services/components/app.tsx +++ b/examples/state_containers_examples/public/with_data_services/components/app.tsx @@ -17,7 +17,7 @@ * under the License. */ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { History } from 'history'; import { FormattedMessage, I18nProvider } from '@kbn/i18n/react'; import { Router } from 'react-router-dom'; @@ -85,16 +85,9 @@ const App = ({ navigation, data, history, kbnUrlStateStorage }: StateDemoAppDeps useGlobalStateSyncing(data.query, kbnUrlStateStorage); useAppStateSyncing(appStateContainer, data.query, kbnUrlStateStorage); - const onQuerySubmit = useCallback( - ({ query }) => { - appStateContainer.set({ ...appState, query }); - }, - [appStateContainer, appState] - ); - const indexPattern = useIndexPattern(data); if (!indexPattern) - return
No index pattern found. Please create an intex patter before loading...
; + return
No index pattern found. Please create an index patter before loading...
; // Render the application DOM. // Note that `navigation.ui.TopNavMenu` is a stateful component exported on the `navigation` plugin's start contract. @@ -107,8 +100,6 @@ const App = ({ navigation, data, history, kbnUrlStateStorage }: StateDemoAppDeps showSearchBar={true} indexPatterns={[indexPattern]} useDefaultBehaviors={true} - onQuerySubmit={onQuerySubmit} - query={appState.query} showSaveQuery={true} /> @@ -200,7 +191,7 @@ function useAppStateSyncing( const stopSyncingQueryAppStateWithStateContainer = connectToQueryState( query, appStateContainer, - { filters: esFilters.FilterStateStore.APP_STATE } + { filters: esFilters.FilterStateStore.APP_STATE, query: true } ); // sets up syncing app state container with url diff --git a/src/plugins/dashboard/public/application/dashboard_app.tsx b/src/plugins/dashboard/public/application/dashboard_app.tsx index f101935b9288d1..6690ae318fc8f2 100644 --- a/src/plugins/dashboard/public/application/dashboard_app.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app.tsx @@ -52,7 +52,10 @@ export interface DashboardAppScope extends ng.IScope { expandedPanel?: string; getShouldShowEditHelp: () => boolean; getShouldShowViewHelp: () => boolean; - updateQueryAndFetch: ({ query, dateRange }: { query: Query; dateRange?: TimeRange }) => void; + handleRefresh: ( + { query, dateRange }: { query?: Query; dateRange: TimeRange }, + isUpdate?: boolean + ) => void; topNavMenu: any; showAddPanel: any; showSaveQuery: boolean; diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index afccf8deaa2179..3a4e49968626f8 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -25,12 +25,11 @@ import React, { useState, ReactElement } from 'react'; import ReactDOM from 'react-dom'; import angular from 'angular'; -import { Observable, pipe, Subscription } from 'rxjs'; -import { filter, map, mapTo, startWith, switchMap } from 'rxjs/operators'; +import { Observable, pipe, Subscription, merge } from 'rxjs'; +import { filter, map, debounceTime, mapTo, startWith, switchMap } from 'rxjs/operators'; import { History } from 'history'; import { SavedObjectSaveOpts } from 'src/plugins/saved_objects/public'; import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; -import { TimeRange } from 'src/plugins/data/public'; import { DashboardEmptyScreen, DashboardEmptyScreenProps } from './dashboard_empty_screen'; import { @@ -38,11 +37,9 @@ import { esFilters, IndexPattern, IndexPatternsContract, - Query, QueryState, SavedQuery, syncQueryStateWithUrl, - UI_SETTINGS, } from '../../../data/public'; import { getSavedObjectFinder, SaveResult, showSaveModal } from '../../../saved_objects/public'; @@ -81,8 +78,8 @@ import { addFatalError, AngularHttpError, KibanaLegacyStart, - migrateLegacyQuery, subscribeWithScope, + migrateLegacyQuery, } from '../../../kibana_legacy/public'; export interface DashboardAppControllerDependencies extends RenderDeps { @@ -127,7 +124,6 @@ export class DashboardAppController { $route, $routeParams, dashboardConfig, - localStorage, indexPatterns, savedQueryService, embeddable, @@ -153,8 +149,8 @@ export class DashboardAppController { navigation, }: DashboardAppControllerDependencies) { const filterManager = queryService.filterManager; - const queryFilter = filterManager; const timefilter = queryService.timefilter.timefilter; + const queryStringManager = queryService.queryString; const isEmbeddedExternally = Boolean($routeParams.embed); // url param rules should only apply when embedded (e.g. url?embed=true) @@ -188,20 +184,30 @@ export class DashboardAppController { // sync initial app filters from state to filterManager // if there is an existing similar global filter, then leave it as global filterManager.setAppFilters(_.cloneDeep(dashboardStateManager.appState.filters)); + queryStringManager.setQuery(migrateLegacyQuery(dashboardStateManager.appState.query)); + // setup syncing of app filters between appState and filterManager const stopSyncingAppFilters = connectToQueryState( queryService, { - set: ({ filters }) => dashboardStateManager.setFilters(filters || []), - get: () => ({ filters: dashboardStateManager.appState.filters }), + set: ({ filters, query }) => { + dashboardStateManager.setFilters(filters || []); + dashboardStateManager.setQuery(query || queryStringManager.getDefaultQuery()); + }, + get: () => ({ + filters: dashboardStateManager.appState.filters, + query: dashboardStateManager.getQuery(), + }), state$: dashboardStateManager.appState$.pipe( map((state) => ({ filters: state.filters, + query: queryStringManager.formatQuery(state.query), })) ), }, { filters: esFilters.FilterStateStore.APP_STATE, + query: true, } ); @@ -331,7 +337,7 @@ export class DashboardAppController { const isEmptyInReadonlyMode = shouldShowUnauthorizedEmptyState(); return { id: dashboardStateManager.savedDashboard.id || '', - filters: queryFilter.getFilters(), + filters: filterManager.getFilters(), hidePanelTitles: dashboardStateManager.getHidePanelTitles(), query: $scope.model.query, timeRange: { @@ -356,7 +362,7 @@ export class DashboardAppController { // https://github.com/angular/angular.js/wiki/Understanding-Scopes $scope.model = { query: dashboardStateManager.getQuery(), - filters: queryFilter.getFilters(), + filters: filterManager.getFilters(), timeRestore: dashboardStateManager.getTimeRestore(), title: dashboardStateManager.getTitle(), description: dashboardStateManager.getDescription(), @@ -420,12 +426,12 @@ export class DashboardAppController { if ( !esFilters.compareFilters( container.getInput().filters, - queryFilter.getFilters(), + filterManager.getFilters(), esFilters.COMPARE_ALL_OPTIONS ) ) { // Add filters modifies the object passed to it, hence the clone deep. - queryFilter.addFilters(_.cloneDeep(container.getInput().filters)); + filterManager.addFilters(_.cloneDeep(container.getInput().filters)); dashboardStateManager.applyFilters( $scope.model.query, @@ -487,13 +493,8 @@ export class DashboardAppController { }); dashboardStateManager.applyFilters( - dashboardStateManager.getQuery() || { - query: '', - language: - localStorage.get('kibana.userQueryLanguage') || - uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE), - }, - queryFilter.getFilters() + dashboardStateManager.getQuery() || queryStringManager.getDefaultQuery(), + filterManager.getFilters() ); timefilter.disableTimeRangeSelector(); @@ -567,21 +568,13 @@ export class DashboardAppController { } }; - $scope.updateQueryAndFetch = function ({ query, dateRange }) { - if (dateRange) { - timefilter.setTime(dateRange); - } - - const oldQuery = $scope.model.query; - if (_.isEqual(oldQuery, query)) { + $scope.handleRefresh = function (_payload, isUpdate) { + if (isUpdate === false) { // The user can still request a reload in the query bar, even if the // query is the same, and in that case, we have to explicitly ask for // a reload, since no state changes will cause it. lastReloadRequestTime = new Date().getTime(); refreshDashboardContainer(); - } else { - $scope.model.query = query; - dashboardStateManager.applyFilters($scope.model.query, $scope.model.filters); } }; @@ -600,7 +593,7 @@ export class DashboardAppController { // Making this method sync broke the updates. // Temporary fix, until we fix the complex state in this file. setTimeout(() => { - queryFilter.setFilters(allFilters); + filterManager.setFilters(allFilters); }, 0); }; @@ -633,11 +626,6 @@ export class DashboardAppController { $scope.indexPatterns = []; - $scope.$watch('model.query', (newQuery: Query) => { - const query = migrateLegacyQuery(newQuery) as Query; - $scope.updateQueryAndFetch({ query }); - }); - $scope.$watch( () => dashboardCapabilities.saveQuery, (newCapability) => { @@ -678,18 +666,11 @@ export class DashboardAppController { showFilterBar, indexPatterns: $scope.indexPatterns, showSaveQuery: $scope.showSaveQuery, - query: $scope.model.query, savedQuery: $scope.savedQuery, onSavedQueryIdChange, savedQueryId: dashboardStateManager.getSavedQueryId(), useDefaultBehaviors: true, - onQuerySubmit: (payload: { dateRange: TimeRange; query?: Query }): void => { - if (!payload.query) { - $scope.updateQueryAndFetch({ query: $scope.model.query, dateRange: payload.dateRange }); - } else { - $scope.updateQueryAndFetch({ query: payload.query, dateRange: payload.dateRange }); - } - }, + onQuerySubmit: $scope.handleRefresh, }; }; const dashboardNavBar = document.getElementById('dashboardChrome'); @@ -704,25 +685,11 @@ export class DashboardAppController { }; $scope.timefilterSubscriptions$ = new Subscription(); - + const timeChanges$ = merge(timefilter.getRefreshIntervalUpdate$(), timefilter.getTimeUpdate$()); $scope.timefilterSubscriptions$.add( subscribeWithScope( $scope, - timefilter.getRefreshIntervalUpdate$(), - { - next: () => { - updateState(); - refreshDashboardContainer(); - }, - }, - (error: AngularHttpError | Error | string) => addFatalError(fatalErrors, error) - ) - ); - - $scope.timefilterSubscriptions$.add( - subscribeWithScope( - $scope, - timefilter.getTimeUpdate$(), + timeChanges$, { next: () => { updateState(); @@ -1095,13 +1062,21 @@ export class DashboardAppController { updateViewMode(dashboardStateManager.getViewMode()); + const filterChanges = merge(filterManager.getUpdates$(), queryStringManager.getUpdates$()).pipe( + debounceTime(100) + ); + // update root source when filters update - const updateSubscription = queryFilter.getUpdates$().subscribe({ + const updateSubscription = filterChanges.subscribe({ next: () => { - $scope.model.filters = queryFilter.getFilters(); + $scope.model.filters = filterManager.getFilters(); + $scope.model.query = queryStringManager.getQuery(); dashboardStateManager.applyFilters($scope.model.query, $scope.model.filters); if (dashboardContainer) { - dashboardContainer.updateInput({ filters: $scope.model.filters }); + dashboardContainer.updateInput({ + filters: $scope.model.filters, + query: $scope.model.query, + }); } }, }); diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 2b904ed9536e04..97e7a96c71e697 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -199,10 +199,11 @@ export const castEsToKbnFieldTypeName: (esType: ES_FIELD_TYPES | string) => KBN_ // Warning: (ae-missing-release-tag) "connectToQueryState" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public -export const connectToQueryState: ({ timefilter: { timefilter }, filterManager, state$, }: Pick, stateContainer: BaseStateContainer, syncConfig: { +export const connectToQueryState: ({ timefilter: { timefilter }, filterManager, queryString, state$, }: Pick, stateContainer: BaseStateContainer, syncConfig: { time?: boolean; refreshInterval?: boolean; filters?: FilterStateStore | boolean; + query?: boolean; }) => () => void; // Warning: (ae-missing-release-tag) "createSavedQueryService" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) @@ -1388,6 +1389,8 @@ export interface QueryState { // (undocumented) filters?: Filter[]; // (undocumented) + query?: Query; + // (undocumented) refreshInterval?: RefreshInterval; // (undocumented) time?: TimeRange; @@ -1762,7 +1765,7 @@ export type StatefulSearchBarProps = SearchBarOwnProps & { // Warning: (ae-missing-release-tag) "syncQueryStateWithUrl" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public -export const syncQueryStateWithUrl: (query: Pick, kbnUrlStateStorage: IKbnUrlStateStorage) => { +export const syncQueryStateWithUrl: (query: Pick, kbnUrlStateStorage: IKbnUrlStateStorage) => { stop: () => void; hasInheritedQueryFromUrl: boolean; }; @@ -1864,54 +1867,53 @@ export const UI_SETTINGS: { // src/plugins/data/common/es_query/filters/match_all_filter.ts:28:3 - (ae-forgotten-export) The symbol "MatchAllFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrase_filter.ts:33:3 - (ae-forgotten-export) The symbol "PhraseFilterMeta" needs to be exported by the entry point index.d.ts // src/plugins/data/common/es_query/filters/phrases_filter.ts:31:3 - (ae-forgotten-export) The symbol "PhrasesFilterMeta" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "FilterLabel" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "generateFilters" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "changeTimeFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "convertRangeFilterToTimeRangeString" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "extractTimeFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:66:23 - (ae-forgotten-export) The symbol "extractTimeRange" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:138:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:138:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:138:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:138:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:178:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:234:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:371:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:371:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:371:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:371:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:373:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:374:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:383:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:385:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:386:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:390:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:391:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:394:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:395:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/index.ts:398:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts -// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:41:60 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "FilterLabel" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "FILTERS" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "getDisplayValueFromFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "generateFilters" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "changeTimeFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "convertRangeFilterToTimeRangeString" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:65:23 - (ae-forgotten-export) The symbol "extractTimeFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "buildEsQuery" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "getEsQueryConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "luceneStringToDsl" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:136:21 - (ae-forgotten-export) The symbol "decorateQuery" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "FieldFormatsRegistry" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "BoolFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "BytesFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "ColorFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "DurationFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "IpFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "NumberFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "PercentFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "RelativeDateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "SourceFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "StaticLookupFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "UrlFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "StringFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:176:26 - (ae-forgotten-export) The symbol "TruncateFormat" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "isFilterable" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "isNestedField" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "validateIndexPattern" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "getFromSavedObject" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "flattenHitWrapper" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:232:27 - (ae-forgotten-export) The symbol "formatHitProvider" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "getRequestInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "getResponseInspectorStats" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "tabifyAggResponse" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:369:20 - (ae-forgotten-export) The symbol "tabifyGetColumns" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:371:1 - (ae-forgotten-export) The symbol "CidrMask" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:372:1 - (ae-forgotten-export) The symbol "dateHistogramInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:381:1 - (ae-forgotten-export) The symbol "InvalidEsCalendarIntervalError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:382:1 - (ae-forgotten-export) The symbol "InvalidEsIntervalFormatError" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:383:1 - (ae-forgotten-export) The symbol "Ipv4Address" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:384:1 - (ae-forgotten-export) The symbol "isDateHistogramBucketAggConfig" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:388:1 - (ae-forgotten-export) The symbol "isValidEsInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:389:1 - (ae-forgotten-export) The symbol "isValidInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:392:1 - (ae-forgotten-export) The symbol "parseInterval" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:393:1 - (ae-forgotten-export) The symbol "propFilter" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/index.ts:396:1 - (ae-forgotten-export) The symbol "toAbsoluteDates" needs to be exported by the entry point index.d.ts +// src/plugins/data/public/query/state_sync/connect_to_query_state.ts:45:5 - (ae-forgotten-export) The symbol "FilterStateStore" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:54:5 - (ae-forgotten-export) The symbol "createFiltersFromValueClickAction" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:55:5 - (ae-forgotten-export) The symbol "createFiltersFromRangeSelectAction" needs to be exported by the entry point index.d.ts // src/plugins/data/public/types.ts:63:5 - (ae-forgotten-export) The symbol "IndexPatternSelectProps" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/data/public/query/mocks.ts b/src/plugins/data/public/query/mocks.ts index 41896107bb8685..8c15d9d6d0152e 100644 --- a/src/plugins/data/public/query/mocks.ts +++ b/src/plugins/data/public/query/mocks.ts @@ -21,6 +21,7 @@ import { Observable } from 'rxjs'; import { QueryService, QuerySetup, QueryStart } from '.'; import { timefilterServiceMock } from './timefilter/timefilter_service.mock'; import { createFilterManagerMock } from './filter_manager/filter_manager.mock'; +import { queryStringManagerMock } from './query_string/query_string_manager.mock'; type QueryServiceClientContract = PublicMethodsOf; @@ -28,6 +29,7 @@ const createSetupContractMock = () => { const setupContract: jest.Mocked = { filterManager: createFilterManagerMock(), timefilter: timefilterServiceMock.createSetupContract(), + queryString: queryStringManagerMock.createSetupContract(), state$: new Observable(), }; @@ -38,6 +40,7 @@ const createStartContractMock = () => { const startContract: jest.Mocked = { addToQueryLog: jest.fn(), filterManager: createFilterManagerMock(), + queryString: queryStringManagerMock.createStartContract(), savedQueries: jest.fn() as any, state$: new Observable(), timefilter: timefilterServiceMock.createStartContract(), diff --git a/src/plugins/data/public/query/query_service.ts b/src/plugins/data/public/query/query_service.ts index eb1f985fa51db9..da514c0e24ea44 100644 --- a/src/plugins/data/public/query/query_service.ts +++ b/src/plugins/data/public/query/query_service.ts @@ -25,6 +25,7 @@ import { createAddToQueryLog } from './lib'; import { TimefilterService, TimefilterSetup } from './timefilter'; import { createSavedQueryService } from './saved_query/saved_query_service'; import { createQueryStateObservable } from './state_sync/create_global_query_observable'; +import { QueryStringManager, QueryStringContract } from './query_string'; /** * Query Service @@ -45,6 +46,7 @@ interface QueryServiceStartDependencies { export class QueryService { filterManager!: FilterManager; timefilter!: TimefilterSetup; + queryStringManager!: QueryStringContract; state$!: ReturnType; @@ -57,14 +59,18 @@ export class QueryService { storage, }); + this.queryStringManager = new QueryStringManager(storage, uiSettings); + this.state$ = createQueryStateObservable({ filterManager: this.filterManager, timefilter: this.timefilter, + queryString: this.queryStringManager, }).pipe(share()); return { filterManager: this.filterManager, timefilter: this.timefilter, + queryString: this.queryStringManager, state$: this.state$, }; } @@ -76,6 +82,7 @@ export class QueryService { uiSettings, }), filterManager: this.filterManager, + queryString: this.queryStringManager, savedQueries: createSavedQueryService(savedObjectsClient), state$: this.state$, timefilter: this.timefilter, diff --git a/src/plugins/data/public/query/query_string/index.ts b/src/plugins/data/public/query/query_string/index.ts new file mode 100644 index 00000000000000..6ea87fde69ac8d --- /dev/null +++ b/src/plugins/data/public/query/query_string/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { QueryStringContract, QueryStringManager } from './query_string_manager'; diff --git a/src/plugins/data/public/query/query_string/query_string_manager.mock.ts b/src/plugins/data/public/query/query_string/query_string_manager.mock.ts new file mode 100644 index 00000000000000..427662cb01ebb2 --- /dev/null +++ b/src/plugins/data/public/query/query_string/query_string_manager.mock.ts @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { QueryStringContract } from '.'; + +const createSetupContractMock = () => { + const queryStringManagerMock: jest.Mocked = { + getQuery: jest.fn(), + setQuery: jest.fn(), + getUpdates$: jest.fn(), + getDefaultQuery: jest.fn(), + formatQuery: jest.fn(), + clearQuery: jest.fn(), + }; + return queryStringManagerMock; +}; + +export const queryStringManagerMock = { + createSetupContract: createSetupContractMock, + createStartContract: createSetupContractMock, +}; diff --git a/src/plugins/data/public/query/query_string/query_string_manager.ts b/src/plugins/data/public/query/query_string/query_string_manager.ts new file mode 100644 index 00000000000000..bd02830f4aed86 --- /dev/null +++ b/src/plugins/data/public/query/query_string/query_string_manager.ts @@ -0,0 +1,90 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { BehaviorSubject } from 'rxjs'; +import { CoreStart } from 'kibana/public'; +import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; +import { Query, UI_SETTINGS } from '../../../common'; + +export class QueryStringManager { + private query$: BehaviorSubject; + + constructor( + private readonly storage: IStorageWrapper, + private readonly uiSettings: CoreStart['uiSettings'] + ) { + this.query$ = new BehaviorSubject(this.getDefaultQuery()); + } + + private getDefaultLanguage() { + return ( + this.storage.get('kibana.userQueryLanguage') || + this.uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE) + ); + } + + public getDefaultQuery() { + return { + query: '', + language: this.getDefaultLanguage(), + }; + } + + public formatQuery(query: Query | string | undefined): Query { + if (!query) { + return this.getDefaultQuery(); + } else if (typeof query === 'string') { + return { + query, + language: this.getDefaultLanguage(), + }; + } else { + return query; + } + } + + public getUpdates$ = () => { + return this.query$.asObservable(); + }; + + public getQuery = (): Query => { + return this.query$.getValue(); + }; + + /** + * Updates the query. + * @param {Query} query + */ + public setQuery = (query: Query) => { + const curQuery = this.query$.getValue(); + if (query?.language !== curQuery.language || query?.query !== curQuery.query) { + this.query$.next(query); + } + }; + + /** + * Resets the query to the default one. + */ + public clearQuery = () => { + this.setQuery(this.getDefaultQuery()); + }; +} + +export type QueryStringContract = PublicMethodsOf; diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts index cf98c87b182675..307d1fe1b2b0b3 100644 --- a/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts +++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.test.ts @@ -48,6 +48,8 @@ setupMock.uiSettings.get.mockImplementation((key: string) => { switch (key) { case UI_SETTINGS.FILTERS_PINNED_BY_DEFAULT: return true; + case UI_SETTINGS.SEARCH_QUERY_LANGUAGE: + return 'kuery'; case 'timepicker:timeDefaults': return { from: 'now-15m', to: 'now' }; case UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: diff --git a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts index 2e62dac87f6efc..55edd04b5dab06 100644 --- a/src/plugins/data/public/query/state_sync/connect_to_query_state.ts +++ b/src/plugins/data/public/query/state_sync/connect_to_query_state.ts @@ -35,15 +35,24 @@ export const connectToQueryState = ( { timefilter: { timefilter }, filterManager, + queryString, state$, - }: Pick, + }: Pick, stateContainer: BaseStateContainer, - syncConfig: { time?: boolean; refreshInterval?: boolean; filters?: FilterStateStore | boolean } + syncConfig: { + time?: boolean; + refreshInterval?: boolean; + filters?: FilterStateStore | boolean; + query?: boolean; + } ) => { const syncKeys: Array = []; if (syncConfig.time) { syncKeys.push('time'); } + if (syncConfig.query) { + syncKeys.push('query'); + } if (syncConfig.refreshInterval) { syncKeys.push('refreshInterval'); } @@ -133,6 +142,9 @@ export const connectToQueryState = ( if (syncConfig.time && changes.time) { newState.time = timefilter.getTime(); } + if (syncConfig.query && changes.query) { + newState.query = queryString.getQuery(); + } if (syncConfig.refreshInterval && changes.refreshInterval) { newState.refreshInterval = timefilter.getRefreshInterval(); } @@ -173,6 +185,13 @@ export const connectToQueryState = ( } } + if (syncConfig.query) { + const curQuery = state.query || queryString.getQuery(); + if (!_.isEqual(curQuery, queryString.getQuery())) { + queryString.setQuery(_.cloneDeep(curQuery)); + } + } + if (syncConfig.filters) { const filters = state.filters || []; if (syncConfig.filters === true) { diff --git a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts b/src/plugins/data/public/query/state_sync/create_global_query_observable.ts index 87032925294c61..5e2c575c74af7e 100644 --- a/src/plugins/data/public/query/state_sync/create_global_query_observable.ts +++ b/src/plugins/data/public/query/state_sync/create_global_query_observable.ts @@ -24,23 +24,31 @@ import { FilterManager } from '../filter_manager'; import { QueryState, QueryStateChange } from './index'; import { createStateContainer } from '../../../../kibana_utils/public'; import { isFilterPinned, compareFilters, COMPARE_ALL_OPTIONS } from '../../../common'; +import { QueryStringContract } from '../query_string'; export function createQueryStateObservable({ timefilter: { timefilter }, filterManager, + queryString, }: { timefilter: TimefilterSetup; filterManager: FilterManager; + queryString: QueryStringContract; }): Observable<{ changes: QueryStateChange; state: QueryState }> { return new Observable((subscriber) => { const state = createStateContainer({ time: timefilter.getTime(), refreshInterval: timefilter.getRefreshInterval(), filters: filterManager.getFilters(), + query: queryString.getQuery(), }); let currentChange: QueryStateChange = {}; const subs: Subscription[] = [ + queryString.getUpdates$().subscribe(() => { + currentChange.query = true; + state.set({ ...state.get(), query: queryString.getQuery() }); + }), timefilter.getTimeUpdate$().subscribe(() => { currentChange.time = true; state.set({ ...state.get(), time: timefilter.getTime() }); diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts index 122eb2ff6a3435..0b4a3f663eb6ba 100644 --- a/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts +++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.test.ts @@ -43,6 +43,8 @@ setupMock.uiSettings.get.mockImplementation((key: string) => { return true; case 'timepicker:timeDefaults': return { from: 'now-15m', to: 'now' }; + case 'search:queryLanguage': + return 'kuery'; case UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS: return { pause: false, value: 0 }; default: diff --git a/src/plugins/data/public/query/state_sync/sync_state_with_url.ts b/src/plugins/data/public/query/state_sync/sync_state_with_url.ts index 4d3da7b9313a33..46be800fbb5588 100644 --- a/src/plugins/data/public/query/state_sync/sync_state_with_url.ts +++ b/src/plugins/data/public/query/state_sync/sync_state_with_url.ts @@ -35,7 +35,7 @@ const GLOBAL_STATE_STORAGE_KEY = '_g'; * @param kbnUrlStateStorage to use for syncing */ export const syncQueryStateWithUrl = ( - query: Pick, + query: Pick, kbnUrlStateStorage: IKbnUrlStateStorage ) => { const { diff --git a/src/plugins/data/public/query/state_sync/types.ts b/src/plugins/data/public/query/state_sync/types.ts index 747d4d45fe29b4..2354db8cad11ad 100644 --- a/src/plugins/data/public/query/state_sync/types.ts +++ b/src/plugins/data/public/query/state_sync/types.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Filter, RefreshInterval, TimeRange } from '../../../common'; +import { Filter, RefreshInterval, TimeRange, Query } from '../../../common'; /** * All query state service state @@ -26,6 +26,7 @@ export interface QueryState { time?: TimeRange; refreshInterval?: RefreshInterval; filters?: Filter[]; + query?: Query; } type QueryStateChangePartial = { diff --git a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx index f8b7e4f4809112..9f0ba2378592a0 100644 --- a/src/plugins/data/public/ui/search_bar/create_search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/create_search_bar.tsx @@ -18,7 +18,7 @@ */ import _ from 'lodash'; -import React, { useState, useEffect, useRef } from 'react'; +import React, { useEffect, useRef } from 'react'; import { CoreStart } from 'src/core/public'; import { IStorageWrapper } from 'src/plugins/kibana_utils/public'; import { KibanaContextProvider } from '../../../../kibana_react/public'; @@ -28,7 +28,8 @@ import { useFilterManager } from './lib/use_filter_manager'; import { useTimefilter } from './lib/use_timefilter'; import { useSavedQuery } from './lib/use_saved_query'; import { DataPublicPluginStart } from '../../types'; -import { Filter, Query, TimeRange, UI_SETTINGS } from '../../../common'; +import { Filter, Query, TimeRange } from '../../../common'; +import { useQueryStringManager } from './lib/use_query_string_manager'; interface StatefulSearchBarDeps { core: CoreStart; @@ -65,8 +66,7 @@ const defaultOnRefreshChange = (queryService: QueryStart) => { const defaultOnQuerySubmit = ( props: StatefulSearchBarProps, queryService: QueryStart, - currentQuery: Query, - setQueryStringState: Function + currentQuery: Query ) => { if (!props.useDefaultBehaviors) return props.onQuerySubmit; @@ -78,7 +78,11 @@ const defaultOnQuerySubmit = ( !_.isEqual(payload.query, currentQuery); if (isUpdate) { timefilter.setTime(payload.dateRange); - setQueryStringState(payload.query); + if (payload.query) { + queryService.queryString.setQuery(payload.query); + } else { + queryService.queryString.clearQuery(); + } } else { // Refresh button triggered for an update if (props.onQuerySubmit) @@ -121,30 +125,7 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) return (props: StatefulSearchBarProps) => { const { useDefaultBehaviors } = props; // Handle queries - const queryRef = useRef(props.query); const onQuerySubmitRef = useRef(props.onQuerySubmit); - const defaultQuery = { - query: '', - language: - storage.get('kibana.userQueryLanguage') || - core.uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE), - }; - const [query, setQuery] = useState(props.query || defaultQuery); - - useEffect(() => { - if (props.query !== queryRef.current) { - queryRef.current = props.query; - setQuery(props.query || defaultQuery); - } - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [defaultQuery, props.query]); - - useEffect(() => { - if (props.onQuerySubmit !== onQuerySubmitRef.current) { - onQuerySubmitRef.current = props.onQuerySubmit; - } - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, [props.onQuerySubmit]); // handle service state updates. // i.e. filters being added from a visualization directly to filterManager. @@ -152,6 +133,10 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) filters: props.filters, filterManager: data.query.filterManager, }); + const { query } = useQueryStringManager({ + query: props.query, + queryStringManager: data.query.queryString, + }); const { timeRange, refreshInterval } = useTimefilter({ dateRangeFrom: props.dateRangeFrom, dateRangeTo: props.dateRangeTo, @@ -163,10 +148,8 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) // Fetch and update UI from saved query const { savedQuery, setSavedQuery, clearSavedQuery } = useSavedQuery({ queryService: data.query, - setQuery, savedQueryId: props.savedQueryId, notifications: core.notifications, - defaultLanguage: defaultQuery.language, }); // Fire onQuerySubmit on query or timerange change @@ -210,7 +193,7 @@ export function createSearchBar({ core, storage, data }: StatefulSearchBarDeps) onFiltersUpdated={defaultFiltersUpdated(data.query)} onRefreshChange={defaultOnRefreshChange(data.query)} savedQuery={savedQuery} - onQuerySubmit={defaultOnQuerySubmit(props, data.query, query, setQuery)} + onQuerySubmit={defaultOnQuerySubmit(props, data.query, query)} onClearSavedQuery={defaultOnClearSavedQuery(props, clearSavedQuery)} onSavedQueryUpdated={defaultOnSavedQueryUpdated(props, setSavedQuery)} onSaved={defaultOnSavedQueryUpdated(props, setSavedQuery)} diff --git a/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.test.ts b/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.test.ts index ccfe5464b95980..10520fc3714d5c 100644 --- a/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.test.ts +++ b/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.test.ts @@ -21,10 +21,8 @@ import { clearStateFromSavedQuery } from './clear_saved_query'; import { dataPluginMock } from '../../../mocks'; import { DataPublicPluginStart } from '../../../types'; -import { Query } from '../../..'; describe('clearStateFromSavedQuery', () => { - const DEFAULT_LANGUAGE = 'banana'; let dataMock: jest.Mocked; beforeEach(() => { @@ -32,19 +30,9 @@ describe('clearStateFromSavedQuery', () => { }); it('should clear filters and query', async () => { - const setQueryState = jest.fn(); dataMock.query.filterManager.removeAll = jest.fn(); - clearStateFromSavedQuery(dataMock.query, setQueryState, DEFAULT_LANGUAGE); - expect(setQueryState).toHaveBeenCalled(); - expect(dataMock.query.filterManager.removeAll).toHaveBeenCalled(); - }); - - it('should use search:queryLanguage', async () => { - const setQueryState = jest.fn(); - dataMock.query.filterManager.removeAll = jest.fn(); - clearStateFromSavedQuery(dataMock.query, setQueryState, DEFAULT_LANGUAGE); - expect(setQueryState).toHaveBeenCalled(); - expect((setQueryState.mock.calls[0][0] as Query).language).toBe(DEFAULT_LANGUAGE); + clearStateFromSavedQuery(dataMock.query); + expect(dataMock.query.queryString.clearQuery).toHaveBeenCalled(); expect(dataMock.query.filterManager.removeAll).toHaveBeenCalled(); }); }); diff --git a/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.ts b/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.ts index b2c777261c2574..06ee56e9e43858 100644 --- a/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.ts +++ b/src/plugins/data/public/ui/search_bar/lib/clear_saved_query.ts @@ -18,14 +18,7 @@ */ import { QueryStart } from '../../../query'; -export const clearStateFromSavedQuery = ( - queryService: QueryStart, - setQueryStringState: Function, - defaultLanguage: string -) => { +export const clearStateFromSavedQuery = (queryService: QueryStart) => { queryService.filterManager.removeAll(); - setQueryStringState({ - query: '', - language: defaultLanguage, - }); + queryService.queryString.clearQuery(); }; diff --git a/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts index 1db900053e078b..660aa2333d49c2 100644 --- a/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts +++ b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts @@ -47,37 +47,34 @@ describe('populateStateFromSavedQuery', () => { }); it('should set query', async () => { - const setQueryState = jest.fn(); const savedQuery: SavedQuery = { ...baseSavedQuery, }; - populateStateFromSavedQuery(dataMock.query, setQueryState, savedQuery); - expect(setQueryState).toHaveBeenCalled(); + populateStateFromSavedQuery(dataMock.query, savedQuery); + expect(dataMock.query.queryString.setQuery).toHaveBeenCalled(); }); it('should set filters', async () => { - const setQueryState = jest.fn(); const savedQuery: SavedQuery = { ...baseSavedQuery, }; const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); savedQuery.attributes.filters = [f1]; - populateStateFromSavedQuery(dataMock.query, setQueryState, savedQuery); - expect(setQueryState).toHaveBeenCalled(); + populateStateFromSavedQuery(dataMock.query, savedQuery); + expect(dataMock.query.queryString.setQuery).toHaveBeenCalled(); expect(dataMock.query.filterManager.setFilters).toHaveBeenCalledWith([f1]); }); it('should preserve global filters', async () => { const globalFilter = getFilter(FilterStateStore.GLOBAL_STATE, false, false, 'age', 34); dataMock.query.filterManager.getGlobalFilters = jest.fn().mockReturnValue([globalFilter]); - const setQueryState = jest.fn(); const savedQuery: SavedQuery = { ...baseSavedQuery, }; const f1 = getFilter(FilterStateStore.APP_STATE, false, false, 'age', 34); savedQuery.attributes.filters = [f1]; - populateStateFromSavedQuery(dataMock.query, setQueryState, savedQuery); - expect(setQueryState).toHaveBeenCalled(); + populateStateFromSavedQuery(dataMock.query, savedQuery); + expect(dataMock.query.queryString.setQuery).toHaveBeenCalled(); expect(dataMock.query.filterManager.setFilters).toHaveBeenCalledWith([globalFilter, f1]); }); @@ -97,7 +94,7 @@ describe('populateStateFromSavedQuery', () => { dataMock.query.timefilter.timefilter.setTime = jest.fn(); dataMock.query.timefilter.timefilter.setRefreshInterval = jest.fn(); - populateStateFromSavedQuery(dataMock.query, jest.fn(), savedQuery); + populateStateFromSavedQuery(dataMock.query, savedQuery); expect(dataMock.query.timefilter.timefilter.setTime).toHaveBeenCalledWith({ from: savedQuery.attributes.timefilter.from, diff --git a/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts index 7ae6726b36df09..bb4b97cc4a9fdd 100644 --- a/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts +++ b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts @@ -19,14 +19,11 @@ import { QueryStart, SavedQuery } from '../../../query'; -export const populateStateFromSavedQuery = ( - queryService: QueryStart, - setQueryStringState: Function, - savedQuery: SavedQuery -) => { +export const populateStateFromSavedQuery = (queryService: QueryStart, savedQuery: SavedQuery) => { const { timefilter: { timefilter }, filterManager, + queryString, } = queryService; // timefilter if (savedQuery.attributes.timefilter) { @@ -40,7 +37,7 @@ export const populateStateFromSavedQuery = ( } // query string - setQueryStringState(savedQuery.attributes.query); + queryString.setQuery(savedQuery.attributes.query); // filters const savedQueryFilters = savedQuery.attributes.filters || []; diff --git a/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts b/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts new file mode 100644 index 00000000000000..e28129f20bb868 --- /dev/null +++ b/src/plugins/data/public/ui/search_bar/lib/use_query_string_manager.ts @@ -0,0 +1,51 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState, useEffect } from 'react'; +import { Subscription } from 'rxjs'; +import { Query } from '../../..'; +import { QueryStringContract } from '../../../query/query_string'; + +interface UseQueryStringProps { + query?: Query; + queryStringManager: QueryStringContract; +} + +export const useQueryStringManager = (props: UseQueryStringProps) => { + // Filters should be either what's passed in the initial state or the current state of the filter manager + const [query, setQuery] = useState(props.query || props.queryStringManager.getQuery()); + useEffect(() => { + const subscriptions = new Subscription(); + + subscriptions.add( + props.queryStringManager.getUpdates$().subscribe({ + next: () => { + const newQuery = props.queryStringManager.getQuery(); + setQuery(newQuery); + }, + }) + ); + + return () => { + subscriptions.unsubscribe(); + }; + }, [props.queryStringManager]); + + return { query }; +}; diff --git a/src/plugins/data/public/ui/search_bar/lib/use_saved_query.ts b/src/plugins/data/public/ui/search_bar/lib/use_saved_query.ts index 79aee3438d7aa6..9f73a401f563b6 100644 --- a/src/plugins/data/public/ui/search_bar/lib/use_saved_query.ts +++ b/src/plugins/data/public/ui/search_bar/lib/use_saved_query.ts @@ -27,10 +27,8 @@ import { clearStateFromSavedQuery } from './clear_saved_query'; interface UseSavedQueriesProps { queryService: DataPublicPluginStart['query']; - setQuery: Function; notifications: CoreStart['notifications']; savedQueryId?: string; - defaultLanguage: string; } interface UseSavedQueriesReturn { @@ -41,7 +39,6 @@ interface UseSavedQueriesReturn { export const useSavedQuery = (props: UseSavedQueriesProps): UseSavedQueriesReturn => { // Handle saved queries - const defaultLanguage = props.defaultLanguage; const [savedQuery, setSavedQuery] = useState(); // Effect is used to convert a saved query id into an object @@ -53,12 +50,12 @@ export const useSavedQuery = (props: UseSavedQueriesProps): UseSavedQueriesRetur // Make sure we set the saved query to the most recent one if (newSavedQuery && newSavedQuery.id === savedQueryId) { setSavedQuery(newSavedQuery); - populateStateFromSavedQuery(props.queryService, props.setQuery, newSavedQuery); + populateStateFromSavedQuery(props.queryService, newSavedQuery); } } catch (error) { // Clear saved query setSavedQuery(undefined); - clearStateFromSavedQuery(props.queryService, props.setQuery, defaultLanguage); + clearStateFromSavedQuery(props.queryService); // notify of saving error props.notifications.toasts.addWarning({ title: i18n.translate('data.search.unableToGetSavedQueryToastTitle', { @@ -73,23 +70,21 @@ export const useSavedQuery = (props: UseSavedQueriesProps): UseSavedQueriesRetur if (props.savedQueryId) fetchSavedQuery(props.savedQueryId); else setSavedQuery(undefined); }, [ - defaultLanguage, props.notifications.toasts, props.queryService, props.queryService.savedQueries, props.savedQueryId, - props.setQuery, ]); return { savedQuery, setSavedQuery: (q: SavedQuery) => { setSavedQuery(q); - populateStateFromSavedQuery(props.queryService, props.setQuery, q); + populateStateFromSavedQuery(props.queryService, q); }, clearSavedQuery: () => { setSavedQuery(undefined); - clearStateFromSavedQuery(props.queryService, props.setQuery, defaultLanguage); + clearStateFromSavedQuery(props.queryService); }, }; }; diff --git a/src/plugins/discover/public/application/angular/discover.html b/src/plugins/discover/public/application/angular/discover.html index 48a8442b063160..d3d4f524873d88 100644 --- a/src/plugins/discover/public/application/angular/discover.html +++ b/src/plugins/discover/public/application/angular/discover.html @@ -6,9 +6,8 @@

{{screenTitle}}

app-name="'discover'" config="topNavMenu" index-patterns="[indexPattern]" - on-query-submit="updateQuery" + on-query-submit="handleRefresh" on-saved-query-id-change="updateSavedQueryId" - query="state.query" saved-query-id="state.savedQuery" screen-title="screenTitle" show-date-picker="indexPattern.isTimeBased()" diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover/public/application/angular/discover.js index c791bdd850151c..4a27f261a62206 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover/public/application/angular/discover.js @@ -70,9 +70,7 @@ import { indexPatterns as indexPatternsUtils, connectToQueryState, syncQueryStateWithUrl, - getDefaultQuery, search, - UI_SETTINGS, } from '../../../../data/public'; import { getIndexPatternId } from '../helpers/get_index_pattern_id'; import { addFatalError } from '../../../../kibana_legacy/public'; @@ -191,16 +189,7 @@ app.directive('discoverApp', function () { }; }); -function discoverController( - $element, - $route, - $scope, - $timeout, - $window, - Promise, - localStorage, - uiCapabilities -) { +function discoverController($element, $route, $scope, $timeout, $window, Promise, uiCapabilities) { const { isDefault: isDefaultType } = indexPatternsUtils; const subscriptions = new Subscription(); const $fetchObservable = new Subject(); @@ -246,11 +235,15 @@ function discoverController( // sync initial app filters from state to filterManager filterManager.setAppFilters(_.cloneDeep(appStateContainer.getState().filters)); + data.query.queryString.setQuery(appStateContainer.getState().query); const stopSyncingQueryAppStateWithStateContainer = connectToQueryState( data.query, appStateContainer, - { filters: esFilters.FilterStateStore.APP_STATE } + { + filters: esFilters.FilterStateStore.APP_STATE, + query: true, + } ); const appStateUnsubscribe = appStateContainer.subscribe(async (newState) => { @@ -262,7 +255,7 @@ function discoverController( $scope.state = { ...newState }; // detect changes that should trigger fetching of new data - const changes = ['interval', 'sort', 'query'].filter( + const changes = ['interval', 'sort'].filter( (prop) => !_.isEqual(newStatePartial[prop], oldStatePartial[prop]) ); @@ -593,12 +586,7 @@ function discoverController( }; function getStateDefaults() { - const query = - $scope.searchSource.getField('query') || - getDefaultQuery( - localStorage.get('kibana.userQueryLanguage') || - config.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE) - ); + const query = $scope.searchSource.getField('query') || data.query.queryString.getDefaultQuery(); return { query, sort: getSortArray(savedSearch.sort, $scope.indexPattern), @@ -635,12 +623,7 @@ function discoverController( const init = _.once(() => { $scope.updateDataSource().then(async () => { - const searchBarChanges = merge( - timefilter.getAutoRefreshFetch$(), - timefilter.getFetch$(), - filterManager.getFetches$(), - $fetchObservable - ).pipe(debounceTime(100)); + const searchBarChanges = merge(data.query.state$, $fetchObservable).pipe(debounceTime(100)); subscriptions.add( subscribeWithScope( @@ -824,9 +807,8 @@ function discoverController( }); }; - $scope.updateQuery = function ({ query }, isUpdate = true) { - if (!_.isEqual(query, appStateContainer.getState().query) || isUpdate === false) { - setAppState({ query }); + $scope.handleRefresh = function (_payload, isUpdate) { + if (isUpdate === false) { $fetchObservable.next(); } }; @@ -976,7 +958,7 @@ function discoverController( config.get(SORT_DEFAULT_ORDER_SETTING) ) ) - .setField('query', $scope.state.query || null) + .setField('query', data.query.queryString.getQuery() || null) .setField('filter', filterManager.getFilters()); return Promise.resolve(); }; diff --git a/src/plugins/index_pattern_management/public/components/field_editor/components/scripting_help/test_script.tsx b/src/plugins/index_pattern_management/public/components/field_editor/components/scripting_help/test_script.tsx index c97f19f59d340d..cb1d5a25c01ae6 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/components/scripting_help/test_script.tsx +++ b/src/plugins/index_pattern_management/public/components/field_editor/components/scripting_help/test_script.tsx @@ -35,12 +35,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { - esQuery, - IndexPattern, - Query, - UI_SETTINGS, -} from '../../../../../../../plugins/data/public'; +import { esQuery, IndexPattern, Query } from '../../../../../../../plugins/data/public'; import { context as contextType } from '../../../../../../kibana_react/public'; import { IndexPatternManagmentContextValue } from '../../../../types'; import { ExecuteScript } from '../../types'; @@ -248,10 +243,7 @@ export class TestScript extends Component { showFilterBar={false} showDatePicker={false} showQueryInput={true} - query={{ - language: this.context.services.uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE), - query: '', - }} + query={this.context.services.data.query.queryString.getDefaultQuery()} onQuerySubmit={this.previewScript} indexPatterns={[this.props.indexPattern]} customSubmitButton={ diff --git a/src/plugins/vis_default_editor/public/components/controls/filters.tsx b/src/plugins/vis_default_editor/public/components/controls/filters.tsx index 04d0df27927fa7..fc676e25ff6d76 100644 --- a/src/plugins/vis_default_editor/public/components/controls/filters.tsx +++ b/src/plugins/vis_default_editor/public/components/controls/filters.tsx @@ -23,7 +23,7 @@ import { htmlIdGenerator, EuiButton, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useMount } from 'react-use'; -import { Query, UI_SETTINGS } from '../../../../data/public'; +import { Query } from '../../../../data/public'; import { useKibana } from '../../../../kibana_react/public'; import { FilterRow } from './filter'; import { AggParamEditorProps } from '../agg_param_props'; @@ -70,7 +70,7 @@ function FiltersParamEditor({ agg, value = [], setValue }: AggParamEditorProps { - if (!isEqual(currentAppState.query, query)) { - stateContainer.transitions.set('query', query || currentAppState.query); - } else { + const handleRefresh = useCallback( + (_payload: any, isUpdate?: boolean) => { + if (isUpdate === false) { savedVisInstance.embeddableHandler.reload(); } }, - [currentAppState.query, savedVisInstance.embeddableHandler, stateContainer.transitions] + [savedVisInstance.embeddableHandler] ); const config = useMemo(() => { @@ -149,8 +145,7 @@ const TopNav = ({ { to: 'now', }; mockFilters = ['mockFilters']; + const mockQuery = { + query: '', + language: 'kuery', + }; // @ts-expect-error mockServices.data.query.timefilter.timefilter.getTime.mockImplementation(() => timeRange); // @ts-expect-error mockServices.data.query.filterManager.getFilters.mockImplementation(() => mockFilters); + // @ts-expect-error + mockServices.data.query.queryString.getQuery.mockImplementation(() => mockQuery); }); test('should set up current app state and render the editor', () => { diff --git a/src/plugins/visualize/public/application/utils/use/use_editor_updates.ts b/src/plugins/visualize/public/application/utils/use/use_editor_updates.ts index 360e7560b1932c..0f4b2d34e8e87e 100644 --- a/src/plugins/visualize/public/application/utils/use/use_editor_updates.ts +++ b/src/plugins/visualize/public/application/utils/use/use_editor_updates.ts @@ -20,9 +20,7 @@ import { useEffect, useState } from 'react'; import { isEqual } from 'lodash'; import { EventEmitter } from 'events'; -import { merge } from 'rxjs'; -import { migrateLegacyQuery } from '../../../../../kibana_legacy/public'; import { VisualizeServices, VisualizeAppState, @@ -47,6 +45,8 @@ export const useEditorUpdates = ( const { timefilter: { timefilter }, filterManager, + queryString, + state$, } = services.data.query; const { embeddableHandler, savedVis, savedSearch, vis } = savedVisInstance; const initialState = appState.getState(); @@ -60,7 +60,7 @@ export const useEditorUpdates = ( uiState: vis.uiState, timeRange: timefilter.getTime(), filters: filterManager.getFilters(), - query: appState.getState().query, + query: queryString.getQuery(), linked: !!vis.data.savedSearchId, savedSearch, }); @@ -68,17 +68,12 @@ export const useEditorUpdates = ( embeddableHandler.updateInput({ timeRange: timefilter.getTime(), filters: filterManager.getFilters(), - query: appState.getState().query, + query: queryString.getQuery(), }); } }; - const subscriptions = merge( - timefilter.getTimeUpdate$(), - timefilter.getAutoRefreshFetch$(), - timefilter.getFetch$(), - filterManager.getFetches$() - ).subscribe({ + const subscriptions = state$.subscribe({ next: reloadVisualization, error: services.fatalErrors.add, }); @@ -116,10 +111,6 @@ export const useEditorUpdates = ( // and initializing different visualizations return; } - const newQuery = migrateLegacyQuery(state.query); - if (!isEqual(state.query, newQuery)) { - appState.transitions.set('query', newQuery); - } if (!isEqual(state.uiState, vis.uiState.getChanges())) { vis.uiState.set(state.uiState); diff --git a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts index e885067c581843..8bde9a049c4927 100644 --- a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts +++ b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.test.ts @@ -96,6 +96,7 @@ describe('useVisualizeAppState', () => { ); expect(connectToQueryState).toHaveBeenCalledWith(mockServices.data.query, expect.any(Object), { filters: 'appState', + query: true, }); expect(result.current).toEqual({ appState: stateContainer, diff --git a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.tsx b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.tsx index e4d891472fbfd8..c44f67df3729f7 100644 --- a/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.tsx +++ b/src/plugins/visualize/public/application/utils/use/use_visualize_app_state.tsx @@ -24,6 +24,7 @@ import { EventEmitter } from 'events'; import { i18n } from '@kbn/i18n'; import { MarkdownSimple, toMountPoint } from '../../../../../kibana_react/public'; +import { migrateLegacyQuery } from '../../../../../kibana_legacy/public'; import { esFilters, connectToQueryState } from '../../../../../data/public'; import { VisualizeServices, VisualizeAppStateContainer, SavedVisInstance } from '../../types'; import { visStateToEditorState } from '../utils'; @@ -61,19 +62,35 @@ export const useVisualizeAppState = ( eventEmitter.on('dirtyStateChange', onDirtyStateChange); - const { filterManager } = services.data.query; - // sync initial app filters from state to filterManager + const { filterManager, queryString } = services.data.query; + // sync initial app state from state to managers filterManager.setAppFilters(cloneDeep(stateContainer.getState().filters)); - // setup syncing of app filters between appState and filterManager + queryString.setQuery(migrateLegacyQuery(stateContainer.getState().query)); + + // setup syncing of app filters between appState and query services const stopSyncingAppFilters = connectToQueryState( services.data.query, { - set: ({ filters }) => stateContainer.transitions.set('filters', filters), - get: () => ({ filters: stateContainer.getState().filters }), - state$: stateContainer.state$.pipe(map((state) => ({ filters: state.filters }))), + set: ({ filters, query }) => { + stateContainer.transitions.set('filters', filters); + stateContainer.transitions.set('query', query); + }, + get: () => { + return { + filters: stateContainer.getState().filters, + query: stateContainer.getState().query, + }; + }, + state$: stateContainer.state$.pipe( + map((state) => ({ + filters: state.filters, + query: state.query, + })) + ), }, { filters: esFilters.FilterStateStore.APP_STATE, + query: true, } ); diff --git a/src/plugins/visualize/public/application/utils/utils.ts b/src/plugins/visualize/public/application/utils/utils.ts index 9f32da3f785b52..532d87985a0b62 100644 --- a/src/plugins/visualize/public/application/utils/utils.ts +++ b/src/plugins/visualize/public/application/utils/utils.ts @@ -20,7 +20,7 @@ import { i18n } from '@kbn/i18n'; import { ChromeStart, DocLinksStart } from 'kibana/public'; -import { Filter, UI_SETTINGS } from '../../../../data/public'; +import { Filter } from '../../../../data/public'; import { VisualizeServices, SavedVisInstance } from '../types'; export const addHelpMenuToAppChrome = (chrome: ChromeStart, docLinks: DocLinksStart) => { @@ -49,12 +49,9 @@ export const addBadgeToAppChrome = (chrome: ChromeStart) => { }); }; -export const getDefaultQuery = ({ localStorage, uiSettings }: VisualizeServices) => ({ - query: '', - language: - localStorage.get('kibana.userQueryLanguage') || - uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE), -}); +export const getDefaultQuery = ({ data }: VisualizeServices) => { + return data.query.queryString.getDefaultQuery(); +}; export const visStateToEditorState = ( { vis, savedVis }: SavedVisInstance, diff --git a/test/functional/apps/visualize/_area_chart.js b/test/functional/apps/visualize/_area_chart.js index 4321f0df892509..9ac2160a359da1 100644 --- a/test/functional/apps/visualize/_area_chart.js +++ b/test/functional/apps/visualize/_area_chart.js @@ -563,6 +563,10 @@ export default function ({ getService, getPageObjects }) { it('should display updated scaled label text after time range is changed', async () => { await PageObjects.visEditor.setInterval('Millisecond'); + + // Apply interval + await testSubjects.clickWhenNotDisabled('visualizeEditorRenderButton'); + const isHelperScaledLabelExists = await find.existsByCssSelector( '[data-test-subj="currentlyScaledText"]' ); diff --git a/x-pack/plugins/lens/public/app_plugin/app.test.tsx b/x-pack/plugins/lens/public/app_plugin/app.test.tsx index 3bd12a87456a0a..a72f4f429a1be9 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.test.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.test.tsx @@ -95,6 +95,14 @@ function createMockFilterManager() { }; } +function createMockQueryString() { + return { + getQuery: jest.fn(() => ({ query: '', language: 'kuery' })), + setQuery: jest.fn(), + getDefaultQuery: jest.fn(() => ({ query: '', language: 'kuery' })), + }; +} + function createMockTimefilter() { const unsubscribe = jest.fn(); @@ -148,6 +156,7 @@ describe('Lens App', () => { timefilter: { timefilter: createMockTimefilter(), }, + queryString: createMockQueryString(), state$: new Observable(), }, indexPatterns: { diff --git a/x-pack/plugins/lens/public/app_plugin/app.tsx b/x-pack/plugins/lens/public/app_plugin/app.tsx index 082a3afcd513e9..2a7eaff32fa081 100644 --- a/x-pack/plugins/lens/public/app_plugin/app.tsx +++ b/x-pack/plugins/lens/public/app_plugin/app.tsx @@ -36,7 +36,6 @@ import { IndexPattern as IndexPatternInstance, IndexPatternsContract, SavedQuery, - UI_SETTINGS, } from '../../../../../src/plugins/data/public'; interface State { @@ -83,17 +82,13 @@ export function App({ onAppLeave: AppMountParameters['onAppLeave']; history: History; }) { - const language = - storage.get('kibana.userQueryLanguage') || - core.uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE); - const [state, setState] = useState(() => { const currentRange = data.query.timefilter.timefilter.getTime(); return { isLoading: !!docId, isSaveModalVisible: false, indexPatternsForTopNav: [], - query: { query: '', language }, + query: data.query.queryString.getDefaultQuery(), dateRange: { fromDate: currentRange.from, toDate: currentRange.to, @@ -473,12 +468,7 @@ export function App({ ...s, savedQuery: undefined, filters: data.query.filterManager.getGlobalFilters(), - query: { - query: '', - language: - storage.get('kibana.userQueryLanguage') || - core.uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE), - }, + query: data.query.queryString.getDefaultQuery(), })); }} query={state.query} diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js b/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js index 45c7507160e980..d2652fac5bd2c1 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/filter_editor/filter_editor.js @@ -20,8 +20,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { UI_SETTINGS } from '../../../../../../../src/plugins/data/public'; -import { getIndexPatternService, getUiSettings, getData } from '../../../kibana_services'; +import { getIndexPatternService, getData } from '../../../kibana_services'; import { GlobalFilterCheckbox } from '../../../components/global_filter_checkbox'; export class FilterEditor extends Component { @@ -82,7 +81,6 @@ export class FilterEditor extends Component { _renderQueryPopover() { const layerQuery = this.props.layer.getQuery(); - const uiSettings = getUiSettings(); const { SearchBar } = getData().ui; return ( @@ -99,11 +97,7 @@ export class FilterEditor extends Component { showFilterBar={false} showDatePicker={false} showQueryInput={true} - query={ - layerQuery - ? layerQuery - : { language: uiSettings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE), query: '' } - } + query={layerQuery ? layerQuery : getData().query.queryString.getDefaultQuery()} onQuerySubmit={this._onQueryChange} indexPatterns={this.state.indexPatterns} customSubmitButton={ diff --git a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js index 8fdb71de2dfed4..60151219a994fc 100644 --- a/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js +++ b/x-pack/plugins/maps/public/connected_components/layer_panel/join_editor/resources/where_expression.js @@ -8,8 +8,7 @@ import React, { Component } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButton, EuiPopover, EuiExpression, EuiFormHelpText } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { UI_SETTINGS } from '../../../../../../../../src/plugins/data/public'; -import { getUiSettings, getData } from '../../../../kibana_services'; +import { getData } from '../../../../kibana_services'; export class WhereExpression extends Component { state = { @@ -77,11 +76,7 @@ export class WhereExpression extends Component { showFilterBar={false} showDatePicker={false} showQueryInput={true} - query={ - whereQuery - ? whereQuery - : { language: getUiSettings().get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE), query: '' } - } + query={whereQuery ? whereQuery : getData().query.queryString.getDefaultQuery()} onQuerySubmit={this._onQueryChange} indexPatterns={[indexPattern]} customSubmitButton={ diff --git a/x-pack/plugins/maps/public/routing/bootstrap/get_initial_query.js b/x-pack/plugins/maps/public/routing/bootstrap/get_initial_query.js index dfc3a1c9de96af..1f2cf270776235 100644 --- a/x-pack/plugins/maps/public/routing/bootstrap/get_initial_query.js +++ b/x-pack/plugins/maps/public/routing/bootstrap/get_initial_query.js @@ -4,12 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getUiSettings } from '../../kibana_services'; -import { UI_SETTINGS } from '../../../../../../src/plugins/data/public'; - -export function getInitialQuery({ mapStateJSON, appState = {}, userQueryLanguage }) { - const settings = getUiSettings(); +import { getData } from '../../kibana_services'; +export function getInitialQuery({ mapStateJSON, appState = {} }) { if (appState.query) { return appState.query; } @@ -21,8 +18,5 @@ export function getInitialQuery({ mapStateJSON, appState = {}, userQueryLanguage } } - return { - query: '', - language: userQueryLanguage || settings.get(UI_SETTINGS.SEARCH_QUERY_LANGUAGE), - }; + return getData().query.queryString.getDefaultQuery(); } diff --git a/x-pack/plugins/maps/public/routing/page_elements/top_nav_menu/top_nav_menu.js b/x-pack/plugins/maps/public/routing/page_elements/top_nav_menu/top_nav_menu.js index ac2dec0db59cc4..2340e3716547ba 100644 --- a/x-pack/plugins/maps/public/routing/page_elements/top_nav_menu/top_nav_menu.js +++ b/x-pack/plugins/maps/public/routing/page_elements/top_nav_menu/top_nav_menu.js @@ -14,7 +14,6 @@ import { getToasts, getCoreI18n, getData, - getUiSettings, } from '../../../kibana_services'; import { SavedObjectSaveModal, @@ -46,16 +45,13 @@ export function MapsTopNavMenu({ isOpenSettingsDisabled, }) { const { TopNavMenu } = getNavigation().ui; - const { filterManager } = getData().query; + const { filterManager, queryString } = getData().query; const showSaveQuery = getMapsCapabilities().saveQuery; const onClearSavedQuery = () => { onQuerySaved(undefined); onQueryChange({ filters: filterManager.getGlobalFilters(), - query: { - query: '', - language: getUiSettings().get('search:queryLanguage'), - }, + query: queryString.getDefaultQuery(), }); }; diff --git a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js index aa7f24155ab430..bccfdbf2467d66 100644 --- a/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js +++ b/x-pack/plugins/maps/public/routing/routes/maps_app/maps_app_view.js @@ -13,7 +13,6 @@ import { getIndexPatternService, getToasts, getData, - getUiSettings, getCoreChrome, } from '../../../kibana_services'; import { copyPersistentState } from '../../../reducers/util'; @@ -274,6 +273,7 @@ export class MapsAppView extends React.Component { _initQueryTimeRefresh() { const { setRefreshConfig, savedMap } = this.props; + const { queryString } = getData().query; // TODO: Handle null when converting to TS const globalState = getGlobalState(); const mapStateJSON = savedMap ? savedMap.mapStateJSON : undefined; @@ -281,7 +281,6 @@ export class MapsAppView extends React.Component { query: getInitialQuery({ mapStateJSON, appState: this._appStateManager.getAppState(), - userQueryLanguage: getUiSettings().get('search:queryLanguage'), }), time: getInitialTimeFilters({ mapStateJSON, @@ -292,6 +291,8 @@ export class MapsAppView extends React.Component { globalState, }), }; + + if (newState.query) queryString.setQuery(newState.query); this.setState({ query: newState.query, time: newState.time }); updateGlobalState( { diff --git a/x-pack/plugins/maps/public/routing/state_syncing/app_sync.js b/x-pack/plugins/maps/public/routing/state_syncing/app_sync.js index 36b20174f2436d..69d6dbbe0c4d32 100644 --- a/x-pack/plugins/maps/public/routing/state_syncing/app_sync.js +++ b/x-pack/plugins/maps/public/routing/state_syncing/app_sync.js @@ -31,6 +31,7 @@ export function useAppStateSyncing(appStateManager) { }; const stopSyncingQueryAppStateWithStateContainer = connectToQueryState(query, stateContainer, { filters: esFilters.FilterStateStore.APP_STATE, + query: true, }); // sets up syncing app state container with url