From 78003671fc18a8c3ee5c1a0b904b82b9de3f9e16 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Wed, 20 Oct 2021 00:13:02 -0400 Subject: [PATCH 01/23] [Uptime] [Synthetics Integration] browser - add script recorder option (#115184) * Add script recorder tab for browser based monitors * implement policy edit flow for script recorder * add tests and translations * adjust types * update metadata key and add test * merge master * adjust usePolicy and source_field * revert merge master * adjust types * use reusable hook * update zip url tls default fields * rename content constant * adjust error handling and remove ts-ignore * adjust error default value * remove unnecessary fragment * adjust types * update tech preview content Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../fleet_package/browser/formatters.ts | 2 +- .../fleet_package/browser/normalizers.ts | 44 +++--- .../browser/script_recorder_fields.test.tsx | 125 ++++++++++++++++ .../browser/script_recorder_fields.tsx | 134 ++++++++++++++++++ .../fleet_package/browser/simple_fields.tsx | 22 ++- .../fleet_package/browser/source_field.tsx | 79 ++++++++++- .../fleet_package/browser/uploader.tsx | 93 ++++++++++++ .../browser/zip_url_tls_fields.tsx | 29 +++- .../contexts/browser_context.tsx | 18 +-- .../fleet_package/contexts/index.ts | 2 +- .../contexts/policy_config_context.tsx | 6 + .../fleet_package/custom_fields.test.tsx | 30 ++-- .../fleet_package/custom_fields.tsx | 8 +- .../fleet_package/hooks/use_policy.ts | 131 +++++++++++++++++ .../{ => hooks}/use_update_policy.test.tsx | 11 +- .../{ => hooks}/use_update_policy.ts | 6 +- .../synthetics_policy_create_extension.tsx | 76 ++-------- .../synthetics_policy_edit_extension.tsx | 73 +--------- ...ics_policy_edit_extension_wrapper.test.tsx | 53 +++++++ ...nthetics_policy_edit_extension_wrapper.tsx | 1 + .../public/components/fleet_package/types.tsx | 4 + 21 files changed, 735 insertions(+), 212 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/fleet_package/browser/script_recorder_fields.test.tsx create mode 100644 x-pack/plugins/uptime/public/components/fleet_package/browser/script_recorder_fields.tsx create mode 100644 x-pack/plugins/uptime/public/components/fleet_package/browser/uploader.tsx create mode 100644 x-pack/plugins/uptime/public/components/fleet_package/hooks/use_policy.ts rename x-pack/plugins/uptime/public/components/fleet_package/{ => hooks}/use_update_policy.test.tsx (99%) rename x-pack/plugins/uptime/public/components/fleet_package/{ => hooks}/use_update_policy.ts (95%) diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts b/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts index 640db94028bc6..e457453a38f19 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/formatters.ts @@ -9,9 +9,9 @@ import { BrowserFields, ConfigKeys } from '../types'; import { Formatter, commonFormatters, + objectToJsonFormatter, arrayToJsonFormatter, stringToJsonFormatter, - objectToJsonFormatter, } from '../common/formatters'; import { tlsValueToYamlFormatter, diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts b/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts index 34b937b80dad0..2c675b9f28804 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/normalizers.ts @@ -14,7 +14,6 @@ import { } from '../common/normalizers'; import { defaultBrowserSimpleFields, defaultBrowserAdvancedFields } from '../contexts'; -import { tlsJsonToObjectNormalizer, tlsStringToObjectNormalizer } from '../tls/normalizers'; export type BrowserNormalizerMap = Record; @@ -42,33 +41,22 @@ export const browserNormalizers: BrowserNormalizerMap = { [ConfigKeys.PARAMS]: getBrowserNormalizer(ConfigKeys.PARAMS), [ConfigKeys.SCREENSHOTS]: getBrowserNormalizer(ConfigKeys.SCREENSHOTS), [ConfigKeys.SYNTHETICS_ARGS]: getBrowserJsonToJavascriptNormalizer(ConfigKeys.SYNTHETICS_ARGS), - [ConfigKeys.ZIP_URL_TLS_CERTIFICATE_AUTHORITIES]: (fields) => - tlsJsonToObjectNormalizer( - fields?.[ConfigKeys.ZIP_URL_TLS_CERTIFICATE_AUTHORITIES]?.value, - ConfigKeys.TLS_CERTIFICATE_AUTHORITIES - ), - [ConfigKeys.ZIP_URL_TLS_CERTIFICATE]: (fields) => - tlsJsonToObjectNormalizer( - fields?.[ConfigKeys.ZIP_URL_TLS_CERTIFICATE]?.value, - ConfigKeys.TLS_CERTIFICATE - ), - [ConfigKeys.ZIP_URL_TLS_KEY]: (fields) => - tlsJsonToObjectNormalizer(fields?.[ConfigKeys.ZIP_URL_TLS_KEY]?.value, ConfigKeys.TLS_KEY), - [ConfigKeys.ZIP_URL_TLS_KEY_PASSPHRASE]: (fields) => - tlsStringToObjectNormalizer( - fields?.[ConfigKeys.ZIP_URL_TLS_KEY_PASSPHRASE]?.value, - ConfigKeys.TLS_KEY_PASSPHRASE - ), - [ConfigKeys.ZIP_URL_TLS_VERIFICATION_MODE]: (fields) => - tlsStringToObjectNormalizer( - fields?.[ConfigKeys.ZIP_URL_TLS_VERIFICATION_MODE]?.value, - ConfigKeys.TLS_VERIFICATION_MODE - ), - [ConfigKeys.ZIP_URL_TLS_VERSION]: (fields) => - tlsJsonToObjectNormalizer( - fields?.[ConfigKeys.ZIP_URL_TLS_VERSION]?.value, - ConfigKeys.TLS_VERSION - ), + [ConfigKeys.ZIP_URL_TLS_CERTIFICATE_AUTHORITIES]: getBrowserJsonToJavascriptNormalizer( + ConfigKeys.ZIP_URL_TLS_CERTIFICATE_AUTHORITIES + ), + [ConfigKeys.ZIP_URL_TLS_CERTIFICATE]: getBrowserJsonToJavascriptNormalizer( + ConfigKeys.ZIP_URL_TLS_CERTIFICATE + ), + [ConfigKeys.ZIP_URL_TLS_KEY]: getBrowserJsonToJavascriptNormalizer(ConfigKeys.ZIP_URL_TLS_KEY), + [ConfigKeys.ZIP_URL_TLS_KEY_PASSPHRASE]: getBrowserNormalizer( + ConfigKeys.ZIP_URL_TLS_KEY_PASSPHRASE + ), + [ConfigKeys.ZIP_URL_TLS_VERIFICATION_MODE]: getBrowserNormalizer( + ConfigKeys.ZIP_URL_TLS_VERIFICATION_MODE + ), + [ConfigKeys.ZIP_URL_TLS_VERSION]: getBrowserJsonToJavascriptNormalizer( + ConfigKeys.ZIP_URL_TLS_VERSION + ), [ConfigKeys.JOURNEY_FILTERS_MATCH]: getBrowserJsonToJavascriptNormalizer( ConfigKeys.JOURNEY_FILTERS_MATCH ), diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/script_recorder_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/script_recorder_fields.test.tsx new file mode 100644 index 0000000000000..b19ca47ab76a5 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/script_recorder_fields.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { render } from '../../../lib/helper/rtl_helpers'; +import { ScriptRecorderFields } from './script_recorder_fields'; +import { PolicyConfigContextProvider } from '../contexts'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + ...jest.requireActual('@elastic/eui/lib/services/accessibility/html_id_generator'), + htmlIdGenerator: () => () => `id-${Math.random()}`, +})); + +const onChange = jest.fn(); + +describe('', () => { + let file: File; + const testScript = 'step(() => {})'; + const WrappedComponent = ({ + isEditable = true, + script = '', + fileName = '', + }: { + isEditable?: boolean; + script?: string; + fileName?: string; + }) => { + return ( + + + + ); + }; + + beforeEach(() => { + jest.clearAllMocks(); + file = new File([testScript], 'samplescript.js', { type: 'text/javascript' }); + }); + + it('renders ScriptRecorderFields', () => { + const { getByText, queryByText } = render(); + + const downloadLink = getByText('Download the Elastic Synthetics Recorder'); + + expect(downloadLink).toBeInTheDocument(); + expect(downloadLink).toHaveAttribute('target', '_blank'); + + expect(queryByText('Show script')).not.toBeInTheDocument(); + expect(queryByText('Remove script')).not.toBeInTheDocument(); + }); + + it('handles uploading files', async () => { + const { getByTestId } = render(); + + const uploader = getByTestId('syntheticsFleetScriptRecorderUploader'); + + fireEvent.change(uploader, { + target: { files: [file] }, + }); + + await waitFor(() => { + expect(onChange).toBeCalledWith({ scriptText: testScript, fileName: 'samplescript.js' }); + }); + }); + + it('shows user errors for invalid file types', async () => { + const { getByTestId, getByText } = render(); + file = new File(['journey(() => {})'], 'samplescript.js', { type: 'text/javascript' }); + + let uploader = getByTestId('syntheticsFleetScriptRecorderUploader') as HTMLInputElement; + + fireEvent.change(uploader, { + target: { files: [file] }, + }); + + uploader = getByTestId('syntheticsFleetScriptRecorderUploader') as HTMLInputElement; + + await waitFor(() => { + expect(onChange).not.toBeCalled(); + expect( + getByText( + 'Error uploading file. Please upload a .js file generated by the Elastic Synthetics Recorder in inline script format.' + ) + ).toBeInTheDocument(); + }); + }); + + it('shows show script button when script is available', () => { + const { getByText, queryByText } = render(); + + const showScriptBtn = getByText('Show script'); + + expect(queryByText(testScript)).not.toBeInTheDocument(); + + fireEvent.click(showScriptBtn); + + expect(getByText(testScript)).toBeInTheDocument(); + }); + + it('shows show remove script button when script is available and isEditable is true', async () => { + const { getByText, getByTestId } = render( + + ); + + const showScriptBtn = getByText('Show script'); + fireEvent.click(showScriptBtn); + + expect(getByText(testScript)).toBeInTheDocument(); + + fireEvent.click(getByTestId('euiFlyoutCloseButton')); + + const removeScriptBtn = getByText('Remove script'); + + fireEvent.click(removeScriptBtn); + + await waitFor(() => { + expect(onChange).toBeCalledWith({ scriptText: '', fileName: '' }); + }); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/script_recorder_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/script_recorder_fields.tsx new file mode 100644 index 0000000000000..9b99b4094e63b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/script_recorder_fields.tsx @@ -0,0 +1,134 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiLink, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutHeader, + EuiFormRow, + EuiCodeBlock, + EuiTitle, + EuiButton, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { usePolicyConfigContext } from '../contexts/policy_config_context'; +import { Uploader } from './uploader'; + +interface Props { + onChange: ({ scriptText, fileName }: { scriptText: string; fileName: string }) => void; + script: string; + fileName?: string; +} + +export function ScriptRecorderFields({ onChange, script, fileName }: Props) { + const [showScript, setShowScript] = useState(false); + const { isEditable } = usePolicyConfigContext(); + + const handleUpload = useCallback( + ({ scriptText, fileName: fileNameT }: { scriptText: string; fileName: string }) => { + onChange({ scriptText, fileName: fileNameT }); + }, + [onChange] + ); + + return ( + <> + + + + + + {isEditable && script ? ( + + + {fileName} + + + ) : ( + + )} + {script && ( + <> + + + + setShowScript(true)} + iconType="editorCodeBlock" + iconSide="right" + > + + + + + {isEditable && ( + onChange({ scriptText: '', fileName: '' })} + iconType="trash" + iconSide="right" + color="danger" + > + + + )} + + + + )} + {showScript && ( + setShowScript(false)} + aria-labelledby="syntheticsBrowserScriptBlockHeader" + closeButtonAriaLabel={CLOSE_BUTTON_LABEL} + > + + + + {fileName || PLACEHOLDER_FILE_NAME} + + + +
+ + {script} + +
+
+ )} + + ); +} + +const PLACEHOLDER_FILE_NAME = i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.scriptRecorder.mockFileName', + { + defaultMessage: 'test_script.js', + } +); + +const CLOSE_BUTTON_LABEL = i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.scriptRecorder.closeButtonLabel', + { + defaultMessage: 'Close script flyout', + } +); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx index 775778296fba8..50ad14aa98287 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/simple_fields.tsx @@ -24,7 +24,17 @@ export const BrowserSimpleFields = memo(({ validate }) => { setFields((prevFields) => ({ ...prevFields, [configKey]: value })); }; const onChangeSourceField = useCallback( - ({ zipUrl, folder, username, password, inlineScript, params, proxyUrl }) => { + ({ + zipUrl, + folder, + username, + password, + inlineScript, + params, + proxyUrl, + isGeneratedScript, + fileName, + }) => { setFields((prevFields) => ({ ...prevFields, [ConfigKeys.SOURCE_ZIP_URL]: zipUrl, @@ -34,6 +44,13 @@ export const BrowserSimpleFields = memo(({ validate }) => { [ConfigKeys.SOURCE_ZIP_PASSWORD]: password, [ConfigKeys.SOURCE_INLINE]: inlineScript, [ConfigKeys.PARAMS]: params, + [ConfigKeys.METADATA]: { + ...prevFields[ConfigKeys.METADATA], + script_source: { + is_generated_script: isGeneratedScript, + file_name: fileName, + }, + }, })); }, [setFields] @@ -87,6 +104,9 @@ export const BrowserSimpleFields = memo(({ validate }) => { password: defaultValues[ConfigKeys.SOURCE_ZIP_PASSWORD], inlineScript: defaultValues[ConfigKeys.SOURCE_INLINE], params: defaultValues[ConfigKeys.PARAMS], + isGeneratedScript: + defaultValues[ConfigKeys.METADATA].script_source?.is_generated_script, + fileName: defaultValues[ConfigKeys.METADATA].script_source?.file_name, }), [defaultValues] )} diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx index 6f2a7c99ad0d5..e4b03e53432dd 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/source_field.tsx @@ -5,23 +5,28 @@ * 2.0. */ import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; - import { EuiTabbedContent, EuiFormRow, EuiFieldText, EuiFieldPassword, EuiSpacer, + EuiBetaBadge, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { OptionalLabel } from '../optional_label'; import { CodeEditor } from '../code_editor'; +import { ScriptRecorderFields } from './script_recorder_fields'; import { ZipUrlTLSFields } from './zip_url_tls_fields'; import { MonacoEditorLangId } from '../types'; enum SourceType { INLINE = 'syntheticsBrowserInlineConfig', + SCRIPT_RECORDER = 'syntheticsBrowserScriptRecorderConfig', ZIP = 'syntheticsBrowserZipURLConfig', } @@ -33,6 +38,8 @@ interface SourceConfig { password: string; inlineScript: string; params: string; + isGeneratedScript?: boolean; + fileName?: string; } interface Props { @@ -48,12 +55,22 @@ export const defaultValues = { password: '', inlineScript: '', params: '', + isGeneratedScript: false, + fileName: '', +}; + +const getDefaultTab = (defaultConfig: SourceConfig) => { + if (defaultConfig.inlineScript && defaultConfig.isGeneratedScript) { + return SourceType.SCRIPT_RECORDER; + } else if (defaultConfig.inlineScript) { + return SourceType.INLINE; + } + + return SourceType.ZIP; }; export const SourceField = ({ onChange, defaultConfig = defaultValues }: Props) => { - const [sourceType, setSourceType] = useState( - defaultConfig.inlineScript ? SourceType.INLINE : SourceType.ZIP - ); + const [sourceType, setSourceType] = useState(getDefaultTab(defaultConfig)); const [config, setConfig] = useState(defaultConfig); useEffect(() => { @@ -264,6 +281,52 @@ export const SourceField = ({ onChange, defaultConfig = defaultValues }: Props) ), }, + { + id: 'syntheticsBrowserScriptRecorderConfig', + name: ( + + + + + + + + + ), + 'data-test-subj': 'syntheticsSourceTab__scriptRecorder', + content: ( + + setConfig((prevConfig) => ({ + ...prevConfig, + inlineScript: scriptText, + isGeneratedScript: true, + fileName, + })) + } + script={config.inlineScript} + fileName={config.fileName} + /> + ), + }, ]; return ( @@ -272,11 +335,17 @@ export const SourceField = ({ onChange, defaultConfig = defaultValues }: Props) initialSelectedTab={tabs.find((tab) => tab.id === sourceType)} autoFocus="selected" onTabClick={(tab) => { - setSourceType(tab.id as SourceType); if (tab.id !== sourceType) { setConfig(defaultValues); } + setSourceType(tab.id as SourceType); }} /> ); }; + +const StyledBetaBadgeWrapper = styled(EuiFlexItem)` + .euiToolTipAnchor { + display: flex; + } +`; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/uploader.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/uploader.tsx new file mode 100644 index 0000000000000..f17f11ebdccb6 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/uploader.tsx @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState, useRef } from 'react'; + +import { i18n } from '@kbn/i18n'; + +import { EuiFormRow, EuiFilePicker } from '@elastic/eui'; + +interface Props { + onUpload: ({ scriptText, fileName }: { scriptText: string; fileName: string }) => void; +} + +export function Uploader({ onUpload }: Props) { + const fileReader = useRef(null); + const [error, setError] = useState(null); + const filePickerRef = useRef(null); + + const handleFileRead = (fileName: string) => { + const content = fileReader?.current?.result as string; + + if (content?.trim().slice(0, 4) !== 'step') { + setError(PARSING_ERROR); + filePickerRef.current?.removeFiles(); + return; + } + + onUpload({ scriptText: content, fileName }); + setError(null); + }; + + const handleFileChosen = (files: FileList | null) => { + if (!files || !files.length) { + onUpload({ scriptText: '', fileName: '' }); + return; + } + if (files.length && files[0].type !== 'text/javascript') { + setError(INVALID_FILE_ERROR); + filePickerRef.current?.removeFiles(); + return; + } + fileReader.current = new FileReader(); + fileReader.current.onloadend = () => handleFileRead(files[0].name); + fileReader.current.readAsText(files[0]); + }; + + return ( + + + + ); +} + +const TESTING_SCRIPT_LABEL = i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.uploader.fieldLabel', + { + defaultMessage: 'Testing script', + } +); + +const PROMPT_TEXT = i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.uploader.label', + { + defaultMessage: 'Select recorder-generated .js file', + } +); + +const INVALID_FILE_ERROR = i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.uploader.invalidFileError', + { + defaultMessage: + 'Invalid file type. Please upload a .js file generated by the Elastic Synthetics Recorder.', + } +); + +const PARSING_ERROR = i18n.translate( + 'xpack.uptime.createPackagePolicy.stepConfigure.monitorIntegrationSettingsSection.browser.uploader.parsingError', + { + defaultMessage: + 'Error uploading file. Please upload a .js file generated by the Elastic Synthetics Recorder in inline script format.', + } +); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/zip_url_tls_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/zip_url_tls_fields.tsx index bd5d2f3e5d4a2..ed1ad9a8ce65c 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/zip_url_tls_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/zip_url_tls_fields.tsx @@ -12,7 +12,11 @@ import { EuiSwitch, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { TLSOptions, TLSConfig } from '../common/tls_options'; -import { useBrowserSimpleFieldsContext, usePolicyConfigContext } from '../contexts'; +import { + useBrowserSimpleFieldsContext, + usePolicyConfigContext, + defaultTLSFields, +} from '../contexts'; import { ConfigKeys } from '../types'; @@ -67,12 +71,23 @@ export const ZipUrlTLSFields = () => { {isZipUrlTLSEnabled ? ( >; @@ -24,6 +23,10 @@ interface IBrowserSimpleFieldsContextProvider { export const initialValues: IBrowserSimpleFields = { ...commonDefaultValues, [ConfigKeys.METADATA]: { + script_source: { + is_generated_script: false, + file_name: '', + }, is_zip_url_tls_enabled: false, }, [ConfigKeys.MONITOR_TYPE]: DataStream.BROWSER, @@ -34,13 +37,12 @@ export const initialValues: IBrowserSimpleFields = { [ConfigKeys.SOURCE_ZIP_PROXY_URL]: '', [ConfigKeys.SOURCE_INLINE]: '', [ConfigKeys.PARAMS]: '', - [ConfigKeys.ZIP_URL_TLS_CERTIFICATE_AUTHORITIES]: - tlsDefaultValues[ConfigKeys.TLS_CERTIFICATE_AUTHORITIES], - [ConfigKeys.ZIP_URL_TLS_CERTIFICATE]: tlsDefaultValues[ConfigKeys.TLS_CERTIFICATE], - [ConfigKeys.ZIP_URL_TLS_KEY]: tlsDefaultValues[ConfigKeys.TLS_KEY], - [ConfigKeys.ZIP_URL_TLS_KEY_PASSPHRASE]: tlsDefaultValues[ConfigKeys.TLS_KEY_PASSPHRASE], - [ConfigKeys.ZIP_URL_TLS_VERIFICATION_MODE]: tlsDefaultValues[ConfigKeys.TLS_VERIFICATION_MODE], - [ConfigKeys.ZIP_URL_TLS_VERSION]: tlsDefaultValues[ConfigKeys.TLS_VERSION], + [ConfigKeys.ZIP_URL_TLS_CERTIFICATE_AUTHORITIES]: undefined, + [ConfigKeys.ZIP_URL_TLS_CERTIFICATE]: undefined, + [ConfigKeys.ZIP_URL_TLS_KEY]: undefined, + [ConfigKeys.ZIP_URL_TLS_KEY_PASSPHRASE]: undefined, + [ConfigKeys.ZIP_URL_TLS_VERIFICATION_MODE]: undefined, + [ConfigKeys.ZIP_URL_TLS_VERSION]: undefined, }; const defaultContext: IBrowserSimpleFieldsContext = { diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts b/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts index 3d08dec46a4be..df2e9cfa6d4ea 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/index.ts @@ -7,7 +7,7 @@ export { PolicyConfigContext, PolicyConfigContextProvider, - initialValue as defaultMonitorType, + initialValue as defaultPolicyConfig, usePolicyConfigContext, } from './policy_config_context'; export { diff --git a/x-pack/plugins/uptime/public/components/fleet_package/contexts/policy_config_context.tsx b/x-pack/plugins/uptime/public/components/fleet_package/contexts/policy_config_context.tsx index 535a415c9a43d..69c0e1d7ba4fe 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/contexts/policy_config_context.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/contexts/policy_config_context.tsx @@ -18,6 +18,7 @@ interface IPolicyConfigContext { isZipUrlTLSEnabled?: boolean; defaultIsTLSEnabled?: boolean; defaultIsZipUrlTLSEnabled?: boolean; + isEditable?: boolean; } interface IPolicyConfigContextProvider { @@ -25,6 +26,7 @@ interface IPolicyConfigContextProvider { defaultMonitorType?: DataStream; defaultIsTLSEnabled?: boolean; defaultIsZipUrlTLSEnabled?: boolean; + isEditable?: boolean; } export const initialValue = DataStream.HTTP; @@ -45,6 +47,7 @@ const defaultContext: IPolicyConfigContext = { defaultMonitorType: initialValue, // immutable, defaultIsTLSEnabled: false, defaultIsZipUrlTLSEnabled: false, + isEditable: false, }; export const PolicyConfigContext = createContext(defaultContext); @@ -54,6 +57,7 @@ export const PolicyConfigContextProvider = ({ defaultMonitorType = initialValue, defaultIsTLSEnabled = false, defaultIsZipUrlTLSEnabled = false, + isEditable = false, }: IPolicyConfigContextProvider) => { const [monitorType, setMonitorType] = useState(defaultMonitorType); const [isTLSEnabled, setIsTLSEnabled] = useState(defaultIsTLSEnabled); @@ -70,6 +74,7 @@ export const PolicyConfigContextProvider = ({ setIsZipUrlTLSEnabled, defaultIsTLSEnabled, defaultIsZipUrlTLSEnabled, + isEditable, }; }, [ monitorType, @@ -78,6 +83,7 @@ export const PolicyConfigContextProvider = ({ isZipUrlTLSEnabled, defaultIsTLSEnabled, defaultIsZipUrlTLSEnabled, + isEditable, ]); return ; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx index 62c6f5598adb4..03bba0f8d2e54 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.test.tsx @@ -54,21 +54,17 @@ const defaultTCPConfig = defaultConfig[DataStream.TCP]; describe.skip('', () => { const WrappedComponent = ({ validate = defaultValidation, - typeEditable = false, + isEditable = false, dataStreams = [DataStream.HTTP, DataStream.TCP, DataStream.ICMP, DataStream.BROWSER], }) => { return ( - + - + @@ -80,7 +76,7 @@ describe.skip('', () => { it('renders CustomFields', async () => { const { getByText, getByLabelText, queryByLabelText } = render(); - const monitorType = queryByLabelText('Monitor Type') as HTMLInputElement; + const monitorType = getByLabelText('Monitor Type') as HTMLInputElement; const url = getByLabelText('URL') as HTMLInputElement; const proxyUrl = getByLabelText('Proxy URL') as HTMLInputElement; const monitorIntervalNumber = getByLabelText('Number') as HTMLInputElement; @@ -88,7 +84,7 @@ describe.skip('', () => { const apmServiceName = getByLabelText('APM service name') as HTMLInputElement; const maxRedirects = getByLabelText('Max redirects') as HTMLInputElement; const timeout = getByLabelText('Timeout in seconds') as HTMLInputElement; - expect(monitorType).not.toBeInTheDocument(); + expect(monitorType).toBeInTheDocument(); expect(url).toBeInTheDocument(); expect(url.value).toEqual(defaultHTTPConfig[ConfigKeys.URLS]); expect(proxyUrl).toBeInTheDocument(); @@ -117,6 +113,13 @@ describe.skip('', () => { }); }); + it('does not show monitor type dropdown when isEditable is true', async () => { + const { queryByLabelText } = render(); + const monitorType = queryByLabelText('Monitor Type') as HTMLInputElement; + + expect(monitorType).not.toBeInTheDocument(); + }); + it('shows SSL fields when Enable SSL Fields is checked', async () => { const { findByLabelText, queryByLabelText } = render(); const enableSSL = queryByLabelText('Enable TLS configuration') as HTMLInputElement; @@ -180,7 +183,7 @@ describe.skip('', () => { it('handles switching monitor type', () => { const { getByText, getByLabelText, queryByLabelText, getAllByLabelText } = render( - + ); const monitorType = getByLabelText('Monitor Type') as HTMLInputElement; expect(monitorType).toBeInTheDocument(); @@ -244,7 +247,7 @@ describe.skip('', () => { }); it('shows resolve hostnames locally field when proxy url is filled for tcp monitors', () => { - const { getByLabelText, queryByLabelText } = render(); + const { getByLabelText, queryByLabelText } = render(); const monitorType = getByLabelText('Monitor Type') as HTMLInputElement; fireEvent.change(monitorType, { target: { value: DataStream.TCP } }); @@ -302,10 +305,7 @@ describe.skip('', () => { it('does not show monitor options that are not contained in datastreams', async () => { const { getByText, queryByText, queryByLabelText } = render( - + ); const monitorType = queryByLabelText('Monitor Type') as HTMLInputElement; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx index 7323464f3e9dd..98eac21a42076 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx @@ -30,13 +30,13 @@ import { BrowserSimpleFields } from './browser/simple_fields'; import { BrowserAdvancedFields } from './browser/advanced_fields'; interface Props { - typeEditable?: boolean; validate: Validation; dataStreams?: DataStream[]; } -export const CustomFields = memo(({ typeEditable = false, validate, dataStreams = [] }) => { - const { monitorType, setMonitorType, isTLSEnabled, setIsTLSEnabled } = usePolicyConfigContext(); +export const CustomFields = memo(({ validate, dataStreams = [] }) => { + const { monitorType, setMonitorType, isTLSEnabled, setIsTLSEnabled, isEditable } = + usePolicyConfigContext(); const isHTTP = monitorType === DataStream.HTTP; const isTCP = monitorType === DataStream.TCP; @@ -88,7 +88,7 @@ export const CustomFields = memo(({ typeEditable = false, validate, dataS > - {typeEditable && ( + {!isEditable && ( { + const { isTLSEnabled, isZipUrlTLSEnabled } = usePolicyConfigContext(); + const { fields: httpSimpleFields } = useHTTPSimpleFieldsContext(); + const { fields: tcpSimpleFields } = useTCPSimpleFieldsContext(); + const { fields: icmpSimpleFields } = useICMPSimpleFieldsContext(); + const { fields: browserSimpleFields } = useBrowserSimpleFieldsContext(); + const { fields: httpAdvancedFields } = useHTTPAdvancedFieldsContext(); + const { fields: tcpAdvancedFields } = useTCPAdvancedFieldsContext(); + const { fields: browserAdvancedFields } = useBrowserAdvancedFieldsContext(); + const { fields: tlsFields } = useTLSFieldsContext(); + + const metadata = useMemo( + () => ({ + is_tls_enabled: isTLSEnabled, + is_zip_url_tls_enabled: isZipUrlTLSEnabled, + }), + [isTLSEnabled, isZipUrlTLSEnabled] + ); + + const policyConfig: PolicyConfig = useMemo( + () => ({ + [DataStream.HTTP]: { + ...httpSimpleFields, + ...httpAdvancedFields, + ...tlsFields, + [ConfigKeys.METADATA]: { + ...httpSimpleFields[ConfigKeys.METADATA], + ...metadata, + }, + [ConfigKeys.NAME]: name, + } as HTTPFields, + [DataStream.TCP]: { + ...tcpSimpleFields, + ...tcpAdvancedFields, + ...tlsFields, + [ConfigKeys.METADATA]: { + ...tcpSimpleFields[ConfigKeys.METADATA], + ...metadata, + }, + [ConfigKeys.NAME]: name, + } as TCPFields, + [DataStream.ICMP]: { + ...icmpSimpleFields, + [ConfigKeys.NAME]: name, + } as ICMPFields, + [DataStream.BROWSER]: { + ...browserSimpleFields, + ...browserAdvancedFields, + [ConfigKeys.METADATA]: { + ...browserSimpleFields[ConfigKeys.METADATA], + ...metadata, + }, + [ConfigKeys.NAME]: name, + } as BrowserFields, + }), + [ + metadata, + httpSimpleFields, + httpAdvancedFields, + tcpSimpleFields, + tcpAdvancedFields, + icmpSimpleFields, + browserSimpleFields, + browserAdvancedFields, + tlsFields, + name, + ] + ); + + return policyConfig; +}; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/hooks/use_update_policy.test.tsx similarity index 99% rename from x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.test.tsx rename to x-pack/plugins/uptime/public/components/fleet_package/hooks/use_update_policy.test.tsx index 3bffab33adb1e..dc46a4b57bcd8 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/hooks/use_update_policy.test.tsx @@ -4,11 +4,10 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import { useUpdatePolicy } from './use_update_policy'; import { renderHook } from '@testing-library/react-hooks'; -import { NewPackagePolicy } from '../../../../fleet/public'; -import { validate } from './validation'; +import { useUpdatePolicy } from './use_update_policy'; +import { NewPackagePolicy } from '../../../../../fleet/public'; +import { validate } from '../validation'; import { ConfigKeys, DataStream, @@ -20,8 +19,8 @@ import { ITLSFields, HTTPFields, BrowserFields, -} from './types'; -import { defaultConfig } from './synthetics_policy_create_extension'; +} from '../types'; +import { defaultConfig } from '../synthetics_policy_create_extension'; describe('useBarChartsHooks', () => { const newPolicy: NewPackagePolicy = { diff --git a/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.ts b/x-pack/plugins/uptime/public/components/fleet_package/hooks/use_update_policy.ts similarity index 95% rename from x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.ts rename to x-pack/plugins/uptime/public/components/fleet_package/hooks/use_update_policy.ts index 145a86c6bd50d..17ded6385da4f 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/use_update_policy.ts +++ b/x-pack/plugins/uptime/public/components/fleet_package/hooks/use_update_policy.ts @@ -5,9 +5,9 @@ * 2.0. */ import { useEffect, useRef, useState } from 'react'; -import { NewPackagePolicy } from '../../../../fleet/public'; -import { ConfigKeys, DataStream, Validation, ICustomFields } from './types'; -import { formatters } from './helpers/formatters'; +import { NewPackagePolicy } from '../../../../../fleet/public'; +import { ConfigKeys, DataStream, Validation, ICustomFields } from '../types'; +import { formatters } from '../helpers/formatters'; interface Props { monitorType: DataStream; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension.tsx index 3db7ac424e651..4fa101a329cd0 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_create_extension.tsx @@ -8,36 +8,21 @@ import React, { memo, useEffect, useMemo } from 'react'; import { PackagePolicyCreateExtensionComponentProps } from '../../../../fleet/public'; import { useTrackPageview } from '../../../../observability/public'; -import { - PolicyConfig, - DataStream, - ConfigKeys, - HTTPFields, - TCPFields, - ICMPFields, - BrowserFields, -} from './types'; +import { PolicyConfig, DataStream } from './types'; import { usePolicyConfigContext, - useTCPSimpleFieldsContext, - useTCPAdvancedFieldsContext, - useICMPSimpleFieldsContext, - useHTTPSimpleFieldsContext, - useHTTPAdvancedFieldsContext, - useTLSFieldsContext, - useBrowserSimpleFieldsContext, - useBrowserAdvancedFieldsContext, - defaultHTTPAdvancedFields, defaultHTTPSimpleFields, - defaultICMPSimpleFields, + defaultHTTPAdvancedFields, defaultTCPSimpleFields, defaultTCPAdvancedFields, + defaultICMPSimpleFields, defaultBrowserSimpleFields, defaultBrowserAdvancedFields, defaultTLSFields, } from './contexts'; import { CustomFields } from './custom_fields'; -import { useUpdatePolicy } from './use_update_policy'; +import { useUpdatePolicy } from './hooks/use_update_policy'; +import { usePolicy } from './hooks/use_policy'; import { validate } from './validation'; export const defaultConfig: PolicyConfig = { @@ -65,55 +50,12 @@ export const defaultConfig: PolicyConfig = { */ export const SyntheticsPolicyCreateExtension = memo( ({ newPolicy, onChange }) => { - const { monitorType, isTLSEnabled, isZipUrlTLSEnabled } = usePolicyConfigContext(); - const { fields: httpSimpleFields } = useHTTPSimpleFieldsContext(); - const { fields: tcpSimpleFields } = useTCPSimpleFieldsContext(); - const { fields: icmpSimpleFields } = useICMPSimpleFieldsContext(); - const { fields: browserSimpleFields } = useBrowserSimpleFieldsContext(); - const { fields: httpAdvancedFields } = useHTTPAdvancedFieldsContext(); - const { fields: tcpAdvancedFields } = useTCPAdvancedFieldsContext(); - const { fields: browserAdvancedFields } = useBrowserAdvancedFieldsContext(); - const { fields: tlsFields } = useTLSFieldsContext(); - - const metaData = useMemo( - () => ({ - is_tls_enabled: isTLSEnabled, - is_zip_url_tls_enabled: isZipUrlTLSEnabled, - }), - [isTLSEnabled, isZipUrlTLSEnabled] - ); - - const policyConfig: PolicyConfig = { - [DataStream.HTTP]: { - ...httpSimpleFields, - ...httpAdvancedFields, - ...tlsFields, - [ConfigKeys.METADATA]: metaData, - [ConfigKeys.NAME]: newPolicy.name, - } as HTTPFields, - [DataStream.TCP]: { - ...tcpSimpleFields, - ...tcpAdvancedFields, - ...tlsFields, - [ConfigKeys.METADATA]: metaData, - [ConfigKeys.NAME]: newPolicy.name, - } as TCPFields, - [DataStream.ICMP]: { - ...icmpSimpleFields, - [ConfigKeys.NAME]: newPolicy.name, - } as ICMPFields, - [DataStream.BROWSER]: { - ...browserSimpleFields, - ...browserAdvancedFields, - ...tlsFields, - [ConfigKeys.METADATA]: metaData, - [ConfigKeys.NAME]: newPolicy.name, - } as BrowserFields, - }; - useTrackPageview({ app: 'fleet', path: 'syntheticsCreate' }); useTrackPageview({ app: 'fleet', path: 'syntheticsCreate', delay: 15000 }); + const { monitorType } = usePolicyConfigContext(); + const policyConfig: PolicyConfig = usePolicy(newPolicy.name); + const dataStreams: DataStream[] = useMemo(() => { return newPolicy.inputs.map((input) => { return input.type.replace(/synthetics\//g, '') as DataStream; @@ -143,7 +85,7 @@ export const SyntheticsPolicyCreateExtension = memo; + return ; } ); SyntheticsPolicyCreateExtension.displayName = 'SyntheticsPolicyCreateExtension'; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx index 8e441d4eed6e3..1e01f43439a31 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension.tsx @@ -5,32 +5,14 @@ * 2.0. */ -import React, { memo, useMemo } from 'react'; +import React, { memo } from 'react'; import { PackagePolicyEditExtensionComponentProps } from '../../../../fleet/public'; import { useTrackPageview } from '../../../../observability/public'; -import { - usePolicyConfigContext, - useTCPSimpleFieldsContext, - useTCPAdvancedFieldsContext, - useICMPSimpleFieldsContext, - useHTTPSimpleFieldsContext, - useHTTPAdvancedFieldsContext, - useTLSFieldsContext, - useBrowserSimpleFieldsContext, - useBrowserAdvancedFieldsContext, -} from './contexts'; -import { - ICustomFields, - DataStream, - HTTPFields, - TCPFields, - ICMPFields, - BrowserFields, - ConfigKeys, - PolicyConfig, -} from './types'; +import { usePolicyConfigContext } from './contexts'; +import { ICustomFields, PolicyConfig } from './types'; import { CustomFields } from './custom_fields'; -import { useUpdatePolicy } from './use_update_policy'; +import { useUpdatePolicy } from './hooks/use_update_policy'; +import { usePolicy } from './hooks/use_policy'; import { validate } from './validation'; interface SyntheticsPolicyEditExtensionProps { @@ -47,50 +29,9 @@ export const SyntheticsPolicyEditExtension = memo { useTrackPageview({ app: 'fleet', path: 'syntheticsEdit' }); useTrackPageview({ app: 'fleet', path: 'syntheticsEdit', delay: 15000 }); - const { monitorType, isTLSEnabled, isZipUrlTLSEnabled } = usePolicyConfigContext(); - const { fields: httpSimpleFields } = useHTTPSimpleFieldsContext(); - const { fields: tcpSimpleFields } = useTCPSimpleFieldsContext(); - const { fields: icmpSimpleFields } = useICMPSimpleFieldsContext(); - const { fields: httpAdvancedFields } = useHTTPAdvancedFieldsContext(); - const { fields: tcpAdvancedFields } = useTCPAdvancedFieldsContext(); - const { fields: tlsFields } = useTLSFieldsContext(); - const { fields: browserSimpleFields } = useBrowserSimpleFieldsContext(); - const { fields: browserAdvancedFields } = useBrowserAdvancedFieldsContext(); - const metadata = useMemo( - () => ({ - is_tls_enabled: isTLSEnabled, - is_zip_url_tls_enabled: isZipUrlTLSEnabled, - }), - [isTLSEnabled, isZipUrlTLSEnabled] - ); - - const policyConfig: PolicyConfig = { - [DataStream.HTTP]: { - ...httpSimpleFields, - ...httpAdvancedFields, - ...tlsFields, - [ConfigKeys.METADATA]: metadata, - [ConfigKeys.NAME]: newPolicy.name, - } as HTTPFields, - [DataStream.TCP]: { - ...tcpSimpleFields, - ...tcpAdvancedFields, - ...tlsFields, - [ConfigKeys.METADATA]: metadata, - [ConfigKeys.NAME]: newPolicy.name, - } as TCPFields, - [DataStream.ICMP]: { - ...icmpSimpleFields, - [ConfigKeys.NAME]: newPolicy.name, - } as ICMPFields, - [DataStream.BROWSER]: { - ...browserSimpleFields, - ...browserAdvancedFields, - [ConfigKeys.METADATA]: metadata, - [ConfigKeys.NAME]: newPolicy.name, - } as BrowserFields, - }; + const { monitorType } = usePolicyConfigContext(); + const policyConfig: PolicyConfig = usePolicy(newPolicy.name); useUpdatePolicy({ defaultConfig, diff --git a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx index e874ca73d951b..c141e33ea1595 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/synthetics_policy_edit_extension_wrapper.test.tsx @@ -1075,6 +1075,59 @@ describe('', () => { expect(queryByLabelText('Host')).not.toBeInTheDocument(); }); + it.each([ + [true, 'Testing script'], + [false, 'Inline script'], + ])( + 'browser monitors - auto selects the right tab depending on source metadata', + async (isGeneratedScript, text) => { + const currentPolicy = { + ...defaultCurrentPolicy, + inputs: [ + { + ...defaultNewPolicy.inputs[0], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[1], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[2], + enabled: false, + }, + { + ...defaultNewPolicy.inputs[3], + enabled: true, + streams: [ + { + ...defaultNewPolicy.inputs[3].streams[0], + vars: { + ...defaultNewPolicy.inputs[3].streams[0].vars, + 'source.inline.script': { + type: 'yaml', + value: JSON.stringify('step(() => {})'), + }, + __ui: { + type: 'yaml', + value: JSON.stringify({ + script_source: { + is_generated_script: isGeneratedScript, + }, + }), + }, + }, + }, + ], + }, + ], + }; + + const { getByText } = render(); + + expect(getByText(text)).toBeInTheDocument(); + } + ); it('hides tls fields when metadata.is_tls_enabled is false', async () => { const { getByLabelText, queryByLabelText } = render( diff --git a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx index f391c6c271f69..13296ee4521cd 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/types.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/types.tsx @@ -129,6 +129,10 @@ export enum ConfigKeys { export interface Metadata { is_tls_enabled?: boolean; is_zip_url_tls_enabled?: boolean; + script_source?: { + is_generated_script: boolean; + file_name: string; + }; } export interface ICommonFields { From 473cabcef53dc7b3d8b9f5c8302c300ab8504ea5 Mon Sep 17 00:00:00 2001 From: Tyler Smalley Date: Tue, 19 Oct 2021 21:37:50 -0700 Subject: [PATCH 02/23] skip flaky suite (#115666) --- x-pack/test/accessibility/apps/ml.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/accessibility/apps/ml.ts b/x-pack/test/accessibility/apps/ml.ts index 4babe0bd6ff88..e06661b000203 100644 --- a/x-pack/test/accessibility/apps/ml.ts +++ b/x-pack/test/accessibility/apps/ml.ts @@ -13,7 +13,8 @@ export default function ({ getService }: FtrProviderContext) { const a11y = getService('a11y'); const ml = getService('ml'); - describe('ml', () => { + // Failing: See https://github.com/elastic/kibana/issues/115666 + describe.skip('ml', () => { const esArchiver = getService('esArchiver'); before(async () => { From a01165ab30ed00ab9d6de8097a1429fab30fcec2 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 19 Oct 2021 22:42:35 -0600 Subject: [PATCH 03/23] [Security Solutions] Fixes 11 different flakey FTR/e2e tests and scenarios (#115688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes flakes across tests that have either been skipped or have been a source of flake in the categories of: * Sorting fixes because Elasticsearch can return hits/arrays back in different orders * Flat array fixes because Elasticsearch can sometimes return `[]` or `[[]]` in-deterministically in some cases 🤷 , so we just flatten the array out completely and test for `[]` within those tests. * `waitForSignalsToBePresent` was missing in a test and sometimes we would get an empty array response which would fail CI. Also I audited other tests for `[[]]` and `waitForSignalsToBePresent` and fixed them where they were present or if the `waitForSignalsToBePresent` count was incorrect. This should give us more stability when the CI is under pressure. Sorting fixes: https://github.com/elastic/kibana/issues/115554 https://github.com/elastic/kibana/issues/115321 https://github.com/elastic/kibana/issues/115319 https://github.com/elastic/kibana/issues/114581 Flat array fixes: https://github.com/elastic/kibana/issues/89052 https://github.com/elastic/kibana/issues/115315 https://github.com/elastic/kibana/issues/115308 https://github.com/elastic/kibana/issues/115304 https://github.com/elastic/kibana/issues/115313 https://github.com/elastic/kibana/issues/113418 Missing additional check for "waitForSignalsToBePresent" or incorrect number of signals to wait for fixes: https://github.com/elastic/kibana/issues/115310 ### Checklist - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../security_and_spaces/tests/aliases.ts | 10 +- .../tests/create_endpoint_exceptions.ts | 188 ++++++++++-------- .../exception_operators_data_types/float.ts | 2 +- .../exception_operators_data_types/integer.ts | 2 +- .../ip_array.ts | 22 +- .../keyword_array.ts | 22 +- .../exception_operators_data_types/long.ts | 2 +- .../exception_operators_data_types/text.ts | 16 +- .../text_array.ts | 20 +- 9 files changed, 150 insertions(+), 134 deletions(-) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts index 9b7c75bab3100..3c033d2077c54 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/aliases.ts @@ -51,9 +51,9 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map( - (signal) => (signal._source?.host_alias as HostAlias).name - ); + const hits = signalsOpen.hits.hits + .map((signal) => (signal._source?.host_alias as HostAlias).name) + .sort(); expect(hits).to.eql(['host name 1', 'host name 2', 'host name 3', 'host name 4']); }); @@ -63,7 +63,9 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((signal) => (signal._source?.host as HostAlias).name); + const hits = signalsOpen.hits.hits + .map((signal) => (signal._source?.host as HostAlias).name) + .sort(); expect(hits).to.eql(['host name 1', 'host name 2', 'host name 3', 'host name 4']); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_endpoint_exceptions.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_endpoint_exceptions.ts index b0f208aadaf1b..6d04ffc67c573 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_endpoint_exceptions.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_endpoint_exceptions.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; +import type SuperTest from 'supertest'; import { createListsIndex, deleteAllExceptions, @@ -25,6 +26,45 @@ import { waitForSignalsToBePresent, } from '../../utils'; +interface Host { + os: { + type?: string; + name?: string; + }; +} + +/** + * Convenience method to get signals by host and sort them for better deterministic testing + * since Elastic can return the hits back in any order we want to sort them on return for testing. + * @param supertest Super test for testing. + * @param id The signals id + * @returns The array of hosts sorted + */ +export const getHostHits = async ( + supertest: SuperTest.SuperTest, + id: string +): Promise => { + const signalsOpen = await getSignalsById(supertest, id); + return signalsOpen.hits.hits + .map((hit) => hit._source?.host as Host) + .sort((a, b) => { + let sortOrder = 0; + if (a.os.name != null && b.os.name != null) { + sortOrder += a.os.name.localeCompare(b.os.name); + } + if (a.os.type != null && b.os.type != null) { + sortOrder += a.os.type.localeCompare(b.os.type); + } + if (a.os.type != null && b.os.name != null) { + sortOrder += a.os.type.localeCompare(b.os.name); + } + if (a.os.name != null && b.os.type != null) { + sortOrder += a.os.name.localeCompare(b.os.type); + } + return sortOrder; + }); +}; + // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); @@ -64,20 +104,19 @@ export default ({ getService }: FtrProviderContext) => { const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host).sort(); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { os: { type: 'linux' }, }, { - os: { type: 'windows' }, + os: { type: 'linux' }, }, { os: { type: 'macos' }, }, { - os: { type: 'linux' }, + os: { type: 'windows' }, }, ]); }); @@ -87,20 +126,19 @@ export default ({ getService }: FtrProviderContext) => { const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host).sort(); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { os: { name: 'Linux' }, }, { - os: { name: 'Windows' }, + os: { name: 'Linux' }, }, { os: { name: 'Macos' }, }, { - os: { name: 'Linux' }, + os: { name: 'Windows' }, }, ]); }); @@ -130,17 +168,16 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 3, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { name: 'Windows' }, + os: { name: 'Linux' }, }, { os: { name: 'Macos' }, }, { - os: { name: 'Linux' }, + os: { name: 'Windows' }, }, ]); }); @@ -167,17 +204,16 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 3, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { name: 'Windows' }, + os: { name: 'Linux' }, }, { os: { name: 'Macos' }, }, { - os: { name: 'Linux' }, + os: { name: 'Windows' }, }, ]); }); @@ -215,14 +251,13 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 2, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { name: 'Macos' }, + os: { name: 'Linux' }, }, { - os: { name: 'Linux' }, + os: { name: 'Macos' }, }, ]); }); @@ -260,14 +295,13 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 2, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { name: 'Macos' }, + os: { name: 'Linux' }, }, { - os: { name: 'Linux' }, + os: { name: 'Macos' }, }, ]); }); @@ -296,17 +330,16 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 3, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { type: 'windows' }, + os: { type: 'linux' }, }, { os: { type: 'macos' }, }, { - os: { type: 'linux' }, + os: { type: 'windows' }, }, ]); }); @@ -333,17 +366,16 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 3, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { type: 'windows' }, + os: { type: 'linux' }, }, { os: { type: 'macos' }, }, { - os: { type: 'linux' }, + os: { type: 'windows' }, }, ]); }); @@ -381,14 +413,13 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 2, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { type: 'macos' }, + os: { type: 'linux' }, }, { - os: { type: 'linux' }, + os: { type: 'macos' }, }, ]); }); @@ -426,14 +457,13 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 2, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { type: 'macos' }, + os: { type: 'linux' }, }, { - os: { type: 'linux' }, + os: { type: 'macos' }, }, ]); }); @@ -462,14 +492,13 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 6, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { type: 'windows' }, + os: { type: 'linux' }, }, { - os: { name: 'Windows' }, + os: { name: 'Linux' }, }, { os: { type: 'macos' }, @@ -478,10 +507,10 @@ export default ({ getService }: FtrProviderContext) => { os: { name: 'Macos' }, }, { - os: { type: 'linux' }, + os: { type: 'windows' }, }, { - os: { name: 'Linux' }, + os: { name: 'Windows' }, }, ]); }); @@ -508,14 +537,13 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 6, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { type: 'windows' }, + os: { type: 'linux' }, }, { - os: { name: 'Windows' }, + os: { name: 'Linux' }, }, { os: { type: 'macos' }, @@ -524,10 +552,10 @@ export default ({ getService }: FtrProviderContext) => { os: { name: 'Macos' }, }, { - os: { type: 'linux' }, + os: { type: 'windows' }, }, { - os: { name: 'Linux' }, + os: { name: 'Windows' }, }, ]); }); @@ -565,20 +593,19 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { type: 'macos' }, + os: { type: 'linux' }, }, { - os: { name: 'Macos' }, + os: { name: 'Linux' }, }, { - os: { type: 'linux' }, + os: { type: 'macos' }, }, { - os: { name: 'Linux' }, + os: { name: 'Macos' }, }, ]); }); @@ -616,20 +643,19 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { type: 'macos' }, + os: { type: 'linux' }, }, { - os: { name: 'Macos' }, + os: { name: 'Linux' }, }, { - os: { type: 'linux' }, + os: { type: 'macos' }, }, { - os: { name: 'Linux' }, + os: { name: 'Macos' }, }, ]); }); @@ -668,8 +694,7 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 1, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { os: { type: 'macos' }, @@ -708,8 +733,7 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 1, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { os: { type: 'macos' }, @@ -741,17 +765,16 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 3, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { os: { type: 'linux' }, }, { - os: { type: 'macos' }, + os: { type: 'linux' }, }, { - os: { type: 'linux' }, + os: { type: 'macos' }, }, ]); }); @@ -778,14 +801,13 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 2, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { type: 'macos' }, + os: { type: 'linux' }, }, { - os: { type: 'linux' }, + os: { type: 'macos' }, }, ]); }); @@ -812,14 +834,13 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 2, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { - os: { type: 'macos' }, + os: { type: 'linux' }, }, { - os: { type: 'linux' }, + os: { type: 'macos' }, }, ]); }); @@ -846,20 +867,19 @@ export default ({ getService }: FtrProviderContext) => { ); await waitForRuleSuccessOrStatus(supertest, id); await waitForSignalsToBePresent(supertest, 4, [id]); - const signalsOpen = await getSignalsById(supertest, id); - const hits = signalsOpen.hits.hits.map((hit) => hit._source?.host); + const hits = await getHostHits(supertest, id); expect(hits).to.eql([ { os: { type: 'linux' }, }, { - os: { type: 'windows' }, + os: { type: 'linux' }, }, { os: { type: 'macos' }, }, { - os: { type: 'linux' }, + os: { type: 'windows' }, }, ]); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts index f7208a8832c4d..912596ed7ca00 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/float.ts @@ -499,7 +499,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.float).sort(); expect(hits).to.eql(['1.1', '1.2', '1.3']); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts index 42152fd18473a..da9219e4b52f6 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/integer.ts @@ -501,7 +501,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.integer).sort(); expect(hits).to.eql(['2', '3', '4']); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip_array.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip_array.ts index 147e6058dffa8..526c6d1c988ce 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip_array.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/ip_array.ts @@ -151,7 +151,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsById(supertest, id); const ips = signalsOpen.hits.hits.map((hit) => hit._source?.ip).sort(); - expect(ips).to.eql([[]]); + expect(ips.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); it('should filter a CIDR range of "127.0.0.1/30"', async () => { @@ -167,7 +167,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const ips = signalsOpen.hits.hits.map((hit) => hit._source?.ip).sort(); expect(ips).to.eql([ @@ -190,7 +190,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 2, [id]); const signalsOpen = await getSignalsById(supertest, id); const ips = signalsOpen.hits.hits.map((hit) => hit._source?.ip).sort(); expect(ips).to.eql([[], ['127.0.0.8', '127.0.0.9', '127.0.0.10']]); @@ -346,7 +346,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsById(supertest, id); const ips = signalsOpen.hits.hits.map((hit) => hit._source?.ip).sort(); - expect(ips).to.eql([[]]); + expect(ips.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); }); @@ -392,8 +392,7 @@ export default ({ getService }: FtrProviderContext) => { }); }); - // FLAKY: https://github.com/elastic/kibana/issues/115315 - describe.skip('"exists" operator', () => { + describe('"exists" operator', () => { it('will return 1 empty result if matching against ip', async () => { const rule = getRuleForSignalTesting(['ip_as_array']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ @@ -408,7 +407,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccessOrStatus(supertest, id); const signalsOpen = await getSignalsById(supertest, id); const ips = signalsOpen.hits.hits.map((hit) => hit._source?.ip).sort(); - expect(ips).to.eql([[]]); + expect(ips.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); }); @@ -487,8 +486,7 @@ export default ({ getService }: FtrProviderContext) => { expect(ips).to.eql([[], ['127.0.0.8', '127.0.0.9', '127.0.0.10']]); }); - // FLAKY https://github.com/elastic/kibana/issues/89052 - it.skip('will return 1 result if we have a list that includes all ips', async () => { + it('will return 1 result if we have a list that includes all ips', async () => { await importFile( supertest, 'ip', @@ -512,7 +510,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccessOrStatus(supertest, id); const signalsOpen = await getSignalsById(supertest, id); const ips = signalsOpen.hits.hits.map((hit) => hit._source?.ip).sort(); - expect(ips).to.eql([[]]); + expect(ips.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); it('will return 2 results if we have a list which contains the CIDR ranges of "127.0.0.1/32, 127.0.0.2/31, 127.0.0.4/30"', async () => { @@ -546,7 +544,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 2, [id]); const signalsOpen = await getSignalsById(supertest, id); const ips = signalsOpen.hits.hits.map((hit) => hit._source?.ip).sort(); expect(ips).to.eql([[], ['127.0.0.8', '127.0.0.9', '127.0.0.10']]); @@ -577,7 +575,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 2, [id]); const signalsOpen = await getSignalsById(supertest, id); const ips = signalsOpen.hits.hits.map((hit) => hit._source?.ip).sort(); expect(ips).to.eql([[], ['127.0.0.8', '127.0.0.9', '127.0.0.10']]); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword_array.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword_array.ts index e852558aaa6a8..8571aa8eeaa60 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword_array.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/keyword_array.ts @@ -60,7 +60,7 @@ export default ({ getService }: FtrProviderContext) => { const rule = getRuleForSignalTesting(['keyword_as_array']); const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 3, [id]); + await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort(); expect(hits).to.eql([ @@ -84,7 +84,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 2, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort(); expect(hits).to.eql([ @@ -153,7 +153,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort(); - expect(hits).to.eql([[]]); + expect(hits.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); }); @@ -281,7 +281,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort(); - expect(hits).to.eql([[]]); + expect(hits.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); }); @@ -328,8 +328,7 @@ export default ({ getService }: FtrProviderContext) => { }); describe('"exists" operator', () => { - // FLAKY https://github.com/elastic/kibana/issues/115308 - it.skip('will return 1 results if matching against keyword for the empty array', async () => { + it('will return 1 results if matching against keyword for the empty array', async () => { const rule = getRuleForSignalTesting(['keyword_as_array']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ @@ -343,7 +342,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccessOrStatus(supertest, id); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort(); - expect(hits).to.eql([[]]); + expect(hits.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); }); @@ -399,7 +398,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 3, [id]); + await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort(); expect(hits).to.eql([ @@ -437,7 +436,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 2, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort(); expect(hits).to.eql([ @@ -497,8 +496,7 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([[], ['word eight', 'word nine', 'word ten']]); }); - // FLAKY https://github.com/elastic/kibana/issues/115304 - it.skip('will return only the empty array for results if we have a list that includes all keyword', async () => { + it('will return only the empty array for results if we have a list that includes all keyword', async () => { await importFile( supertest, 'keyword', @@ -522,7 +520,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccessOrStatus(supertest, id); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.keyword).sort(); - expect(hits).to.eql([[]]); + expect(hits.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts index 35573edea3c39..8d5f1515e4ab6 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/long.ts @@ -499,7 +499,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.long).sort(); expect(hits).to.eql(['2', '3', '4']); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts index 4e4823fcf747f..367e68f7f9ed1 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text.ts @@ -56,8 +56,7 @@ export default ({ getService }: FtrProviderContext) => { await deleteListsIndex(supertest); }); - // FLAKY: https://github.com/elastic/kibana/issues/115310 - describe.skip('"is" operator', () => { + describe('"is" operator', () => { it('should find all the text from the data set when no exceptions are set on the rule', async () => { const rule = getRuleForSignalTesting(['text']); const { id } = await createRule(supertest, rule); @@ -241,7 +240,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); expect(hits).to.eql(['word four', 'word three', 'word two']); @@ -344,6 +343,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); + await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']); @@ -618,7 +618,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); expect(hits).to.eql(['word four', 'word three', 'word two']); @@ -646,7 +646,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); expect(hits).to.eql(['word four', 'word three', 'word two']); @@ -669,7 +669,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 2, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); expect(hits).to.eql(['word four', 'word two']); @@ -850,7 +850,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 2, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); expect(hits).to.eql(['word one', 'word three']); @@ -878,7 +878,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 1, [id]); + await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); expect(hits).to.eql(['word four', 'word one', 'word three', 'word two']); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text_array.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text_array.ts index f0a5fe7c1ffb1..3eedabd41d663 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text_array.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/exception_operators_data_types/text_array.ts @@ -58,7 +58,7 @@ export default ({ getService }: FtrProviderContext) => { const rule = getRuleForSignalTesting(['text_as_array']); const { id } = await createRule(supertest, rule); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 3, [id]); + await waitForSignalsToBePresent(supertest, 4, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); expect(hits).to.eql([ @@ -82,7 +82,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 2, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); expect(hits).to.eql([ @@ -151,7 +151,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); - expect(hits).to.eql([[]]); + expect(hits.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); }); @@ -279,7 +279,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForSignalsToBePresent(supertest, 1, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); - expect(hits).to.eql([[]]); + expect(hits.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); }); @@ -326,8 +326,7 @@ export default ({ getService }: FtrProviderContext) => { }); describe('"exists" operator', () => { - // FLAKY https://github.com/elastic/kibana/issues/115313 - it.skip('will return 1 results if matching against text for the empty array', async () => { + it('will return 1 results if matching against text for the empty array', async () => { const rule = getRuleForSignalTesting(['text_as_array']); const { id } = await createRuleWithExceptionEntries(supertest, rule, [ [ @@ -341,7 +340,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccessOrStatus(supertest, id); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); - expect(hits).to.eql([[]]); + expect(hits.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); }); @@ -435,7 +434,7 @@ export default ({ getService }: FtrProviderContext) => { ], ]); await waitForRuleSuccessOrStatus(supertest, id); - await waitForSignalsToBePresent(supertest, 2, [id]); + await waitForSignalsToBePresent(supertest, 3, [id]); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); expect(hits).to.eql([ @@ -495,8 +494,7 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([[], ['word eight', 'word nine', 'word ten']]); }); - // FLAKY https://github.com/elastic/kibana/issues/113418 - it.skip('will return only the empty array for results if we have a list that includes all text', async () => { + it('will return only the empty array for results if we have a list that includes all text', async () => { await importFile( supertest, 'text', @@ -520,7 +518,7 @@ export default ({ getService }: FtrProviderContext) => { await waitForRuleSuccessOrStatus(supertest, id); const signalsOpen = await getSignalsById(supertest, id); const hits = signalsOpen.hits.hits.map((hit) => hit._source?.text).sort(); - expect(hits).to.eql([[]]); + expect(hits.flat(Number.MAX_SAFE_INTEGER)).to.eql([]); }); }); From 6a2b7fe3d3bc41e30de977f946fd009a8fa1f0d2 Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Tue, 19 Oct 2021 22:17:08 -0700 Subject: [PATCH 04/23] [Security Solution][Platform] - Export exceptions with rule (#115144) ### Summary Introduces exports of exception lists with rules. Import of exception lists not yet supported. --- x-pack/plugins/lists/server/index.ts | 1 + .../routes/export_exception_list_route.ts | 63 +++-------- .../exception_list_client.mock.ts | 11 ++ .../exception_lists/exception_list_client.ts | 20 ++++ .../exception_list_client_types.ts | 18 ++++ .../export_exception_list_and_items.test.ts | 62 +++++++++++ .../export_exception_list_and_items.ts | 93 ++++++++++++++++ .../server/services/exception_lists/index.ts | 1 + .../downloads/test_exception_list.ndjson | 2 - .../cypress/objects/exception.ts | 2 +- .../security_solution/cypress/objects/rule.ts | 2 +- .../routes/rules/export_rules_route.ts | 16 ++- .../routes/rules/perform_bulk_action_route.ts | 4 +- .../create_rules_stream_from_ndjson.test.ts | 2 +- .../rules/create_rules_stream_from_ndjson.ts | 6 +- .../rules/get_export_all.test.ts | 17 ++- .../detection_engine/rules/get_export_all.ts | 21 ++-- .../rules/get_export_by_object_ids.test.ts | 16 ++- .../rules/get_export_by_object_ids.ts | 18 +++- .../rules/get_export_details_ndjson.test.ts | 6 +- .../rules/get_export_details_ndjson.ts | 6 +- .../rules/get_export_rule_exceptions.test.ts | 79 ++++++++++++++ .../rules/get_export_rule_exceptions.ts | 102 ++++++++++++++++++ .../read_stream/create_stream_from_ndjson.ts | 12 +++ .../basic/tests/export_rules.ts | 8 +- .../security_and_spaces/tests/export_rules.ts | 8 +- .../tests/perform_bulk_action.ts | 8 +- .../tests/export_exception_list.ts | 2 +- 28 files changed, 523 insertions(+), 83 deletions(-) create mode 100644 x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts delete mode 100644 x-pack/plugins/security_solution/cypress/downloads/test_exception_list.ndjson create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_rule_exceptions.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_rule_exceptions.ts diff --git a/x-pack/plugins/lists/server/index.ts b/x-pack/plugins/lists/server/index.ts index 772a8cbe7ec35..9f395cb0d94bc 100644 --- a/x-pack/plugins/lists/server/index.ts +++ b/x-pack/plugins/lists/server/index.ts @@ -18,6 +18,7 @@ export { } from './services/exception_lists/exception_list_client_types'; export { ExceptionListClient } from './services/exception_lists/exception_list_client'; export type { ListPluginSetup, ListsApiRequestHandlerContext } from './types'; +export type { ExportExceptionListAndItemsReturn } from './services/exception_lists/export_exception_list_and_items'; export const config: PluginConfigDescriptor = { schema: ConfigSchema, diff --git a/x-pack/plugins/lists/server/routes/export_exception_list_route.ts b/x-pack/plugins/lists/server/routes/export_exception_list_route.ts index aa30c8a7d435d..b91537b6cb3b1 100644 --- a/x-pack/plugins/lists/server/routes/export_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/export_exception_list_route.ts @@ -6,7 +6,6 @@ */ import { transformError } from '@kbn/securitysolution-es-utils'; -import { transformDataToNdjson } from '@kbn/securitysolution-utils'; import { exportExceptionListQuerySchema } from '@kbn/securitysolution-io-ts-list-types'; import { EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; @@ -30,43 +29,28 @@ export const exportExceptionListRoute = (router: ListsPluginRouter): void => { try { const { id, list_id: listId, namespace_type: namespaceType } = request.query; - const exceptionLists = getExceptionListClient(context); - const exceptionList = await exceptionLists.getExceptionList({ + const exceptionListsClient = getExceptionListClient(context); + + const exportContent = await exceptionListsClient.exportExceptionListAndItems({ id, listId, namespaceType, }); - if (exceptionList == null) { + if (exportContent == null) { return siemResponse.error({ - body: `exception list with list_id: ${listId} does not exist`, + body: `exception list with list_id: ${listId} or id: ${id} does not exist`, statusCode: 400, }); - } else { - const listItems = await exceptionLists.findExceptionListItem({ - filter: undefined, - listId, - namespaceType, - page: 1, - perPage: 10000, - sortField: 'exception-list.created_at', - sortOrder: 'desc', - }); - const exceptionItems = listItems?.data ?? []; - - const { exportData } = getExport([exceptionList, ...exceptionItems]); - const { exportDetails } = getExportDetails(exceptionItems); - - // TODO: Allow the API to override the name of the file to export - const fileName = exceptionList.list_id; - return response.ok({ - body: `${exportData}${exportDetails}`, - headers: { - 'Content-Disposition': `attachment; filename="${fileName}"`, - 'Content-Type': 'application/ndjson', - }, - }); } + + return response.ok({ + body: `${exportContent.exportData}${JSON.stringify(exportContent.exportDetails)}\n`, + headers: { + 'Content-Disposition': `attachment; filename="${listId}"`, + 'Content-Type': 'application/ndjson', + }, + }); } catch (err) { const error = transformError(err); return siemResponse.error({ @@ -77,24 +61,3 @@ export const exportExceptionListRoute = (router: ListsPluginRouter): void => { } ); }; - -export const getExport = ( - data: unknown[] -): { - exportData: string; -} => { - const ndjson = transformDataToNdjson(data); - - return { exportData: ndjson }; -}; - -export const getExportDetails = ( - items: unknown[] -): { - exportDetails: string; -} => { - const exportDetails = JSON.stringify({ - exported_list_items_count: items.length, - }); - return { exportDetails: `${exportDetails}\n` }; -}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts index 1566241e7351e..f5f6a4f1f2d5a 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.mock.ts @@ -30,6 +30,17 @@ export class ExceptionListClientMock extends ExceptionListClient { public findExceptionList = jest.fn().mockResolvedValue(getFoundExceptionListSchemaMock()); public createTrustedAppsList = jest.fn().mockResolvedValue(getTrustedAppsListSchemaMock()); public createEndpointList = jest.fn().mockResolvedValue(getExceptionListSchemaMock()); + public exportExceptionListAndItems = jest.fn().mockResolvedValue({ + exportData: 'exportString', + exportDetails: { + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, + }, + }); } export const getExceptionListClientMock = ( 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 77e82bf0f7578..542598fc82c90 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 @@ -24,6 +24,7 @@ import { DeleteExceptionListItemByIdOptions, DeleteExceptionListItemOptions, DeleteExceptionListOptions, + ExportExceptionListAndItemsOptions, FindEndpointListItemOptions, FindExceptionListItemOptions, FindExceptionListOptions, @@ -38,6 +39,10 @@ import { UpdateExceptionListOptions, } from './exception_list_client_types'; import { getExceptionList } from './get_exception_list'; +import { + ExportExceptionListAndItemsReturn, + exportExceptionListAndItems, +} from './export_exception_list_and_items'; import { getExceptionListSummary } from './get_exception_list_summary'; import { createExceptionList } from './create_exception_list'; import { getExceptionListItem } from './get_exception_list_item'; @@ -492,4 +497,19 @@ export class ExceptionListClient { sortOrder, }); }; + + public exportExceptionListAndItems = async ({ + listId, + id, + namespaceType, + }: ExportExceptionListAndItemsOptions): Promise => { + const { savedObjectsClient } = this; + + return exportExceptionListAndItems({ + id, + listId, + namespaceType, + savedObjectsClient, + }); + }; } 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 b734d3a7b1a3b..14de474974c11 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 @@ -220,3 +220,21 @@ export interface FindExceptionListOptions { sortField: SortFieldOrUndefined; sortOrder: SortOrderOrUndefined; } + +export interface ExportExceptionListAndItemsOptions { + listId: ListIdOrUndefined; + id: IdOrUndefined; + namespaceType: NamespaceType; +} + +export interface ExportExceptionListAndItemsReturn { + exportData: string; + exportDetails: { + exported_exception_list_count: number; + exported_exception_list_item_count: number; + missing_exception_list_item_count: number; + missing_exception_list_items: string[]; + missing_exception_lists: string[]; + missing_exception_lists_count: number; + }; +} diff --git a/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts new file mode 100644 index 0000000000000..9f3c02fecca20 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsClientContract } from 'kibana/server'; + +import { getExceptionListItemSchemaMock } from '../../../common/schemas/response/exception_list_item_schema.mock'; +import { getExceptionListSchemaMock } from '../../../common/schemas/response/exception_list_schema.mock'; + +import { exportExceptionListAndItems } from './export_exception_list_and_items'; +import { findExceptionListItem } from './find_exception_list_item'; +import { getExceptionList } from './get_exception_list'; + +jest.mock('./get_exception_list'); +jest.mock('./find_exception_list_item'); + +describe('export_exception_list_and_items', () => { + describe('exportExceptionListAndItems', () => { + test('it should return null if no matching exception list found', async () => { + (getExceptionList as jest.Mock).mockResolvedValue(null); + (findExceptionListItem as jest.Mock).mockResolvedValue({ data: [] }); + + const result = await exportExceptionListAndItems({ + id: '123', + listId: 'non-existent', + namespaceType: 'single', + savedObjectsClient: {} as SavedObjectsClientContract, + }); + expect(result).toBeNull(); + }); + + test('it should return stringified list and items', async () => { + (getExceptionList as jest.Mock).mockResolvedValue(getExceptionListSchemaMock()); + (findExceptionListItem as jest.Mock).mockResolvedValue({ + data: [getExceptionListItemSchemaMock()], + }); + + const result = await exportExceptionListAndItems({ + id: '123', + listId: 'non-existent', + namespaceType: 'single', + savedObjectsClient: {} as SavedObjectsClientContract, + }); + expect(result?.exportData).toEqual( + `${JSON.stringify(getExceptionListSchemaMock())}\n${JSON.stringify( + getExceptionListItemSchemaMock() + )}\n` + ); + expect(result?.exportDetails).toEqual({ + exported_exception_list_count: 1, + exported_exception_list_item_count: 1, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, + }); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts new file mode 100644 index 0000000000000..46b3df4e5ac44 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/export_exception_list_and_items.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + IdOrUndefined, + ListIdOrUndefined, + NamespaceType, +} from '@kbn/securitysolution-io-ts-list-types'; +import { transformDataToNdjson } from '@kbn/securitysolution-utils'; +import { SavedObjectsClientContract } from 'kibana/server'; + +import { findExceptionListItem } from './find_exception_list_item'; +import { getExceptionList } from './get_exception_list'; + +interface ExportExceptionListAndItemsOptions { + id: IdOrUndefined; + listId: ListIdOrUndefined; + savedObjectsClient: SavedObjectsClientContract; + namespaceType: NamespaceType; +} + +export interface ExportExceptionListAndItemsReturn { + exportData: string; + exportDetails: { + exported_exception_list_count: number; + exported_exception_list_item_count: number; + missing_exception_list_item_count: number; + missing_exception_list_items: string[]; + missing_exception_lists: string[]; + missing_exception_lists_count: number; + }; +} + +export const exportExceptionListAndItems = async ({ + id, + listId, + namespaceType, + savedObjectsClient, +}: ExportExceptionListAndItemsOptions): Promise => { + const exceptionList = await getExceptionList({ + id, + listId, + namespaceType, + savedObjectsClient, + }); + + if (exceptionList == null) { + return null; + } else { + // TODO: Will need to address this when we switch over to + // using PIT, don't want it to get lost + // https://github.com/elastic/kibana/issues/103944 + const listItems = await findExceptionListItem({ + filter: undefined, + listId: exceptionList.list_id, + namespaceType: exceptionList.namespace_type, + page: 1, + perPage: 10000, + savedObjectsClient, + sortField: 'exception-list.created_at', + sortOrder: 'desc', + }); + const exceptionItems = listItems?.data ?? []; + const { exportData } = getExport([exceptionList, ...exceptionItems]); + + // TODO: Add logic for missing lists and items on errors + return { + exportData: `${exportData}`, + exportDetails: { + exported_exception_list_count: 1, + exported_exception_list_item_count: exceptionItems.length, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, + }, + }; + } +}; + +export const getExport = ( + data: unknown[] +): { + exportData: string; +} => { + const ndjson = transformDataToNdjson(data); + + return { exportData: ndjson }; +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/index.ts b/x-pack/plugins/lists/server/services/exception_lists/index.ts index e6a6dd7ef8c3c..fbc052936931a 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/index.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/index.ts @@ -10,6 +10,7 @@ export * from './create_exception_list_item'; export * from './delete_exception_list'; export * from './delete_exception_list_item'; export * from './delete_exception_list_items_by_list'; +export * from './export_exception_list_and_items'; export * from './find_exception_list'; export * from './find_exception_list_item'; export * from './find_exception_list_items'; diff --git a/x-pack/plugins/security_solution/cypress/downloads/test_exception_list.ndjson b/x-pack/plugins/security_solution/cypress/downloads/test_exception_list.ndjson deleted file mode 100644 index 54420eff29e0d..0000000000000 --- a/x-pack/plugins/security_solution/cypress/downloads/test_exception_list.ndjson +++ /dev/null @@ -1,2 +0,0 @@ -{"_version":"WzQyNjA0LDFd","created_at":"2021-10-14T01:30:22.034Z","created_by":"elastic","description":"Test exception list description","id":"4c65a230-2c8e-11ec-be1c-2bbdec602f88","immutable":false,"list_id":"test_exception_list","name":"Test exception list","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"b04983b4-1617-441c-bb6c-c729281fa2e9","type":"detection","updated_at":"2021-10-14T01:30:22.036Z","updated_by":"elastic","version":1} -{"exported_list_items_count":0} diff --git a/x-pack/plugins/security_solution/cypress/objects/exception.ts b/x-pack/plugins/security_solution/cypress/objects/exception.ts index b772924697148..1a70bb1038320 100644 --- a/x-pack/plugins/security_solution/cypress/objects/exception.ts +++ b/x-pack/plugins/security_solution/cypress/objects/exception.ts @@ -41,5 +41,5 @@ export const expectedExportedExceptionList = ( exceptionListResponse: Cypress.Response ): string => { const jsonrule = exceptionListResponse.body; - return `{"_version":"${jsonrule._version}","created_at":"${jsonrule.created_at}","created_by":"elastic","description":"${jsonrule.description}","id":"${jsonrule.id}","immutable":false,"list_id":"test_exception_list","name":"Test exception list","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"${jsonrule.tie_breaker_id}","type":"detection","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","version":1}\n{"exported_list_items_count":0}\n`; + return `{"_version":"${jsonrule._version}","created_at":"${jsonrule.created_at}","created_by":"elastic","description":"${jsonrule.description}","id":"${jsonrule.id}","immutable":false,"list_id":"test_exception_list","name":"Test exception list","namespace_type":"single","os_types":[],"tags":[],"tie_breaker_id":"${jsonrule.tie_breaker_id}","type":"detection","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","version":1}\n{"exported_exception_list_count":1,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n`; }; diff --git a/x-pack/plugins/security_solution/cypress/objects/rule.ts b/x-pack/plugins/security_solution/cypress/objects/rule.ts index 27973854097db..ae04e20dfe86e 100644 --- a/x-pack/plugins/security_solution/cypress/objects/rule.ts +++ b/x-pack/plugins/security_solution/cypress/objects/rule.ts @@ -421,5 +421,5 @@ export const getEditedRule = (): CustomRule => ({ export const expectedExportedRule = (ruleResponse: Cypress.Response): string => { const jsonrule = ruleResponse.body; - return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"100m","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-50000h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n`; + return `{"id":"${jsonrule.id}","updated_at":"${jsonrule.updated_at}","updated_by":"elastic","created_at":"${jsonrule.created_at}","created_by":"elastic","name":"${jsonrule.name}","tags":[],"interval":"100m","enabled":false,"description":"${jsonrule.description}","risk_score":${jsonrule.risk_score},"severity":"${jsonrule.severity}","output_index":".siem-signals-default","author":[],"false_positives":[],"from":"now-50000h","rule_id":"rule_testing","max_signals":100,"risk_score_mapping":[],"severity_mapping":[],"threat":[],"to":"now","references":[],"version":1,"exceptions_list":[],"immutable":false,"type":"query","language":"kuery","index":["exceptions-*"],"query":"${jsonrule.query}","throttle":"no_actions","actions":[]}\n{"exported_rules_count":1,"missing_rules":[],"missing_rules_count":0,"exported_exception_list_count":0,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n`; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts index c84dd8147ebcc..277590820850b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/export_rules_route.ts @@ -46,6 +46,7 @@ export const exportRulesRoute = ( async (context, request, response) => { const siemResponse = buildSiemResponse(response); const rulesClient = context.alerting?.getRulesClient(); + const exceptionsClient = context.lists?.getExceptionListClient(); const savedObjectsClient = context.core.savedObjects.client; if (!rulesClient) { @@ -72,20 +73,27 @@ export const exportRulesRoute = ( } } - const exported = + const exportedRulesAndExceptions = request.body?.objects != null ? await getExportByObjectIds( rulesClient, + exceptionsClient, savedObjectsClient, request.body.objects, logger, isRuleRegistryEnabled ) - : await getExportAll(rulesClient, savedObjectsClient, logger, isRuleRegistryEnabled); + : await getExportAll( + rulesClient, + exceptionsClient, + savedObjectsClient, + logger, + isRuleRegistryEnabled + ); const responseBody = request.query.exclude_export_details - ? exported.rulesNdjson - : `${exported.rulesNdjson}${exported.exportDetails}`; + ? exportedRulesAndExceptions.rulesNdjson + : `${exportedRulesAndExceptions.rulesNdjson}${exportedRulesAndExceptions.exceptionLists}${exportedRulesAndExceptions.exportDetails}`; return response.ok({ headers: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts index fb5a2315479da..d043149f8474e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/perform_bulk_action_route.ts @@ -47,6 +47,7 @@ export const performBulkActionRoute = ( try { const rulesClient = context.alerting?.getRulesClient(); + const exceptionsClient = context.lists?.getExceptionListClient(); const savedObjectsClient = context.core.savedObjects.client; const ruleStatusClient = context.securitySolution.getExecutionLogClient(); @@ -136,13 +137,14 @@ export const performBulkActionRoute = ( case BulkAction.export: const exported = await getExportByObjectIds( rulesClient, + exceptionsClient, savedObjectsClient, rules.data.map(({ params }) => ({ rule_id: params.ruleId })), logger, isRuleRegistryEnabled ); - const responseBody = `${exported.rulesNdjson}${exported.exportDetails}`; + const responseBody = `${exported.rulesNdjson}${exported.exceptionLists}${exported.exportDetails}`; return response.ok({ headers: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts index c5b3e98c4c44e..f56d1d83eb873 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.test.ts @@ -207,7 +207,7 @@ describe('create_rules_stream_from_ndjson', () => { read() { this.push(getSampleAsNdjson(sample1)); this.push(getSampleAsNdjson(sample2)); - this.push('{"exported_count":1,"missing_rules":[],"missing_rules_count":0}\n'); + this.push('{"exported_rules_count":1,"missing_rules":[],"missing_rules_count":0}\n'); this.push(null); }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts index 0c2d81c18646b..d4357c45fd373 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules_stream_from_ndjson.ts @@ -21,7 +21,8 @@ import { } from '../../../../common/detection_engine/schemas/request/import_rules_schema'; import { parseNdjsonStrings, - filterExportedCounts, + filterExportedRulesCounts, + filterExceptions, createLimitStream, } from '../../../utils/read_stream/create_stream_from_ndjson'; @@ -59,7 +60,8 @@ export const createRulesStreamFromNdJson = (ruleLimit: number) => { return [ createSplitStream('\n'), parseNdjsonStrings(), - filterExportedCounts(), + filterExportedRulesCounts(), + filterExceptions(), validateRules(), createLimitStream(ruleLimit), createConcatStream([]), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts index 92e4f0bbb4a5e..80df4c94971cc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.test.ts @@ -17,9 +17,12 @@ import { getListArrayMock } from '../../../../common/detection_engine/schemas/ty import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; +import { getExceptionListClientMock } from '../../../../../lists/server/services/exception_lists/exception_list_client.mock'; import { loggingSystemMock } from 'src/core/server/mocks'; import { requestContextMock } from '../routes/__mocks__/request_context'; +const exceptionsClient = getExceptionListClientMock(); + describe.each([ ['Legacy', false], ['RAC', true], @@ -49,6 +52,7 @@ describe.each([ const exports = await getExportAll( rulesClient, + exceptionsClient, clients.savedObjectsClient, logger, isRuleRegistryEnabled @@ -97,7 +101,13 @@ describe.each([ exceptions_list: getListArrayMock(), }); expect(detailsJson).toEqual({ - exported_count: 1, + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + exported_rules_count: 1, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, missing_rules: [], missing_rules_count: 0, }); @@ -116,13 +126,16 @@ describe.each([ const exports = await getExportAll( rulesClient, + exceptionsClient, clients.savedObjectsClient, logger, isRuleRegistryEnabled ); expect(exports).toEqual({ rulesNdjson: '', - exportDetails: '{"exported_count":0,"missing_rules":[],"missing_rules_count":0}\n', + exportDetails: + '{"exported_rules_count":0,"missing_rules":[],"missing_rules_count":0,"exported_exception_list_count":0,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n', + exceptionLists: '', }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts index cbbda5df7e2bf..c0389de766ea5 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_all.ts @@ -8,35 +8,44 @@ import { transformDataToNdjson } from '@kbn/securitysolution-utils'; import { Logger } from 'src/core/server'; +import { ExceptionListClient } from '../../../../../lists/server'; import { RulesClient, AlertServices } from '../../../../../alerting/server'; import { getNonPackagedRules } from './get_existing_prepackaged_rules'; import { getExportDetailsNdjson } from './get_export_details_ndjson'; import { transformAlertsToRules } from '../routes/rules/utils'; +import { getRuleExceptionsForExport } from './get_export_rule_exceptions'; // eslint-disable-next-line no-restricted-imports import { legacyGetBulkRuleActionsSavedObject } from '../rule_actions/legacy_get_bulk_rule_actions_saved_object'; export const getExportAll = async ( rulesClient: RulesClient, + exceptionsClient: ExceptionListClient | undefined, savedObjectsClient: AlertServices['savedObjectsClient'], logger: Logger, isRuleRegistryEnabled: boolean ): Promise<{ rulesNdjson: string; exportDetails: string; + exceptionLists: string | null; }> => { const ruleAlertTypes = await getNonPackagedRules({ rulesClient, isRuleRegistryEnabled }); const alertIds = ruleAlertTypes.map((rule) => rule.id); + + // Gather actions const legacyActions = await legacyGetBulkRuleActionsSavedObject({ alertIds, savedObjectsClient, logger, }); - const rules = transformAlertsToRules(ruleAlertTypes, legacyActions); - // We do not support importing/exporting actions. When we do, delete this line of code - const rulesWithoutActions = rules.map((rule) => ({ ...rule, actions: [] })); - const rulesNdjson = transformDataToNdjson(rulesWithoutActions); - const exportDetails = getExportDetailsNdjson(rules); - return { rulesNdjson, exportDetails }; + + // Gather exceptions + const exceptions = rules.flatMap((rule) => rule.exceptions_list ?? []); + const { exportData: exceptionLists, exportDetails: exceptionDetails } = + await getRuleExceptionsForExport(exceptions, exceptionsClient); + + const rulesNdjson = transformDataToNdjson(rules); + const exportDetails = getExportDetailsNdjson(rules, [], exceptionDetails); + return { rulesNdjson, exportDetails, exceptionLists }; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts index 961f2c6a41866..7aa55a8163e1a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.test.ts @@ -16,6 +16,9 @@ import { rulesClientMock } from '../../../../../alerting/server/mocks'; import { getListArrayMock } from '../../../../common/detection_engine/schemas/types/lists.mock'; import { getThreatMock } from '../../../../common/detection_engine/schemas/types/threat.mock'; import { getQueryRuleParams } from '../schemas/rule_schemas.mock'; +import { getExceptionListClientMock } from '../../../../../lists/server/services/exception_lists/exception_list_client.mock'; + +const exceptionsClient = getExceptionListClientMock(); import { loggingSystemMock } from 'src/core/server/mocks'; import { requestContextMock } from '../routes/__mocks__/request_context'; @@ -42,6 +45,7 @@ describe.each([ const objects = [{ rule_id: 'rule-1' }]; const exports = await getExportByObjectIds( rulesClient, + exceptionsClient, clients.savedObjectsClient, objects, logger, @@ -94,7 +98,13 @@ describe.each([ exceptions_list: getListArrayMock(), }, exportDetails: { - exported_count: 1, + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + exported_rules_count: 1, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, missing_rules: [], missing_rules_count: 0, }, @@ -119,6 +129,7 @@ describe.each([ const objects = [{ rule_id: 'rule-1' }]; const exports = await getExportByObjectIds( rulesClient, + exceptionsClient, clients.savedObjectsClient, objects, logger, @@ -127,7 +138,8 @@ describe.each([ expect(exports).toEqual({ rulesNdjson: '', exportDetails: - '{"exported_count":0,"missing_rules":[{"rule_id":"rule-1"}],"missing_rules_count":1}\n', + '{"exported_rules_count":0,"missing_rules":[{"rule_id":"rule-1"}],"missing_rules_count":1,"exported_exception_list_count":0,"exported_exception_list_item_count":0,"missing_exception_list_item_count":0,"missing_exception_list_items":[],"missing_exception_lists":[],"missing_exception_lists_count":0}\n', + exceptionLists: '', }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts index 8233fe6d4948c..81295c9197644 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_by_object_ids.ts @@ -9,6 +9,7 @@ import { chunk } from 'lodash'; import { transformDataToNdjson } from '@kbn/securitysolution-utils'; import { Logger } from 'src/core/server'; +import { ExceptionListClient } from '../../../../../lists/server'; import { RulesSchema } from '../../../../common/detection_engine/schemas/response/rules_schema'; import { RulesClient, AlertServices } from '../../../../../alerting/server'; @@ -18,6 +19,7 @@ import { isAlertType } from '../rules/types'; import { transformAlertToRule } from '../routes/rules/utils'; import { INTERNAL_RULE_ID_KEY } from '../../../../common/constants'; import { findRules } from './find_rules'; +import { getRuleExceptionsForExport } from './get_export_rule_exceptions'; // eslint-disable-next-line no-restricted-imports import { legacyGetBulkRuleActionsSavedObject } from '../rule_actions/legacy_get_bulk_rule_actions_saved_object'; @@ -40,6 +42,7 @@ export interface RulesErrors { export const getExportByObjectIds = async ( rulesClient: RulesClient, + exceptionsClient: ExceptionListClient | undefined, savedObjectsClient: AlertServices['savedObjectsClient'], objects: Array<{ rule_id: string }>, logger: Logger, @@ -47,6 +50,7 @@ export const getExportByObjectIds = async ( ): Promise<{ rulesNdjson: string; exportDetails: string; + exceptionLists: string | null; }> => { const rulesAndErrors = await getRulesFromObjects( rulesClient, @@ -56,9 +60,19 @@ export const getExportByObjectIds = async ( isRuleRegistryEnabled ); + // Retrieve exceptions + const exceptions = rulesAndErrors.rules.flatMap((rule) => rule.exceptions_list ?? []); + const { exportData: exceptionLists, exportDetails: exceptionDetails } = + await getRuleExceptionsForExport(exceptions, exceptionsClient); + const rulesNdjson = transformDataToNdjson(rulesAndErrors.rules); - const exportDetails = getExportDetailsNdjson(rulesAndErrors.rules, rulesAndErrors.missingRules); - return { rulesNdjson, exportDetails }; + const exportDetails = getExportDetailsNdjson( + rulesAndErrors.rules, + rulesAndErrors.missingRules, + exceptionDetails + ); + + return { rulesNdjson, exportDetails, exceptionLists }; }; export const getRulesFromObjects = async ( diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_details_ndjson.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_details_ndjson.test.ts index f4d50524d27b4..171233a861466 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_details_ndjson.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_details_ndjson.test.ts @@ -20,7 +20,7 @@ describe('getExportDetailsNdjson', () => { const details = getExportDetailsNdjson([rule]); const reParsed = JSON.parse(details); expect(reParsed).toEqual({ - exported_count: 1, + exported_rules_count: 1, missing_rules: [], missing_rules_count: 0, }); @@ -31,7 +31,7 @@ describe('getExportDetailsNdjson', () => { const details = getExportDetailsNdjson([], [missingRule]); const reParsed = JSON.parse(details); expect(reParsed).toEqual({ - exported_count: 0, + exported_rules_count: 0, missing_rules: [{ rule_id: 'rule-1' }], missing_rules_count: 1, }); @@ -49,7 +49,7 @@ describe('getExportDetailsNdjson', () => { const details = getExportDetailsNdjson([rule1, rule2], [missingRule1, missingRule2]); const reParsed = JSON.parse(details); expect(reParsed).toEqual({ - exported_count: 2, + exported_rules_count: 2, missing_rules: [missingRule1, missingRule2], missing_rules_count: 2, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_details_ndjson.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_details_ndjson.ts index 7f9ec77e9df79..429bf4f2926bf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_details_ndjson.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_details_ndjson.ts @@ -9,12 +9,14 @@ import { RulesSchema } from '../../../../common/detection_engine/schemas/respons export const getExportDetailsNdjson = ( rules: Array>, - missingRules: Array<{ rule_id: string }> = [] + missingRules: Array<{ rule_id: string }> = [], + extraMeta: Record = {} ): string => { const stringified = JSON.stringify({ - exported_count: rules.length, + exported_rules_count: rules.length, missing_rules: missingRules, missing_rules_count: missingRules.length, + ...extraMeta, }); return `${stringified}\n`; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_rule_exceptions.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_rule_exceptions.test.ts new file mode 100644 index 0000000000000..dd7e59c74601c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_rule_exceptions.test.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; + +import { getExceptionListClientMock } from '../../../../../lists/server/services/exception_lists/exception_list_client.mock'; +import { + getRuleExceptionsForExport, + getExportableExceptions, + getDefaultExportDetails, +} from './get_export_rule_exceptions'; +import { + getListArrayMock, + getListMock, +} from '../../../../common/detection_engine/schemas/types/lists.mock'; + +describe('get_export_rule_exceptions', () => { + describe('getRuleExceptionsForExport', () => { + test('it returns empty exceptions array if no rules have exceptions associated', async () => { + const { exportData, exportDetails } = await getRuleExceptionsForExport( + [], + getExceptionListClientMock() + ); + + expect(exportData).toEqual(''); + expect(exportDetails).toEqual(getDefaultExportDetails()); + }); + + test('it returns stringified exceptions ready for export', async () => { + const { exportData } = await getRuleExceptionsForExport( + [getListMock()], + getExceptionListClientMock() + ); + + expect(exportData).toEqual('exportString'); + }); + + test('it does not return a global endpoint list', async () => { + const { exportData } = await getRuleExceptionsForExport( + [ + { + id: ENDPOINT_LIST_ID, + list_id: ENDPOINT_LIST_ID, + namespace_type: 'agnostic', + type: 'endpoint', + }, + ], + getExceptionListClientMock() + ); + + expect(exportData).toEqual(''); + }); + }); + + describe('getExportableExceptions', () => { + test('it returns stringified exception lists and items', async () => { + // This rule has 2 exception lists tied to it + const { exportData } = await getExportableExceptions( + getListArrayMock(), + getExceptionListClientMock() + ); + + expect(exportData).toEqual('exportStringexportString'); + }); + + test('it throws error if error occurs in getting exceptions', async () => { + const exceptionsClient = getExceptionListClientMock(); + exceptionsClient.exportExceptionListAndItems = jest.fn().mockRejectedValue(new Error('oops')); + // This rule has 2 exception lists tied to it + await expect(async () => { + await getExportableExceptions(getListArrayMock(), exceptionsClient); + }).rejects.toThrowErrorMatchingInlineSnapshot(`"oops"`); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_rule_exceptions.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_rule_exceptions.ts new file mode 100644 index 0000000000000..719649d35c0f0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/get_export_rule_exceptions.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { chunk } from 'lodash/fp'; +import { ListArray } from '@kbn/securitysolution-io-ts-list-types'; +import { ENDPOINT_LIST_ID } from '@kbn/securitysolution-list-constants'; + +import { + ExceptionListClient, + ExportExceptionListAndItemsReturn, +} from '../../../../../lists/server'; + +const NON_EXPORTABLE_LIST_IDS = [ENDPOINT_LIST_ID]; +export const EXCEPTIONS_EXPORT_CHUNK_SIZE = 50; + +export const getRuleExceptionsForExport = async ( + exceptions: ListArray, + exceptionsListClient: ExceptionListClient | undefined +): Promise => { + if (exceptionsListClient != null) { + const exceptionsWithoutUnexportableLists = exceptions.filter( + ({ list_id: listId }) => !NON_EXPORTABLE_LIST_IDS.includes(listId) + ); + return getExportableExceptions(exceptionsWithoutUnexportableLists, exceptionsListClient); + } else { + return { exportData: '', exportDetails: getDefaultExportDetails() }; + } +}; + +export const getExportableExceptions = async ( + exceptions: ListArray, + exceptionsListClient: ExceptionListClient +): Promise => { + let exportString = ''; + const exportDetails = getDefaultExportDetails(); + + const exceptionChunks = chunk(EXCEPTIONS_EXPORT_CHUNK_SIZE, exceptions); + for await (const exceptionChunk of exceptionChunks) { + const promises = createPromises(exceptionsListClient, exceptionChunk); + + const responses = await Promise.all(promises); + + for (const res of responses) { + if (res != null) { + const { + exportDetails: { + exported_exception_list_count: exportedExceptionListCount, + exported_exception_list_item_count: exportedExceptionListItemCount, + }, + exportData, + } = res; + + exportDetails.exported_exception_list_count = + exportDetails.exported_exception_list_count + exportedExceptionListCount; + + exportDetails.exported_exception_list_item_count = + exportDetails.exported_exception_list_item_count + exportedExceptionListItemCount; + + exportString = `${exportString}${exportData}`; + } + } + } + + return { + exportDetails, + exportData: exportString, + }; +}; + +/** + * Creates promises of the rules and returns them. + * @param exceptionsListClient Exception Lists client + * @param exceptions The rules to apply the update for + * @returns Promise of export ready exceptions. + */ +export const createPromises = ( + exceptionsListClient: ExceptionListClient, + exceptions: ListArray +): Array> => { + return exceptions.map>( + async ({ id, list_id: listId, namespace_type: namespaceType }) => { + return exceptionsListClient.exportExceptionListAndItems({ + id, + listId, + namespaceType, + }); + } + ); +}; + +export const getDefaultExportDetails = () => ({ + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, +}); diff --git a/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts b/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts index eb5abaee8cd3b..914c684fe8813 100644 --- a/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts +++ b/x-pack/plugins/security_solution/server/utils/read_stream/create_stream_from_ndjson.ts @@ -34,6 +34,18 @@ export const filterExportedCounts = (): Transform => { ); }; +export const filterExportedRulesCounts = (): Transform => { + return createFilterStream( + (obj) => obj != null && !has('exported_rules_count', obj) + ); +}; + +export const filterExceptions = (): Transform => { + return createFilterStream( + (obj) => obj != null && !has('list_id', obj) + ); +}; + // Adaptation from: saved_objects/import/create_limit_stream.ts export const createLimitStream = (limit: number): Transform => { let counter = 0; diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts index 1071e9fae3417..03b1beffa7993 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/export_rules.ts @@ -76,7 +76,13 @@ export default ({ getService }: FtrProviderContext): void => { const bodySplitAndParsed = JSON.parse(body.toString().split(/\n/)[1]); expect(bodySplitAndParsed).to.eql({ - exported_count: 1, + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + exported_rules_count: 1, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, missing_rules: [], missing_rules_count: 0, }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts index 1071e9fae3417..03b1beffa7993 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/export_rules.ts @@ -76,7 +76,13 @@ export default ({ getService }: FtrProviderContext): void => { const bodySplitAndParsed = JSON.parse(body.toString().split(/\n/)[1]); expect(bodySplitAndParsed).to.eql({ - exported_count: 1, + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + exported_rules_count: 1, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, missing_rules: [], missing_rules_count: 0, }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts index 53613624067e1..83166619b152d 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/perform_bulk_action.ts @@ -57,7 +57,13 @@ export default ({ getService }: FtrProviderContext): void => { const exportDetails = JSON.parse(exportDetailsJson); expect(exportDetails).to.eql({ - exported_count: 1, + exported_exception_list_count: 0, + exported_exception_list_item_count: 0, + exported_rules_count: 1, + missing_exception_list_item_count: 0, + missing_exception_list_items: [], + missing_exception_lists: [], + missing_exception_lists_count: 0, missing_rules: [], missing_rules_count: 0, }); diff --git a/x-pack/test/lists_api_integration/security_and_spaces/tests/export_exception_list.ts b/x-pack/test/lists_api_integration/security_and_spaces/tests/export_exception_list.ts index d35d34fde5bcc..c21026d5df3d2 100644 --- a/x-pack/test/lists_api_integration/security_and_spaces/tests/export_exception_list.ts +++ b/x-pack/test/lists_api_integration/security_and_spaces/tests/export_exception_list.ts @@ -77,7 +77,7 @@ export default ({ getService }: FtrProviderContext): void => { .expect(400); expect(exportBody).to.eql({ - message: 'exception list with list_id: not_exist does not exist', + message: 'exception list with list_id: not_exist or id: not_exist does not exist', status_code: 400, }); }); From 5894d75901844b964fe935af7aba22bbabf99f24 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Wed, 20 Oct 2021 08:14:55 +0200 Subject: [PATCH 05/23] Register `kibana_user` role deprecations in UA. (#115573) --- x-pack/plugins/security/common/index.ts | 2 +- .../security/server/deprecations/index.ts | 9 +- .../deprecations/kibana_user_role.test.ts | 328 ++++++++++++++++++ .../server/deprecations/kibana_user_role.ts | 238 +++++++++++++ x-pack/plugins/security/server/plugin.ts | 17 +- .../server/routes/deprecations/index.ts | 13 + .../deprecations/kibana_user_role.test.ts | 283 +++++++++++++++ .../routes/deprecations/kibana_user_role.ts | 145 ++++++++ .../plugins/security/server/routes/index.ts | 4 +- 9 files changed, 1030 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/security/server/deprecations/kibana_user_role.test.ts create mode 100644 x-pack/plugins/security/server/deprecations/kibana_user_role.ts create mode 100644 x-pack/plugins/security/server/routes/deprecations/index.ts create mode 100644 x-pack/plugins/security/server/routes/deprecations/kibana_user_role.test.ts create mode 100644 x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts diff --git a/x-pack/plugins/security/common/index.ts b/x-pack/plugins/security/common/index.ts index ac5d252c98a8b..1d05036191635 100644 --- a/x-pack/plugins/security/common/index.ts +++ b/x-pack/plugins/security/common/index.ts @@ -6,4 +6,4 @@ */ export type { SecurityLicense } from './licensing'; -export type { AuthenticatedUser } from './model'; +export type { AuthenticatedUser, PrivilegeDeprecationsService } from './model'; diff --git a/x-pack/plugins/security/server/deprecations/index.ts b/x-pack/plugins/security/server/deprecations/index.ts index 05802a5a673c5..2c4b47ba41a0a 100644 --- a/x-pack/plugins/security/server/deprecations/index.ts +++ b/x-pack/plugins/security/server/deprecations/index.ts @@ -5,8 +5,9 @@ * 2.0. */ -/** - * getKibanaRolesByFeature - */ - export { getPrivilegeDeprecationsService } from './privilege_deprecations'; +export { + registerKibanaUserRoleDeprecation, + KIBANA_ADMIN_ROLE_NAME, + KIBANA_USER_ROLE_NAME, +} from './kibana_user_role'; diff --git a/x-pack/plugins/security/server/deprecations/kibana_user_role.test.ts b/x-pack/plugins/security/server/deprecations/kibana_user_role.test.ts new file mode 100644 index 0000000000000..da728b12fca91 --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/kibana_user_role.test.ts @@ -0,0 +1,328 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { errors } from '@elastic/elasticsearch'; +import type { SecurityRoleMapping, SecurityUser } from '@elastic/elasticsearch/api/types'; + +import type { PackageInfo, RegisterDeprecationsConfig } from 'src/core/server'; +import { + deprecationsServiceMock, + elasticsearchServiceMock, + loggingSystemMock, + savedObjectsClientMock, +} from 'src/core/server/mocks'; + +import { licenseMock } from '../../common/licensing/index.mock'; +import { securityMock } from '../mocks'; +import { registerKibanaUserRoleDeprecation } from './kibana_user_role'; + +function getDepsMock() { + return { + logger: loggingSystemMock.createLogger(), + deprecationsService: deprecationsServiceMock.createSetupContract(), + license: licenseMock.create(), + packageInfo: { + branch: 'some-branch', + buildSha: 'sha', + dist: true, + version: '8.0.0', + buildNum: 1, + } as PackageInfo, + }; +} + +function getContextMock() { + return { + esClient: elasticsearchServiceMock.createScopedClusterClient(), + savedObjectsClient: savedObjectsClientMock.create(), + }; +} + +function createMockUser(user: Partial = {}) { + return { enabled: true, username: 'userA', roles: ['roleA'], metadata: {}, ...user }; +} + +function createMockRoleMapping(mapping: Partial = {}) { + return { enabled: true, roles: ['roleA'], rules: {}, metadata: {}, ...mapping }; +} + +describe('Kibana Dashboard Only User role deprecations', () => { + let mockDeps: ReturnType; + let mockContext: ReturnType; + let deprecationHandler: RegisterDeprecationsConfig; + beforeEach(() => { + mockContext = getContextMock(); + mockDeps = getDepsMock(); + registerKibanaUserRoleDeprecation(mockDeps); + + expect(mockDeps.deprecationsService.registerDeprecations).toHaveBeenCalledTimes(1); + deprecationHandler = mockDeps.deprecationsService.registerDeprecations.mock.calls[0][0]; + }); + + it('does not return any deprecations if security is not enabled', async () => { + mockDeps.license.isEnabled.mockReturnValue(false); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toEqual([]); + expect(mockContext.esClient.asCurrentUser.security.getUser).not.toHaveBeenCalled(); + expect(mockContext.esClient.asCurrentUser.security.getRoleMapping).not.toHaveBeenCalled(); + }); + + it('does not return any deprecations if none of the users and role mappings has a kibana user role', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: { enabled: true, roles: ['roleA'], rules: {}, metadata: {} }, + }, + }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toEqual([]); + }); + + it('returns deprecations even if cannot retrieve users due to permission error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 403, body: {} })) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Make sure you have a \\"manage_security\\" cluster privilege assigned.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/kibana/some-branch/xpack-security.html#_required_permissions_7", + "level": "fetch_error", + "message": "You do not have enough permissions to fix this deprecation.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns deprecations even if cannot retrieve users due to unknown error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 500, body: {} })) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Check Kibana logs for more details.", + ], + }, + "deprecationType": "feature", + "level": "fetch_error", + "message": "Failed to perform deprecation check. Check Kibana logs for more details.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns deprecations even if cannot retrieve role mappings due to permission error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 403, body: {} })) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Make sure you have a \\"manage_security\\" cluster privilege assigned.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/kibana/some-branch/xpack-security.html#_required_permissions_7", + "level": "fetch_error", + "message": "You do not have enough permissions to fix this deprecation.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns deprecations even if cannot retrieve role mappings due to unknown error', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockRejectedValue( + new errors.ResponseError(securityMock.createApiResponse({ statusCode: 500, body: {} })) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "manualSteps": Array [ + "Check Kibana logs for more details.", + ], + }, + "deprecationType": "feature", + "level": "fetch_error", + "message": "Failed to perform deprecation check. Check Kibana logs for more details.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns only user-related deprecations', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ + body: { + userA: createMockUser({ username: 'userA', roles: ['roleA'] }), + userB: createMockUser({ username: 'userB', roles: ['roleB', 'kibana_user'] }), + userC: createMockUser({ username: 'userC', roles: ['roleC'] }), + userD: createMockUser({ username: 'userD', roles: ['kibana_user'] }), + }, + }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "api": Object { + "method": "POST", + "path": "/internal/security/deprecations/kibana_user_role/_fix_users", + }, + "manualSteps": Array [ + "Remove the \\"kibana_user\\" role from all users and add the \\"kibana_admin\\" role. The affected users are: userB, userD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Use the \\"kibana_admin\\" role to grant access to all Kibana features in all spaces.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns only role-mapping-related deprecations', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: createMockRoleMapping({ roles: ['roleA'] }), + mappingB: createMockRoleMapping({ roles: ['roleB', 'kibana_user'] }), + mappingC: createMockRoleMapping({ roles: ['roleC'] }), + mappingD: createMockRoleMapping({ roles: ['kibana_user'] }), + }, + }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "api": Object { + "method": "POST", + "path": "/internal/security/deprecations/kibana_user_role/_fix_role_mappings", + }, + "manualSteps": Array [ + "Remove the \\"kibana_user\\" role from all role mappings and add the \\"kibana_admin\\" role. The affected role mappings are: mappingB, mappingD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Use the \\"kibana_admin\\" role to grant access to all Kibana features in all spaces.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); + + it('returns both user-related and role-mapping-related deprecations', async () => { + mockContext.esClient.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ + body: { + userA: createMockUser({ username: 'userA', roles: ['roleA'] }), + userB: createMockUser({ username: 'userB', roles: ['roleB', 'kibana_user'] }), + userC: createMockUser({ username: 'userC', roles: ['roleC'] }), + userD: createMockUser({ username: 'userD', roles: ['kibana_user'] }), + }, + }) + ); + + mockContext.esClient.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: createMockRoleMapping({ roles: ['roleA'] }), + mappingB: createMockRoleMapping({ roles: ['roleB', 'kibana_user'] }), + mappingC: createMockRoleMapping({ roles: ['roleC'] }), + mappingD: createMockRoleMapping({ roles: ['kibana_user'] }), + }, + }) + ); + + await expect(deprecationHandler.getDeprecations(mockContext)).resolves.toMatchInlineSnapshot(` + Array [ + Object { + "correctiveActions": Object { + "api": Object { + "method": "POST", + "path": "/internal/security/deprecations/kibana_user_role/_fix_users", + }, + "manualSteps": Array [ + "Remove the \\"kibana_user\\" role from all users and add the \\"kibana_admin\\" role. The affected users are: userB, userD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Use the \\"kibana_admin\\" role to grant access to all Kibana features in all spaces.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + Object { + "correctiveActions": Object { + "api": Object { + "method": "POST", + "path": "/internal/security/deprecations/kibana_user_role/_fix_role_mappings", + }, + "manualSteps": Array [ + "Remove the \\"kibana_user\\" role from all role mappings and add the \\"kibana_admin\\" role. The affected role mappings are: mappingB, mappingD.", + ], + }, + "deprecationType": "feature", + "documentationUrl": "https://www.elastic.co/guide/en/elasticsearch/reference/some-branch/built-in-roles.html", + "level": "warning", + "message": "Use the \\"kibana_admin\\" role to grant access to all Kibana features in all spaces.", + "title": "The \\"kibana_user\\" role is deprecated", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/security/server/deprecations/kibana_user_role.ts b/x-pack/plugins/security/server/deprecations/kibana_user_role.ts new file mode 100644 index 0000000000000..d659ea273f05f --- /dev/null +++ b/x-pack/plugins/security/server/deprecations/kibana_user_role.ts @@ -0,0 +1,238 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + SecurityGetRoleMappingResponse, + SecurityGetUserResponse, +} from '@elastic/elasticsearch/api/types'; + +import { i18n } from '@kbn/i18n'; +import type { + DeprecationsDetails, + DeprecationsServiceSetup, + ElasticsearchClient, + Logger, + PackageInfo, +} from 'src/core/server'; + +import type { SecurityLicense } from '../../common'; +import { getDetailedErrorMessage, getErrorStatusCode } from '../errors'; + +export const KIBANA_USER_ROLE_NAME = 'kibana_user'; +export const KIBANA_ADMIN_ROLE_NAME = 'kibana_admin'; + +interface Deps { + deprecationsService: DeprecationsServiceSetup; + license: SecurityLicense; + logger: Logger; + packageInfo: PackageInfo; +} + +function getDeprecationTitle() { + return i18n.translate('xpack.security.deprecations.kibanaUser.deprecationTitle', { + defaultMessage: 'The "{userRoleName}" role is deprecated', + values: { userRoleName: KIBANA_USER_ROLE_NAME }, + }); +} + +function getDeprecationMessage() { + return i18n.translate('xpack.security.deprecations.kibanaUser.deprecationMessage', { + defaultMessage: + 'Use the "{adminRoleName}" role to grant access to all Kibana features in all spaces.', + values: { adminRoleName: KIBANA_ADMIN_ROLE_NAME }, + }); +} + +export const registerKibanaUserRoleDeprecation = ({ + deprecationsService, + logger, + license, + packageInfo, +}: Deps) => { + deprecationsService.registerDeprecations({ + getDeprecations: async (context) => { + // Nothing to do if security is disabled + if (!license.isEnabled()) { + return []; + } + + return [ + ...(await getUsersDeprecations(context.esClient.asCurrentUser, logger, packageInfo)), + ...(await getRoleMappingsDeprecations(context.esClient.asCurrentUser, logger, packageInfo)), + ]; + }, + }); +}; + +async function getUsersDeprecations( + client: ElasticsearchClient, + logger: Logger, + packageInfo: PackageInfo +): Promise { + let users: SecurityGetUserResponse; + try { + users = (await client.security.getUser()).body; + } catch (err) { + if (getErrorStatusCode(err) === 403) { + logger.warn( + `Failed to retrieve users when checking for deprecations: the "manage_security" cluster privilege is required.` + ); + } else { + logger.error( + `Failed to retrieve users when checking for deprecations, unexpected error: ${getDetailedErrorMessage( + err + )}.` + ); + } + return deprecationError(packageInfo, err); + } + + const usersWithKibanaUserRole = Object.values(users) + .filter((user) => user.roles.includes(KIBANA_USER_ROLE_NAME)) + .map((user) => user.username); + if (usersWithKibanaUserRole.length === 0) { + return []; + } + + return [ + { + title: getDeprecationTitle(), + message: getDeprecationMessage(), + level: 'warning', + deprecationType: 'feature', + documentationUrl: `https://www.elastic.co/guide/en/elasticsearch/reference/${packageInfo.branch}/built-in-roles.html`, + correctiveActions: { + api: { + method: 'POST', + path: '/internal/security/deprecations/kibana_user_role/_fix_users', + }, + manualSteps: [ + i18n.translate( + 'xpack.security.deprecations.kibanaUser.usersDeprecationCorrectiveAction', + { + defaultMessage: + 'Remove the "{userRoleName}" role from all users and add the "{adminRoleName}" role. The affected users are: {users}.', + values: { + userRoleName: KIBANA_USER_ROLE_NAME, + adminRoleName: KIBANA_ADMIN_ROLE_NAME, + users: usersWithKibanaUserRole.join(', '), + }, + } + ), + ], + }, + }, + ]; +} + +async function getRoleMappingsDeprecations( + client: ElasticsearchClient, + logger: Logger, + packageInfo: PackageInfo +): Promise { + let roleMappings: SecurityGetRoleMappingResponse; + try { + roleMappings = (await client.security.getRoleMapping()).body; + } catch (err) { + if (getErrorStatusCode(err) === 403) { + logger.warn( + `Failed to retrieve role mappings when checking for deprecations: the "manage_security" cluster privilege is required.` + ); + } else { + logger.error( + `Failed to retrieve role mappings when checking for deprecations, unexpected error: ${getDetailedErrorMessage( + err + )}.` + ); + } + return deprecationError(packageInfo, err); + } + + const roleMappingsWithKibanaUserRole = Object.entries(roleMappings) + .filter(([, roleMapping]) => roleMapping.roles.includes(KIBANA_USER_ROLE_NAME)) + .map(([mappingName]) => mappingName); + if (roleMappingsWithKibanaUserRole.length === 0) { + return []; + } + + return [ + { + title: getDeprecationTitle(), + message: getDeprecationMessage(), + level: 'warning', + deprecationType: 'feature', + documentationUrl: `https://www.elastic.co/guide/en/elasticsearch/reference/${packageInfo.branch}/built-in-roles.html`, + correctiveActions: { + api: { + method: 'POST', + path: '/internal/security/deprecations/kibana_user_role/_fix_role_mappings', + }, + manualSteps: [ + i18n.translate( + 'xpack.security.deprecations.kibanaUser.roleMappingsDeprecationCorrectiveAction', + { + defaultMessage: + 'Remove the "{userRoleName}" role from all role mappings and add the "{adminRoleName}" role. The affected role mappings are: {roleMappings}.', + values: { + userRoleName: KIBANA_USER_ROLE_NAME, + adminRoleName: KIBANA_ADMIN_ROLE_NAME, + roleMappings: roleMappingsWithKibanaUserRole.join(', '), + }, + } + ), + ], + }, + }, + ]; +} + +function deprecationError(packageInfo: PackageInfo, error: Error): DeprecationsDetails[] { + const title = getDeprecationTitle(); + + if (getErrorStatusCode(error) === 403) { + return [ + { + title, + level: 'fetch_error', + deprecationType: 'feature', + message: i18n.translate('xpack.security.deprecations.kibanaUser.forbiddenErrorMessage', { + defaultMessage: 'You do not have enough permissions to fix this deprecation.', + }), + documentationUrl: `https://www.elastic.co/guide/en/kibana/${packageInfo.branch}/xpack-security.html#_required_permissions_7`, + correctiveActions: { + manualSteps: [ + i18n.translate( + 'xpack.security.deprecations.kibanaUser.forbiddenErrorCorrectiveAction', + { + defaultMessage: + 'Make sure you have a "manage_security" cluster privilege assigned.', + } + ), + ], + }, + }, + ]; + } + + return [ + { + title, + level: 'fetch_error', + deprecationType: 'feature', + message: i18n.translate('xpack.security.deprecations.kibanaUser.unknownErrorMessage', { + defaultMessage: 'Failed to perform deprecation check. Check Kibana logs for more details.', + }), + correctiveActions: { + manualSteps: [ + i18n.translate('xpack.security.deprecations.kibanaUser.unknownErrorCorrectiveAction', { + defaultMessage: 'Check Kibana logs for more details.', + }), + ], + }, + }, + ]; +} diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 98e77038f168a..1e42d10b205aa 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -27,9 +27,8 @@ import type { import type { LicensingPluginSetup, LicensingPluginStart } from '../../licensing/server'; import type { SpacesPluginSetup, SpacesPluginStart } from '../../spaces/server'; import type { TaskManagerSetupContract, TaskManagerStartContract } from '../../task_manager/server'; -import type { SecurityLicense } from '../common/licensing'; +import type { AuthenticatedUser, PrivilegeDeprecationsService, SecurityLicense } from '../common'; import { SecurityLicenseService } from '../common/licensing'; -import type { AuthenticatedUser, PrivilegeDeprecationsService } from '../common/model'; import type { AnonymousAccessServiceStart } from './anonymous_access'; import { AnonymousAccessService } from './anonymous_access'; import type { AuditServiceSetup } from './audit'; @@ -43,7 +42,7 @@ import type { AuthorizationServiceSetup, AuthorizationServiceSetupInternal } fro import { AuthorizationService } from './authorization'; import type { ConfigSchema, ConfigType } from './config'; import { createConfig } from './config'; -import { getPrivilegeDeprecationsService } from './deprecations'; +import { getPrivilegeDeprecationsService, registerKibanaUserRoleDeprecation } from './deprecations'; import { ElasticsearchService } from './elasticsearch'; import type { SecurityFeatureUsageServiceStart } from './feature_usage'; import { SecurityFeatureUsageService } from './feature_usage'; @@ -290,6 +289,8 @@ export class SecurityPlugin getSpacesService: () => spaces?.spacesService, }); + this.registerDeprecations(core, license); + defineRoutes({ router: core.http.createRouter(), basePath: core.http.basePath, @@ -414,4 +415,14 @@ export class SecurityPlugin this.authorizationService.stop(); this.sessionManagementService.stop(); } + + private registerDeprecations(core: CoreSetup, license: SecurityLicense) { + const logger = this.logger.get('deprecations'); + registerKibanaUserRoleDeprecation({ + deprecationsService: core.deprecations, + license, + logger, + packageInfo: this.initializerContext.env.packageInfo, + }); + } } diff --git a/x-pack/plugins/security/server/routes/deprecations/index.ts b/x-pack/plugins/security/server/routes/deprecations/index.ts new file mode 100644 index 0000000000000..cbc186ed2e925 --- /dev/null +++ b/x-pack/plugins/security/server/routes/deprecations/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RouteDefinitionParams } from '../'; +import { defineKibanaUserRoleDeprecationRoutes } from './kibana_user_role'; + +export function defineDeprecationsRoutes(params: RouteDefinitionParams) { + defineKibanaUserRoleDeprecationRoutes(params); +} diff --git a/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.test.ts b/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.test.ts new file mode 100644 index 0000000000000..b2ae2543bd652 --- /dev/null +++ b/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.test.ts @@ -0,0 +1,283 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { errors } from '@elastic/elasticsearch'; +import type { SecurityRoleMapping, SecurityUser } from '@elastic/elasticsearch/api/types'; + +import type { DeeplyMockedKeys } from '@kbn/utility-types/jest'; +import type { RequestHandler, RouteConfig } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; +import { coreMock, httpServerMock } from 'src/core/server/mocks'; + +import { securityMock } from '../../mocks'; +import type { SecurityRequestHandlerContext, SecurityRouter } from '../../types'; +import { routeDefinitionParamsMock } from '../index.mock'; +import { defineKibanaUserRoleDeprecationRoutes } from './kibana_user_role'; + +function createMockUser(user: Partial = {}) { + return { enabled: true, username: 'userA', roles: ['roleA'], metadata: {}, ...user }; +} + +function createMockRoleMapping(mapping: Partial = {}) { + return { enabled: true, roles: ['roleA'], rules: {}, metadata: {}, ...mapping }; +} + +describe('Kibana user deprecation routes', () => { + let router: jest.Mocked; + let mockContext: DeeplyMockedKeys; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create(); + router = routeParamsMock.router; + + mockContext = { + core: coreMock.createRequestHandlerContext(), + licensing: { license: { check: jest.fn().mockReturnValue({ state: 'valid' }) } }, + } as any; + + defineKibanaUserRoleDeprecationRoutes(routeParamsMock); + }); + + describe('Users with Kibana user role', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [fixUsersRouteConfig, fixUsersRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/deprecations/kibana_user_role/_fix_users' + )!; + + routeConfig = fixUsersRouteConfig; + routeHandler = fixUsersRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toBeUndefined(); + expect(routeConfig.validate).toBe(false); + }); + + it('fails if cannot retrieve users', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getUser.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: new Error('Oh no') }) + ) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 500 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).not.toHaveBeenCalled(); + }); + + it('fails if fails to update user', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ + body: { + userA: createMockUser({ username: 'userA', roles: ['roleA', 'kibana_user'] }), + userB: createMockUser({ username: 'userB', roles: ['kibana_user'] }), + }, + }) + ); + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: new Error('Oh no') }) + ) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 500 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledTimes(1); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledWith({ + username: 'userA', + body: createMockUser({ username: 'userA', roles: ['roleA', 'kibana_admin'] }), + }); + }); + + it('does nothing if there are no users with Kibana user role', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ body: { userA: createMockUser() } }) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 200 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).not.toHaveBeenCalled(); + }); + + it('updates users with Kibana user role', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getUser.mockResolvedValue( + securityMock.createApiResponse({ + body: { + userA: createMockUser({ username: 'userA', roles: ['roleA'] }), + userB: createMockUser({ username: 'userB', roles: ['roleB', 'kibana_user'] }), + userC: createMockUser({ username: 'userC', roles: ['roleC'] }), + userD: createMockUser({ username: 'userD', roles: ['kibana_user'] }), + userE: createMockUser({ + username: 'userE', + roles: ['kibana_user', 'kibana_admin', 'roleE'], + }), + }, + }) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 200 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledTimes(3); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledWith({ + username: 'userB', + body: createMockUser({ username: 'userB', roles: ['roleB', 'kibana_admin'] }), + }); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledWith({ + username: 'userD', + body: createMockUser({ username: 'userD', roles: ['kibana_admin'] }), + }); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putUser + ).toHaveBeenCalledWith({ + username: 'userE', + body: createMockUser({ username: 'userE', roles: ['kibana_admin', 'roleE'] }), + }); + }); + }); + + describe('Role mappings with Kibana user role', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [fixRoleMappingsRouteConfig, fixRoleMappingsRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => + path === '/internal/security/deprecations/kibana_user_role/_fix_role_mappings' + )!; + + routeConfig = fixRoleMappingsRouteConfig; + routeHandler = fixRoleMappingsRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toBeUndefined(); + expect(routeConfig.validate).toBe(false); + }); + + it('fails if cannot retrieve role mappings', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getRoleMapping.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: new Error('Oh no') }) + ) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 500 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).not.toHaveBeenCalled(); + }); + + it('fails if fails to update role mapping', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: createMockRoleMapping({ roles: ['roleA', 'kibana_user'] }), + mappingB: createMockRoleMapping({ roles: ['kibana_user'] }), + }, + }) + ); + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping.mockRejectedValue( + new errors.ResponseError( + securityMock.createApiResponse({ statusCode: 500, body: new Error('Oh no') }) + ) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 500 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledTimes(1); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledWith({ + name: 'mappingA', + body: createMockRoleMapping({ roles: ['roleA', 'kibana_admin'] }), + }); + }); + + it('does nothing if there are no role mappings with Kibana user role', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ body: { mappingA: createMockRoleMapping() } }) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 200 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).not.toHaveBeenCalled(); + }); + + it('updates role mappings with Kibana user role', async () => { + mockContext.core.elasticsearch.client.asCurrentUser.security.getRoleMapping.mockResolvedValue( + securityMock.createApiResponse({ + body: { + mappingA: createMockRoleMapping({ roles: ['roleA'] }), + mappingB: createMockRoleMapping({ roles: ['roleB', 'kibana_user'] }), + mappingC: createMockRoleMapping({ roles: ['roleC'] }), + mappingD: createMockRoleMapping({ roles: ['kibana_user'] }), + mappingE: createMockRoleMapping({ roles: ['kibana_user', 'kibana_admin', 'roleE'] }), + }, + }) + ); + + await expect( + routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual(expect.objectContaining({ status: 200 })); + + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledTimes(3); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledWith({ + name: 'mappingB', + body: createMockRoleMapping({ roles: ['roleB', 'kibana_admin'] }), + }); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledWith({ + name: 'mappingD', + body: createMockRoleMapping({ roles: ['kibana_admin'] }), + }); + expect( + mockContext.core.elasticsearch.client.asCurrentUser.security.putRoleMapping + ).toHaveBeenCalledWith({ + name: 'mappingE', + body: createMockRoleMapping({ roles: ['kibana_admin', 'roleE'] }), + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts b/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts new file mode 100644 index 0000000000000..21bb9db7329b6 --- /dev/null +++ b/x-pack/plugins/security/server/routes/deprecations/kibana_user_role.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + SecurityGetRoleMappingResponse, + SecurityGetUserResponse, +} from '@elastic/elasticsearch/api/types'; + +import type { RouteDefinitionParams } from '..'; +import { KIBANA_ADMIN_ROLE_NAME, KIBANA_USER_ROLE_NAME } from '../../deprecations'; +import { + getDetailedErrorMessage, + getErrorStatusCode, + wrapIntoCustomErrorResponse, +} from '../../errors'; +import { createLicensedRouteHandler } from '../licensed_route_handler'; + +/** + * Defines routes required to handle `kibana_user` deprecation. + */ +export function defineKibanaUserRoleDeprecationRoutes({ router, logger }: RouteDefinitionParams) { + router.post( + { + path: '/internal/security/deprecations/kibana_user_role/_fix_users', + validate: false, + }, + createLicensedRouteHandler(async (context, request, response) => { + let users: SecurityGetUserResponse; + try { + users = (await context.core.elasticsearch.client.asCurrentUser.security.getUser()).body; + } catch (err) { + if (getErrorStatusCode(err) === 403) { + logger.warn( + `Failed to retrieve users when checking for deprecations: the manage_security cluster privilege is required` + ); + } else { + logger.error( + `Failed to retrieve users when checking for deprecations, unexpected error: ${getDetailedErrorMessage( + err + )}` + ); + } + return response.customError(wrapIntoCustomErrorResponse(err)); + } + + const usersWithKibanaUserRole = Object.values(users).filter((user) => + user.roles.includes(KIBANA_USER_ROLE_NAME) + ); + + if (usersWithKibanaUserRole.length === 0) { + logger.debug(`No users with "${KIBANA_USER_ROLE_NAME}" role found.`); + } else { + logger.debug( + `The following users with "${KIBANA_USER_ROLE_NAME}" role found and will be migrated to "${KIBANA_ADMIN_ROLE_NAME}" role: ${usersWithKibanaUserRole + .map((user) => user.username) + .join(', ')}.` + ); + } + + for (const userToUpdate of usersWithKibanaUserRole) { + const roles = userToUpdate.roles.filter((role) => role !== KIBANA_USER_ROLE_NAME); + if (!roles.includes(KIBANA_ADMIN_ROLE_NAME)) { + roles.push(KIBANA_ADMIN_ROLE_NAME); + } + + try { + await context.core.elasticsearch.client.asCurrentUser.security.putUser({ + username: userToUpdate.username, + body: { ...userToUpdate, roles }, + }); + } catch (err) { + logger.error( + `Failed to update user "${userToUpdate.username}": ${getDetailedErrorMessage(err)}.` + ); + return response.customError(wrapIntoCustomErrorResponse(err)); + } + + logger.debug(`Successfully updated user "${userToUpdate.username}".`); + } + + return response.ok({ body: {} }); + }) + ); + + router.post( + { + path: '/internal/security/deprecations/kibana_user_role/_fix_role_mappings', + validate: false, + }, + createLicensedRouteHandler(async (context, request, response) => { + let roleMappings: SecurityGetRoleMappingResponse; + try { + roleMappings = ( + await context.core.elasticsearch.client.asCurrentUser.security.getRoleMapping() + ).body; + } catch (err) { + logger.error(`Failed to retrieve role mappings: ${getDetailedErrorMessage(err)}.`); + return response.customError(wrapIntoCustomErrorResponse(err)); + } + + const roleMappingsWithKibanaUserRole = Object.entries(roleMappings).filter(([, mapping]) => + mapping.roles.includes(KIBANA_USER_ROLE_NAME) + ); + + if (roleMappingsWithKibanaUserRole.length === 0) { + logger.debug(`No role mappings with "${KIBANA_USER_ROLE_NAME}" role found.`); + } else { + logger.debug( + `The following role mappings with "${KIBANA_USER_ROLE_NAME}" role found and will be migrated to "${KIBANA_ADMIN_ROLE_NAME}" role: ${roleMappingsWithKibanaUserRole + .map(([mappingName]) => mappingName) + .join(', ')}.` + ); + } + + for (const [mappingNameToUpdate, mappingToUpdate] of roleMappingsWithKibanaUserRole) { + const roles = mappingToUpdate.roles.filter((role) => role !== KIBANA_USER_ROLE_NAME); + if (!roles.includes(KIBANA_ADMIN_ROLE_NAME)) { + roles.push(KIBANA_ADMIN_ROLE_NAME); + } + + try { + await context.core.elasticsearch.client.asCurrentUser.security.putRoleMapping({ + name: mappingNameToUpdate, + body: { ...mappingToUpdate, roles }, + }); + } catch (err) { + logger.error( + `Failed to update role mapping "${mappingNameToUpdate}": ${getDetailedErrorMessage( + err + )}.` + ); + return response.customError(wrapIntoCustomErrorResponse(err)); + } + + logger.debug(`Successfully updated role mapping "${mappingNameToUpdate}".`); + } + + return response.ok({ body: {} }); + }) + ); +} diff --git a/x-pack/plugins/security/server/routes/index.ts b/x-pack/plugins/security/server/routes/index.ts index 851e70a357cf9..6785fe57c6b32 100644 --- a/x-pack/plugins/security/server/routes/index.ts +++ b/x-pack/plugins/security/server/routes/index.ts @@ -11,7 +11,7 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import type { HttpResources, IBasePath, Logger } from 'src/core/server'; import type { KibanaFeature } from '../../../features/server'; -import type { SecurityLicense } from '../../common/licensing'; +import type { SecurityLicense } from '../../common'; import type { AnonymousAccessServiceStart } from '../anonymous_access'; import type { InternalAuthenticationServiceStart } from '../authentication'; import type { AuthorizationServiceSetupInternal } from '../authorization'; @@ -23,6 +23,7 @@ import { defineAnonymousAccessRoutes } from './anonymous_access'; import { defineApiKeysRoutes } from './api_keys'; import { defineAuthenticationRoutes } from './authentication'; import { defineAuthorizationRoutes } from './authorization'; +import { defineDeprecationsRoutes } from './deprecations'; import { defineIndicesRoutes } from './indices'; import { defineRoleMappingRoutes } from './role_mapping'; import { defineSecurityCheckupGetStateRoutes } from './security_checkup'; @@ -58,6 +59,7 @@ export function defineRoutes(params: RouteDefinitionParams) { defineUsersRoutes(params); defineRoleMappingRoutes(params); defineViewRoutes(params); + defineDeprecationsRoutes(params); defineAnonymousAccessRoutes(params); defineSecurityCheckupGetStateRoutes(params); } From c954c3b1e6341d3eb407dfbd19ffd906dffc8ba0 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Wed, 20 Oct 2021 02:52:07 -0400 Subject: [PATCH 06/23] [Observability] [Exploratory View] add chart creation context (#114784) * add chart creation context * add chart creation tooltip, remove outer panel, and change default chart type for synthetics data * adjust types * remove extra translations * add panel back * update chart creation time date format * update time format * adjust tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/data/common/constants.ts | 1 + .../exploratory_view/exploratory_view.tsx | 12 ++-- .../header/chart_creation_info.test.tsx | 35 +++++++++++ .../header/chart_creation_info.tsx | 61 +++++++++++++++++++ .../shared/exploratory_view/header/header.tsx | 9 +-- .../exploratory_view/header/last_updated.tsx | 28 +++++++-- .../exploratory_view/lens_embeddable.tsx | 14 +++-- .../common/header/action_menu_content.tsx | 2 +- 8 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/header/chart_creation_info.test.tsx create mode 100644 x-pack/plugins/observability/public/components/shared/exploratory_view/header/chart_creation_info.tsx diff --git a/src/plugins/data/common/constants.ts b/src/plugins/data/common/constants.ts index c236be18a8e41..e141bfbef3c89 100644 --- a/src/plugins/data/common/constants.ts +++ b/src/plugins/data/common/constants.ts @@ -34,4 +34,5 @@ export const UI_SETTINGS = { FILTERS_EDITOR_SUGGEST_VALUES: 'filterEditor:suggestValues', AUTOCOMPLETE_USE_TIMERANGE: 'autocomplete:useTimeRange', AUTOCOMPLETE_VALUE_SUGGESTION_METHOD: 'autocomplete:valueSuggestionMethod', + DATE_FORMAT: 'dateFormat', } as const; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx index f3f332b5094b6..ceb87429f9de4 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/exploratory_view.tsx @@ -7,8 +7,8 @@ import { i18n } from '@kbn/i18n'; import React, { useEffect, useRef, useState } from 'react'; -import { EuiButtonEmpty, EuiPanel, EuiResizableContainer, EuiTitle } from '@elastic/eui'; import styled from 'styled-components'; +import { EuiButtonEmpty, EuiResizableContainer, EuiTitle, EuiPanel } from '@elastic/eui'; import { PanelDirection } from '@elastic/eui/src/components/resizable_container/types'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; @@ -20,6 +20,7 @@ import { useAppIndexPatternContext } from './hooks/use_app_index_pattern'; import { SeriesViews } from './views/series_views'; import { LensEmbeddable } from './lens_embeddable'; import { EmptyView } from './components/empty_view'; +import type { ChartTimeRange } from './header/last_updated'; export type PanelId = 'seriesPanel' | 'chartPanel'; @@ -37,7 +38,7 @@ export function ExploratoryView({ const [height, setHeight] = useState('100vh'); - const [lastUpdated, setLastUpdated] = useState(); + const [chartTimeRangeContext, setChartTimeRangeContext] = useState(); const [lensAttributes, setLensAttributes] = useState( null @@ -96,7 +97,10 @@ export function ExploratoryView({ {lens ? ( <> - + {lensAttributes ? ( ) : ( diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/chart_creation_info.test.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/chart_creation_info.test.tsx new file mode 100644 index 0000000000000..570362a63c33f --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/chart_creation_info.test.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { screen } from '@testing-library/dom'; +import { render } from '../rtl_helpers'; +import { ChartCreationInfo } from './chart_creation_info'; + +const info = { + to: 1634071132571, + from: 1633406400000, + lastUpdated: 1634071140788, +}; + +describe('ChartCreationInfo', () => { + it('renders chart creation info', async () => { + render(); + + expect(screen.getByText('Chart created')).toBeInTheDocument(); + expect(screen.getByText('Oct 12, 2021 4:39 PM')).toBeInTheDocument(); + expect(screen.getByText('Displaying from')).toBeInTheDocument(); + expect(screen.getByText('Oct 5, 2021 12:00 AM → Oct 12, 2021 4:38 PM')).toBeInTheDocument(); + }); + + it('does not display info when props are falsey', async () => { + render(); + + expect(screen.queryByText('Chart created')).not.toBeInTheDocument(); + expect(screen.queryByText('Displaying from')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/chart_creation_info.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/chart_creation_info.tsx new file mode 100644 index 0000000000000..4814bc8d8630a --- /dev/null +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/chart_creation_info.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import moment from 'moment'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFlexGroup, EuiFlexItem, EuiText, EuiSpacer } from '@elastic/eui'; +import type { ChartTimeRange } from './last_updated'; + +export function ChartCreationInfo(props: Partial) { + const dateFormat = 'lll'; + const from = moment(props.from).format(dateFormat); + const to = moment(props.to).format(dateFormat); + const created = moment(props.lastUpdated).format(dateFormat); + + return ( + <> + {props.lastUpdated && ( + <> + + + + + + + + {created} + + + + + )} + {props.to && props.from && ( + <> + + + + + + + + + {from} → {to} + + + + + )} + + ); +} diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx index 181c8342b87af..22245f111293c 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/header.tsx @@ -10,16 +10,17 @@ import { i18n } from '@kbn/i18n'; import { EuiBetaBadge, EuiButton, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; import { TypedLensByValueInput } from '../../../../../../lens/public'; import { useSeriesStorage } from '../hooks/use_series_storage'; -import { LastUpdated } from './last_updated'; import { ExpViewActionMenu } from '../components/action_menu'; import { useExpViewTimeRange } from '../hooks/use_time_range'; +import { LastUpdated } from './last_updated'; +import type { ChartTimeRange } from './last_updated'; interface Props { - lastUpdated?: number; + chartTimeRange?: ChartTimeRange; lensAttributes: TypedLensByValueInput['attributes'] | null; } -export function ExploratoryViewHeader({ lensAttributes, lastUpdated }: Props) { +export function ExploratoryViewHeader({ lensAttributes, chartTimeRange }: Props) { const { setLastRefresh } = useSeriesStorage(); const timeRange = useExpViewTimeRange(); @@ -46,7 +47,7 @@ export function ExploratoryViewHeader({ lensAttributes, lastUpdated }: Props) { - + setLastRefresh(Date.now())}> diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/last_updated.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/last_updated.tsx index c352ec0423dd8..bc82c48214a01 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/header/last_updated.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/header/last_updated.tsx @@ -6,14 +6,24 @@ */ import React, { useEffect, useState } from 'react'; -import { EuiIcon, EuiText } from '@elastic/eui'; import moment from 'moment'; +import styled from 'styled-components'; +import { EuiIcon, EuiText, EuiToolTip } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ChartCreationInfo } from './chart_creation_info'; + +export interface ChartTimeRange { + lastUpdated: number; + to: number; + from: number; +} interface Props { - lastUpdated?: number; + chartTimeRange?: ChartTimeRange; } -export function LastUpdated({ lastUpdated }: Props) { + +export function LastUpdated({ chartTimeRange }: Props) { + const { lastUpdated } = chartTimeRange || {}; const [refresh, setRefresh] = useState(() => Date.now()); useEffect(() => { @@ -39,7 +49,13 @@ export function LastUpdated({ lastUpdated }: Props) { return ( - + } + > + + {' '} ); } + +export const StyledToolTipWrapper = styled.div` + min-width: 30vw; +`; diff --git a/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx b/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx index 235790e72862c..b3ec7ee184f00 100644 --- a/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx +++ b/x-pack/plugins/observability/public/components/shared/exploratory_view/lens_embeddable.tsx @@ -13,14 +13,16 @@ import { useSeriesStorage } from './hooks/use_series_storage'; import { ObservabilityPublicPluginsStart } from '../../../plugin'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useExpViewTimeRange } from './hooks/use_time_range'; +import { parseRelativeDate } from './components/date_range_picker'; +import type { ChartTimeRange } from './header/last_updated'; interface Props { lensAttributes: TypedLensByValueInput['attributes']; - setLastUpdated: Dispatch>; + setChartTimeRangeContext: Dispatch>; } export function LensEmbeddable(props: Props) { - const { lensAttributes, setLastUpdated } = props; + const { lensAttributes, setChartTimeRangeContext } = props; const { services: { lens, notifications }, @@ -35,8 +37,12 @@ export function LensEmbeddable(props: Props) { const timeRange = useExpViewTimeRange(); const onLensLoad = useCallback(() => { - setLastUpdated(Date.now()); - }, [setLastUpdated]); + setChartTimeRangeContext({ + lastUpdated: Date.now(), + to: parseRelativeDate(timeRange?.to || '').valueOf(), + from: parseRelativeDate(timeRange?.from || '').valueOf(), + }); + }, [setChartTimeRangeContext, timeRange]); const onBrushEnd = useCallback( ({ range }: { range: number[] }) => { diff --git a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx index 789953258750b..26f9e28101ea4 100644 --- a/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx +++ b/x-pack/plugins/uptime/public/components/common/header/action_menu_content.tsx @@ -51,7 +51,7 @@ export function ActionMenuContent(): React.ReactElement { allSeries: [ { dataType: 'synthetics', - seriesType: 'area_stacked', + seriesType: 'area', selectedMetricField: 'monitor.duration.us', time: { from: dateRangeStart, to: dateRangeEnd }, breakdown: monitorId ? 'observer.geo.name' : 'monitor.type', From 4ff3cb46f612107745f84b582ba9b67d550d5c7f Mon Sep 17 00:00:00 2001 From: Dmitry Tomashevich <39378793+Dmitriynj@users.noreply.github.com> Date: Wed, 20 Oct 2021 10:34:03 +0300 Subject: [PATCH 07/23] [Discover] Fix search highlighting of expanded document (#114884) * [Discover] fix searches highlighting of expanded document * [Discover] apply suggestion * [Discover] apply suggestion Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/discover_grid/discover_grid_flyout.tsx | 4 +++- .../application/components/doc_viewer/doc_viewer_tab.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx index e4b67c49689ab..f6e5e25f284ca 100644 --- a/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx +++ b/src/plugins/discover/public/application/components/discover_grid/discover_grid_flyout.tsx @@ -70,6 +70,8 @@ export function DiscoverGridFlyout({ services, setExpandedDoc, }: Props) { + // Get actual hit with updated highlighted searches + const actualHit = useMemo(() => hits?.find(({ _id }) => _id === hit?._id) || hit, [hit, hits]); const pageCount = useMemo(() => (hits ? hits.length : 0), [hits]); const activePage = useMemo(() => { const id = getDocFingerprintId(hit); @@ -188,7 +190,7 @@ export function DiscoverGridFlyout({ { diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx index e2af88b91b3ff..75ec5a62e9299 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_tab.tsx @@ -45,6 +45,7 @@ export class DocViewerTab extends React.Component { shouldComponentUpdate(nextProps: Props, nextState: State) { return ( nextProps.renderProps.hit._id !== this.props.renderProps.hit._id || + !isEqual(nextProps.renderProps.hit.highlight, this.props.renderProps.hit.highlight) || nextProps.id !== this.props.id || !isEqual(nextProps.renderProps.columns, this.props.renderProps.columns) || nextState.hasError From 7fe9573c00af2d8ec6f856f71459e315fd799eef Mon Sep 17 00:00:00 2001 From: Artem Shelkovnikov Date: Wed, 20 Oct 2021 13:25:26 +0500 Subject: [PATCH 08/23] Enterprise Search - Add note about Cases to Salesforce Content Source (#114457) * Enterprise Search - Add note about Cases to Salesforce Content Source * Minor copy update for xpack.enterpriseSearch.workplaceSearch.sources.objTypes.cases Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../public/applications/workplace_search/constants.ts | 3 +++ .../workplace_search/views/content_sources/source_data.tsx | 2 ++ 2 files changed, 5 insertions(+) diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts index a43fb6f293457..a0df5337b2e2e 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/constants.ts @@ -288,6 +288,9 @@ export const SOURCE_OBJ_TYPES = { CAMPAIGNS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.objTypes.campaigns', { defaultMessage: 'Campaigns', }), + CASES: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.objTypes.cases', { + defaultMessage: 'Cases (including feeds and comments)', + }), USERS: i18n.translate('xpack.enterpriseSearch.workplaceSearch.sources.objTypes.users', { defaultMessage: 'Users', }), diff --git a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx index c303190651f57..244ce79135cab 100644 --- a/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/workplace_search/views/content_sources/source_data.tsx @@ -432,6 +432,7 @@ export const staticSourceData = [ SOURCE_OBJ_TYPES.LEADS, SOURCE_OBJ_TYPES.ACCOUNTS, SOURCE_OBJ_TYPES.CAMPAIGNS, + SOURCE_OBJ_TYPES.CASES, ], features: { basicOrgContext: [ @@ -466,6 +467,7 @@ export const staticSourceData = [ SOURCE_OBJ_TYPES.LEADS, SOURCE_OBJ_TYPES.ACCOUNTS, SOURCE_OBJ_TYPES.CAMPAIGNS, + SOURCE_OBJ_TYPES.CASES, ], features: { basicOrgContext: [ From 8fcfa79e73666bd59a5d28ba93739596a75a1367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Wed, 20 Oct 2021 10:29:23 +0200 Subject: [PATCH 09/23] Fix ESLint example (#115553) --- x-pack/plugins/apm/dev_docs/linting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/apm/dev_docs/linting.md b/x-pack/plugins/apm/dev_docs/linting.md index 7db7053e59061..edf3e813a88e9 100644 --- a/x-pack/plugins/apm/dev_docs/linting.md +++ b/x-pack/plugins/apm/dev_docs/linting.md @@ -17,5 +17,5 @@ yarn prettier "./x-pack/plugins/apm/**/*.{tsx,ts,js}" --write ### ESLint ``` -node scripts/eslint.js x-pack/legacy/plugins/apm +node scripts/eslint.js x-pack/plugins/apm ``` From 6e7dfcd99ae3109dc22c7a7f6b7d4cb84261f531 Mon Sep 17 00:00:00 2001 From: Thom Heymann <190132+thomheymann@users.noreply.github.com> Date: Wed, 20 Oct 2021 10:22:04 +0100 Subject: [PATCH 10/23] Fix maps font path (#115453) * fixed font path * fix functional tests * fix directory issue Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/maps/server/routes.js | 5 ++-- .../api_integration/apis/maps/fonts_api.js | 30 ++++++++++++++++--- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/maps/server/routes.js b/x-pack/plugins/maps/server/routes.js index a25a28c4da21c..162c305a69ca5 100644 --- a/x-pack/plugins/maps/server/routes.js +++ b/x-pack/plugins/maps/server/routes.js @@ -488,10 +488,11 @@ export async function initRoutes(core, getLicenseId, emsSettings, kbnVersion, lo }, (context, request, response) => { const range = path.normalize(request.params.range); - return range.startsWith('..') + const rootPath = path.resolve(__dirname, 'fonts', 'open_sans'); + const fontPath = path.resolve(rootPath, `${range}.pbf`); + return !fontPath.startsWith(rootPath) ? response.notFound() : new Promise((resolve) => { - const fontPath = path.join(__dirname, 'fonts', 'open_sans', `${range}.pbf`); fs.readFile(fontPath, (error, data) => { if (error) { resolve(response.notFound()); diff --git a/x-pack/test/api_integration/apis/maps/fonts_api.js b/x-pack/test/api_integration/apis/maps/fonts_api.js index afde003b05f2d..35017e6e37db8 100644 --- a/x-pack/test/api_integration/apis/maps/fonts_api.js +++ b/x-pack/test/api_integration/apis/maps/fonts_api.js @@ -6,11 +6,33 @@ */ import expect from '@kbn/expect'; +import path from 'path'; +import { copyFile, rm } from 'fs/promises'; export default function ({ getService }) { const supertest = getService('supertest'); + const log = getService('log'); describe('fonts', () => { + // [HACK]: On CI tests are run from the different directories than the built and running Kibana + // instance. To workaround that we use Kibana `process.cwd()` to construct font path manually. + // x-pack tests can be run from root directory or from within x-pack so need to cater for both possibilities. + const fontPath = path.join( + process.cwd().replace(/x-pack.*$/, ''), + 'x-pack/plugins/maps/server/fonts/open_sans/0-255.pbf' + ); + const destinationPath = path.join(path.dirname(fontPath), '..', path.basename(fontPath)); + + before(async () => { + log.debug(`Copying test file from '${fontPath}' to '${destinationPath}'`); + await copyFile(fontPath, destinationPath); + }); + + after(async () => { + log.debug(`Removing test file '${destinationPath}'`); + await rm(destinationPath); + }); + it('should return fonts', async () => { const resp = await supertest .get(`/api/maps/fonts/Open%20Sans%20Regular,Arial%20Unicode%20MS%20Regular/0-255`) @@ -25,12 +47,12 @@ export default function ({ getService }) { .expect(404); }); - it('should return 404 when file is not in font folder (../)', async () => { - await supertest.get(`/api/maps/fonts/open_sans/..%2fopen_sans%2f0-255`).expect(404); + it('should return 404 when file is not in font folder (..)', async () => { + await supertest.get(`/api/maps/fonts/open_sans/..%2f0-255`).expect(404); }); - it('should return 404 when file is not in font folder (./../)', async () => { - await supertest.get(`/api/maps/fonts/open_sans/.%2f..%2fopen_sans%2f0-255`).expect(404); + it('should return 404 when file is not in font folder (./..)', async () => { + await supertest.get(`/api/maps/fonts/open_sans/.%2f..%2f0-255`).expect(404); }); }); } From cea4504c873b3710c1a533b378e10d78345fb052 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ester=20Mart=C3=AD=20Vilaseca?= Date: Wed, 20 Oct 2021 11:22:18 +0200 Subject: [PATCH 11/23] [Stack monitoring] Setup mode cleaning post migration (#115556) * Update tests for setup mode * Remove old setup mode renderer and replace it with the react one * Remove unnecessary await * Update tests for setup mode renderer --- .../application/pages/apm/instances.tsx | 2 +- .../application/pages/beats/instances.tsx | 2 +- .../pages/cluster/overview_page.tsx | 2 +- .../pages/elasticsearch/ccr_page.tsx | 2 +- .../pages/elasticsearch/ccr_shard_page.tsx | 2 +- .../elasticsearch/index_advanced_page.tsx | 2 +- .../pages/elasticsearch/index_page.tsx | 2 +- .../pages/elasticsearch/indices_page.tsx | 2 +- .../pages/elasticsearch/ml_jobs_page.tsx | 2 +- .../pages/elasticsearch/node_page.tsx | 2 +- .../pages/elasticsearch/nodes_page.tsx | 2 +- .../application/pages/kibana/instances.tsx | 2 +- .../application/pages/logstash/nodes.tsx | 2 +- .../public/application/route_init.tsx | 2 +- .../public/application/setup_mode/index.ts | 8 - .../setup_mode/setup_mode_renderer.js | 225 ---------------- .../renderers/setup_mode.d.ts} | 0 .../public/components/renderers/setup_mode.js | 22 +- .../components/renderers/setup_mode.test.js | 60 ++--- .../monitoring/public/lib/setup_mode.test.js | 242 ++++++++++-------- 20 files changed, 195 insertions(+), 390 deletions(-) delete mode 100644 x-pack/plugins/monitoring/public/application/setup_mode/index.ts delete mode 100644 x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js rename x-pack/plugins/monitoring/public/{application/setup_mode/setup_mode_renderer.d.ts => components/renderers/setup_mode.d.ts} (100%) diff --git a/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx index fedb07fa65a40..2543b054ee5bb 100644 --- a/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx @@ -15,7 +15,7 @@ import { useTable } from '../../hooks/use_table'; import { ApmTemplate } from './apm_template'; // @ts-ignore import { ApmServerInstances } from '../../../components/apm/instances'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; import { APM_SYSTEM_ID } from '../../../../common/constants'; diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx index 4611f17159621..b33789f510f2e 100644 --- a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx @@ -15,7 +15,7 @@ import { useTable } from '../../hooks/use_table'; import { BeatsTemplate } from './beats_template'; // @ts-ignore import { Listing } from '../../../components/beats/listing'; -import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; import { BEATS_SYSTEM_ID } from '../../../../common/constants'; diff --git a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx index 04074762c8d22..2ffbc3a75ce05 100644 --- a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx @@ -14,7 +14,7 @@ import { GlobalStateContext } from '../../contexts/global_state_context'; import { TabMenuItem } from '../page_template'; import { Overview } from '../../../components/cluster/overview'; import { ExternalConfigContext } from '../../contexts/external_config_context'; -import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; import { fetchClusters } from '../../../lib/fetch_clusters'; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx index cb37705c959aa..2ab18331d1cdb 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx @@ -13,7 +13,7 @@ import { GlobalStateContext } from '../../contexts/global_state_context'; // @ts-ignore import { Ccr } from '../../../components/elasticsearch/ccr'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { AlertsByName } from '../../../alerts/types'; import { fetchAlerts } from '../../../lib/fetch_alerts'; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx index 29cf9ade8d997..2ded26df16323 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx @@ -15,7 +15,7 @@ import { GlobalStateContext } from '../../contexts/global_state_context'; // @ts-ignore import { CcrShardReact } from '../../../components/elasticsearch/ccr_shard'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { AlertsByName } from '../../../alerts/types'; import { fetchAlerts } from '../../../lib/fetch_alerts'; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx index f2f2ec36b7cd9..c51027636b287 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx @@ -11,7 +11,7 @@ import { useParams } from 'react-router-dom'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useCharts } from '../../hooks/use_charts'; import { ItemTemplate } from './item_template'; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx index 8e70a99e67914..422f051c7d718 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_page.tsx @@ -13,7 +13,7 @@ import { GlobalStateContext } from '../../contexts/global_state_context'; // @ts-ignore import { IndexReact } from '../../../components/elasticsearch/index/index_react'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useCharts } from '../../hooks/use_charts'; import { ItemTemplate } from './item_template'; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx index 277bde2ac35cb..6618db7eebe66 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/indices_page.tsx @@ -12,7 +12,7 @@ import { useKibana } from '../../../../../../../src/plugins/kibana_react/public' import { GlobalStateContext } from '../../contexts/global_state_context'; import { ElasticsearchIndices } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useTable } from '../../hooks/use_table'; import { useLocalStorage } from '../../hooks/use_local_storage'; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx index b97007f1c1462..46bb4cc20242f 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ml_jobs_page.tsx @@ -12,7 +12,7 @@ import { useKibana } from '../../../../../../../src/plugins/kibana_react/public' import { GlobalStateContext } from '../../contexts/global_state_context'; import { ElasticsearchMLJobs } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useTable } from '../../hooks/use_table'; import type { MLJobs } from '../../../types'; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx index b2d6fb94183ec..a75c8447a3561 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/node_page.tsx @@ -13,7 +13,7 @@ import { useKibana } from '../../../../../../../src/plugins/kibana_react/public' import { GlobalStateContext } from '../../contexts/global_state_context'; import { NodeReact } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useLocalStorage } from '../../hooks/use_local_storage'; import { useCharts } from '../../hooks/use_charts'; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/nodes_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/nodes_page.tsx index ac7e267cbc9ac..9933188b887d5 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/nodes_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/nodes_page.tsx @@ -13,7 +13,7 @@ import { GlobalStateContext } from '../../contexts/global_state_context'; import { ExternalConfigContext } from '../../contexts/external_config_context'; import { ElasticsearchNodes } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; -import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useTable } from '../../hooks/use_table'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx index 076e9413216fb..a27c1418eabc1 100644 --- a/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx @@ -16,7 +16,7 @@ import { KibanaTemplate } from './kibana_template'; // @ts-ignore import { KibanaInstances } from '../../../components/kibana/instances'; // @ts-ignore -import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer, SetupModeProps } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; import { AlertsByName } from '../../../alerts/types'; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx index 0fd10a93bcd83..447a7b1792fb9 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx @@ -13,7 +13,7 @@ import { ComponentProps } from '../../route_init'; // @ts-ignore import { Listing } from '../../../components/logstash/listing'; import { LogstashTemplate } from './logstash_template'; -import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; +import { SetupModeRenderer } from '../../../components/renderers/setup_mode'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useTable } from '../../hooks/use_table'; import { LOGSTASH_SYSTEM_ID, RULE_LOGSTASH_VERSION_MISMATCH } from '../../../../common/constants'; diff --git a/x-pack/plugins/monitoring/public/application/route_init.tsx b/x-pack/plugins/monitoring/public/application/route_init.tsx index 52780aa280707..92def5b2c36f2 100644 --- a/x-pack/plugins/monitoring/public/application/route_init.tsx +++ b/x-pack/plugins/monitoring/public/application/route_init.tsx @@ -9,7 +9,7 @@ import { Route, Redirect, useLocation } from 'react-router-dom'; import { useClusters } from './hooks/use_clusters'; import { GlobalStateContext } from './contexts/global_state_context'; import { getClusterFromClusters } from '../lib/get_cluster_from_clusters'; -import { isInSetupMode } from './setup_mode'; +import { isInSetupMode } from '../lib/setup_mode'; import { LoadingPage } from './pages/loading_page'; export interface ComponentProps { diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/index.ts b/x-pack/plugins/monitoring/public/application/setup_mode/index.ts deleted file mode 100644 index 57d734fc6d056..0000000000000 --- a/x-pack/plugins/monitoring/public/application/setup_mode/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export * from '../../lib/setup_mode'; diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js deleted file mode 100644 index df524fa99ae53..0000000000000 --- a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js +++ /dev/null @@ -1,225 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { Fragment } from 'react'; -import { - getSetupModeState, - initSetupModeState, - updateSetupModeData, - disableElasticsearchInternalCollection, - toggleSetupMode, - setSetupModeMenuItem, -} from '../../lib/setup_mode'; -import { Flyout } from '../../components/metricbeat_migration/flyout'; -import { - EuiBottomBar, - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiTextColor, - EuiIcon, - EuiSpacer, -} from '@elastic/eui'; -import { findNewUuid } from '../../components/renderers/lib/find_new_uuid'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { GlobalStateContext } from '../../application/contexts/global_state_context'; -import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; -import { useRequestErrorHandler } from '../hooks/use_request_error_handler'; - -class WrappedSetupModeRenderer extends React.Component { - globalState; - state = { - renderState: false, - isFlyoutOpen: false, - instance: null, - newProduct: null, - isSettingUpNew: false, - }; - - UNSAFE_componentWillMount() { - this.globalState = this.context; - const { kibana, onHttpError } = this.props; - initSetupModeState(this.globalState, kibana.services.http, onHttpError, (_oldData) => { - const newState = { renderState: true }; - - const { productName } = this.props; - if (!productName) { - this.setState(newState); - return; - } - - const setupModeState = getSetupModeState(); - if (!setupModeState.enabled || !setupModeState.data) { - this.setState(newState); - return; - } - - const data = setupModeState.data[productName]; - const oldData = _oldData ? _oldData[productName] : null; - if (data && oldData) { - const newUuid = findNewUuid(Object.keys(oldData.byUuid), Object.keys(data.byUuid)); - if (newUuid) { - newState.newProduct = data.byUuid[newUuid]; - } - } - - this.setState(newState); - }); - setSetupModeMenuItem(); - } - - reset() { - this.setState({ - renderState: false, - isFlyoutOpen: false, - instance: null, - newProduct: null, - isSettingUpNew: false, - }); - } - - getFlyout(data, meta) { - const { productName } = this.props; - const { isFlyoutOpen, instance, isSettingUpNew, newProduct } = this.state; - if (!data || !isFlyoutOpen) { - return null; - } - - let product = null; - if (newProduct) { - product = newProduct; - } - // For new instance discovery flow, we pass in empty instance object - else if (instance && Object.keys(instance).length) { - product = data.byUuid[instance.uuid]; - } - - if (!product) { - const uuids = Object.values(data.byUuid); - if (uuids.length && !isSettingUpNew) { - product = uuids[0]; - } else { - product = { - isNetNewUser: true, - }; - } - } - - return ( - this.reset()} - productName={productName} - product={product} - meta={meta} - instance={instance} - updateProduct={updateSetupModeData} - isSettingUpNew={isSettingUpNew} - /> - ); - } - - getBottomBar(setupModeState) { - if (!setupModeState.enabled || setupModeState.hideBottomBar) { - return null; - } - - return ( - - - - - - - - - , - }} - /> - - - - - - - - toggleSetupMode(false)} - > - {i18n.translate('xpack.monitoring.setupMode.exit', { - defaultMessage: `Exit setup mode`, - })} - - - - - - - - ); - } - - async shortcutToFinishMigration() { - await disableElasticsearchInternalCollection(); - await updateSetupModeData(); - } - - render() { - const { render, productName } = this.props; - const setupModeState = getSetupModeState(); - - let data = { byUuid: {} }; - if (setupModeState.data) { - if (productName && setupModeState.data[productName]) { - data = setupModeState.data[productName]; - } else if (setupModeState.data) { - data = setupModeState.data; - } - } - - const meta = setupModeState.data ? setupModeState.data._meta : null; - - return render({ - setupMode: { - data, - meta, - enabled: setupModeState.enabled, - productName, - updateSetupModeData, - shortcutToFinishMigration: () => this.shortcutToFinishMigration(), - openFlyout: (instance, isSettingUpNew) => - this.setState({ isFlyoutOpen: true, instance, isSettingUpNew }), - closeFlyout: () => this.setState({ isFlyoutOpen: false }), - }, - flyoutComponent: this.getFlyout(data, meta), - bottomBarComponent: this.getBottomBar(setupModeState), - }); - } -} - -function withErrorHandler(Component) { - return function WrappedComponent(props) { - const handleRequestError = useRequestErrorHandler(); - return ; - }; -} - -WrappedSetupModeRenderer.contextType = GlobalStateContext; -export const SetupModeRenderer = withKibana(withErrorHandler(WrappedSetupModeRenderer)); diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts b/x-pack/plugins/monitoring/public/components/renderers/setup_mode.d.ts similarity index 100% rename from x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.d.ts rename to x-pack/plugins/monitoring/public/components/renderers/setup_mode.d.ts diff --git a/x-pack/plugins/monitoring/public/components/renderers/setup_mode.js b/x-pack/plugins/monitoring/public/components/renderers/setup_mode.js index 573f7e7e33c5e..cfa57559d2bc9 100644 --- a/x-pack/plugins/monitoring/public/components/renderers/setup_mode.js +++ b/x-pack/plugins/monitoring/public/components/renderers/setup_mode.js @@ -27,8 +27,12 @@ import { import { findNewUuid } from './lib/find_new_uuid'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { GlobalStateContext } from '../../application/contexts/global_state_context'; +import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { useRequestErrorHandler } from '../../application/hooks/use_request_error_handler'; -export class SetupModeRenderer extends React.Component { +export class WrappedSetupModeRenderer extends React.Component { + globalState; state = { renderState: false, isFlyoutOpen: false, @@ -38,9 +42,11 @@ export class SetupModeRenderer extends React.Component { }; UNSAFE_componentWillMount() { - const { scope, injector } = this.props; - initSetupModeState(scope, injector, (_oldData) => { + this.globalState = this.context; + const { kibana, onHttpError } = this.props; + initSetupModeState(this.globalState, kibana.services.http, onHttpError, (_oldData) => { const newState = { renderState: true }; + const { productName } = this.props; if (!productName) { this.setState(newState); @@ -207,3 +213,13 @@ export class SetupModeRenderer extends React.Component { }); } } + +function withErrorHandler(Component) { + return function WrappedComponent(props) { + const handleRequestError = useRequestErrorHandler(); + return ; + }; +} + +WrappedSetupModeRenderer.contextType = GlobalStateContext; +export const SetupModeRenderer = withKibana(withErrorHandler(WrappedSetupModeRenderer)); diff --git a/x-pack/plugins/monitoring/public/components/renderers/setup_mode.test.js b/x-pack/plugins/monitoring/public/components/renderers/setup_mode.test.js index cb1593d084366..9672da8b8088a 100644 --- a/x-pack/plugins/monitoring/public/components/renderers/setup_mode.test.js +++ b/x-pack/plugins/monitoring/public/components/renderers/setup_mode.test.js @@ -9,6 +9,14 @@ import React, { Fragment } from 'react'; import { shallow } from 'enzyme'; import { ELASTICSEARCH_SYSTEM_ID } from '../../../common/constants'; +const kibanaMock = { + services: { + http: jest.fn(), + }, +}; + +const onHttpErrorMock = jest.fn(); + describe('SetupModeRenderer', () => { beforeEach(() => jest.resetModules()); @@ -21,16 +29,14 @@ describe('SetupModeRenderer', () => { updateSetupModeData: () => {}, setSetupModeMenuItem: () => {}, })); - const SetupModeRenderer = require('./setup_mode').SetupModeRenderer; + const SetupModeRenderer = require('./setup_mode').WrappedSetupModeRenderer; const ChildComponent = () =>

Hi

; - const scope = {}; - const injector = {}; const component = shallow( ( {flyoutComponent} @@ -57,16 +63,14 @@ describe('SetupModeRenderer', () => { updateSetupModeData: () => {}, setSetupModeMenuItem: () => {}, })); - const SetupModeRenderer = require('./setup_mode').SetupModeRenderer; + const SetupModeRenderer = require('./setup_mode').WrappedSetupModeRenderer; const ChildComponent = () =>

Hi

; - const scope = {}; - const injector = {}; const component = shallow( ( {flyoutComponent} @@ -95,16 +99,14 @@ describe('SetupModeRenderer', () => { updateSetupModeData: () => {}, setSetupModeMenuItem: () => {}, })); - const SetupModeRenderer = require('./setup_mode').SetupModeRenderer; + const SetupModeRenderer = require('./setup_mode').WrappedSetupModeRenderer; const ChildComponent = () =>

Hi

; - const scope = {}; - const injector = {}; const component = shallow( ( {flyoutComponent} @@ -135,16 +137,14 @@ describe('SetupModeRenderer', () => { updateSetupModeData: () => {}, setSetupModeMenuItem: () => {}, })); - const SetupModeRenderer = require('./setup_mode').SetupModeRenderer; + const SetupModeRenderer = require('./setup_mode').WrappedSetupModeRenderer; const ChildComponent = () =>

Hi

; - const scope = {}; - const injector = {}; const component = shallow( ( {flyoutComponent} @@ -176,7 +176,7 @@ describe('SetupModeRenderer', () => { _meta: {}, }, }), - initSetupModeState: (_scope, _injectir, cb) => { + initSetupModeState: (_globalState, _httpService, _onError, cb) => { setTimeout(() => { cb({ elasticsearch: { @@ -190,16 +190,14 @@ describe('SetupModeRenderer', () => { updateSetupModeData: () => {}, setSetupModeMenuItem: () => {}, })); - const SetupModeRenderer = require('./setup_mode').SetupModeRenderer; + const SetupModeRenderer = require('./setup_mode').WrappedSetupModeRenderer; const ChildComponent = () =>

Hi

; - const scope = {}; - const injector = {}; const component = shallow( ( {flyoutComponent} @@ -235,7 +233,7 @@ describe('SetupModeRenderer', () => { _meta: {}, }, }), - initSetupModeState: (_scope, _injectir, cb) => { + initSetupModeState: (_globalState, _httpService, _onError, cb) => { setTimeout(() => { cb({ elasticsearch: { @@ -249,16 +247,14 @@ describe('SetupModeRenderer', () => { updateSetupModeData: () => {}, setSetupModeMenuItem, })); - const SetupModeRenderer = require('./setup_mode').SetupModeRenderer; + const SetupModeRenderer = require('./setup_mode').WrappedSetupModeRenderer; const ChildComponent = () =>

Hi

; - const scope = {}; - const injector = {}; const component = shallow( ( {flyoutComponent} diff --git a/x-pack/plugins/monitoring/public/lib/setup_mode.test.js b/x-pack/plugins/monitoring/public/lib/setup_mode.test.js index 47cae9c4f0851..bacc305764d68 100644 --- a/x-pack/plugins/monitoring/public/lib/setup_mode.test.js +++ b/x-pack/plugins/monitoring/public/lib/setup_mode.test.js @@ -11,11 +11,8 @@ let getSetupModeState; let updateSetupModeData; let setSetupModeMenuItem; -jest.mock('./ajax_error_handler', () => ({ - ajaxErrorHandlersProvider: (err) => { - throw err; - }, -})); +const handleErrorsMock = jest.fn(); +const callbackMock = jest.fn(); jest.mock('react-dom', () => ({ render: jest.fn(), @@ -25,7 +22,6 @@ jest.mock('../legacy_shims', () => { return { Legacy: { shims: { - getAngularInjector: () => ({ get: () => ({ get: () => 'utc' }) }), toastNotifications: { addDanger: jest.fn(), }, @@ -35,41 +31,8 @@ jest.mock('../legacy_shims', () => { }; }); -let data = {}; - -const injectorModulesMock = { - globalState: { - save: jest.fn(), - }, - Private: (module) => module, - $http: { - post: jest.fn().mockImplementation(() => { - return { data }; - }), - }, - $executor: { - run: jest.fn(), - }, -}; - -const angularStateMock = { - injector: { - get: (module) => { - return injectorModulesMock[module] || {}; - }, - }, - scope: { - $apply: (fn) => fn && fn(), - $evalAsync: (fn) => fn && fn(), - }, -}; - -// We are no longer waiting for setup mode data to be fetched when enabling -// so we need to wait for the next tick for the async action to finish - function setModulesAndMocks() { jest.clearAllMocks().resetModules(); - injectorModulesMock.globalState.inSetupMode = false; const setupMode = require('./setup_mode'); toggleSetupMode = setupMode.toggleSetupMode; @@ -83,53 +46,76 @@ function waitForSetupModeData() { return new Promise((resolve) => process.nextTick(resolve)); } -xdescribe('setup_mode', () => { +describe('setup_mode', () => { beforeEach(async () => { setModulesAndMocks(); }); describe('setup', () => { - it('should require angular state', async () => { - let error; - try { - toggleSetupMode(true); - } catch (err) { - error = err; - } - expect(error.message).toEqual( - 'Unable to interact with setup ' + - 'mode because the angular injector was not previously set. This needs to be ' + - 'set by calling `initSetupModeState`.' - ); - }); - it('should enable toggle mode', async () => { - await initSetupModeState(angularStateMock.scope, angularStateMock.injector); + const globalState = { + inSetupMode: false, + save: jest.fn(), + }; + const httpServiceMock = { + post: jest.fn(), + }; + + await initSetupModeState(globalState, httpServiceMock, handleErrorsMock, callbackMock); toggleSetupMode(true); - expect(injectorModulesMock.globalState.inSetupMode).toBe(true); + expect(globalState.inSetupMode).toBe(true); }); it('should disable toggle mode', async () => { - await initSetupModeState(angularStateMock.scope, angularStateMock.injector); + const globalState = { + inSetupMode: true, + save: jest.fn(), + }; + const httpServiceMock = { + post: jest.fn(), + }; + const handleErrorsMock = jest.fn(); + const callbackMock = jest.fn(); + await initSetupModeState(globalState, httpServiceMock, handleErrorsMock, callbackMock); toggleSetupMode(false); - expect(injectorModulesMock.globalState.inSetupMode).toBe(false); + expect(globalState.inSetupMode).toBe(false); }); it('should set top nav config', async () => { + const globalState = { + inSetupMode: false, + save: jest.fn(), + }; + const httpServiceMock = { + post: jest.fn(), + }; + const render = require('react-dom').render; - await initSetupModeState(angularStateMock.scope, angularStateMock.injector); + + await initSetupModeState(globalState, httpServiceMock, handleErrorsMock, callbackMock); setSetupModeMenuItem(); toggleSetupMode(true); + expect(render.mock.calls.length).toBe(2); }); }); describe('in setup mode', () => { - afterEach(async () => { - data = {}; - }); - it('should not fetch data if the user does not have sufficient permissions', async () => { + const globalState = { + inSetupMode: false, + save: jest.fn(), + }; + const httpServiceMock = { + post: jest.fn().mockReturnValue( + Promise.resolve({ + _meta: { + hasPermissions: false, + }, + }) + ), + }; + const addDanger = jest.fn(); jest.doMock('../legacy_shims', () => ({ Legacy: { @@ -141,13 +127,9 @@ xdescribe('setup_mode', () => { }, }, })); - data = { - _meta: { - hasPermissions: false, - }, - }; + setModulesAndMocks(); - await initSetupModeState(angularStateMock.scope, angularStateMock.injector); + await initSetupModeState(globalState, httpServiceMock, handleErrorsMock, callbackMock); toggleSetupMode(true); await waitForSetupModeData(); @@ -160,78 +142,122 @@ xdescribe('setup_mode', () => { }); it('should set the newly discovered cluster uuid', async () => { + const globalState = { + inSetupMode: false, + cluster_uuid: undefined, + save: jest.fn(), + }; const clusterUuid = '1ajy'; - data = { - _meta: { - liveClusterUuid: clusterUuid, - hasPermissions: true, - }, - elasticsearch: { - byUuid: { - 123: { - isPartiallyMigrated: true, + const httpServiceMock = { + post: jest.fn().mockReturnValue( + Promise.resolve({ + _meta: { + liveClusterUuid: clusterUuid, + hasPermissions: true, }, - }, - }, + elasticsearch: { + byUuid: { + 123: { + isPartiallyMigrated: true, + }, + }, + }, + }) + ), }; - await initSetupModeState(angularStateMock.scope, angularStateMock.injector); + + await initSetupModeState(globalState, httpServiceMock, handleErrorsMock, callbackMock); toggleSetupMode(true); await waitForSetupModeData(); - expect(injectorModulesMock.globalState.cluster_uuid).toBe(clusterUuid); + expect(globalState.cluster_uuid).toBe(clusterUuid); }); it('should fetch data for a given cluster', async () => { const clusterUuid = '1ajy'; - data = { - _meta: { - liveClusterUuid: clusterUuid, - hasPermissions: true, - }, - elasticsearch: { - byUuid: { - 123: { - isPartiallyMigrated: true, + const globalState = { + inSetupMode: false, + cluster_uuid: clusterUuid, + save: jest.fn(), + }; + const httpServiceMock = { + post: jest.fn().mockReturnValue( + Promise.resolve({ + _meta: { + liveClusterUuid: clusterUuid, + hasPermissions: true, }, - }, - }, + elasticsearch: { + byUuid: { + 123: { + isPartiallyMigrated: true, + }, + }, + }, + }) + ), }; - await initSetupModeState(angularStateMock.scope, angularStateMock.injector); + await initSetupModeState(globalState, httpServiceMock, handleErrorsMock, callbackMock); toggleSetupMode(true); await waitForSetupModeData(); - expect(injectorModulesMock.$http.post).toHaveBeenCalledWith( + expect(httpServiceMock.post).toHaveBeenCalledWith( `../api/monitoring/v1/setup/collection/cluster/${clusterUuid}`, - { - ccs: undefined, - } + { body: '{}' } ); }); it('should fetch data for a single node', async () => { - await initSetupModeState(angularStateMock.scope, angularStateMock.injector); + const clusterUuid = '1ajy'; + const globalState = { + inSetupMode: false, + save: jest.fn(), + }; + const httpServiceMock = { + post: jest.fn().mockReturnValue( + Promise.resolve({ + _meta: { + liveClusterUuid: clusterUuid, + hasPermissions: true, + }, + elasticsearch: { + byUuid: { + 123: { + isPartiallyMigrated: true, + }, + }, + }, + }) + ), + }; + + await initSetupModeState(globalState, httpServiceMock, handleErrorsMock, callbackMock); toggleSetupMode(true); await waitForSetupModeData(); - injectorModulesMock.$http.post.mockClear(); await updateSetupModeData('45asd'); - expect(injectorModulesMock.$http.post).toHaveBeenCalledWith( + expect(httpServiceMock.post).toHaveBeenCalledWith( '../api/monitoring/v1/setup/collection/node/45asd', - { - ccs: undefined, - } + { body: '{}' } ); }); it('should fetch data without a cluster uuid', async () => { - initSetupModeState(angularStateMock.scope, angularStateMock.injector); + const globalState = { + inSetupMode: false, + save: jest.fn(), + }; + const httpServiceMock = { + post: jest.fn(), + }; + + await initSetupModeState(globalState, httpServiceMock, handleErrorsMock, callbackMock); await toggleSetupMode(true); - injectorModulesMock.$http.post.mockClear(); await updateSetupModeData(undefined, true); const url = '../api/monitoring/v1/setup/collection/cluster'; - const args = { ccs: undefined }; - expect(injectorModulesMock.$http.post).toHaveBeenCalledWith(url, args); + const args = { body: '{}' }; + expect(httpServiceMock.post).toHaveBeenCalledWith(url, args); }); }); }); From 22e41727810a677ba81de9d28aa91bd3d2ba3c6c Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Wed, 20 Oct 2021 11:23:51 +0200 Subject: [PATCH 12/23] [ML] Functional tests - re-enable transform runtime mappings suite (#115547) This PR stabilizes and re-enables the transform runtime mappings tests. --- test/functional/page_objects/time_picker.ts | 3 ++- .../functional/apps/transform/creation_runtime_mappings.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/test/functional/page_objects/time_picker.ts b/test/functional/page_objects/time_picker.ts index 6cc4fda513ea6..bf2db6c25ad12 100644 --- a/test/functional/page_objects/time_picker.ts +++ b/test/functional/page_objects/time_picker.ts @@ -134,7 +134,7 @@ export class TimePickerPageObject extends FtrService { }); // set from time - await this.retry.waitFor(`endDate is set to ${fromTime}`, async () => { + await this.retry.waitFor(`startDate is set to ${fromTime}`, async () => { await this.testSubjects.click('superDatePickerstartDatePopoverButton'); await this.waitPanelIsGone(panel); panel = await this.getTimePickerPanel(); @@ -150,6 +150,7 @@ export class TimePickerPageObject extends FtrService { }); await this.retry.waitFor('Timepicker popover to close', async () => { + await this.browser.pressKeys(this.browser.keys.ESCAPE); return !(await this.testSubjects.exists('superDatePickerAbsoluteDateInput')); }); diff --git a/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts b/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts index e244c907a76d6..a0b3c636a2f1a 100644 --- a/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts +++ b/x-pack/test/functional/apps/transform/creation_runtime_mappings.ts @@ -34,8 +34,7 @@ export default function ({ getService }: FtrProviderContext) { }, }; - // FLAKY https://github.com/elastic/kibana/issues/113890 - describe.skip('creation with runtime mappings', function () { + describe('creation with runtime mappings', function () { before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); await transform.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp'); From f69fa6a8a2398a72234ed98b4785bc46a1595998 Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Wed, 20 Oct 2021 11:25:46 +0200 Subject: [PATCH 13/23] [ML] Functional tests - adjust test retries and waiting conditions (#115592) This PR fixes a few test stability issues, mostly by adding/adjusting retries. * Stabilize data viz full time range selection * Stabilize DFA wizard source data loading * Stabilize feature importance service methods * Stabilize DFA table row existence assertion * Stabilize dashboard embeddable service methods --- .../services/ml/dashboard_embeddables.ts | 10 ++++----- .../ml/data_frame_analytics_creation.ts | 5 ++++- .../ml/data_frame_analytics_results.ts | 22 ++++++++++--------- .../services/ml/data_frame_analytics_table.ts | 5 +++++ .../ml/data_visualizer_index_based.ts | 7 ++++-- x-pack/test/functional/services/ml/index.ts | 1 - 6 files changed, 30 insertions(+), 20 deletions(-) diff --git a/x-pack/test/functional/services/ml/dashboard_embeddables.ts b/x-pack/test/functional/services/ml/dashboard_embeddables.ts index c4fa9f643e69e..0dc5cc8fae2d5 100644 --- a/x-pack/test/functional/services/ml/dashboard_embeddables.ts +++ b/x-pack/test/functional/services/ml/dashboard_embeddables.ts @@ -7,12 +7,10 @@ import expect from '@kbn/expect'; import { FtrProviderContext } from '../../ftr_provider_context'; -import { MlCommonUI } from './common_ui'; import { MlDashboardJobSelectionTable } from './dashboard_job_selection_table'; export function MachineLearningDashboardEmbeddablesProvider( { getService }: FtrProviderContext, - mlCommonUI: MlCommonUI, mlDashboardJobSelectionTable: MlDashboardJobSelectionTable ) { const retry = getService('retry'); @@ -22,14 +20,14 @@ export function MachineLearningDashboardEmbeddablesProvider( return { async assertAnomalyChartsEmbeddableInitializerExists() { - await retry.tryForTime(5000, async () => { - await testSubjects.existOrFail('mlAnomalyChartsEmbeddableInitializer'); + await retry.tryForTime(10 * 1000, async () => { + await testSubjects.existOrFail('mlAnomalyChartsEmbeddableInitializer', { timeout: 1000 }); }); }, async assertAnomalyChartsEmbeddableInitializerNotExists() { - await retry.tryForTime(5000, async () => { - await testSubjects.missingOrFail('mlAnomalyChartsEmbeddableInitializer'); + await retry.tryForTime(10 * 1000, async () => { + await testSubjects.missingOrFail('mlAnomalyChartsEmbeddableInitializer', { timeout: 1000 }); }); }, diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts index 45ba4c5c34833..2c375d47b0b3b 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts @@ -18,10 +18,11 @@ import { } from '../../../../plugins/ml/common/util/analytics_utils'; export function MachineLearningDataFrameAnalyticsCreationProvider( - { getService }: FtrProviderContext, + { getPageObject, getService }: FtrProviderContext, mlCommonUI: MlCommonUI, mlApi: MlApi ) { + const headerPage = getPageObject('header'); const testSubjects = getService('testSubjects'); const comboBox = getService('comboBox'); const retry = getService('retry'); @@ -111,10 +112,12 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( }, async assertSourceDataPreviewExists() { + await headerPage.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('mlAnalyticsCreationDataGrid loaded', { timeout: 5000 }); }, async assertIndexPreviewHistogramChartButtonExists() { + await headerPage.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('mlAnalyticsCreationDataGridHistogramButton'); }, diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts index ac728e6b88303..a1bf8c6a65d70 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_results.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_results.ts @@ -73,7 +73,7 @@ export function MachineLearningDataFrameAnalyticsResultsProvider( async assertTotalFeatureImportanceEvaluatePanelExists() { await testSubjects.existOrFail('mlDFExpandableSection-FeatureImportanceSummary'); - await testSubjects.existOrFail('mlTotalFeatureImportanceChart', { timeout: 5000 }); + await testSubjects.existOrFail('mlTotalFeatureImportanceChart', { timeout: 30 * 1000 }); }, async assertFeatureImportanceDecisionPathElementsExists() { @@ -167,17 +167,19 @@ export function MachineLearningDataFrameAnalyticsResultsProvider( async openFeatureImportancePopover() { this.assertResultsTableNotEmpty(); - const featureImportanceCell = await this.getFirstFeatureImportanceCell(); - await featureImportanceCell.focus(); - const interactionButton = await featureImportanceCell.findByTagName('button'); + await retry.tryForTime(30 * 1000, async () => { + const featureImportanceCell = await this.getFirstFeatureImportanceCell(); + await featureImportanceCell.focus(); + const interactionButton = await featureImportanceCell.findByTagName('button'); - // simulate hover and wait for button to appear - await featureImportanceCell.moveMouseTo(); - await this.waitForInteractionButtonToDisplay(interactionButton); + // simulate hover and wait for button to appear + await featureImportanceCell.moveMouseTo(); + await this.waitForInteractionButtonToDisplay(interactionButton); - // open popover - await interactionButton.click(); - await testSubjects.existOrFail('mlDFAFeatureImportancePopover'); + // open popover + await interactionButton.click(); + await testSubjects.existOrFail('mlDFAFeatureImportancePopover', { timeout: 1000 }); + }); }, async getFirstFeatureImportanceCell(): Promise { diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_table.ts b/x-pack/test/functional/services/ml/data_frame_analytics_table.ts index 5850919f2adc3..0bfb37c6c94f8 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_table.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_table.ts @@ -196,6 +196,11 @@ export function MachineLearningDataFrameAnalyticsTableProvider({ getService }: F analyticsId: string, shouldBeDisplayed: boolean ) { + await this.waitForRefreshButtonLoaded(); + await testSubjects.click('~mlAnalyticsRefreshListButton'); + await this.waitForRefreshButtonLoaded(); + await testSubjects.existOrFail('mlAnalyticsJobList', { timeout: 30 * 1000 }); + if (shouldBeDisplayed) { await this.filterWithSearchString(analyticsId, 1); } else { diff --git a/x-pack/test/functional/services/ml/data_visualizer_index_based.ts b/x-pack/test/functional/services/ml/data_visualizer_index_based.ts index 7f32968ec4326..6883946452629 100644 --- a/x-pack/test/functional/services/ml/data_visualizer_index_based.ts +++ b/x-pack/test/functional/services/ml/data_visualizer_index_based.ts @@ -33,8 +33,11 @@ export function MachineLearningDataVisualizerIndexBasedProvider({ }, async clickUseFullDataButton(expectedFormattedTotalDocCount: string) { - await testSubjects.clickWhenNotDisabled('dataVisualizerButtonUseFullData'); - await this.assertTotalDocumentCount(expectedFormattedTotalDocCount); + await retry.tryForTime(30 * 1000, async () => { + await testSubjects.clickWhenNotDisabled('dataVisualizerButtonUseFullData'); + await testSubjects.clickWhenNotDisabled('superDatePickerApplyTimeButton'); + await this.assertTotalDocumentCount(expectedFormattedTotalDocCount); + }); }, async assertTotalDocCountHeaderExist() { diff --git a/x-pack/test/functional/services/ml/index.ts b/x-pack/test/functional/services/ml/index.ts index d50ec371d7c23..17302b2782223 100644 --- a/x-pack/test/functional/services/ml/index.ts +++ b/x-pack/test/functional/services/ml/index.ts @@ -67,7 +67,6 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dashboardJobSelectionTable = MachineLearningDashboardJobSelectionTableProvider(context); const dashboardEmbeddables = MachineLearningDashboardEmbeddablesProvider( context, - commonUI, dashboardJobSelectionTable ); From cdd9d0cdef530d31a235724c9154cc2fd08de997 Mon Sep 17 00:00:00 2001 From: Tre Date: Wed, 20 Oct 2021 05:28:02 -0400 Subject: [PATCH 14/23] [QA][refactor] Use ui settings - saved queries (#114820) --- test/functional/apps/discover/_saved_queries.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/functional/apps/discover/_saved_queries.ts b/test/functional/apps/discover/_saved_queries.ts index 832d895fcea3d..b7d19807e563e 100644 --- a/test/functional/apps/discover/_saved_queries.ts +++ b/test/functional/apps/discover/_saved_queries.ts @@ -28,9 +28,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const setUpQueriesWithFilters = async () => { // set up a query with filters and a time filter log.debug('set up a query with filters to save'); - const fromTime = 'Sep 20, 2015 @ 08:00:00.000'; - const toTime = 'Sep 21, 2015 @ 08:00:00.000'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + const from = 'Sep 20, 2015 @ 08:00:00.000'; + const to = 'Sep 21, 2015 @ 08:00:00.000'; + await PageObjects.common.setTime({ from, to }); + await PageObjects.common.navigateToApp('discover'); await filterBar.addFilter('extension.raw', 'is one of', 'jpg'); await queryBar.setQuery('response:200'); }; @@ -54,6 +55,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await kibanaServer.importExport.unload('test/functional/fixtures/kbn_archiver/discover'); await esArchiver.unload('test/functional/fixtures/es_archiver/date_nested'); await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional'); + await PageObjects.common.unsetTime(); }); describe('saved query selection', () => { From c761909c1e15e68c17f4d067068fa308021f5470 Mon Sep 17 00:00:00 2001 From: Christos Nasikas Date: Wed, 20 Oct 2021 12:28:18 +0300 Subject: [PATCH 15/23] [Cases] Fix connector's cypress test (#115531) --- .../cypress/integration/cases/connectors.spec.ts | 3 +-- .../security_solution/cypress/screens/configure_cases.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts index 69b623de0b43c..287d86c6fba9e 100644 --- a/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/cases/connectors.spec.ts @@ -20,8 +20,7 @@ import { loginAndWaitForPageWithoutDateRange } from '../../tasks/login'; import { CASES_URL } from '../../urls/navigation'; -// Skipping flakey test: https://github.com/elastic/kibana/issues/115438 -describe.skip('Cases connectors', () => { +describe('Cases connectors', () => { const configureResult = { connector: { id: 'e271c3b8-f702-4fbc-98e0-db942b573bbd', diff --git a/x-pack/plugins/security_solution/cypress/screens/configure_cases.ts b/x-pack/plugins/security_solution/cypress/screens/configure_cases.ts index 1014835f81efe..c9ed5299c0336 100644 --- a/x-pack/plugins/security_solution/cypress/screens/configure_cases.ts +++ b/x-pack/plugins/security_solution/cypress/screens/configure_cases.ts @@ -24,7 +24,7 @@ export const SERVICE_NOW_CONNECTOR_CARD = '[data-test-subj=".servicenow-card"]'; export const TOASTER = '[data-test-subj="euiToastHeader"]'; -export const URL = '[data-test-subj="apiUrlFromInput"]'; +export const URL = '[data-test-subj="credentialsApiUrlFromInput"]'; export const USERNAME = '[data-test-subj="connector-servicenow-username-form-input"]'; From d2c6c4104cd7587ef2342fac755ff6eca7092862 Mon Sep 17 00:00:00 2001 From: Brian Seeders Date: Wed, 20 Oct 2021 05:43:24 -0400 Subject: [PATCH 16/23] [buildkite] Add uptime playwright tests to PR pipeline (#115590) --- .buildkite/pipelines/pull_request/uptime.yml | 11 +++++++++++ .../scripts/pipelines/pull_request/pipeline.js | 4 ++++ .buildkite/scripts/steps/functional/common.sh | 2 ++ .buildkite/scripts/steps/functional/uptime.sh | 17 +++++++++++++++++ x-pack/plugins/uptime/e2e/config.ts | 2 +- x-pack/plugins/uptime/e2e/playwright_start.ts | 15 +++++---------- x-pack/plugins/uptime/scripts/e2e.js | 18 +++++++++++++----- 7 files changed, 53 insertions(+), 16 deletions(-) create mode 100644 .buildkite/pipelines/pull_request/uptime.yml create mode 100755 .buildkite/scripts/steps/functional/uptime.sh diff --git a/.buildkite/pipelines/pull_request/uptime.yml b/.buildkite/pipelines/pull_request/uptime.yml new file mode 100644 index 0000000000000..60fdea1add04c --- /dev/null +++ b/.buildkite/pipelines/pull_request/uptime.yml @@ -0,0 +1,11 @@ +steps: + - command: .buildkite/scripts/steps/functional/uptime.sh + label: 'Uptime @elastic/synthetics Tests' + agents: + queue: ci-group-6 + depends_on: build + timeout_in_minutes: 120 + retry: + automatic: + - exit_status: '*' + limit: 1 diff --git a/.buildkite/scripts/pipelines/pull_request/pipeline.js b/.buildkite/scripts/pipelines/pull_request/pipeline.js index 028c90020a0b8..7b5c944d31c1c 100644 --- a/.buildkite/scripts/pipelines/pull_request/pipeline.js +++ b/.buildkite/scripts/pipelines/pull_request/pipeline.js @@ -73,6 +73,10 @@ const uploadPipeline = (pipelineContent) => { // pipeline.push(getPipeline('.buildkite/pipelines/pull_request/apm_cypress.yml')); // } + if (await doAnyChangesMatch([/^x-pack\/plugins\/uptime/])) { + pipeline.push(getPipeline('.buildkite/pipelines/pull_request/uptime.yml')); + } + pipeline.push(getPipeline('.buildkite/pipelines/pull_request/post_build.yml')); uploadPipeline(pipeline.join('\n')); diff --git a/.buildkite/scripts/steps/functional/common.sh b/.buildkite/scripts/steps/functional/common.sh index b60ed835799e5..bedd22c53c7ec 100755 --- a/.buildkite/scripts/steps/functional/common.sh +++ b/.buildkite/scripts/steps/functional/common.sh @@ -2,6 +2,8 @@ set -euo pipefail +# Note, changes here might also need to be made in other scripts, e.g. uptime.sh + source .buildkite/scripts/common/util.sh .buildkite/scripts/bootstrap.sh diff --git a/.buildkite/scripts/steps/functional/uptime.sh b/.buildkite/scripts/steps/functional/uptime.sh new file mode 100755 index 0000000000000..5a59f4dfa48bd --- /dev/null +++ b/.buildkite/scripts/steps/functional/uptime.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source .buildkite/scripts/common/util.sh + +.buildkite/scripts/bootstrap.sh +.buildkite/scripts/download_build_artifacts.sh + +export JOB=kibana-uptime-playwright + +echo "--- Uptime @elastic/synthetics Tests" + +cd "$XPACK_DIR" + +checks-reporter-with-killswitch "Uptime @elastic/synthetics Tests" \ + node plugins/uptime/scripts/e2e.js --kibana-install-dir "$KIBANA_BUILD_LOCATION" diff --git a/x-pack/plugins/uptime/e2e/config.ts b/x-pack/plugins/uptime/e2e/config.ts index 70cc57247d490..c5d573afccd96 100644 --- a/x-pack/plugins/uptime/e2e/config.ts +++ b/x-pack/plugins/uptime/e2e/config.ts @@ -39,7 +39,7 @@ async function config({ readConfigFile }: FtrConfigProviderContext) { '--csp.warnLegacyBrowsers=false', // define custom kibana server args here `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, - `--elasticsearch.ignoreVersionMismatch=true`, + `--elasticsearch.ignoreVersionMismatch=${process.env.CI ? 'false' : 'true'}`, `--uiSettings.overrides.theme:darkMode=true`, `--elasticsearch.username=kibana_system`, `--elasticsearch.password=changeme`, diff --git a/x-pack/plugins/uptime/e2e/playwright_start.ts b/x-pack/plugins/uptime/e2e/playwright_start.ts index aedb255b058be..5949339c1ba25 100644 --- a/x-pack/plugins/uptime/e2e/playwright_start.ts +++ b/x-pack/plugins/uptime/e2e/playwright_start.ts @@ -15,15 +15,10 @@ import './journeys'; export function playwrightRunTests() { return async ({ getService }: any) => { - try { - const result = await playwrightStart(getService); - - if (result && result.uptime.status !== 'succeeded') { - process.exit(1); - } - } catch (error) { - console.error('errors: ', error); - process.exit(1); + const result = await playwrightStart(getService); + + if (result && result.uptime.status !== 'succeeded') { + throw new Error('Tests failed'); } }; } @@ -42,7 +37,7 @@ async function playwrightStart(getService: any) { const res = await playwrightRun({ params: { kibanaUrl }, - playwrightOptions: { chromiumSandbox: false, timeout: 60 * 1000 }, + playwrightOptions: { headless: true, chromiumSandbox: false, timeout: 60 * 1000 }, }); console.log('Removing esArchiver...'); diff --git a/x-pack/plugins/uptime/scripts/e2e.js b/x-pack/plugins/uptime/scripts/e2e.js index e2a8dfaf25c93..e7c0cb612646d 100644 --- a/x-pack/plugins/uptime/scripts/e2e.js +++ b/x-pack/plugins/uptime/scripts/e2e.js @@ -28,9 +28,14 @@ const { argv } = yargs(process.argv.slice(2)) type: 'boolean', description: 'Opens the Playwright Test Runner', }) + .option('kibana-install-dir', { + default: '', + type: 'string', + description: 'Path to the Kibana install directory', + }) .help(); -const { server, runner, open } = argv; +const { server, runner, open, kibanaInstallDir } = argv; const e2eDir = path.join(__dirname, '../e2e'); @@ -44,9 +49,12 @@ if (server) { const config = './playwright_run.ts'; function executeRunner() { - childProcess.execSync(`node ../../../scripts/${ftrScript} --config ${config}`, { - cwd: e2eDir, - stdio: 'inherit', - }); + childProcess.execSync( + `node ../../../scripts/${ftrScript} --config ${config} --kibana-install-dir '${kibanaInstallDir}'`, + { + cwd: e2eDir, + stdio: 'inherit', + } + ); } executeRunner(); From eb5af49de1cafca9204211f61b4ee0695099f11f Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 20 Oct 2021 13:57:12 +0300 Subject: [PATCH 17/23] [TSVB] Fixes the long text problem that appears behind the gauge chart (#115516) --- .../timeseries/public/application/visualizations/views/gauge.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/plugins/vis_types/timeseries/public/application/visualizations/views/gauge.js b/src/plugins/vis_types/timeseries/public/application/visualizations/views/gauge.js index ca5021a882932..0e4322a7ba82c 100644 --- a/src/plugins/vis_types/timeseries/public/application/visualizations/views/gauge.js +++ b/src/plugins/vis_types/timeseries/public/application/visualizations/views/gauge.js @@ -75,6 +75,7 @@ export class Gauge extends Component { top: this.state.top || 0, left: this.state.left || 0, transform: `matrix(${scale}, 0, 0, ${scale}, ${translateX}, ${translateY})`, + zIndex: 1, }, valueColor: { color: this.props.valueColor, From 109e966a7a6fca3c46ccf0132a9ba6027c424d06 Mon Sep 17 00:00:00 2001 From: Jason Stoltzfus Date: Wed, 20 Oct 2021 07:35:25 -0400 Subject: [PATCH 18/23] [App Search] Put History tab behind platinum check (#115636) --- .../curations/views/curations.test.tsx | 17 +++++ .../components/curations/views/curations.tsx | 72 +++++++++++-------- 2 files changed, 58 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx index f7e9f5437fc3f..42d808da6d9ee 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.test.tsx @@ -25,6 +25,7 @@ import { CurationsSettings } from './curations_settings'; describe('Curations', () => { const values = { + // CurationsLogic dataLoading: false, curations: [ { @@ -46,6 +47,8 @@ describe('Curations', () => { }, }, selectedPageTab: 'overview', + // LicensingLogic + hasPlatinumLicense: true, }; const actions = { @@ -75,6 +78,20 @@ describe('Curations', () => { tabs.at(2).simulate('click'); expect(actions.onSelectPageTab).toHaveBeenNthCalledWith(3, 'settings'); + // The settings tab should NOT have an icon next to it + expect(tabs.at(2).prop('prepend')).toBeUndefined(); + }); + + it('renders less tabs when less than platinum license', () => { + setMockValues({ ...values, hasPlatinumLicense: false }); + const wrapper = shallow(); + + expect(getPageTitle(wrapper)).toEqual('Curated results'); + + const tabs = getPageHeaderTabs(wrapper).find(EuiTab); + expect(tabs.length).toBe(2); + // The settings tab should have an icon next to it + expect(tabs.at(1).prop('prepend')).not.toBeUndefined(); }); it('renders an overview view', () => { diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx index c55fde7626488..7440e0cf42b44 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/curations/views/curations.tsx @@ -9,8 +9,10 @@ import React, { useEffect } from 'react'; import { useValues, useActions } from 'kea'; +import { EuiIcon } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { LicensingLogic } from '../../../../shared/licensing'; import { EuiButtonTo } from '../../../../shared/react_router_helpers'; import { ENGINE_CURATIONS_NEW_PATH } from '../../../routes'; @@ -28,39 +30,47 @@ import { CurationsSettings } from './curations_settings'; export const Curations: React.FC = () => { const { dataLoading, curations, meta, selectedPageTab } = useValues(CurationsLogic); const { loadCurations, onSelectPageTab } = useActions(CurationsLogic); + const { hasPlatinumLicense } = useValues(LicensingLogic); - const pageTabs = [ - { - label: i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.curations.overviewPageTabLabel', - { - defaultMessage: 'Overview', - } - ), - isSelected: selectedPageTab === 'overview', - onClick: () => onSelectPageTab('overview'), - }, - { - label: i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.curations.historyPageTabLabel', - { - defaultMessage: 'History', - } - ), - isSelected: selectedPageTab === 'history', - onClick: () => onSelectPageTab('history'), - }, - { - label: i18n.translate( - 'xpack.enterpriseSearch.appSearch.engine.curations.settingsPageTabLabel', + const OVERVIEW_TAB = { + label: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.overviewPageTabLabel', + { + defaultMessage: 'Overview', + } + ), + isSelected: selectedPageTab === 'overview', + onClick: () => onSelectPageTab('overview'), + }; + + const HISTORY_TAB = { + label: i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.historyPageTabLabel', { + defaultMessage: 'History', + }), + isSelected: selectedPageTab === 'history', + onClick: () => onSelectPageTab('history'), + }; + + const SETTINGS_TAB = { + label: i18n.translate( + 'xpack.enterpriseSearch.appSearch.engine.curations.settingsPageTabLabel', + { + defaultMessage: 'Settings', + } + ), + isSelected: selectedPageTab === 'settings', + onClick: () => onSelectPageTab('settings'), + }; + + const pageTabs = hasPlatinumLicense + ? [OVERVIEW_TAB, HISTORY_TAB, SETTINGS_TAB] + : [ + OVERVIEW_TAB, { - defaultMessage: 'Settings', - } - ), - isSelected: selectedPageTab === 'settings', - onClick: () => onSelectPageTab('settings'), - }, - ]; + ...SETTINGS_TAB, + prepend: , + }, + ]; useEffect(() => { loadCurations(); From 73a0fc0948bb4e5cd8bc4b3ccbcb60f9270bc23f Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Wed, 20 Oct 2021 13:56:37 +0200 Subject: [PATCH 19/23] [expressions] decrease bundle size (#114229) --- .../public/actions_and_expressions.tsx | 13 +- .../public/actions_and_expressions2.tsx | 13 +- .../public/render_expressions.tsx | 16 +- .../public/run_expressions.tsx | 28 ++- .../data/public/search/expressions/esaggs.ts | 13 +- .../common/execution/execution_contract.ts | 3 +- .../expressions/common/execution/types.ts | 4 +- .../specs/cumulative_sum.ts | 38 +--- .../specs/cumulative_sum_fn.ts | 43 +++++ .../expression_functions/specs/derivative.ts | 44 +---- .../specs/derivative_fn.ts | 77 ++++++++ .../common/expression_functions/specs/math.ts | 103 +--------- .../expression_functions/specs/math_column.ts | 56 +++--- .../expression_functions/specs/math_fn.ts | 109 +++++++++++ .../specs/moving_average.ts | 44 +---- .../specs/moving_average_fn.ts | 74 +++++++ .../specs/overall_metric.ts | 82 +------- .../specs/overall_metric_fn.ts | 113 +++++++++++ .../specs/tests/cumulative_sum.test.ts | 62 +++--- .../specs/tests/derivative.test.ts | 62 +++--- .../specs/tests/math.test.ts | 182 +++++++++++------- .../specs/tests/math_column.test.ts | 46 +++-- .../specs/tests/moving_average.test.ts | 68 +++---- .../specs/tests/overall_metric.test.ts | 86 ++++----- src/plugins/expressions/public/index.ts | 31 +-- src/plugins/expressions/public/loader.test.ts | 4 +- src/plugins/expressions/public/loader.ts | 8 +- src/plugins/expressions/public/mocks.tsx | 4 +- src/plugins/expressions/public/plugin.ts | 30 ++- .../public/react_expression_renderer.test.tsx | 5 +- .../public/react_expression_renderer.tsx | 10 +- .../react_expression_renderer_wrapper.tsx | 19 ++ src/plugins/expressions/public/render.test.ts | 4 +- src/plugins/expressions/public/render.ts | 25 ++- src/plugins/expressions/public/types/index.ts | 4 + .../public/embeddable/visualize_embeddable.ts | 6 +- .../plugins/kbn_tp_run_pipeline/kibana.json | 2 +- .../public/app/components/main.tsx | 12 +- .../merge_tables/merge_tables.test.ts | 1 + 39 files changed, 873 insertions(+), 671 deletions(-) create mode 100644 src/plugins/expressions/common/expression_functions/specs/cumulative_sum_fn.ts create mode 100644 src/plugins/expressions/common/expression_functions/specs/derivative_fn.ts create mode 100644 src/plugins/expressions/common/expression_functions/specs/math_fn.ts create mode 100644 src/plugins/expressions/common/expression_functions/specs/moving_average_fn.ts create mode 100644 src/plugins/expressions/common/expression_functions/specs/overall_metric_fn.ts create mode 100644 src/plugins/expressions/public/react_expression_renderer_wrapper.tsx diff --git a/examples/expressions_explorer/public/actions_and_expressions.tsx b/examples/expressions_explorer/public/actions_and_expressions.tsx index 6d0c8886a79f3..4d6d8b9f05ed3 100644 --- a/examples/expressions_explorer/public/actions_and_expressions.tsx +++ b/examples/expressions_explorer/public/actions_and_expressions.tsx @@ -19,11 +19,7 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import { - ExpressionsStart, - ReactExpressionRenderer, - ExpressionsInspectorAdapter, -} from '../../../src/plugins/expressions/public'; +import { ExpressionsStart } from '../../../src/plugins/expressions/public'; import { ExpressionEditor } from './editor/expression_editor'; import { UiActionsStart } from '../../../src/plugins/ui_actions/public'; import { NAVIGATE_TRIGGER_ID } from './actions/navigate_trigger'; @@ -42,10 +38,6 @@ export function ActionsExpressionsExample({ expressions, actions }: Props) { updateExpression(value); }; - const inspectorAdapters = { - expression: new ExpressionsInspectorAdapter(), - }; - const handleEvents = (event: any) => { if (event.name !== 'NAVIGATE') return; // enrich event context with some extra data @@ -83,10 +75,9 @@ export function ActionsExpressionsExample({ expressions, actions }: Props) {
- { return
{message}
; diff --git a/examples/expressions_explorer/public/actions_and_expressions2.tsx b/examples/expressions_explorer/public/actions_and_expressions2.tsx index e7dc28b8b97cd..28c698df38d1b 100644 --- a/examples/expressions_explorer/public/actions_and_expressions2.tsx +++ b/examples/expressions_explorer/public/actions_and_expressions2.tsx @@ -19,11 +19,7 @@ import { EuiText, EuiTitle, } from '@elastic/eui'; -import { - ExpressionsStart, - ReactExpressionRenderer, - ExpressionsInspectorAdapter, -} from '../../../src/plugins/expressions/public'; +import { ExpressionsStart } from '../../../src/plugins/expressions/public'; import { ExpressionEditor } from './editor/expression_editor'; import { UiActionsStart } from '../../../src/plugins/ui_actions/public'; @@ -45,10 +41,6 @@ export function ActionsExpressionsExample2({ expressions, actions }: Props) { updateExpression(value); }; - const inspectorAdapters = { - expression: new ExpressionsInspectorAdapter(), - }; - const handleEvents = (event: any) => { updateVariables({ color: event.data.href === 'http://www.google.com' ? 'red' : 'blue' }); }; @@ -81,11 +73,10 @@ export function ActionsExpressionsExample2({ expressions, actions }: Props) {
- { diff --git a/examples/expressions_explorer/public/render_expressions.tsx b/examples/expressions_explorer/public/render_expressions.tsx index d91db964c5352..b7780939e1913 100644 --- a/examples/expressions_explorer/public/render_expressions.tsx +++ b/examples/expressions_explorer/public/render_expressions.tsx @@ -20,11 +20,7 @@ import { EuiTitle, EuiButton, } from '@elastic/eui'; -import { - ExpressionsStart, - ReactExpressionRenderer, - ExpressionsInspectorAdapter, -} from '../../../src/plugins/expressions/public'; +import { ExpressionsStart } from '../../../src/plugins/expressions/public'; import { ExpressionEditor } from './editor/expression_editor'; import { Start as InspectorStart } from '../../../src/plugins/inspector/public'; @@ -42,9 +38,7 @@ export function RenderExpressionsExample({ expressions, inspector }: Props) { updateExpression(value); }; - const inspectorAdapters = { - expression: new ExpressionsInspectorAdapter(), - }; + const inspectorAdapters = {}; return ( @@ -83,10 +77,12 @@ export function RenderExpressionsExample({ expressions, inspector }: Props) { - { + Object.assign(inspectorAdapters, panels); + }} renderError={(message: any) => { return
{message}
; }} diff --git a/examples/expressions_explorer/public/run_expressions.tsx b/examples/expressions_explorer/public/run_expressions.tsx index 93cab0e9f2b6f..18caa97171847 100644 --- a/examples/expressions_explorer/public/run_expressions.tsx +++ b/examples/expressions_explorer/public/run_expressions.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect } from 'react'; import { pluck } from 'rxjs/operators'; import { EuiCodeBlock, @@ -22,12 +22,9 @@ import { EuiTitle, EuiButton, } from '@elastic/eui'; -import { - ExpressionsStart, - ExpressionsInspectorAdapter, -} from '../../../src/plugins/expressions/public'; +import { ExpressionsStart } from '../../../src/plugins/expressions/public'; import { ExpressionEditor } from './editor/expression_editor'; -import { Start as InspectorStart } from '../../../src/plugins/inspector/public'; +import { Adapters, Start as InspectorStart } from '../../../src/plugins/inspector/public'; interface Props { expressions: ExpressionsStart; @@ -37,25 +34,24 @@ interface Props { export function RunExpressionsExample({ expressions, inspector }: Props) { const [expression, updateExpression] = useState('markdownVis "## expressions explorer"'); const [result, updateResult] = useState({}); + const [inspectorAdapters, updateInspectorAdapters] = useState({}); const expressionChanged = (value: string) => { updateExpression(value); }; - const inspectorAdapters = useMemo( - () => ({ - expression: new ExpressionsInspectorAdapter(), - }), - [] - ); - useEffect(() => { const execution = expressions.execute(expression, null, { debug: true, - inspectorAdapters, }); - const subscription = execution.getData().pipe(pluck('result')).subscribe(updateResult); - + const subscription = execution + .getData() + .pipe(pluck('result')) + .subscribe((data) => { + updateResult(data); + updateInspectorAdapters(execution.inspect()); + }); + execution.inspect(); return () => subscription.unsubscribe(); }, [expression, expressions, inspectorAdapters]); diff --git a/src/plugins/data/public/search/expressions/esaggs.ts b/src/plugins/data/public/search/expressions/esaggs.ts index 64b3f3c8dd7de..b691dc2237c22 100644 --- a/src/plugins/data/public/search/expressions/esaggs.ts +++ b/src/plugins/data/public/search/expressions/esaggs.ts @@ -14,7 +14,6 @@ import { EsaggsExpressionFunctionDefinition, EsaggsStartDependencies, getEsaggsMeta, - handleEsaggsRequest, } from '../../../common/search/expressions'; import { DataPublicPluginStart, DataStartDependencies } from '../../types'; @@ -48,10 +47,12 @@ export function getFunctionDefinition({ ); aggConfigs.hierarchical = args.metricsAtAllLevels; - return { aggConfigs, indexPattern, searchSource, getNow }; + const { handleEsaggsRequest } = await import('../../../common/search/expressions'); + + return { aggConfigs, indexPattern, searchSource, getNow, handleEsaggsRequest }; }).pipe( - switchMap(({ aggConfigs, indexPattern, searchSource, getNow }) => - handleEsaggsRequest({ + switchMap(({ aggConfigs, indexPattern, searchSource, getNow, handleEsaggsRequest }) => { + return handleEsaggsRequest({ abortSignal, aggs: aggConfigs, filters: get(input, 'filters', undefined), @@ -65,8 +66,8 @@ export function getFunctionDefinition({ timeRange: get(input, 'timeRange', undefined), getNow, executionContext: getExecutionContext(), - }) - ) + }); + }) ); }, }); diff --git a/src/plugins/expressions/common/execution/execution_contract.ts b/src/plugins/expressions/common/execution/execution_contract.ts index 445ceb30b58db..14ab7bebbf057 100644 --- a/src/plugins/expressions/common/execution/execution_contract.ts +++ b/src/plugins/expressions/common/execution/execution_contract.ts @@ -11,6 +11,7 @@ import { catchError } from 'rxjs/operators'; import { Execution, ExecutionResult } from './execution'; import { ExpressionValueError } from '../expression_types/specs'; import { ExpressionAstExpression } from '../ast'; +import { Adapters } from '../../../inspector/common/adapters'; /** * `ExecutionContract` is a wrapper around `Execution` class. It provides the @@ -75,5 +76,5 @@ export class ExecutionContract this.execution.inspectorAdapters; + inspect = (): Adapters => this.execution.inspectorAdapters; } diff --git a/src/plugins/expressions/common/execution/types.ts b/src/plugins/expressions/common/execution/types.ts index 9264891b2e0b8..e1e65ada632a9 100644 --- a/src/plugins/expressions/common/execution/types.ts +++ b/src/plugins/expressions/common/execution/types.ts @@ -14,6 +14,7 @@ import type { KibanaExecutionContext } from 'src/core/public'; import { Datatable, ExpressionType } from '../expression_types'; import { Adapters, RequestAdapter } from '../../../inspector/common'; import { TablesAdapter } from '../util/tables_adapter'; +import { ExpressionsInspectorAdapter } from '../util'; /** * `ExecutionContext` is an object available to all functions during a single execution; @@ -79,7 +80,8 @@ export interface ExecutionContext< /** * Default inspector adapters created if inspector adapters are not set explicitly. */ -export interface DefaultInspectorAdapters extends Adapters { +export interface DefaultInspectorAdapters { requests: RequestAdapter; tables: TablesAdapter; + expression: ExpressionsInspectorAdapter; } diff --git a/src/plugins/expressions/common/expression_functions/specs/cumulative_sum.ts b/src/plugins/expressions/common/expression_functions/specs/cumulative_sum.ts index 9568ba3a32f29..3269ac3c61864 100644 --- a/src/plugins/expressions/common/expression_functions/specs/cumulative_sum.ts +++ b/src/plugins/expressions/common/expression_functions/specs/cumulative_sum.ts @@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n'; import { ExpressionFunctionDefinition } from '../types'; import { Datatable } from '../../expression_types'; -import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers'; export interface CumulativeSumArgs { by?: string[]; @@ -22,7 +21,7 @@ export type ExpressionFunctionCumulativeSum = ExpressionFunctionDefinition< 'cumulative_sum', Datatable, CumulativeSumArgs, - Datatable + Promise >; /** @@ -94,37 +93,8 @@ export const cumulativeSum: ExpressionFunctionCumulativeSum = { }, }, - fn(input, { by, inputColumnId, outputColumnId, outputColumnName }) { - const resultColumns = buildResultColumns( - input, - outputColumnId, - inputColumnId, - outputColumnName - ); - - if (!resultColumns) { - return input; - } - - const accumulators: Partial> = {}; - return { - ...input, - columns: resultColumns, - rows: input.rows.map((row) => { - const newRow = { ...row }; - - const bucketIdentifier = getBucketIdentifier(row, by); - const accumulatorValue = accumulators[bucketIdentifier] ?? 0; - const currentValue = newRow[inputColumnId]; - if (currentValue != null) { - newRow[outputColumnId] = Number(currentValue) + accumulatorValue; - accumulators[bucketIdentifier] = newRow[outputColumnId]; - } else { - newRow[outputColumnId] = accumulatorValue; - } - - return newRow; - }), - }; + async fn(input, args) { + const { cumulativeSumFn } = await import('./cumulative_sum_fn'); + return cumulativeSumFn(input, args); }, }; diff --git a/src/plugins/expressions/common/expression_functions/specs/cumulative_sum_fn.ts b/src/plugins/expressions/common/expression_functions/specs/cumulative_sum_fn.ts new file mode 100644 index 0000000000000..3d2d9740c89c9 --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/cumulative_sum_fn.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datatable } from '../../expression_types'; +import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers'; +import { CumulativeSumArgs } from './cumulative_sum'; + +export const cumulativeSumFn = ( + input: Datatable, + { by, inputColumnId, outputColumnId, outputColumnName }: CumulativeSumArgs +): Datatable => { + const resultColumns = buildResultColumns(input, outputColumnId, inputColumnId, outputColumnName); + + if (!resultColumns) { + return input; + } + + const accumulators: Partial> = {}; + return { + ...input, + columns: resultColumns, + rows: input.rows.map((row) => { + const newRow = { ...row }; + + const bucketIdentifier = getBucketIdentifier(row, by); + const accumulatorValue = accumulators[bucketIdentifier] ?? 0; + const currentValue = newRow[inputColumnId]; + if (currentValue != null) { + newRow[outputColumnId] = Number(currentValue) + accumulatorValue; + accumulators[bucketIdentifier] = newRow[outputColumnId]; + } else { + newRow[outputColumnId] = accumulatorValue; + } + + return newRow; + }), + }; +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/derivative.ts b/src/plugins/expressions/common/expression_functions/specs/derivative.ts index 50c9cc32ee4f3..1e139c93ab326 100644 --- a/src/plugins/expressions/common/expression_functions/specs/derivative.ts +++ b/src/plugins/expressions/common/expression_functions/specs/derivative.ts @@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n'; import { ExpressionFunctionDefinition } from '../types'; import { Datatable } from '../../expression_types'; -import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers'; export interface DerivativeArgs { by?: string[]; @@ -22,7 +21,7 @@ export type ExpressionFunctionDerivative = ExpressionFunctionDefinition< 'derivative', Datatable, DerivativeArgs, - Datatable + Promise >; /** @@ -95,43 +94,8 @@ export const derivative: ExpressionFunctionDerivative = { }, }, - fn(input, { by, inputColumnId, outputColumnId, outputColumnName }) { - const resultColumns = buildResultColumns( - input, - outputColumnId, - inputColumnId, - outputColumnName - ); - - if (!resultColumns) { - return input; - } - - const previousValues: Partial> = {}; - return { - ...input, - columns: resultColumns, - rows: input.rows.map((row) => { - const newRow = { ...row }; - - const bucketIdentifier = getBucketIdentifier(row, by); - const previousValue = previousValues[bucketIdentifier]; - const currentValue = newRow[inputColumnId]; - - if (currentValue != null && previousValue != null) { - newRow[outputColumnId] = Number(currentValue) - previousValue; - } else { - newRow[outputColumnId] = undefined; - } - - if (currentValue != null) { - previousValues[bucketIdentifier] = Number(currentValue); - } else { - previousValues[bucketIdentifier] = undefined; - } - - return newRow; - }), - }; + async fn(input, args) { + const { derivativeFn } = await import('./derivative_fn'); + return derivativeFn(input, args); }, }; diff --git a/src/plugins/expressions/common/expression_functions/specs/derivative_fn.ts b/src/plugins/expressions/common/expression_functions/specs/derivative_fn.ts new file mode 100644 index 0000000000000..b2694ebdf4013 --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/derivative_fn.ts @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datatable } from '../../expression_types'; +import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers'; +import { DerivativeArgs } from './derivative'; + +/** + * Calculates the derivative of a specified column in the data table. + * + * Also supports multiple series in a single data table - use the `by` argument + * to specify the columns to split the calculation by. + * For each unique combination of all `by` columns a separate derivative will be calculated. + * The order of rows won't be changed - this function is not modifying any existing columns, it's only + * adding the specified `outputColumnId` column to every row of the table without adding or removing rows. + * + * Behavior: + * * Will write the derivative of `inputColumnId` into `outputColumnId` + * * If provided will use `outputColumnName` as name for the newly created column. Otherwise falls back to `outputColumnId` + * * Derivative always start with an undefined value for the first row of a series, a cell will contain its own value minus the + * value of the previous cell of the same series. + * + * Edge cases: + * * Will return the input table if `inputColumnId` does not exist + * * Will throw an error if `outputColumnId` exists already in provided data table + * * If there is no previous row of the current series with a non `null` or `undefined` value, the output cell of the current row + * will be set to `undefined`. + * * If the row value contains `null` or `undefined`, it will be ignored and the output cell will be set to `undefined` + * * If the value of the previous row of the same series contains `null` or `undefined`, the output cell of the current row will be set to `undefined` as well + * * For all values besides `null` and `undefined`, the value will be cast to a number before it's used in the + * calculation of the current series even if this results in `NaN` (like in case of objects). + * * To determine separate series defined by the `by` columns, the values of these columns will be cast to strings + * before comparison. If the values are objects, the return value of their `toString` method will be used for comparison. + * Missing values (`null` and `undefined`) will be treated as empty strings. + */ +export const derivativeFn = ( + input: Datatable, + { by, inputColumnId, outputColumnId, outputColumnName }: DerivativeArgs +): Datatable => { + const resultColumns = buildResultColumns(input, outputColumnId, inputColumnId, outputColumnName); + + if (!resultColumns) { + return input; + } + + const previousValues: Partial> = {}; + return { + ...input, + columns: resultColumns, + rows: input.rows.map((row) => { + const newRow = { ...row }; + + const bucketIdentifier = getBucketIdentifier(row, by); + const previousValue = previousValues[bucketIdentifier]; + const currentValue = newRow[inputColumnId]; + + if (currentValue != null && previousValue != null) { + newRow[outputColumnId] = Number(currentValue) - previousValue; + } else { + newRow[outputColumnId] = undefined; + } + + if (currentValue != null) { + previousValues[bucketIdentifier] = Number(currentValue); + } else { + previousValues[bucketIdentifier] = undefined; + } + + return newRow; + }), + }; +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/math.ts b/src/plugins/expressions/common/expression_functions/specs/math.ts index f843f53e4dd88..f79fc43488487 100644 --- a/src/plugins/expressions/common/expression_functions/specs/math.ts +++ b/src/plugins/expressions/common/expression_functions/specs/math.ts @@ -6,11 +6,9 @@ * Side Public License, v 1. */ -import { map, zipObject, isString } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { evaluate } from '@kbn/tinymath'; import { ExpressionFunctionDefinition } from '../types'; -import { Datatable, isDatatable } from '../../expression_types'; +import { Datatable } from '../../expression_types'; export type MathArguments = { expression: string; @@ -23,63 +21,11 @@ const TINYMATH = '`TinyMath`'; const TINYMATH_URL = 'https://www.elastic.co/guide/en/kibana/current/canvas-tinymath-functions.html'; -function pivotObjectArray< - RowType extends { [key: string]: unknown }, - ReturnColumns extends keyof RowType & string ->(rows: RowType[], columns?: ReturnColumns[]) { - const columnNames = columns || Object.keys(rows[0]); - if (!columnNames.every(isString)) { - throw new Error('Columns should be an array of strings'); - } - - const columnValues = map(columnNames, (name) => map(rows, name)); - - return zipObject(columnNames, columnValues) as { [K in ReturnColumns]: Array }; -} - -export const errors = { - emptyExpression: () => - new Error( - i18n.translate('expressions.functions.math.emptyExpressionErrorMessage', { - defaultMessage: 'Empty expression', - }) - ), - tooManyResults: () => - new Error( - i18n.translate('expressions.functions.math.tooManyResultsErrorMessage', { - defaultMessage: - 'Expressions must return a single number. Try wrapping your expression in {mean} or {sum}', - values: { - mean: 'mean()', - sum: 'sum()', - }, - }) - ), - executionFailed: () => - new Error( - i18n.translate('expressions.functions.math.executionFailedErrorMessage', { - defaultMessage: 'Failed to execute math expression. Check your column names', - }) - ), - emptyDatatable: () => - new Error( - i18n.translate('expressions.functions.math.emptyDatatableErrorMessage', { - defaultMessage: 'Empty datatable', - }) - ), -}; - -const fallbackValue = { - null: null, - zero: 0, - false: false, -} as const; - export const math: ExpressionFunctionDefinition< 'math', MathInput, MathArguments, - boolean | number | null + Promise > = { name: 'math', type: undefined, @@ -121,47 +67,8 @@ export const math: ExpressionFunctionDefinition< }), }, }, - fn: (input, args) => { - const { expression, onError } = args; - const onErrorValue = onError ?? 'throw'; - - if (!expression || expression.trim() === '') { - throw errors.emptyExpression(); - } - - // Use unique ID if available, otherwise fall back to names - const mathContext = isDatatable(input) - ? pivotObjectArray( - input.rows, - input.columns.map((col) => col.id) - ) - : { value: input }; - - try { - const result = evaluate(expression, mathContext); - if (Array.isArray(result)) { - if (result.length === 1) { - return result[0]; - } - throw errors.tooManyResults(); - } - if (isNaN(result)) { - // make TS happy - if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { - return fallbackValue[onErrorValue]; - } - throw errors.executionFailed(); - } - return result; - } catch (e) { - if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { - return fallbackValue[onErrorValue]; - } - if (isDatatable(input) && input.rows.length === 0) { - throw errors.emptyDatatable(); - } else { - throw e; - } - } + fn: async (input, args) => { + const { mathFn } = await import('./math_fn'); + return mathFn(input, args); }, }; diff --git a/src/plugins/expressions/common/expression_functions/specs/math_column.ts b/src/plugins/expressions/common/expression_functions/specs/math_column.ts index a2a79ef3f0286..fe6049b49c969 100644 --- a/src/plugins/expressions/common/expression_functions/specs/math_column.ts +++ b/src/plugins/expressions/common/expression_functions/specs/math_column.ts @@ -21,7 +21,7 @@ export const mathColumn: ExpressionFunctionDefinition< 'mathColumn', Datatable, MathColumnArguments, - Datatable + Promise > = { name: 'mathColumn', type: 'datatable', @@ -63,7 +63,7 @@ export const mathColumn: ExpressionFunctionDefinition< default: null, }, }, - fn: (input, args, context) => { + fn: async (input, args, context) => { const columns = [...input.columns]; const existingColumnIndex = columns.findIndex(({ id }) => { return id === args.id; @@ -76,34 +76,36 @@ export const mathColumn: ExpressionFunctionDefinition< ); } - const newRows = input.rows.map((row) => { - const result = math.fn( - { - type: 'datatable', - columns: input.columns, - rows: [row], - }, - { - expression: args.expression, - onError: args.onError, - }, - context - ); + const newRows = await Promise.all( + input.rows.map(async (row) => { + const result = await math.fn( + { + type: 'datatable', + columns: input.columns, + rows: [row], + }, + { + expression: args.expression, + onError: args.onError, + }, + context + ); - if (Array.isArray(result)) { - if (result.length === 1) { - return { ...row, [args.id]: result[0] }; + if (Array.isArray(result)) { + if (result.length === 1) { + return { ...row, [args.id]: result[0] }; + } + throw new Error( + i18n.translate('expressions.functions.mathColumn.arrayValueError', { + defaultMessage: 'Cannot perform math on array values at {name}', + values: { name: args.name }, + }) + ); } - throw new Error( - i18n.translate('expressions.functions.mathColumn.arrayValueError', { - defaultMessage: 'Cannot perform math on array values at {name}', - values: { name: args.name }, - }) - ); - } - return { ...row, [args.id]: result }; - }); + return { ...row, [args.id]: result }; + }) + ); let type: DatatableColumnType = 'null'; if (newRows.length) { for (const row of newRows) { diff --git a/src/plugins/expressions/common/expression_functions/specs/math_fn.ts b/src/plugins/expressions/common/expression_functions/specs/math_fn.ts new file mode 100644 index 0000000000000..72ac5a6da445a --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/math_fn.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { map, zipObject, isString } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { evaluate } from '@kbn/tinymath'; +import { isDatatable } from '../../expression_types'; +import { MathArguments, MathInput } from './math'; + +function pivotObjectArray< + RowType extends { [key: string]: unknown }, + ReturnColumns extends keyof RowType & string +>(rows: RowType[], columns?: ReturnColumns[]) { + const columnNames = columns || Object.keys(rows[0]); + if (!columnNames.every(isString)) { + throw new Error('Columns should be an array of strings'); + } + + const columnValues = map(columnNames, (name) => map(rows, name)); + + return zipObject(columnNames, columnValues) as { [K in ReturnColumns]: Array }; +} + +export const errors = { + emptyExpression: () => + new Error( + i18n.translate('expressions.functions.math.emptyExpressionErrorMessage', { + defaultMessage: 'Empty expression', + }) + ), + tooManyResults: () => + new Error( + i18n.translate('expressions.functions.math.tooManyResultsErrorMessage', { + defaultMessage: + 'Expressions must return a single number. Try wrapping your expression in {mean} or {sum}', + values: { + mean: 'mean()', + sum: 'sum()', + }, + }) + ), + executionFailed: () => + new Error( + i18n.translate('expressions.functions.math.executionFailedErrorMessage', { + defaultMessage: 'Failed to execute math expression. Check your column names', + }) + ), + emptyDatatable: () => + new Error( + i18n.translate('expressions.functions.math.emptyDatatableErrorMessage', { + defaultMessage: 'Empty datatable', + }) + ), +}; + +const fallbackValue = { + null: null, + zero: 0, + false: false, +} as const; + +export const mathFn = (input: MathInput, args: MathArguments): boolean | number | null => { + const { expression, onError } = args; + const onErrorValue = onError ?? 'throw'; + + if (!expression || expression.trim() === '') { + throw errors.emptyExpression(); + } + + // Use unique ID if available, otherwise fall back to names + const mathContext = isDatatable(input) + ? pivotObjectArray( + input.rows, + input.columns.map((col) => col.id) + ) + : { value: input }; + + try { + const result = evaluate(expression, mathContext); + if (Array.isArray(result)) { + if (result.length === 1) { + return result[0]; + } + throw errors.tooManyResults(); + } + if (isNaN(result)) { + // make TS happy + if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { + return fallbackValue[onErrorValue]; + } + throw errors.executionFailed(); + } + return result; + } catch (e) { + if (onErrorValue !== 'throw' && onErrorValue in fallbackValue) { + return fallbackValue[onErrorValue]; + } + if (isDatatable(input) && input.rows.length === 0) { + throw errors.emptyDatatable(); + } else { + throw e; + } + } +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/moving_average.ts b/src/plugins/expressions/common/expression_functions/specs/moving_average.ts index 1fa69501401de..08c978fa53bd1 100644 --- a/src/plugins/expressions/common/expression_functions/specs/moving_average.ts +++ b/src/plugins/expressions/common/expression_functions/specs/moving_average.ts @@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n'; import { ExpressionFunctionDefinition } from '../types'; import { Datatable } from '../../expression_types'; -import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers'; export interface MovingAverageArgs { by?: string[]; @@ -23,7 +22,7 @@ export type ExpressionFunctionMovingAverage = ExpressionFunctionDefinition< 'moving_average', Datatable, MovingAverageArgs, - Datatable + Promise >; /** @@ -100,43 +99,8 @@ export const movingAverage: ExpressionFunctionMovingAverage = { }, }, - fn(input, { by, inputColumnId, outputColumnId, outputColumnName, window }) { - const resultColumns = buildResultColumns( - input, - outputColumnId, - inputColumnId, - outputColumnName - ); - - if (!resultColumns) { - return input; - } - - const lastNValuesByBucket: Partial> = {}; - return { - ...input, - columns: resultColumns, - rows: input.rows.map((row) => { - const newRow = { ...row }; - const bucketIdentifier = getBucketIdentifier(row, by); - const lastNValues = lastNValuesByBucket[bucketIdentifier]; - const currentValue = newRow[inputColumnId]; - if (lastNValues != null && currentValue != null) { - const sum = lastNValues.reduce((acc, current) => acc + current, 0); - newRow[outputColumnId] = sum / lastNValues.length; - } else { - newRow[outputColumnId] = undefined; - } - - if (currentValue != null) { - lastNValuesByBucket[bucketIdentifier] = [ - ...(lastNValues || []), - Number(currentValue), - ].slice(-window); - } - - return newRow; - }), - }; + async fn(input, args) { + const { movingAverageFn } = await import('./moving_average_fn'); + return movingAverageFn(input, args); }, }; diff --git a/src/plugins/expressions/common/expression_functions/specs/moving_average_fn.ts b/src/plugins/expressions/common/expression_functions/specs/moving_average_fn.ts new file mode 100644 index 0000000000000..43e6cb140a350 --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/moving_average_fn.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datatable } from '../../expression_types'; +import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers'; +import { MovingAverageArgs } from './moving_average'; + +/** + * Calculates the moving average of a specified column in the data table. + * + * Also supports multiple series in a single data table - use the `by` argument + * to specify the columns to split the calculation by. + * For each unique combination of all `by` columns a separate moving average will be calculated. + * The order of rows won't be changed - this function is not modifying any existing columns, it's only + * adding the specified `outputColumnId` column to every row of the table without adding or removing rows. + * + * Behavior: + * * Will write the moving average of `inputColumnId` into `outputColumnId` + * * If provided will use `outputColumnName` as name for the newly created column. Otherwise falls back to `outputColumnId` + * * Moving average always starts with an undefined value for the first row of a series. Each next cell will contain sum of the last + * * [window] of values divided by [window] excluding the current bucket. + * If either of window edges moves outside the borders of data series, the window shrinks to include available values only. + * + * Edge cases: + * * Will return the input table if `inputColumnId` does not exist + * * Will throw an error if `outputColumnId` exists already in provided data table + * * If null or undefined value is encountered, skip the current row and do not change the window + * * For all values besides `null` and `undefined`, the value will be cast to a number before it's used in the + * calculation of the current series even if this results in `NaN` (like in case of objects). + * * To determine separate series defined by the `by` columns, the values of these columns will be cast to strings + * before comparison. If the values are objects, the return value of their `toString` method will be used for comparison. + */ +export const movingAverageFn = ( + input: Datatable, + { by, inputColumnId, outputColumnId, outputColumnName, window }: MovingAverageArgs +): Datatable => { + const resultColumns = buildResultColumns(input, outputColumnId, inputColumnId, outputColumnName); + + if (!resultColumns) { + return input; + } + + const lastNValuesByBucket: Partial> = {}; + return { + ...input, + columns: resultColumns, + rows: input.rows.map((row) => { + const newRow = { ...row }; + const bucketIdentifier = getBucketIdentifier(row, by); + const lastNValues = lastNValuesByBucket[bucketIdentifier]; + const currentValue = newRow[inputColumnId]; + if (lastNValues != null && currentValue != null) { + const sum = lastNValues.reduce((acc, current) => acc + current, 0); + newRow[outputColumnId] = sum / lastNValues.length; + } else { + newRow[outputColumnId] = undefined; + } + + if (currentValue != null) { + lastNValuesByBucket[bucketIdentifier] = [ + ...(lastNValues || []), + Number(currentValue), + ].slice(-window); + } + + return newRow; + }), + }; +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/overall_metric.ts b/src/plugins/expressions/common/expression_functions/specs/overall_metric.ts index f30f0e1e1b1d6..4bb1d97cb2f17 100644 --- a/src/plugins/expressions/common/expression_functions/specs/overall_metric.ts +++ b/src/plugins/expressions/common/expression_functions/specs/overall_metric.ts @@ -9,7 +9,6 @@ import { i18n } from '@kbn/i18n'; import { ExpressionFunctionDefinition } from '../types'; import { Datatable } from '../../expression_types'; -import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers'; export interface OverallMetricArgs { by?: string[]; @@ -23,17 +22,9 @@ export type ExpressionFunctionOverallMetric = ExpressionFunctionDefinition< 'overall_metric', Datatable, OverallMetricArgs, - Datatable + Promise >; -function getValueAsNumberArray(value: unknown) { - if (Array.isArray(value)) { - return value.map((innerVal) => Number(innerVal)); - } else { - return [Number(value)]; - } -} - /** * Calculates the overall metric of a specified column in the data table. * @@ -109,73 +100,8 @@ export const overallMetric: ExpressionFunctionOverallMetric = { }, }, - fn(input, { by, inputColumnId, outputColumnId, outputColumnName, metric }) { - const resultColumns = buildResultColumns( - input, - outputColumnId, - inputColumnId, - outputColumnName - ); - - if (!resultColumns) { - return input; - } - - const accumulators: Partial> = {}; - const valueCounter: Partial> = {}; - input.rows.forEach((row) => { - const bucketIdentifier = getBucketIdentifier(row, by); - const accumulatorValue = accumulators[bucketIdentifier]; - - const currentValue = row[inputColumnId]; - if (currentValue != null) { - const currentNumberValues = getValueAsNumberArray(currentValue); - switch (metric) { - case 'average': - valueCounter[bucketIdentifier] = - (valueCounter[bucketIdentifier] ?? 0) + currentNumberValues.length; - case 'sum': - accumulators[bucketIdentifier] = currentNumberValues.reduce( - (a, b) => a + b, - accumulatorValue || 0 - ); - break; - case 'min': - if (typeof accumulatorValue !== 'undefined') { - accumulators[bucketIdentifier] = Math.min(accumulatorValue, ...currentNumberValues); - } else { - accumulators[bucketIdentifier] = Math.min(...currentNumberValues); - } - break; - case 'max': - if (typeof accumulatorValue !== 'undefined') { - accumulators[bucketIdentifier] = Math.max(accumulatorValue, ...currentNumberValues); - } else { - accumulators[bucketIdentifier] = Math.max(...currentNumberValues); - } - break; - } - } - }); - if (metric === 'average') { - Object.keys(accumulators).forEach((bucketIdentifier) => { - const accumulatorValue = accumulators[bucketIdentifier]; - const valueCount = valueCounter[bucketIdentifier]; - if (typeof accumulatorValue !== 'undefined' && typeof valueCount !== 'undefined') { - accumulators[bucketIdentifier] = accumulatorValue / valueCount; - } - }); - } - return { - ...input, - columns: resultColumns, - rows: input.rows.map((row) => { - const newRow = { ...row }; - const bucketIdentifier = getBucketIdentifier(row, by); - newRow[outputColumnId] = accumulators[bucketIdentifier]; - - return newRow; - }), - }; + async fn(input, args) { + const { overallMetricFn } = await import('./overall_metric_fn'); + return overallMetricFn(input, args); }, }; diff --git a/src/plugins/expressions/common/expression_functions/specs/overall_metric_fn.ts b/src/plugins/expressions/common/expression_functions/specs/overall_metric_fn.ts new file mode 100644 index 0000000000000..002b3ad9667cc --- /dev/null +++ b/src/plugins/expressions/common/expression_functions/specs/overall_metric_fn.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { Datatable } from '../../expression_types'; +import { buildResultColumns, getBucketIdentifier } from '../series_calculation_helpers'; +import { OverallMetricArgs } from './overall_metric'; + +function getValueAsNumberArray(value: unknown) { + if (Array.isArray(value)) { + return value.map((innerVal) => Number(innerVal)); + } else { + return [Number(value)]; + } +} + +/** + * Calculates the overall metric of a specified column in the data table. + * + * Also supports multiple series in a single data table - use the `by` argument + * to specify the columns to split the calculation by. + * For each unique combination of all `by` columns a separate overall metric will be calculated. + * The order of rows won't be changed - this function is not modifying any existing columns, it's only + * adding the specified `outputColumnId` column to every row of the table without adding or removing rows. + * + * Behavior: + * * Will write the overall metric of `inputColumnId` into `outputColumnId` + * * If provided will use `outputColumnName` as name for the newly created column. Otherwise falls back to `outputColumnId` + * * Each cell will contain the calculated metric based on the values of all cells belonging to the current series. + * + * Edge cases: + * * Will return the input table if `inputColumnId` does not exist + * * Will throw an error if `outputColumnId` exists already in provided data table + * * If the row value contains `null` or `undefined`, it will be ignored and overwritten with the overall metric of + * all cells of the same series. + * * For all values besides `null` and `undefined`, the value will be cast to a number before it's added to the + * overall metric of the current series - if this results in `NaN` (like in case of objects), all cells of the + * current series will be set to `NaN`. + * * To determine separate series defined by the `by` columns, the values of these columns will be cast to strings + * before comparison. If the values are objects, the return value of their `toString` method will be used for comparison. + * Missing values (`null` and `undefined`) will be treated as empty strings. + */ +export const overallMetricFn = ( + input: Datatable, + { by, inputColumnId, outputColumnId, outputColumnName, metric }: OverallMetricArgs +): Datatable => { + const resultColumns = buildResultColumns(input, outputColumnId, inputColumnId, outputColumnName); + + if (!resultColumns) { + return input; + } + + const accumulators: Partial> = {}; + const valueCounter: Partial> = {}; + input.rows.forEach((row) => { + const bucketIdentifier = getBucketIdentifier(row, by); + const accumulatorValue = accumulators[bucketIdentifier]; + + const currentValue = row[inputColumnId]; + if (currentValue != null) { + const currentNumberValues = getValueAsNumberArray(currentValue); + switch (metric) { + case 'average': + valueCounter[bucketIdentifier] = + (valueCounter[bucketIdentifier] ?? 0) + currentNumberValues.length; + case 'sum': + accumulators[bucketIdentifier] = currentNumberValues.reduce( + (a, b) => a + b, + accumulatorValue || 0 + ); + break; + case 'min': + if (typeof accumulatorValue !== 'undefined') { + accumulators[bucketIdentifier] = Math.min(accumulatorValue, ...currentNumberValues); + } else { + accumulators[bucketIdentifier] = Math.min(...currentNumberValues); + } + break; + case 'max': + if (typeof accumulatorValue !== 'undefined') { + accumulators[bucketIdentifier] = Math.max(accumulatorValue, ...currentNumberValues); + } else { + accumulators[bucketIdentifier] = Math.max(...currentNumberValues); + } + break; + } + } + }); + if (metric === 'average') { + Object.keys(accumulators).forEach((bucketIdentifier) => { + const accumulatorValue = accumulators[bucketIdentifier]; + const valueCount = valueCounter[bucketIdentifier]; + if (typeof accumulatorValue !== 'undefined' && typeof valueCount !== 'undefined') { + accumulators[bucketIdentifier] = accumulatorValue / valueCount; + } + }); + } + return { + ...input, + columns: resultColumns, + rows: input.rows.map((row) => { + const newRow = { ...row }; + const bucketIdentifier = getBucketIdentifier(row, by); + newRow[outputColumnId] = accumulators[bucketIdentifier]; + + return newRow; + }), + }; +}; diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/cumulative_sum.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/cumulative_sum.test.ts index 23fb9abb4fb4d..67210c742989f 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/cumulative_sum.test.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/cumulative_sum.test.ts @@ -14,10 +14,10 @@ import { Datatable } from '../../../expression_types/specs/datatable'; describe('interpreter/functions#cumulative_sum', () => { const fn = functionWrapper(cumulativeSum); const runFn = (input: Datatable, args: CumulativeSumArgs) => - fn(input, args, {} as ExecutionContext) as Datatable; + fn(input, args, {} as ExecutionContext) as Promise; - it('calculates cumulative sum', () => { - const result = runFn( + it('calculates cumulative sum', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -33,8 +33,8 @@ describe('interpreter/functions#cumulative_sum', () => { expect(result.rows.map((row) => row.output)).toEqual([5, 12, 15, 17]); }); - it('replaces null or undefined data with zeroes until there is real data', () => { - const result = runFn( + it('replaces null or undefined data with zeroes until there is real data', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -50,8 +50,8 @@ describe('interpreter/functions#cumulative_sum', () => { expect(result.rows.map((row) => row.output)).toEqual([0, 0, 0, 1]); }); - it('calculates cumulative sum for multiple series', () => { - const result = runFn( + it('calculates cumulative sum for multiple series', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -84,8 +84,8 @@ describe('interpreter/functions#cumulative_sum', () => { ]); }); - it('treats missing split column as separate series', () => { - const result = runFn( + it('treats missing split column as separate series', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -117,8 +117,8 @@ describe('interpreter/functions#cumulative_sum', () => { ]); }); - it('treats null like undefined and empty string for split columns', () => { - const result = runFn( + it('treats null like undefined and empty string for split columns', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -152,8 +152,8 @@ describe('interpreter/functions#cumulative_sum', () => { ]); }); - it('calculates cumulative sum for multiple series by multiple split columns', () => { - const result = runFn( + it('calculates cumulative sum for multiple series by multiple split columns', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -177,8 +177,8 @@ describe('interpreter/functions#cumulative_sum', () => { expect(result.rows.map((row) => row.output)).toEqual([1, 2, 3, 1 + 4, 5, 6, 7, 7 + 8]); }); - it('splits separate series by the string representation of the cell values', () => { - const result = runFn( + it('splits separate series by the string representation of the cell values', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -198,8 +198,8 @@ describe('interpreter/functions#cumulative_sum', () => { expect(result.rows.map((row) => row.output)).toEqual([1, 1 + 2, 10, 21]); }); - it('casts values to number before calculating cumulative sum', () => { - const result = runFn( + it('casts values to number before calculating cumulative sum', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -210,8 +210,8 @@ describe('interpreter/functions#cumulative_sum', () => { expect(result.rows.map((row) => row.output)).toEqual([5, 12, 15, 17]); }); - it('casts values to number before calculating cumulative sum for NaN like values', () => { - const result = runFn( + it('casts values to number before calculating cumulative sum for NaN like values', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -222,8 +222,8 @@ describe('interpreter/functions#cumulative_sum', () => { expect(result.rows.map((row) => row.output)).toEqual([5, 12, NaN, NaN]); }); - it('skips undefined and null values', () => { - const result = runFn( + it('skips undefined and null values', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -244,8 +244,8 @@ describe('interpreter/functions#cumulative_sum', () => { expect(result.rows.map((row) => row.output)).toEqual([0, 7, 7, 7, 7, 7, 10, 12, 12]); }); - it('copies over meta information from the source column', () => { - const result = runFn( + it('copies over meta information from the source column', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -286,8 +286,8 @@ describe('interpreter/functions#cumulative_sum', () => { }); }); - it('sets output name on output column if specified', () => { - const result = runFn( + it('sets output name on output column if specified', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -310,7 +310,7 @@ describe('interpreter/functions#cumulative_sum', () => { }); }); - it('returns source table if input column does not exist', () => { + it('returns source table if input column does not exist', async () => { const input: Datatable = { type: 'datatable', columns: [ @@ -324,11 +324,13 @@ describe('interpreter/functions#cumulative_sum', () => { ], rows: [{ val: 5 }], }; - expect(runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output' })).toBe(input); + expect(await runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output' })).toBe( + input + ); }); - it('throws an error if output column exists already', () => { - expect(() => + it('throws an error if output column exists already', async () => { + await expect( runFn( { type: 'datatable', @@ -345,6 +347,6 @@ describe('interpreter/functions#cumulative_sum', () => { }, { inputColumnId: 'val', outputColumnId: 'val' } ) - ).toThrow(); + ).rejects.toBeDefined(); }); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/derivative.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/derivative.test.ts index 27601f533b64e..615f4eb51493b 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/derivative.test.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/derivative.test.ts @@ -14,10 +14,10 @@ import { Datatable } from '../../../expression_types/specs/datatable'; describe('interpreter/functions#derivative', () => { const fn = functionWrapper(derivative); const runFn = (input: Datatable, args: DerivativeArgs) => - fn(input, args, {} as ExecutionContext) as Datatable; + fn(input, args, {} as ExecutionContext) as Promise; - it('calculates derivative', () => { - const result = runFn( + it('calculates derivative', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -33,8 +33,8 @@ describe('interpreter/functions#derivative', () => { expect(result.rows.map((row) => row.output)).toEqual([undefined, 2, -4, -1]); }); - it('skips null or undefined values until there is real data', () => { - const result = runFn( + it('skips null or undefined values until there is real data', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -70,8 +70,8 @@ describe('interpreter/functions#derivative', () => { ]); }); - it('treats 0 as real data', () => { - const result = runFn( + it('treats 0 as real data', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -108,8 +108,8 @@ describe('interpreter/functions#derivative', () => { ]); }); - it('calculates derivative for multiple series', () => { - const result = runFn( + it('calculates derivative for multiple series', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -142,8 +142,8 @@ describe('interpreter/functions#derivative', () => { ]); }); - it('treats missing split column as separate series', () => { - const result = runFn( + it('treats missing split column as separate series', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -175,8 +175,8 @@ describe('interpreter/functions#derivative', () => { ]); }); - it('treats null like undefined and empty string for split columns', () => { - const result = runFn( + it('treats null like undefined and empty string for split columns', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -210,8 +210,8 @@ describe('interpreter/functions#derivative', () => { ]); }); - it('calculates derivative for multiple series by multiple split columns', () => { - const result = runFn( + it('calculates derivative for multiple series by multiple split columns', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -244,8 +244,8 @@ describe('interpreter/functions#derivative', () => { ]); }); - it('splits separate series by the string representation of the cell values', () => { - const result = runFn( + it('splits separate series by the string representation of the cell values', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -265,8 +265,8 @@ describe('interpreter/functions#derivative', () => { expect(result.rows.map((row) => row.output)).toEqual([undefined, 2 - 1, undefined, 11 - 10]); }); - it('casts values to number before calculating derivative', () => { - const result = runFn( + it('casts values to number before calculating derivative', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -277,8 +277,8 @@ describe('interpreter/functions#derivative', () => { expect(result.rows.map((row) => row.output)).toEqual([undefined, 7 - 5, 3 - 7, 2 - 3]); }); - it('casts values to number before calculating derivative for NaN like values', () => { - const result = runFn( + it('casts values to number before calculating derivative for NaN like values', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -289,8 +289,8 @@ describe('interpreter/functions#derivative', () => { expect(result.rows.map((row) => row.output)).toEqual([undefined, 7 - 5, NaN, NaN, 5 - 2]); }); - it('copies over meta information from the source column', () => { - const result = runFn( + it('copies over meta information from the source column', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -331,8 +331,8 @@ describe('interpreter/functions#derivative', () => { }); }); - it('sets output name on output column if specified', () => { - const result = runFn( + it('sets output name on output column if specified', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -355,7 +355,7 @@ describe('interpreter/functions#derivative', () => { }); }); - it('returns source table if input column does not exist', () => { + it('returns source table if input column does not exist', async () => { const input: Datatable = { type: 'datatable', columns: [ @@ -369,11 +369,13 @@ describe('interpreter/functions#derivative', () => { ], rows: [{ val: 5 }], }; - expect(runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output' })).toBe(input); + expect(await runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output' })).toBe( + input + ); }); - it('throws an error if output column exists already', () => { - expect(() => + it('throws an error if output column exists already', async () => { + await expect( runFn( { type: 'datatable', @@ -390,6 +392,6 @@ describe('interpreter/functions#derivative', () => { }, { inputColumnId: 'val', outputColumnId: 'val' } ) - ).toThrow(); + ).rejects.toBeDefined(); }); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts index 3761fe0a4f909..f0dadad15b67e 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/math.test.ts @@ -6,129 +6,163 @@ * Side Public License, v 1. */ -import { errors, math, MathArguments, MathInput } from '../math'; +import { math, MathArguments, MathInput } from '../math'; +import { errors } from '../math_fn'; import { emptyTable, functionWrapper, testTable } from './utils'; describe('math', () => { const fn = functionWrapper(math); - it('evaluates math expressions without reference to context', () => { - expect(fn(null as unknown as MathInput, { expression: '10.5345' })).toBe(10.5345); - expect(fn(null as unknown as MathInput, { expression: '123 + 456' })).toBe(579); - expect(fn(null as unknown as MathInput, { expression: '100 - 46' })).toBe(54); - expect(fn(1, { expression: '100 / 5' })).toBe(20); - expect(fn('foo' as unknown as MathInput, { expression: '100 / 5' })).toBe(20); - expect(fn(true as unknown as MathInput, { expression: '100 / 5' })).toBe(20); - expect(fn(testTable, { expression: '100 * 5' })).toBe(500); - expect(fn(emptyTable, { expression: '100 * 5' })).toBe(500); + it('evaluates math expressions without reference to context', async () => { + expect(await fn(null as unknown as MathInput, { expression: '10.5345' })).toBe(10.5345); + expect(await fn(null as unknown as MathInput, { expression: '123 + 456' })).toBe(579); + expect(await fn(null as unknown as MathInput, { expression: '100 - 46' })).toBe(54); + expect(await fn(1, { expression: '100 / 5' })).toBe(20); + expect(await fn('foo' as unknown as MathInput, { expression: '100 / 5' })).toBe(20); + expect(await fn(true as unknown as MathInput, { expression: '100 / 5' })).toBe(20); + expect(await fn(testTable, { expression: '100 * 5' })).toBe(500); + expect(await fn(emptyTable, { expression: '100 * 5' })).toBe(500); }); - it('evaluates math expressions with reference to the value of the context, must be a number', () => { - expect(fn(-103, { expression: 'abs(value)' })).toBe(103); + it('evaluates math expressions with reference to the value of the context, must be a number', async () => { + expect(await fn(-103, { expression: 'abs(value)' })).toBe(103); }); - it('evaluates math expressions with references to columns by id in a datatable', () => { - expect(fn(testTable, { expression: 'unique(in_stock)' })).toBe(2); - expect(fn(testTable, { expression: 'sum(quantity)' })).toBe(2508); - expect(fn(testTable, { expression: 'mean(price)' })).toBe(320); - expect(fn(testTable, { expression: 'min(price)' })).toBe(67); - expect(fn(testTable, { expression: 'median(quantity)' })).toBe(256); - expect(fn(testTable, { expression: 'max(price)' })).toBe(605); + it('evaluates math expressions with references to columns by id in a datatable', async () => { + expect(await fn(testTable, { expression: 'unique(in_stock)' })).toBe(2); + expect(await fn(testTable, { expression: 'sum(quantity)' })).toBe(2508); + expect(await fn(testTable, { expression: 'mean(price)' })).toBe(320); + expect(await fn(testTable, { expression: 'min(price)' })).toBe(67); + expect(await fn(testTable, { expression: 'median(quantity)' })).toBe(256); + expect(await fn(testTable, { expression: 'max(price)' })).toBe(605); }); - it('does not use the name for math', () => { - expect(() => fn(testTable, { expression: 'unique("in_stock label")' })).toThrow( - 'Unknown variable' + it('does not use the name for math', async () => { + await expect(fn(testTable, { expression: 'unique("in_stock label")' })).rejects.toHaveProperty( + 'message', + 'Unknown variable: in_stock label' ); - expect(() => fn(testTable, { expression: 'sum("quantity label")' })).toThrow( - 'Unknown variable' + + await expect(fn(testTable, { expression: 'sum("quantity label")' })).rejects.toHaveProperty( + 'message', + 'Unknown variable: quantity label' + ); + + await expect(fn(testTable, { expression: 'mean("price label")' })).rejects.toHaveProperty( + 'message', + 'Unknown variable: price label' + ); + await expect(fn(testTable, { expression: 'min("price label")' })).rejects.toHaveProperty( + 'message', + 'Unknown variable: price label' + ); + + await expect(fn(testTable, { expression: 'median("quantity label")' })).rejects.toHaveProperty( + 'message', + 'Unknown variable: quantity label' ); - expect(() => fn(testTable, { expression: 'mean("price label")' })).toThrow('Unknown variable'); - expect(() => fn(testTable, { expression: 'min("price label")' })).toThrow('Unknown variable'); - expect(() => fn(testTable, { expression: 'median("quantity label")' })).toThrow( - 'Unknown variable' + + await expect(fn(testTable, { expression: 'max("price label")' })).rejects.toHaveProperty( + 'message', + 'Unknown variable: price label' ); - expect(() => fn(testTable, { expression: 'max("price label")' })).toThrow('Unknown variable'); }); describe('args', () => { describe('expression', () => { - it('sets the math expression to be evaluted', () => { - expect(fn(null as unknown as MathInput, { expression: '10' })).toBe(10); - expect(fn(23.23, { expression: 'floor(value)' })).toBe(23); - expect(fn(testTable, { expression: 'count(price)' })).toBe(9); - expect(fn(testTable, { expression: 'count(name)' })).toBe(9); + it('sets the math expression to be evaluted', async () => { + expect(await fn(null as unknown as MathInput, { expression: '10' })).toBe(10); + expect(await fn(23.23, { expression: 'floor(value)' })).toBe(23); + expect(await fn(testTable, { expression: 'count(price)' })).toBe(9); + expect(await fn(testTable, { expression: 'count(name)' })).toBe(9); }); }); describe('onError', () => { - it('should return the desired fallback value, for invalid expressions', () => { - expect(fn(testTable, { expression: 'mean(name)', onError: 'zero' })).toBe(0); - expect(fn(testTable, { expression: 'mean(name)', onError: 'null' })).toBe(null); - expect(fn(testTable, { expression: 'mean(name)', onError: 'false' })).toBe(false); + it('should return the desired fallback value, for invalid expressions', async () => { + expect(await fn(testTable, { expression: 'mean(name)', onError: 'zero' })).toBe(0); + expect(await fn(testTable, { expression: 'mean(name)', onError: 'null' })).toBe(null); + expect(await fn(testTable, { expression: 'mean(name)', onError: 'false' })).toBe(false); }); - it('should return the desired fallback value, for division by zero', () => { - expect(fn(testTable, { expression: '1/0', onError: 'zero' })).toBe(0); - expect(fn(testTable, { expression: '1/0', onError: 'null' })).toBe(null); - expect(fn(testTable, { expression: '1/0', onError: 'false' })).toBe(false); + it('should return the desired fallback value, for division by zero', async () => { + expect(await fn(testTable, { expression: '1/0', onError: 'zero' })).toBe(0); + expect(await fn(testTable, { expression: '1/0', onError: 'null' })).toBe(null); + expect(await fn(testTable, { expression: '1/0', onError: 'false' })).toBe(false); }); }); }); describe('invalid expressions', () => { - it('throws when expression evaluates to an array', () => { - expect(() => fn(testTable, { expression: 'multiply(price, 2)' })).toThrow( - new RegExp(errors.tooManyResults().message.replace(/[()]/g, '\\$&')) + it('throws when expression evaluates to an array', async () => { + await expect(fn(testTable, { expression: 'multiply(price, 2)' })).rejects.toHaveProperty( + 'message', + errors.tooManyResults().message ); }); - it('throws when using an unknown context variable', () => { - expect(() => fn(testTable, { expression: 'sum(foo)' })).toThrow('Unknown variable: foo'); + it('throws when using an unknown context variable', async () => { + await expect(fn(testTable, { expression: 'sum(foo)' })).rejects.toHaveProperty( + 'message', + 'Unknown variable: foo' + ); }); - it('throws when using non-numeric data', () => { - expect(() => fn(testTable, { expression: 'mean(name)' })).toThrow( - new RegExp(errors.executionFailed().message) + it('throws when using non-numeric data', async () => { + await expect(fn(testTable, { expression: 'mean(name)' })).rejects.toHaveProperty( + 'message', + errors.executionFailed().message ); - expect(() => fn(testTable, { expression: 'mean(in_stock)' })).toThrow( - new RegExp(errors.executionFailed().message) + await expect(fn(testTable, { expression: 'mean(in_stock)' })).rejects.toHaveProperty( + 'message', + errors.executionFailed().message ); }); - it('throws when missing expression', () => { - expect(() => fn(testTable)).toThrow(new RegExp(errors.emptyExpression().message)); - - expect(() => fn(testTable, { expession: '' } as unknown as MathArguments)).toThrow( - new RegExp(errors.emptyExpression().message) + it('throws when missing expression', async () => { + await expect(fn(testTable)).rejects.toHaveProperty( + 'message', + errors.emptyExpression().message ); - expect(() => fn(testTable, { expession: ' ' } as unknown as MathArguments)).toThrow( - new RegExp(errors.emptyExpression().message) - ); + await expect( + fn(testTable, { expession: '' } as unknown as MathArguments) + ).rejects.toHaveProperty('message', errors.emptyExpression().message); + + await expect( + fn(testTable, { expession: ' ' } as unknown as MathArguments) + ).rejects.toHaveProperty('message', errors.emptyExpression().message); }); - it('throws when passing a context variable from an empty datatable', () => { - expect(() => fn(emptyTable, { expression: 'mean(foo)' })).toThrow( - new RegExp(errors.emptyDatatable().message) + it('throws when passing a context variable from an empty datatable', async () => { + await expect(() => fn(emptyTable, { expression: 'mean(foo)' })).rejects.toHaveProperty( + 'message', + errors.emptyDatatable().message ); }); - it('should not throw when requesting fallback values for invalid expression', () => { - expect(() => fn(testTable, { expression: 'mean(name)', onError: 'zero' })).not.toThrow(); - expect(() => fn(testTable, { expression: 'mean(name)', onError: 'false' })).not.toThrow(); - expect(() => fn(testTable, { expression: 'mean(name)', onError: 'null' })).not.toThrow(); + it('should not throw when requesting fallback values for invalid expression', async () => { + await expect( + fn(testTable, { expression: 'mean(name)', onError: 'zero' }) + ).resolves.toBeDefined(); + await expect( + fn(testTable, { expression: 'mean(name)', onError: 'false' }) + ).resolves.toBeDefined(); + await expect( + fn(testTable, { expression: 'mean(name)', onError: 'null' }) + ).resolves.toBeDefined(); }); - it('should throw when declared in the onError argument', () => { - expect(() => fn(testTable, { expression: 'mean(name)', onError: 'throw' })).toThrow( - new RegExp(errors.executionFailed().message) - ); + it('should throw when declared in the onError argument', async () => { + await expect( + fn(testTable, { expression: 'mean(name)', onError: 'throw' }) + ).rejects.toHaveProperty('message', errors.executionFailed().message); }); - it('should throw when dividing by zero', () => { - expect(() => fn(testTable, { expression: '1/0', onError: 'throw' })).toThrow( - new RegExp('Cannot divide by 0') + it('should throw when dividing by zero', async () => { + await expect(fn(testTable, { expression: '1/0', onError: 'throw' })).rejects.toHaveProperty( + 'message', + 'Cannot divide by 0' ); }); }); diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts index 3464736fe0ad3..739e31199d1f8 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/math_column.test.ts @@ -12,14 +12,18 @@ import { functionWrapper, testTable, tableWithNulls } from './utils'; describe('mathColumn', () => { const fn = functionWrapper(mathColumn); - it('throws if the id is used', () => { - expect(() => fn(testTable, { id: 'price', name: 'price', expression: 'price * 2' })).toThrow( - `ID must be unique` - ); + it('throws if the id is used', async () => { + await expect( + fn(testTable, { id: 'price', name: 'price', expression: 'price * 2' }) + ).rejects.toHaveProperty('message', `ID must be unique`); }); - it('applies math to each row by id', () => { - const result = fn(testTable, { id: 'output', name: 'output', expression: 'quantity * price' }); + it('applies math to each row by id', async () => { + const result = await fn(testTable, { + id: 'output', + name: 'output', + expression: 'quantity * price', + }); expect(result.columns).toEqual([ ...testTable.columns, { id: 'output', name: 'output', meta: { params: { id: 'number' }, type: 'number' } }, @@ -34,7 +38,7 @@ describe('mathColumn', () => { }); }); - it('extracts a single array value, but not a multi-value array', () => { + it('extracts a single array value, but not a multi-value array', async () => { const arrayTable = { ...testTable, rows: [ @@ -52,23 +56,24 @@ describe('mathColumn', () => { name: 'output', expression: 'quantity', }; - expect(fn(arrayTable, args).rows[0].output).toEqual(100); - expect(() => fn(arrayTable, { ...args, expression: 'price' })).toThrowError( - `Cannot perform math on array values` + expect((await fn(arrayTable, args)).rows[0].output).toEqual(100); + await expect(fn(arrayTable, { ...args, expression: 'price' })).rejects.toHaveProperty( + 'message', + `Cannot perform math on array values at output` ); }); - it('handles onError', () => { + it('handles onError', async () => { const args = { id: 'output', name: 'output', expression: 'quantity / 0', }; - expect(() => fn(testTable, args)).toThrowError(`Cannot divide by 0`); - expect(() => fn(testTable, { ...args, onError: 'throw' })).toThrow(); - expect(fn(testTable, { ...args, onError: 'zero' }).rows[0].output).toEqual(0); - expect(fn(testTable, { ...args, onError: 'false' }).rows[0].output).toEqual(false); - expect(fn(testTable, { ...args, onError: 'null' }).rows[0].output).toEqual(null); + await expect(fn(testTable, args)).rejects.toHaveProperty('message', `Cannot divide by 0`); + await expect(fn(testTable, { ...args, onError: 'throw' })).rejects.toBeDefined(); + expect((await fn(testTable, { ...args, onError: 'zero' })).rows[0].output).toEqual(0); + expect((await fn(testTable, { ...args, onError: 'false' })).rows[0].output).toEqual(false); + expect((await fn(testTable, { ...args, onError: 'null' })).rows[0].output).toEqual(null); }); it('should copy over the meta information from the specified column', async () => { @@ -96,9 +101,14 @@ describe('mathColumn', () => { }); }); - it('should correctly infer the type from the first non-null row', () => { + it('should correctly infer the type from the first non-null row', async () => { expect( - fn(tableWithNulls, { id: 'value', name: 'value', expression: 'price + 2', onError: 'null' }) + await fn(tableWithNulls, { + id: 'value', + name: 'value', + expression: 'price + 2', + onError: 'null', + }) ).toEqual( expect.objectContaining({ type: 'datatable', diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/moving_average.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/moving_average.test.ts index 6f816f8310314..5c2463b90f137 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/moving_average.test.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/moving_average.test.ts @@ -16,10 +16,10 @@ const defaultArgs = { window: 5, inputColumnId: 'val', outputColumnId: 'output' describe('interpreter/functions#movingAverage', () => { const fn = functionWrapper(movingAverage); const runFn = (input: Datatable, args: MovingAverageArgs) => - fn(input, args, {} as ExecutionContext) as Datatable; + fn(input, args, {} as ExecutionContext) as Promise; - it('calculates movingAverage', () => { - const result = runFn( + it('calculates movingAverage', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -40,8 +40,8 @@ describe('interpreter/functions#movingAverage', () => { ]); }); - it('skips null or undefined values until there is real data', () => { - const result = runFn( + it('skips null or undefined values until there is real data', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -77,8 +77,8 @@ describe('interpreter/functions#movingAverage', () => { ]); }); - it('treats 0 as real data', () => { - const result = runFn( + it('treats 0 as real data', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -115,8 +115,8 @@ describe('interpreter/functions#movingAverage', () => { ]); }); - it('calculates movingAverage for multiple series', () => { - const result = runFn( + it('calculates movingAverage for multiple series', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -149,8 +149,8 @@ describe('interpreter/functions#movingAverage', () => { ]); }); - it('treats missing split column as separate series', () => { - const result = runFn( + it('treats missing split column as separate series', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -182,8 +182,8 @@ describe('interpreter/functions#movingAverage', () => { ]); }); - it('treats null like undefined and empty string for split columns', () => { - const result = runFn( + it('treats null like undefined and empty string for split columns', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -217,8 +217,8 @@ describe('interpreter/functions#movingAverage', () => { ]); }); - it('calculates movingAverage for multiple series by multiple split columns', () => { - const result = runFn( + it('calculates movingAverage for multiple series by multiple split columns', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -251,8 +251,8 @@ describe('interpreter/functions#movingAverage', () => { ]); }); - it('splits separate series by the string representation of the cell values', () => { - const result = runFn( + it('splits separate series by the string representation of the cell values', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -272,8 +272,8 @@ describe('interpreter/functions#movingAverage', () => { expect(result.rows.map((row) => row.output)).toEqual([undefined, 1, undefined, 10]); }); - it('casts values to number before calculating movingAverage', () => { - const result = runFn( + it('casts values to number before calculating movingAverage', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -289,8 +289,8 @@ describe('interpreter/functions#movingAverage', () => { ]); }); - it('skips NaN like values', () => { - const result = runFn( + it('skips NaN like values', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -301,8 +301,8 @@ describe('interpreter/functions#movingAverage', () => { expect(result.rows.map((row) => row.output)).toEqual([undefined, 5, (5 + 7) / 2, NaN, NaN]); }); - it('copies over meta information from the source column', () => { - const result = runFn( + it('copies over meta information from the source column', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -343,8 +343,8 @@ describe('interpreter/functions#movingAverage', () => { }); }); - it('sets output name on output column if specified', () => { - const result = runFn( + it('sets output name on output column if specified', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -367,7 +367,7 @@ describe('interpreter/functions#movingAverage', () => { }); }); - it('returns source table if input column does not exist', () => { + it('returns source table if input column does not exist', async () => { const input: Datatable = { type: 'datatable', columns: [ @@ -382,12 +382,12 @@ describe('interpreter/functions#movingAverage', () => { rows: [{ val: 5 }], }; expect( - runFn(input, { ...defaultArgs, inputColumnId: 'nonexisting', outputColumnId: 'output' }) + await runFn(input, { ...defaultArgs, inputColumnId: 'nonexisting', outputColumnId: 'output' }) ).toBe(input); }); - it('throws an error if output column exists already', () => { - expect(() => + it('throws an error if output column exists already', async () => { + await expect( runFn( { type: 'datatable', @@ -404,11 +404,11 @@ describe('interpreter/functions#movingAverage', () => { }, { ...defaultArgs, inputColumnId: 'val', outputColumnId: 'val' } ) - ).toThrow(); + ).rejects.toBeDefined(); }); - it('calculates moving average for window equal to 1', () => { - const result = runFn( + it('calculates moving average for window equal to 1', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -441,8 +441,8 @@ describe('interpreter/functions#movingAverage', () => { ]); }); - it('calculates moving average for window bigger than array', () => { - const result = runFn( + it('calculates moving average for window bigger than array', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], diff --git a/src/plugins/expressions/common/expression_functions/specs/tests/overall_metric.test.ts b/src/plugins/expressions/common/expression_functions/specs/tests/overall_metric.test.ts index 94bd3a7edba5d..d43c0650dc9d0 100644 --- a/src/plugins/expressions/common/expression_functions/specs/tests/overall_metric.test.ts +++ b/src/plugins/expressions/common/expression_functions/specs/tests/overall_metric.test.ts @@ -14,10 +14,10 @@ import { overallMetric, OverallMetricArgs } from '../overall_metric'; describe('interpreter/functions#overall_metric', () => { const fn = functionWrapper(overallMetric); const runFn = (input: Datatable, args: OverallMetricArgs) => - fn(input, args, {} as ExecutionContext) as Datatable; + fn(input, args, {} as ExecutionContext) as Promise; - it('ignores null or undefined with sum', () => { - const result = runFn( + it('ignores null or undefined with sum', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -33,8 +33,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([12, 12, 12, 12]); }); - it('ignores null or undefined with average', () => { - const result = runFn( + it('ignores null or undefined with average', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -50,8 +50,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([3, 3, 3, 3, 3]); }); - it('ignores null or undefined with min', () => { - const result = runFn( + it('ignores null or undefined with min', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -67,8 +67,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([1, 1, 1, 1, 1]); }); - it('ignores null or undefined with max', () => { - const result = runFn( + it('ignores null or undefined with max', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -84,8 +84,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([-1, -1, -1, -1, -1]); }); - it('calculates overall sum for multiple series', () => { - const result = runFn( + it('calculates overall sum for multiple series', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -118,8 +118,8 @@ describe('interpreter/functions#overall_metric', () => { ]); }); - it('treats missing split column as separate series', () => { - const result = runFn( + it('treats missing split column as separate series', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -142,7 +142,7 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([1, 2, 3, 1, 3, 1, 2, 2]); }); - it('treats null like undefined and empty string for split columns', () => { + it('treats null like undefined and empty string for split columns', async () => { const table: Datatable = { type: 'datatable', columns: [ @@ -162,7 +162,7 @@ describe('interpreter/functions#overall_metric', () => { ], }; - const result = runFn(table, { + const result = await runFn(table, { inputColumnId: 'val', outputColumnId: 'output', by: ['split'], @@ -180,7 +180,7 @@ describe('interpreter/functions#overall_metric', () => { 3 + 5 + 7 + 9, ]); - const result2 = runFn(table, { + const result2 = await runFn(table, { inputColumnId: 'val', outputColumnId: 'output', by: ['split'], @@ -188,7 +188,7 @@ describe('interpreter/functions#overall_metric', () => { }); expect(result2.rows.map((row) => row.output)).toEqual([6, 8, 9, 6, 9, 6, 9, 8, 9]); - const result3 = runFn(table, { + const result3 = await runFn(table, { inputColumnId: 'val', outputColumnId: 'output', by: ['split'], @@ -207,8 +207,8 @@ describe('interpreter/functions#overall_metric', () => { ]); }); - it('handles array values', () => { - const result = runFn( + it('handles array values', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -224,8 +224,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([28, 28, 28, 28]); }); - it('takes array values into account for average calculation', () => { - const result = runFn( + it('takes array values into account for average calculation', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -241,7 +241,7 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([3, 3]); }); - it('handles array values for split columns', () => { + it('handles array values for split columns', async () => { const table: Datatable = { type: 'datatable', columns: [ @@ -261,7 +261,7 @@ describe('interpreter/functions#overall_metric', () => { ], }; - const result = runFn(table, { + const result = await runFn(table, { inputColumnId: 'val', outputColumnId: 'output', by: ['split'], @@ -279,7 +279,7 @@ describe('interpreter/functions#overall_metric', () => { 3 + 5 + 7 + 9 + 99, ]); - const result2 = runFn(table, { + const result2 = await runFn(table, { inputColumnId: 'val', outputColumnId: 'output', by: ['split'], @@ -288,8 +288,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result2.rows.map((row) => row.output)).toEqual([6, 11, 99, 6, 99, 6, 99, 11, 99]); }); - it('calculates cumulative sum for multiple series by multiple split columns', () => { - const result = runFn( + it('calculates cumulative sum for multiple series by multiple split columns', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -313,8 +313,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([1 + 4, 2, 3, 1 + 4, 5, 6, 7 + 8, 7 + 8]); }); - it('splits separate series by the string representation of the cell values', () => { - const result = runFn( + it('splits separate series by the string representation of the cell values', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -334,8 +334,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([1 + 2, 1 + 2, 10 + 11, 10 + 11]); }); - it('casts values to number before calculating cumulative sum', () => { - const result = runFn( + it('casts values to number before calculating cumulative sum', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -346,8 +346,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([7, 7, 7, 7]); }); - it('casts values to number before calculating metric for NaN like values', () => { - const result = runFn( + it('casts values to number before calculating metric for NaN like values', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -358,8 +358,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([NaN, NaN, NaN, NaN]); }); - it('skips undefined and null values', () => { - const result = runFn( + it('skips undefined and null values', async () => { + const result = await runFn( { type: 'datatable', columns: [{ id: 'val', name: 'val', meta: { type: 'number' } }], @@ -380,8 +380,8 @@ describe('interpreter/functions#overall_metric', () => { expect(result.rows.map((row) => row.output)).toEqual([4, 4, 4, 4, 4, 4, 4, 4, 4]); }); - it('copies over meta information from the source column', () => { - const result = runFn( + it('copies over meta information from the source column', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -422,8 +422,8 @@ describe('interpreter/functions#overall_metric', () => { }); }); - it('sets output name on output column if specified', () => { - const result = runFn( + it('sets output name on output column if specified', async () => { + const result = await runFn( { type: 'datatable', columns: [ @@ -451,7 +451,7 @@ describe('interpreter/functions#overall_metric', () => { }); }); - it('returns source table if input column does not exist', () => { + it('returns source table if input column does not exist', async () => { const input: Datatable = { type: 'datatable', columns: [ @@ -466,12 +466,12 @@ describe('interpreter/functions#overall_metric', () => { rows: [{ val: 5 }], }; expect( - runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output', metric: 'sum' }) + await runFn(input, { inputColumnId: 'nonexisting', outputColumnId: 'output', metric: 'sum' }) ).toBe(input); }); - it('throws an error if output column exists already', () => { - expect(() => + it('throws an error if output column exists already', async () => { + await expect( runFn( { type: 'datatable', @@ -488,6 +488,6 @@ describe('interpreter/functions#overall_metric', () => { }, { inputColumnId: 'val', outputColumnId: 'val', metric: 'max' } ) - ).toThrow(); + ).rejects.toBeDefined(); }); }); diff --git a/src/plugins/expressions/public/index.ts b/src/plugins/expressions/public/index.ts index 6e8d220a3ca0c..3746d4d61a7bc 100644 --- a/src/plugins/expressions/public/index.ts +++ b/src/plugins/expressions/public/index.ts @@ -22,20 +22,23 @@ export function plugin(initializerContext: PluginInitializerContext) { } // Static exports. -export { ExpressionExecutor, IExpressionLoaderParams, ExpressionRenderError } from './types'; -export { +export type { + ExpressionExecutor, + IExpressionLoaderParams, + ExpressionRenderError, + ExpressionRendererEvent, +} from './types'; +export type { ExpressionLoader } from './loader'; +export type { ExpressionRenderHandler } from './render'; +export type { ExpressionRendererComponent, - ReactExpressionRenderer, ReactExpressionRendererProps, ReactExpressionRendererType, } from './react_expression_renderer'; -export { ExpressionRenderHandler, ExpressionRendererEvent } from './render'; -export { +export type { AnyExpressionFunctionDefinition, AnyExpressionTypeDefinition, ArgumentType, - buildExpression, - buildExpressionFunction, Datatable, DatatableColumn, DatatableColumnType, @@ -79,17 +82,12 @@ export { FontStyle, FontValue, FontWeight, - format, - formatExpression, FunctionsRegistry, IInterpreterRenderHandlers, InterpreterErrorType, IRegistry, - isExpressionAstBuilder, KnownTypeToString, Overflow, - parse, - parseExpression, PointSeries, PointSeriesColumn, PointSeriesColumnName, @@ -109,6 +107,13 @@ export { ExpressionsServiceSetup, ExpressionsServiceStart, TablesAdapter, - ExpressionsInspectorAdapter, +} from '../common'; + +export { + buildExpression, + buildExpressionFunction, + formatExpression, + isExpressionAstBuilder, + parseExpression, createDefaultInspectorAdapters, } from '../common'; diff --git a/src/plugins/expressions/public/loader.test.ts b/src/plugins/expressions/public/loader.test.ts index f22963cedf612..61a6ee3bda912 100644 --- a/src/plugins/expressions/public/loader.test.ts +++ b/src/plugins/expressions/public/loader.test.ts @@ -88,8 +88,8 @@ jest.mock('./services', () => { }); describe('execute helper function', () => { - it('returns ExpressionLoader instance', () => { - const response = loader(element, '', {}); + it('returns ExpressionLoader instance', async () => { + const response = await loader(element, '', {}); expect(response).toBeInstanceOf(ExpressionLoader); }); }); diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index b0a54e3dec35d..64384ebbfc852 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -194,10 +194,10 @@ export class ExpressionLoader { export type IExpressionLoader = ( element: HTMLElement, - expression: string | ExpressionAstExpression, - params: IExpressionLoaderParams -) => ExpressionLoader; + expression?: string | ExpressionAstExpression, + params?: IExpressionLoaderParams +) => Promise; -export const loader: IExpressionLoader = (element, expression, params) => { +export const loader: IExpressionLoader = async (element, expression?, params?) => { return new ExpressionLoader(element, expression, params); }; diff --git a/src/plugins/expressions/public/mocks.tsx b/src/plugins/expressions/public/mocks.tsx index f2f6a6807f339..353ae2908b54c 100644 --- a/src/plugins/expressions/public/mocks.tsx +++ b/src/plugins/expressions/public/mocks.tsx @@ -30,8 +30,6 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { return { execute: jest.fn(), - ExpressionLoader: jest.fn(), - ExpressionRenderHandler: jest.fn(), getFunction: jest.fn(), getFunctions: jest.fn(), getRenderer: jest.fn(), @@ -39,8 +37,8 @@ const createStartContract = (): Start => { getType: jest.fn(), getTypes: jest.fn(), loader: jest.fn(), - ReactExpressionRenderer: jest.fn((props) => <>), render: jest.fn(), + ReactExpressionRenderer: jest.fn((props) => <>), run: jest.fn(), }; }; diff --git a/src/plugins/expressions/public/plugin.ts b/src/plugins/expressions/public/plugin.ts index 37d519ac45133..ccbeab791fe2c 100644 --- a/src/plugins/expressions/public/plugin.ts +++ b/src/plugins/expressions/public/plugin.ts @@ -8,16 +8,17 @@ import { pick } from 'lodash'; import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { ExpressionsServiceSetup, ExpressionsServiceStart } from '../common'; +import { SerializableRecord } from '@kbn/utility-types'; +import type { ExpressionsServiceSetup, ExpressionsServiceStart } from '../common'; import { ExpressionsService, setRenderersRegistry, setNotifications, setExpressionsService, } from './services'; -import { ReactExpressionRenderer } from './react_expression_renderer'; -import { ExpressionLoader, IExpressionLoader, loader } from './loader'; -import { render, ExpressionRenderHandler } from './render'; +import { ReactExpressionRenderer } from './react_expression_renderer_wrapper'; +import type { IExpressionLoader } from './loader'; +import type { IExpressionRenderer } from './render'; /** * Expressions public setup contract, extends {@link ExpressionsServiceSetup} @@ -28,11 +29,9 @@ export type ExpressionsSetup = ExpressionsServiceSetup; * Expressions public start contrect, extends {@link ExpressionServiceStart} */ export interface ExpressionsStart extends ExpressionsServiceStart { - ExpressionLoader: typeof ExpressionLoader; - ExpressionRenderHandler: typeof ExpressionRenderHandler; loader: IExpressionLoader; + render: IExpressionRenderer; ReactExpressionRenderer: typeof ReactExpressionRenderer; - render: typeof render; } export class ExpressionsPublicPlugin implements Plugin { @@ -66,13 +65,24 @@ export class ExpressionsPublicPlugin implements Plugin { + const { ExpressionLoader } = await import('./loader'); + return new ExpressionLoader(element, expression, params); + }; + + const render: IExpressionRenderer = async (element, data, options) => { + const { ExpressionRenderHandler } = await import('./render'); + const handler = new ExpressionRenderHandler(element, options); + handler.render(data as SerializableRecord); + return handler; + }; + const start = { ...expressions.start(), - ExpressionLoader, - ExpressionRenderHandler, loader, - ReactExpressionRenderer, render, + ReactExpressionRenderer, }; return Object.freeze(start); diff --git a/src/plugins/expressions/public/react_expression_renderer.test.tsx b/src/plugins/expressions/public/react_expression_renderer.test.tsx index f1932ce7dd6ba..cf19b333fed45 100644 --- a/src/plugins/expressions/public/react_expression_renderer.test.tsx +++ b/src/plugins/expressions/public/react_expression_renderer.test.tsx @@ -10,13 +10,12 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { Subject } from 'rxjs'; import { share } from 'rxjs/operators'; -import { ReactExpressionRenderer } from './react_expression_renderer'; +import { default as ReactExpressionRenderer } from './react_expression_renderer'; import { ExpressionLoader } from './loader'; import { mount } from 'enzyme'; import { EuiProgress } from '@elastic/eui'; import { IInterpreterRenderHandlers } from '../common'; -import { RenderErrorHandlerFnType } from './types'; -import { ExpressionRendererEvent } from './render'; +import { RenderErrorHandlerFnType, ExpressionRendererEvent } from './types'; jest.mock('./loader', () => { return { diff --git a/src/plugins/expressions/public/react_expression_renderer.tsx b/src/plugins/expressions/public/react_expression_renderer.tsx index 428419c4ff022..77b4402b22c06 100644 --- a/src/plugins/expressions/public/react_expression_renderer.tsx +++ b/src/plugins/expressions/public/react_expression_renderer.tsx @@ -13,10 +13,9 @@ import { filter } from 'rxjs/operators'; import useShallowCompareEffect from 'react-use/lib/useShallowCompareEffect'; import { EuiLoadingChart, EuiProgress } from '@elastic/eui'; import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { IExpressionLoaderParams, ExpressionRenderError } from './types'; +import { IExpressionLoaderParams, ExpressionRenderError, ExpressionRendererEvent } from './types'; import { ExpressionAstExpression, IInterpreterRenderHandlers } from '../common'; import { ExpressionLoader } from './loader'; -import { ExpressionRendererEvent } from './render'; // Accept all options of the runner as props except for the // dom element which is provided by the component itself @@ -58,7 +57,8 @@ const defaultState: State = { error: null, }; -export const ReactExpressionRenderer = ({ +// eslint-disable-next-line import/no-default-export +export default function ReactExpressionRenderer({ className, dataAttrs, padding, @@ -69,7 +69,7 @@ export const ReactExpressionRenderer = ({ reload$, debounce, ...expressionLoaderOptions -}: ReactExpressionRendererProps) => { +}: ReactExpressionRendererProps) { const mountpoint: React.MutableRefObject = useRef(null); const [state, setState] = useState({ ...defaultState }); const hasCustomRenderErrorHandler = !!renderError; @@ -237,4 +237,4 @@ export const ReactExpressionRenderer = ({ /> ); -}; +} diff --git a/src/plugins/expressions/public/react_expression_renderer_wrapper.tsx b/src/plugins/expressions/public/react_expression_renderer_wrapper.tsx new file mode 100644 index 0000000000000..45295da0a9ae8 --- /dev/null +++ b/src/plugins/expressions/public/react_expression_renderer_wrapper.tsx @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { lazy, Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import type { ReactExpressionRendererProps } from './react_expression_renderer'; + +const ReactExpressionRendererComponent = lazy(() => import('./react_expression_renderer')); + +export const ReactExpressionRenderer = (props: ReactExpressionRendererProps) => ( + }> + + +); diff --git a/src/plugins/expressions/public/render.test.ts b/src/plugins/expressions/public/render.test.ts index 8d4298785572a..f522ea5fe5971 100644 --- a/src/plugins/expressions/public/render.test.ts +++ b/src/plugins/expressions/public/render.test.ts @@ -53,8 +53,8 @@ const getHandledError = () => { }; describe('render helper function', () => { - it('returns ExpressionRenderHandler instance', () => { - const response = render(element, {}); + it('returns ExpressionRenderHandler instance', async () => { + const response = await render(element, {}); expect(response).toBeInstanceOf(ExpressionRenderHandler); }); }); diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index 8635a4033bde5..25bffdca089ee 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -11,14 +11,14 @@ import { Observable } from 'rxjs'; import { filter } from 'rxjs/operators'; import { isNumber } from 'lodash'; import { SerializableRecord } from '@kbn/utility-types'; -import { ExpressionRenderError, RenderErrorHandlerFnType, IExpressionLoaderParams } from './types'; -import { renderErrorHandler as defaultRenderErrorHandler } from './render_error_handler'; import { - IInterpreterRenderHandlers, - IInterpreterRenderEvent, - IInterpreterRenderUpdateParams, - RenderMode, -} from '../common'; + ExpressionRenderError, + RenderErrorHandlerFnType, + IExpressionLoaderParams, + ExpressionRendererEvent, +} from './types'; +import { renderErrorHandler as defaultRenderErrorHandler } from './render_error_handler'; +import { IInterpreterRenderHandlers, IInterpreterRenderUpdateParams, RenderMode } from '../common'; import { getRenderersRegistry } from './services'; @@ -32,9 +32,6 @@ export interface ExpressionRenderHandlerParams { hasCompatibleActions?: (event: ExpressionRendererEvent) => Promise; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type ExpressionRendererEvent = IInterpreterRenderEvent; - type UpdateValue = IInterpreterRenderUpdateParams; export class ExpressionRenderHandler { @@ -154,12 +151,14 @@ export class ExpressionRenderHandler { }; } -export function render( +export type IExpressionRenderer = ( element: HTMLElement, data: unknown, options?: ExpressionRenderHandlerParams -): ExpressionRenderHandler { +) => Promise; + +export const render: IExpressionRenderer = async (element, data, options) => { const handler = new ExpressionRenderHandler(element, options); handler.render(data as SerializableRecord); return handler; -} +}; diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index ea47403332c74..35bda400756d0 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -14,6 +14,7 @@ import { ExpressionValue, ExpressionsService, RenderMode, + IInterpreterRenderEvent, } from '../../common'; import { ExpressionRenderHandlerParams } from '../render'; @@ -75,3 +76,6 @@ export type RenderErrorHandlerFnType = ( error: ExpressionRenderError, handlers: IInterpreterRenderHandlers ) => void; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type ExpressionRendererEvent = IInterpreterRenderEvent; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 5868489934dc5..928cbec9c3747 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -30,7 +30,7 @@ import { } from '../../../../plugins/embeddable/public'; import { IExpressionLoaderParams, - ExpressionsStart, + ExpressionLoader, ExpressionRenderError, ExpressionAstExpression, } from '../../../../plugins/expressions/public'; @@ -81,8 +81,6 @@ export type VisualizeSavedObjectAttributes = SavedObjectAttributes & { export type VisualizeByValueInput = { attributes: VisualizeSavedObjectAttributes } & VisualizeInput; export type VisualizeByReferenceInput = SavedObjectEmbeddableInput & VisualizeInput; -type ExpressionLoader = InstanceType; - export class VisualizeEmbeddable extends Embeddable implements ReferenceOrValueEmbeddable @@ -302,7 +300,7 @@ export class VisualizeEmbeddable super.render(this.domNode); const expressions = getExpressions(); - this.handler = new expressions.ExpressionLoader(this.domNode, undefined, { + this.handler = await expressions.loader(this.domNode, undefined, { onRenderError: (element: HTMLElement, error: ExpressionRenderError) => { this.onContainerError(error); }, diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json index 67316b1d0d7eb..e78f294cde7c9 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/kibana.json @@ -9,5 +9,5 @@ "requiredPlugins": ["data", "savedObjects", "kibanaUtils", "expressions"], "server": true, "ui": true, - "requiredBundles": ["inspector"] + "requiredBundles": [] } diff --git a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app/components/main.tsx b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app/components/main.tsx index b03451bdebad2..6d132c3acb730 100644 --- a/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app/components/main.tsx +++ b/test/interpreter_functional/plugins/kbn_tp_run_pipeline/public/app/components/main.tsx @@ -13,10 +13,8 @@ import { first, pluck } from 'rxjs/operators'; import { IInterpreterRenderHandlers, ExpressionValue, - TablesAdapter, } from '../../../../../../../src/plugins/expressions/public'; -import { RequestAdapter } from '../../../../../../../src/plugins/inspector/public'; -import { Adapters, ExpressionRenderHandler } from '../../types'; +import { ExpressionRenderHandler } from '../../types'; import { getExpressions } from '../../services'; declare global { @@ -50,13 +48,9 @@ class Main extends React.Component<{}, State> { initialContext: ExpressionValue = {} ) => { this.setState({ expression }); - const adapters: Adapters = { - requests: new RequestAdapter(), - tables: new TablesAdapter(), - }; + return getExpressions() .execute(expression, context || { type: 'null' }, { - inspectorAdapters: adapters, searchContext: initialContext as any, }) .getData() @@ -70,7 +64,7 @@ class Main extends React.Component<{}, State> { lastRenderHandler.destroy(); } - lastRenderHandler = getExpressions().render(this.chartRef.current!, context, { + lastRenderHandler = await getExpressions().render(this.chartRef.current!, context, { onRenderError: (el: HTMLElement, error: unknown, handler: IInterpreterRenderHandlers) => { this.setState({ expression: 'Render error!\n\n' + JSON.stringify(error), diff --git a/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts b/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts index c883f6b7cb479..cd4fc5ed945d4 100644 --- a/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts +++ b/x-pack/plugins/lens/common/expressions/merge_tables/merge_tables.test.ts @@ -58,6 +58,7 @@ describe('lens_merge_tables', () => { const adapters: DefaultInspectorAdapters = { tables: new TablesAdapter(), requests: {} as never, + expression: {} as never, }; mergeTables.fn(null, { layerIds: ['first', 'second'], tables: [sampleTable1, sampleTable2] }, { inspectorAdapters: adapters, From 3f12a90766a98427ff703334a3f1a8cb0d9f87e8 Mon Sep 17 00:00:00 2001 From: Peter Pisljar Date: Wed, 20 Oct 2021 13:56:58 +0200 Subject: [PATCH 20/23] remove any from filter persistable state (#115128) --- src/plugins/data/common/query/persistable_state.ts | 7 +------ .../public/query/filter_manager/filter_manager.ts | 12 +++--------- src/plugins/data/server/query/query_service.ts | 9 +-------- .../kibana_utils/common/persistable_state/types.ts | 6 +++--- .../header/__snapshots__/index.test.tsx.snap | 1 - 5 files changed, 8 insertions(+), 27 deletions(-) diff --git a/src/plugins/data/common/query/persistable_state.ts b/src/plugins/data/common/query/persistable_state.ts index 367234e9ff4f0..934d481685db4 100644 --- a/src/plugins/data/common/query/persistable_state.ts +++ b/src/plugins/data/common/query/persistable_state.ts @@ -8,7 +8,6 @@ import uuid from 'uuid'; import { Filter } from '@kbn/es-query'; -import type { SerializableRecord } from '@kbn/utility-types'; import { SavedObjectReference } from '../../../../core/types'; export const extract = (filters: Filter[]) => { @@ -51,14 +50,10 @@ export const inject = (filters: Filter[], references: SavedObjectReference[]) => }); }; -export const telemetry = (filters: SerializableRecord, collector: unknown) => { +export const telemetry = (filters: Filter[], collector: unknown) => { return {}; }; -export const migrateToLatest = (filters: Filter[], version: string) => { - return filters; -}; - export const getAllMigrations = () => { return {}; }; diff --git a/src/plugins/data/public/query/filter_manager/filter_manager.ts b/src/plugins/data/public/query/filter_manager/filter_manager.ts index 34af80a483e6b..f076a2c591fb1 100644 --- a/src/plugins/data/public/query/filter_manager/filter_manager.ts +++ b/src/plugins/data/public/query/filter_manager/filter_manager.ts @@ -26,13 +26,12 @@ import { import { PersistableStateService } from '../../../../kibana_utils/common/persistable_state'; import { getAllMigrations, - migrateToLatest, inject, extract, telemetry, } from '../../../common/query/persistable_state'; -export class FilterManager implements PersistableStateService { +export class FilterManager implements PersistableStateService { private filters: Filter[] = []; private updated$: Subject = new Subject(); private fetch$: Subject = new Subject(); @@ -228,16 +227,11 @@ export class FilterManager implements PersistableStateService { }); } - // Filter needs to implement SerializableRecord - public extract = extract as any; + public extract = extract; - // Filter needs to implement SerializableRecord - public inject = inject as any; + public inject = inject; public telemetry = telemetry; - // Filter needs to implement SerializableRecord - public migrateToLatest = migrateToLatest as any; - public getAllMigrations = getAllMigrations; } diff --git a/src/plugins/data/server/query/query_service.ts b/src/plugins/data/server/query/query_service.ts index 9e67f0ab825df..1bf5ff901e90f 100644 --- a/src/plugins/data/server/query/query_service.ts +++ b/src/plugins/data/server/query/query_service.ts @@ -8,13 +8,7 @@ import { CoreSetup, Plugin } from 'kibana/server'; import { querySavedObjectType } from '../saved_objects'; -import { - extract, - inject, - telemetry, - migrateToLatest, - getAllMigrations, -} from '../../common/query/persistable_state'; +import { extract, inject, telemetry, getAllMigrations } from '../../common/query/persistable_state'; export class QueryService implements Plugin { public setup(core: CoreSetup) { @@ -25,7 +19,6 @@ export class QueryService implements Plugin { extract, inject, telemetry, - migrateToLatest, getAllMigrations, }, }; diff --git a/src/plugins/kibana_utils/common/persistable_state/types.ts b/src/plugins/kibana_utils/common/persistable_state/types.ts index 6fea0a3a4eab6..80b791a75758f 100644 --- a/src/plugins/kibana_utils/common/persistable_state/types.ts +++ b/src/plugins/kibana_utils/common/persistable_state/types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { SerializableRecord } from '@kbn/utility-types'; +import type { SerializableRecord, Serializable } from '@kbn/utility-types'; import { SavedObjectReference } from '../../../../core/types'; /** @@ -26,7 +26,7 @@ import { SavedObjectReference } from '../../../../core/types'; * }; * ``` */ -export interface VersionedState { +export interface VersionedState { version: string; state: S; } @@ -116,7 +116,7 @@ export type PersistableStateDefinition

{ +export interface PersistableStateService

{ /** * Function which reports telemetry information. This function is essentially * a "reducer" - it receives the existing "stats" object and returns an diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap index 17a9c7efbf301..3e764b34d74c5 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/__snapshots__/index.test.tsx.snap @@ -20,7 +20,6 @@ exports[`Header rendering renders correctly against snapshot 1`] = ` "filters": Array [], "getAllMigrations": [Function], "inject": [Function], - "migrateToLatest": [Function], "telemetry": [Function], "uiSettings": Object { "get": [MockFunction], From 73566ad285f703e216d35f6775d62367e1010900 Mon Sep 17 00:00:00 2001 From: Marco Vettorello Date: Wed, 20 Oct 2021 14:03:45 +0200 Subject: [PATCH 21/23] [charts] Replace usage of linear xScaleType in barchart [skip-ci] (#114778) --- .../alerting/chart_preview/index.tsx | 2 +- .../field_data_row/action_menu/lens_utils.ts | 2 +- .../boolean_content.tsx | 6 +- .../boolean_content_preview.tsx | 1 + .../field_data_row/column_chart.tsx | 12 ++- .../field_data_row/use_column_chart.test.tsx | 85 +++++++++++-------- .../field_data_row/use_column_chart.tsx | 8 +- .../criterion_preview_chart.tsx | 2 +- .../single_metric_sparkline.tsx | 8 +- .../indexpattern_datasource/field_item.tsx | 6 +- .../components/data_grid/column_chart.tsx | 8 +- .../alerts_histogram.tsx | 11 +-- .../charts/duration_line_series_list.tsx | 6 +- .../common/charts/ping_histogram.tsx | 5 +- .../components/waterfall_chart_fixed_axis.tsx | 2 +- 15 files changed, 91 insertions(+), 73 deletions(-) diff --git a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx index ee6a58b0dbb76..fb1a99db0bf5b 100644 --- a/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx +++ b/x-pack/plugins/apm/public/components/alerting/chart_preview/index.tsx @@ -103,7 +103,7 @@ export function ChartPreview({ data={data} id="chart_preview_bar_series" xAccessor="x" - xScaleType={ScaleType.Linear} + xScaleType={ScaleType.Time} yAccessors={['y']} yScaleType={ScaleType.Linear} /> diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts index 615ba84afb5b7..db04712271587 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts +++ b/x-pack/plugins/data_visualizer/public/application/common/components/field_data_row/action_menu/lens_utils.ts @@ -153,7 +153,7 @@ export function getKeywordSettings(item: FieldVisConfig) { accessors: ['col2'], layerId: 'layer1', layerType: 'data', - seriesType: 'bar', + seriesType: 'bar_horizontal', xAccessor: 'col1', }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/boolean_content.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/boolean_content.tsx index 754d0e470fe40..b56e6ae815645 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/boolean_content.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_expanded_row/boolean_content.tsx @@ -7,7 +7,7 @@ import React, { FC, ReactNode, useMemo } from 'react'; import { EuiBasicTable, EuiSpacer, RIGHT_ALIGNMENT, HorizontalAlignment } from '@elastic/eui'; -import { Axis, BarSeries, Chart, Settings } from '@elastic/charts'; +import { Axis, BarSeries, Chart, Settings, ScaleType } from '@elastic/charts'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -137,9 +137,9 @@ export const BooleanContent: FC = ({ config }) => { splitSeriesAccessors={['x']} stackAccessors={['x']} xAccessor="x" - xScaleType="ordinal" + xScaleType={ScaleType.Ordinal} yAccessors={['count']} - yScaleType="linear" + yScaleType={ScaleType.Linear} /> diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/boolean_content_preview.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/boolean_content_preview.tsx index ceb2e6f241682..a44cd2a2e83c9 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/boolean_content_preview.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/boolean_content_preview.tsx @@ -37,6 +37,7 @@ export const BooleanContentPreview: FC = ({ config }) => { chartData={chartData} columnType={columnType} hideLabel={true} + maxChartColumns={10} /> ); }; diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx index 453754d4d6bd4..8b0ba08498600 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/column_chart.tsx @@ -22,7 +22,7 @@ interface Props { columnType: EuiDataGridColumn; dataTestSubj: string; hideLabel?: boolean; - maxChartColumns?: number; + maxChartColumns: number; } const zeroSize = { bottom: 0, left: 0, right: 0, top: 0 }; @@ -42,8 +42,12 @@ export const ColumnChart: FC = ({ {!isUnsupportedChartData(chartData) && data.length > 0 && ( i)} + theme={{ + chartMargins: zeroSize, + chartPaddings: zeroSize, + crosshair: { band: { visible: false } }, + }} /> = ({ /> { describe('getLegendText()', () => { it('should return the chart legend text for unsupported chart types', () => { - expect(getLegendText(validUnsupportedChartData)).toBe('Chart not supported.'); + expect(getLegendText(validUnsupportedChartData, 20)).toBe('Chart not supported.'); }); it('should return the chart legend text for empty datasets', () => { - expect(getLegendText(validNumericChartData)).toBe('0 documents contain field.'); + expect(getLegendText(validNumericChartData, 20)).toBe('0 documents contain field.'); }); it('should return the chart legend text for boolean chart types', () => { const { getByText } = render( <> - {getLegendText({ - cardinality: 2, - data: [ - { key: 'true', key_as_string: 'true', doc_count: 10 }, - { key: 'false', key_as_string: 'false', doc_count: 20 }, - ], - id: 'the-id', - type: 'boolean', - })} + {getLegendText( + { + cardinality: 2, + data: [ + { key: 'true', key_as_string: 'true', doc_count: 10 }, + { key: 'false', key_as_string: 'false', doc_count: 20 }, + ], + id: 'the-id', + type: 'boolean', + }, + 20 + )} ); expect(getByText('t')).toBeInTheDocument(); expect(getByText('f')).toBeInTheDocument(); }); it('should return the chart legend text for ordinal chart data with less than max categories', () => { - expect(getLegendText({ ...validOrdinalChartData, data: [{ key: 'cat', doc_count: 10 }] })).toBe( - '10 categories' - ); + expect( + getLegendText({ ...validOrdinalChartData, data: [{ key: 'cat', doc_count: 10 }] }, 20) + ).toBe('10 categories'); }); it('should return the chart legend text for ordinal chart data with more than max categories', () => { expect( - getLegendText({ - ...validOrdinalChartData, - cardinality: 30, - data: [{ key: 'cat', doc_count: 10 }], - }) + getLegendText( + { + ...validOrdinalChartData, + cardinality: 30, + data: [{ key: 'cat', doc_count: 10 }], + }, + 20 + ) ).toBe('top 20 of 30 categories'); }); it('should return the chart legend text for numeric datasets', () => { expect( - getLegendText({ - ...validNumericChartData, - data: [{ key: 1, doc_count: 10 }], - stats: [1, 100], - }) + getLegendText( + { + ...validNumericChartData, + data: [{ key: 1, doc_count: 10 }], + stats: [1, 100], + }, + 20 + ) ).toBe('1 - 100'); expect( - getLegendText({ - ...validNumericChartData, - data: [{ key: 1, doc_count: 10 }], - stats: [100, 100], - }) + getLegendText( + { + ...validNumericChartData, + data: [{ key: 1, doc_count: 10 }], + stats: [100, 100], + }, + 20 + ) ).toBe('100'); expect( - getLegendText({ - ...validNumericChartData, - data: [{ key: 1, doc_count: 10 }], - stats: [1.2345, 6.3456], - }) + getLegendText( + { + ...validNumericChartData, + data: [{ key: 1, doc_count: 10 }], + stats: [1.2345, 6.3456], + }, + 20 + ) ).toBe('1.23 - 6.35'); }); }); @@ -167,7 +182,7 @@ describe('getLegendText()', () => { describe('useColumnChart()', () => { it('should return the column chart hook data', () => { const { result } = renderHook(() => - useColumnChart(validNumericChartData, { id: 'the-id', schema: 'numeric' }) + useColumnChart(validNumericChartData, { id: 'the-id', schema: 'numeric' }, 20) ); expect(result.current.data).toStrictEqual([]); diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.tsx index 2c0817228655e..60e1595c64ece 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/stats_table/components/field_data_row/use_column_chart.tsx @@ -32,7 +32,6 @@ export const hoveredRow$ = new BehaviorSubject(null); export const BAR_COLOR = euiPaletteColorBlind()[0]; const BAR_COLOR_BLUR = euiPaletteColorBlind({ rotations: 2 })[10]; -const MAX_CHART_COLUMNS = 20; type XScaleType = 'ordinal' | 'time' | 'linear' | undefined; export const getXScaleType = (kbnFieldType: KBN_FIELD_TYPES | undefined): XScaleType => { @@ -76,10 +75,7 @@ export const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYP }; type LegendText = string | JSX.Element; -export const getLegendText = ( - chartData: ChartData, - maxChartColumns = MAX_CHART_COLUMNS -): LegendText => { +export const getLegendText = (chartData: ChartData, maxChartColumns: number): LegendText => { if (chartData.type === 'unsupported') { return i18n.translate('xpack.dataVisualizer.dataGridChart.histogramNotAvailable', { defaultMessage: 'Chart not supported.', @@ -146,7 +142,7 @@ interface ColumnChart { export const useColumnChart = ( chartData: ChartData, columnType: EuiDataGridColumn, - maxChartColumns?: number + maxChartColumns: number ): ColumnChart => { const fieldType = getFieldType(columnType.schema); diff --git a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx index 0ad6378a22960..c5f5ace2f5470 100644 --- a/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx +++ b/x-pack/plugins/infra/public/alerting/log_threshold/components/expression_editor/criterion_preview_chart.tsx @@ -224,7 +224,7 @@ const CriterionPreviewChart: React.FC = ({ - + diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx index 9c22ec9d4bb05..854075a94dcaa 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/field_item.tsx @@ -27,7 +27,7 @@ import { } from '@elastic/eui'; import { Axis, - BarSeries, + HistogramBarSeries, Chart, niceTimeFormatter, Position, @@ -636,7 +636,7 @@ function FieldItemPopoverContents(props: State & FieldItemProps) { showOverlappingTicks={true} /> - formatter.convert(d)} /> - = ({ hideLabel, maxChartColumns, }) => { - const { data, legendText, xScaleType } = useColumnChart(chartData, columnType, maxChartColumns); + const { data, legendText } = useColumnChart(chartData, columnType, maxChartColumns); return (

@@ -59,8 +59,8 @@ export const ColumnChart: FC = ({ d.datum.color} diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.tsx index d09d33ce5aded..2d3e6dcdca4c5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_kpis/alerts_histogram_panel/alerts_histogram.tsx @@ -12,6 +12,7 @@ import { Position, Settings, ChartSizeArray, + ScaleType, } from '@elastic/charts'; import { EuiFlexGroup, EuiFlexItem, EuiProgress } from '@elastic/eui'; import React, { useMemo } from 'react'; @@ -42,7 +43,7 @@ export const AlertsHistogram = React.memo( data, from, legendItems, - legendPosition = 'right', + legendPosition = Position.Right, loading, showLegend, to, @@ -81,14 +82,14 @@ export const AlertsHistogram = React.memo( theme={theme} /> - + - + ( key={`loc-line-${name}`} name={name} xAccessor={0} - xScaleType="time" + xScaleType={ScaleType.Time} yAccessors={[1]} - yScaleType="linear" + yScaleType={ScaleType.Linear} fit={Fit.Linear} tickFormat={(d) => monitorType === 'browser' diff --git a/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx b/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx index aa981071b7ee2..5c4be0e6719f4 100644 --- a/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx +++ b/x-pack/plugins/uptime/public/components/common/charts/ping_histogram.tsx @@ -15,6 +15,7 @@ import { BrushEndListener, XYChartElementEvent, ElementClickListener, + ScaleType, } from '@elastic/charts'; import { EuiTitle, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -182,9 +183,9 @@ export const PingHistogramComponent: React.FC = ({ splitSeriesAccessors={['type']} timeZone="local" xAccessor="x" - xScaleType="time" + xScaleType={ScaleType.Time} yAccessors={['y']} - yScaleType="linear" + yScaleType={ScaleType.Linear} /> diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx index 3824b9ae19d0f..c73fce07f9779 100644 --- a/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/waterfall/components/waterfall_chart_fixed_axis.tsx @@ -52,7 +52,7 @@ export const WaterfallChartFixedAxis = ({ tickFormat, domain, barStyleAccessor } Date: Wed, 20 Oct 2021 14:19:42 +0200 Subject: [PATCH 22/23] [ftr] update webdriver dependency to 4.0 (#115649) * [ftr] update webdriver dependency to 4.0 * [ftr] cast options on assign Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 4 +- test/functional/services/remote/webdriver.ts | 124 +++++++++---------- yarn.lock | 59 ++++----- 3 files changed, 87 insertions(+), 100 deletions(-) diff --git a/package.json b/package.json index f4706b5afe7cd..7c915f2f12228 100644 --- a/package.json +++ b/package.json @@ -598,7 +598,7 @@ "@types/reduce-reducers": "^1.0.0", "@types/redux-actions": "^2.6.1", "@types/seedrandom": ">=2.0.0 <4.0.0", - "@types/selenium-webdriver": "^4.0.9", + "@types/selenium-webdriver": "^4.0.15", "@types/semver": "^7", "@types/set-value": "^2.0.0", "@types/sinon": "^7.0.13", @@ -780,7 +780,7 @@ "rxjs-marbles": "^5.0.6", "sass-loader": "^10.2.0", "sass-resources-loader": "^2.0.1", - "selenium-webdriver": "^4.0.0-alpha.7", + "selenium-webdriver": "^4.0.0", "serve-static": "1.14.1", "shelljs": "^0.8.4", "simple-git": "1.116.0", diff --git a/test/functional/services/remote/webdriver.ts b/test/functional/services/remote/webdriver.ts index 7d629ee817252..82a9f9b7d3f27 100644 --- a/test/functional/services/remote/webdriver.ts +++ b/test/functional/services/remote/webdriver.ts @@ -71,6 +71,60 @@ export interface BrowserConfig { acceptInsecureCerts: boolean; } +function initChromiumOptions(browserType: Browsers, acceptInsecureCerts: boolean) { + const options = browserType === Browsers.Chrome ? new chrome.Options() : new edge.Options(); + + options.addArguments( + // Disables the sandbox for all process types that are normally sandboxed. + 'no-sandbox', + // Launches URL in new browser window. + 'new-window', + // By default, file:// URIs cannot read other file:// URIs. This is an override for developers who need the old behavior for testing. + 'allow-file-access-from-files', + // Use fake device for Media Stream to replace actual camera and microphone. + 'use-fake-device-for-media-stream', + // Bypass the media stream infobar by selecting the default device for media streams (e.g. WebRTC). Works with --use-fake-device-for-media-stream. + 'use-fake-ui-for-media-stream' + ); + + if (process.platform === 'linux') { + // The /dev/shm partition is too small in certain VM environments, causing + // Chrome to fail or crash. Use this flag to work-around this issue + // (a temporary directory will always be used to create anonymous shared memory files). + options.addArguments('disable-dev-shm-usage'); + } + + if (headlessBrowser === '1') { + // Use --disable-gpu to avoid an error from a missing Mesa library, as per + // See: https://chromium.googlesource.com/chromium/src/+/lkgr/headless/README.md + options.headless(); + options.addArguments('disable-gpu'); + } + + if (certValidation === '0') { + options.addArguments('ignore-certificate-errors'); + } + + if (remoteDebug === '1') { + // Visit chrome://inspect in chrome to remotely view/debug + options.headless(); + options.addArguments('disable-gpu', 'remote-debugging-port=9222'); + } + + if (browserBinaryPath) { + options.setChromeBinaryPath(browserBinaryPath); + } + + const prefs = new logging.Preferences(); + prefs.setLevel(logging.Type.BROWSER, logging.Level.ALL); + options.setUserPreferences(chromiumUserPrefs); + options.setLoggingPrefs(prefs); + options.set('unexpectedAlertBehaviour', 'accept'); + options.setAcceptInsecureCerts(acceptInsecureCerts); + + return options; +} + let attemptCounter = 0; let edgePaths: { driverPath: string | undefined; browserPath: string | undefined }; async function attemptToCreateCommand( @@ -86,55 +140,10 @@ async function attemptToCreateCommand( const buildDriverInstance = async () => { switch (browserType) { case 'chrome': { - const chromeOptions = new chrome.Options(); - chromeOptions.addArguments( - // Disables the sandbox for all process types that are normally sandboxed. - 'no-sandbox', - // Launches URL in new browser window. - 'new-window', - // By default, file:// URIs cannot read other file:// URIs. This is an override for developers who need the old behavior for testing. - 'allow-file-access-from-files', - // Use fake device for Media Stream to replace actual camera and microphone. - 'use-fake-device-for-media-stream', - // Bypass the media stream infobar by selecting the default device for media streams (e.g. WebRTC). Works with --use-fake-device-for-media-stream. - 'use-fake-ui-for-media-stream' - ); - - if (process.platform === 'linux') { - // The /dev/shm partition is too small in certain VM environments, causing - // Chrome to fail or crash. Use this flag to work-around this issue - // (a temporary directory will always be used to create anonymous shared memory files). - chromeOptions.addArguments('disable-dev-shm-usage'); - } - - if (headlessBrowser === '1') { - // Use --disable-gpu to avoid an error from a missing Mesa library, as per - // See: https://chromium.googlesource.com/chromium/src/+/lkgr/headless/README.md - chromeOptions.headless(); - chromeOptions.addArguments('disable-gpu'); - } - - if (certValidation === '0') { - chromeOptions.addArguments('ignore-certificate-errors'); - } - - if (remoteDebug === '1') { - // Visit chrome://inspect in chrome to remotely view/debug - chromeOptions.headless(); - chromeOptions.addArguments('disable-gpu', 'remote-debugging-port=9222'); - } - - if (browserBinaryPath) { - chromeOptions.setChromeBinaryPath(browserBinaryPath); - } - - const prefs = new logging.Preferences(); - prefs.setLevel(logging.Type.BROWSER, logging.Level.ALL); - chromeOptions.setUserPreferences(chromiumUserPrefs); - chromeOptions.setLoggingPrefs(prefs); - chromeOptions.set('unexpectedAlertBehaviour', 'accept'); - chromeOptions.setAcceptInsecureCerts(config.acceptInsecureCerts); - + const chromeOptions = initChromiumOptions( + browserType, + config.acceptInsecureCerts + ) as chrome.Options; let session; if (remoteSessionUrl) { session = await new Builder() @@ -169,19 +178,10 @@ async function attemptToCreateCommand( case 'msedge': { if (edgePaths && edgePaths.browserPath && edgePaths.driverPath) { - const edgeOptions = new edge.Options(); - if (headlessBrowser === '1') { - // @ts-ignore internal modules are not typed - edgeOptions.headless(); - } - // @ts-ignore internal modules are not typed - edgeOptions.setEdgeChromium(true); - // @ts-ignore internal modules are not typed - edgeOptions.setBinaryPath(edgePaths.browserPath); - const options = edgeOptions.get('ms:edgeOptions'); - // overriding options to include preferences - Object.assign(options, { prefs: chromiumUserPrefs }); - edgeOptions.set('ms:edgeOptions', options); + const edgeOptions = initChromiumOptions( + browserType, + config.acceptInsecureCerts + ) as edge.Options; const session = await new Builder() .forBrowser('MicrosoftEdge') .setEdgeOptions(edgeOptions) diff --git a/yarn.lock b/yarn.lock index 375e748564283..8d174464d0a92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6985,10 +6985,10 @@ resolved "https://registry.yarnpkg.com/@types/seedrandom/-/seedrandom-2.4.28.tgz#9ce8fa048c1e8c85cb71d7fe4d704e000226036f" integrity sha512-SMA+fUwULwK7sd/ZJicUztiPs8F1yCPwF3O23Z9uQ32ME5Ha0NmDK9+QTsYE4O2tHXChzXomSWWeIhCnoN1LqA== -"@types/selenium-webdriver@^4.0.9": - version "4.0.9" - resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.0.9.tgz#12621e55b2ef8f6c98bd17fe23fa720c6cba16bd" - integrity sha512-HopIwBE7GUXsscmt/J0DhnFXLSmO04AfxT6b8HAprknwka7pqEWquWDMXxCjd+NUHK9MkCe1SDKKsMiNmCItbQ== +"@types/selenium-webdriver@^4.0.15": + version "4.0.15" + resolved "https://registry.yarnpkg.com/@types/selenium-webdriver/-/selenium-webdriver-4.0.15.tgz#03012b84155cf6bbae2f64aa9dccf2a84c78c7c8" + integrity sha512-5760PIZkzhPejy3hsKAdCKe5LJygGdxLKOLxmZL9GEUcFlO5OgzM6G2EbdbvOnaw4xvUSa9Uip6Ipwkih12BPA== "@types/semver@^7": version "7.3.4" @@ -18808,10 +18808,10 @@ jsts@^1.6.2: array-includes "^3.1.2" object.assign "^4.1.2" -jszip@^3.2.2: - version "3.3.0" - resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.3.0.tgz#29d72c21a54990fa885b11fc843db320640d5271" - integrity sha512-EJ9k766htB1ZWnsV5ZMDkKLgA+201r/ouFF8R2OigVjVdcm2rurcBrrdXaeqBJbqnUVMko512PYmlncBKE1Huw== +jszip@^3.6.0: + version "3.7.1" + resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.7.1.tgz#bd63401221c15625a1228c556ca8a68da6fda3d9" + integrity sha512-ghL0tz1XG9ZEmRMcEN2vt7xabrDdqHHeykgARpmZ0BiIctWxM47Vt63ZO2dnp4QYt/xJVLLy5Zv1l/xRdh2byg== dependencies: lie "~3.3.0" pako "~1.0.2" @@ -21737,7 +21737,7 @@ os-shim@^0.1.2: resolved "https://registry.yarnpkg.com/os-shim/-/os-shim-0.1.3.tgz#6b62c3791cf7909ea35ed46e17658bb417cb3917" integrity sha1-a2LDeRz3kJ6jXtRuF2WLtBfLORc= -os-tmpdir@^1.0.0, os-tmpdir@~1.0.1, os-tmpdir@~1.0.2: +os-tmpdir@^1.0.0, os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= @@ -25397,13 +25397,6 @@ rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3: dependencies: glob "^7.1.3" -rimraf@^2.7.1: - version "2.7.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - rimraf@~2.4.0: version "2.4.5" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.4.5.tgz#ee710ce5d93a8fdb856fb5ea8ff0e2d75934b2da" @@ -25709,14 +25702,15 @@ select-hose@^2.0.0: resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" integrity sha1-Yl2GWPhlr0Psliv8N2o3NZpJlMo= -selenium-webdriver@^4.0.0-alpha.7: - version "4.0.0-alpha.7" - resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.0.0-alpha.7.tgz#e3879d8457fd7ad8e4424094b7dc0540d99e6797" - integrity sha512-D4qnTsyTr91jT8f7MfN+OwY0IlU5+5FmlO5xlgRUV6hDEV8JyYx2NerdTEqDDkNq7RZDYc4VoPALk8l578RBHw== +selenium-webdriver@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/selenium-webdriver/-/selenium-webdriver-4.0.0.tgz#7dc8969facee3be634459e173f557b7e34308e73" + integrity sha512-tOlu6FnTjPq2FKpd153pl8o2cB7H40Rvl/ogiD2sapMv4IDjQqpIxbd+swDJe9UDLdszeh5CDis6lgy4e9UG1w== dependencies: - jszip "^3.2.2" - rimraf "^2.7.1" - tmp "0.0.30" + jszip "^3.6.0" + rimraf "^3.0.2" + tmp "^0.2.1" + ws ">=7.4.6" selfsigned@^1.10.7: version "1.10.8" @@ -27871,13 +27865,6 @@ title-case@^2.1.1: no-case "^2.2.0" upper-case "^1.0.3" -tmp@0.0.30: - version "0.0.30" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.30.tgz#72419d4a8be7d6ce75148fd8b324e593a711c2ed" - integrity sha1-ckGdSovn1s51FI/YsyTlk6cRwu0= - dependencies: - os-tmpdir "~1.0.1" - tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -27885,7 +27872,7 @@ tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" -tmp@~0.2.1: +tmp@^0.2.1, tmp@~0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.1.tgz#8457fc3037dcf4719c251367a1af6500ee1ccf14" integrity sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ== @@ -30289,6 +30276,11 @@ write-pkg@^4.0.0: type-fest "^0.4.1" write-json-file "^3.2.0" +ws@>=7.4.6, ws@^7.4.6: + version "7.5.5" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" + integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w== + ws@^6.1.2, ws@^6.2.1: version "6.2.2" resolved "https://registry.yarnpkg.com/ws/-/ws-6.2.2.tgz#dd5cdbd57a9979916097652d78f1cc5faea0c32e" @@ -30301,11 +30293,6 @@ ws@^7.2.3: resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.6.tgz#5654ca8ecdeee47c33a9a4bf6d28e2be2980377c" integrity sha512-YmhHDO4MzaDLB+M9ym/mDA5z0naX8j7SIlT8f8z+I0VtzsRbekxEutHSme7NPS2qE8StCYQNUnfWdXta/Yu85A== -ws@^7.4.6: - version "7.5.5" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" - integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w== - x-is-function@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/x-is-function/-/x-is-function-1.0.4.tgz#5d294dc3d268cbdd062580e0c5df77a391d1fa1e" From 453d4bca3678669ecb6833bd4800d9cbfbd29354 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Wed, 20 Oct 2021 15:26:22 +0300 Subject: [PATCH 23/23] [XY] Fixes the formatting on multiple axis (#115552) * [XY] fixes the formatting on multiple axis * Move mock data to its own file --- .../xy/public/config/get_config.test.ts | 44 ++++ .../vis_types/xy/public/config/get_config.ts | 14 +- src/plugins/vis_types/xy/public/mocks.ts | 223 ++++++++++++++++++ 3 files changed, 277 insertions(+), 4 deletions(-) create mode 100644 src/plugins/vis_types/xy/public/config/get_config.test.ts create mode 100644 src/plugins/vis_types/xy/public/mocks.ts diff --git a/src/plugins/vis_types/xy/public/config/get_config.test.ts b/src/plugins/vis_types/xy/public/config/get_config.test.ts new file mode 100644 index 0000000000000..d046ac17c2b27 --- /dev/null +++ b/src/plugins/vis_types/xy/public/config/get_config.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getConfig } from './get_config'; +import { visData, visParamsWithTwoYAxes } from '../mocks'; + +// ToDo: add more tests for all the config properties +describe('getConfig', () => { + it('identifies it as a timeChart if the x axis has a date field', () => { + const config = getConfig(visData, visParamsWithTwoYAxes); + expect(config.isTimeChart).toBe(true); + }); + + it('not adds the current time marker if the param is set to false', () => { + const config = getConfig(visData, visParamsWithTwoYAxes); + expect(config.showCurrentTime).toBe(false); + }); + + it('adds the current time marker if the param is set to false', () => { + const newVisParams = { + ...visParamsWithTwoYAxes, + addTimeMarker: true, + }; + const config = getConfig(visData, newVisParams); + expect(config.showCurrentTime).toBe(true); + }); + + it('enables the histogram mode for a date_histogram', () => { + const config = getConfig(visData, visParamsWithTwoYAxes); + expect(config.enableHistogramMode).toBe(true); + }); + + it('assigns the correct formatter per y axis', () => { + const config = getConfig(visData, visParamsWithTwoYAxes); + expect(config.yAxes.length).toBe(2); + expect(config.yAxes[0].ticks?.formatter).toStrictEqual(config.aspects.y[1].formatter); + expect(config.yAxes[1].ticks?.formatter).toStrictEqual(config.aspects.y[0].formatter); + }); +}); diff --git a/src/plugins/vis_types/xy/public/config/get_config.ts b/src/plugins/vis_types/xy/public/config/get_config.ts index 13c9a6c275f8e..d2a3b9ad2a103 100644 --- a/src/plugins/vis_types/xy/public/config/get_config.ts +++ b/src/plugins/vis_types/xy/public/config/get_config.ts @@ -50,10 +50,16 @@ export function getConfig(table: Datatable, params: VisParams): VisConfig { params.dimensions.x?.aggType === BUCKET_TYPES.DATE_HISTOGRAM ); const tooltip = getTooltip(aspects, params); - const yAxes = params.valueAxes.map((a) => - // uses first y aspect in array for formatting axis - getAxis(a, params.grid, aspects.y[0], params.seriesParams) - ); + const yAxes = params.valueAxes.map((a) => { + // find the correct aspect for each value axis + const aspectsIdx = params.seriesParams.findIndex((s) => s.valueAxis === a.id); + return getAxis( + a, + params.grid, + aspects.y[aspectsIdx > -1 ? aspectsIdx : 0], + params.seriesParams + ); + }); const enableHistogramMode = (params.dimensions.x?.aggType === BUCKET_TYPES.DATE_HISTOGRAM || params.dimensions.x?.aggType === BUCKET_TYPES.HISTOGRAM) && diff --git a/src/plugins/vis_types/xy/public/mocks.ts b/src/plugins/vis_types/xy/public/mocks.ts new file mode 100644 index 0000000000000..bb74035485723 --- /dev/null +++ b/src/plugins/vis_types/xy/public/mocks.ts @@ -0,0 +1,223 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ +import type { Datatable } from '../../../expressions/public'; +import type { VisParams } from './types'; + +export const visData = { + type: 'datatable', + columns: [ + { + id: 'col-0-2', + name: 'timestamp per 12 hours', + meta: { + type: 'date', + field: 'timestamp', + index: 'kibana_sample_data_logs', + }, + }, + { + id: 'col-1-3', + name: 'Average memory', + meta: { + type: 'number', + field: 'memory', + index: 'kibana_sample_data_logs', + }, + }, + { + id: 'col-2-1', + name: 'Average bytes', + meta: { + type: 'number', + field: 'bytes', + index: 'kibana_sample_data_logs', + }, + }, + ], + rows: [ + { + 'col-0-2': 1632603600000, + 'col-1-3': 27400, + 'col-2-1': 6079.305555555556, + }, + { + 'col-0-2': 1632646800000, + 'col-1-3': 148270, + 'col-2-1': 6164.056818181818, + }, + { + 'col-0-2': 1632690000000, + 'col-1-3': 235280, + 'col-2-1': 6125.469387755102, + }, + { + 'col-0-2': 1632733200000, + 'col-1-3': 206750, + 'col-2-1': 5362.68306010929, + }, + ], +} as Datatable; + +export const visParamsWithTwoYAxes = { + type: 'histogram', + addLegend: true, + addTooltip: true, + legendPosition: 'right', + addTimeMarker: false, + maxLegendLines: 1, + truncateLegend: true, + categoryAxes: [ + { + type: 'category', + id: 'CategoryAxis-1', + show: true, + position: 'bottom', + title: {}, + scale: { + type: 'linear', + }, + labels: { + filter: true, + show: true, + truncate: 100, + }, + }, + ], + labels: { + type: 'label', + show: false, + }, + thresholdLine: { + type: 'threshold_line', + show: false, + value: 10, + width: 1, + style: 'full', + color: '#E7664C', + }, + valueAxes: [ + { + type: 'value', + name: 'LeftAxis-1', + id: 'ValueAxis-1', + show: true, + position: 'left', + axisType: 'value', + title: { + text: 'my custom title', + }, + scale: { + type: 'linear', + mode: 'normal', + scaleType: 'linear', + }, + labels: { + type: 'label', + filter: false, + rotate: 0, + show: true, + truncate: 100, + }, + }, + { + type: 'value', + name: 'RightAxis-1', + id: 'ValueAxis-2', + show: true, + position: 'right', + title: { + text: 'my custom title', + }, + scale: { + type: 'linear', + mode: 'normal', + }, + labels: { + filter: false, + rotate: 0, + show: true, + truncate: 100, + }, + }, + ], + grid: { + categoryLines: false, + }, + seriesParams: [ + { + type: 'histogram', + data: { + label: 'Average memory', + id: '3', + }, + drawLinesBetweenPoints: true, + interpolate: 'linear', + lineWidth: 2, + mode: 'stacked', + show: true, + showCircles: true, + circlesRadius: 3, + seriesParamType: 'histogram', + valueAxis: 'ValueAxis-2', + }, + { + type: 'line', + data: { + label: 'Average bytes', + id: '1', + }, + drawLinesBetweenPoints: true, + interpolate: 'linear', + lineWidth: 2, + mode: 'stacked', + show: true, + showCircles: true, + circlesRadius: 3, + valueAxis: 'ValueAxis-1', + }, + ], + dimensions: { + x: { + type: 'xy_dimension', + label: 'timestamp per 12 hours', + aggType: 'date_histogram', + accessor: 0, + format: { + id: 'date', + params: { + pattern: 'YYYY-MM-DD HH:mm', + }, + }, + params: { + date: true, + }, + }, + y: [ + { + label: 'Average memory', + aggType: 'avg', + params: {}, + accessor: 1, + format: { + id: 'number', + params: {}, + }, + }, + { + label: 'Average bytes', + aggType: 'avg', + params: {}, + accessor: 2, + format: { + id: 'bytes', + params: {}, + }, + }, + ], + }, +} as unknown as VisParams;