From 2da5d635bf810b926176e48e1e5e23a26a3dbb5b Mon Sep 17 00:00:00 2001 From: Poff Poffenberger Date: Mon, 23 Mar 2020 14:09:05 -0500 Subject: [PATCH 01/34] Re-enable a CSS fix for the Monaco Editor's focus behavior (#60803) Co-authored-by: Elastic Machine --- src/plugins/kibana_react/public/code_editor/code_editor.tsx | 2 ++ src/plugins/kibana_react/public/code_editor/editor.scss | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/kibana_react/public/code_editor/code_editor.tsx b/src/plugins/kibana_react/public/code_editor/code_editor.tsx index 37707fdec2140..e8b118b804347 100644 --- a/src/plugins/kibana_react/public/code_editor/code_editor.tsx +++ b/src/plugins/kibana_react/public/code_editor/code_editor.tsx @@ -25,6 +25,8 @@ import { monaco } from '@kbn/ui-shared-deps/monaco'; import { LIGHT_THEME, DARK_THEME } from './editor_theme'; +import './editor.scss'; + export interface Props { /** Width of editor. Defaults to 100%. */ width?: string | number; diff --git a/src/plugins/kibana_react/public/code_editor/editor.scss b/src/plugins/kibana_react/public/code_editor/editor.scss index 86a764716bc7c..23a3e3af7e656 100644 --- a/src/plugins/kibana_react/public/code_editor/editor.scss +++ b/src/plugins/kibana_react/public/code_editor/editor.scss @@ -1,3 +1,3 @@ .react-monaco-editor-container .monaco-editor .inputarea:focus { - animation: none; // Removes textarea EUI blue underline animation from EUI + animation: none !important; // Removes textarea EUI blue underline animation from EUI } From 65359856a0db4671558b10e5ee0fdc560485f4d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Louv-Jansen?= Date: Mon, 23 Mar 2020 20:14:26 +0100 Subject: [PATCH 02/34] [APM] Remote Agent Config: Add additional (java) options (#59860) --- .../app/Main/route_config/index.tsx | 29 +- .../route_handlers/agent_configuration.tsx | 56 ++++ .../app/Main/route_config/route_names.tsx | 2 + .../AddEditFlyout/DeleteButton.tsx | 96 ------ .../AddEditFlyout/ServiceSection.tsx | 159 --------- .../AddEditFlyout/SettingsSection.tsx | 174 ---------- .../AddEditFlyout/index.tsx | 256 --------------- .../AddEditFlyout/saveConfig.ts | 107 ------- .../ServicePage/FormRowSelect.tsx | 53 +++ .../ServicePage/ServicePage.tsx | 208 ++++++++++++ .../SettingsPage/SettingFormRow.tsx | 178 +++++++++++ .../SettingsPage/SettingsPage.tsx | 301 ++++++++++++++++++ .../SettingsPage/saveConfig.ts | 68 ++++ .../index.stories.tsx | 63 ++++ .../AgentConfigurationCreateEdit/index.tsx | 157 +++++++++ .../AgentConfigurationList.tsx | 232 -------------- .../List/ConfirmDeleteModal.tsx | 119 +++++++ .../AgentConfigurations/List/index.tsx | 221 +++++++++++++ .../Settings/AgentConfigurations/index.tsx | 66 +--- .../public/components/app/Settings/index.tsx | 16 +- .../components/shared/Links/apm/APMLink.tsx | 2 +- .../Links/apm/agentConfigurationLinks.tsx | 26 ++ .../shared/SelectWithPlaceholder/index.tsx | 47 +-- .../apm/public/context/LocationContext.tsx | 8 +- .../plugins/apm/public/hooks/useFetcher.tsx | 39 ++- .../all_option.ts} | 6 +- .../agent_configuration/amount_and_unit.ts | 19 ++ .../configuration_types.d.ts | 3 +- .../agent_configuration_intake_rt.test.ts | 58 ++++ .../agent_configuration_intake_rt.ts | 35 ++ .../runtime_types/boolean_rt.test.ts | 26 ++ .../runtime_types/boolean_rt.ts | 9 + .../runtime_types/bytes_rt.test.ts | 39 +++ .../runtime_types/bytes_rt.ts | 33 ++ .../runtime_types/capture_body_rt.test.ts | 26 ++ .../runtime_types/capture_body_rt.ts | 14 + .../runtime_types/duration_rt.test.ts | 34 ++ .../runtime_types/duration_rt.ts | 33 ++ .../runtime_types/integer_rt.test.ts | 47 +++ .../runtime_types/integer_rt.ts | 31 ++ .../runtime_types/number_float_rt.test.ts | 36 +++ .../runtime_types/number_float_rt.ts | 36 +++ .../__snapshots__/index.test.ts.snap | 191 +++++++++++ .../setting_definitions/general_settings.ts | 219 +++++++++++++ .../setting_definitions/index.test.ts | 187 +++++++++++ .../setting_definitions/index.ts | 113 +++++++ .../setting_definitions/java_settings.ts | 256 +++++++++++++++ .../setting_definitions/types.d.ts | 107 +++++++ .../index.test.ts | 41 --- .../agent_configuration_intake_rt/index.ts | 26 -- .../transaction_max_spans_rt/index.test.ts | 28 -- .../transaction_max_spans_rt/index.ts | 19 -- .../transaction_sample_rate_rt/index.test.ts | 38 --- .../transaction_sample_rate_rt/index.ts | 19 -- .../lib/helpers/create_or_update_index.ts | 3 +- .../convert_settings_to_string.ts | 26 ++ .../create_agent_config_index.ts | 31 +- .../create_or_update_configuration.ts | 2 +- .../find_exact_configuration.ts | 11 +- .../get_environments/get_all_environments.ts | 2 +- .../get_existing_environments_for_service.ts | 2 +- .../agent_configuration/get_service_names.ts | 2 +- .../list_configurations.ts | 10 +- .../mark_applied_by_agent.ts | 2 +- .../search_configurations.ts | 11 +- .../custom_link/create_custom_link_index.ts | 1 + .../apm/server/routes/create_apm_api.ts | 2 + .../routes/settings/agent_configuration.ts | 118 ++++--- .../translations/translations/ja-JP.json | 42 --- .../translations/translations/zh-CN.json | 42 --- .../apis/apm/agent_configuration.ts | 54 ++-- .../apis/apm/feature_controls.ts | 2 +- 72 files changed, 3290 insertions(+), 1485 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx delete mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx create mode 100644 x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx rename x-pack/plugins/apm/common/{agent_configuration_constants.ts => agent_configuration/all_option.ts} (74%) create mode 100644 x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts rename x-pack/plugins/apm/{server/lib/settings => common}/agent_configuration/configuration_types.d.ts (82%) create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts create mode 100644 x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.test.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.test.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts delete mode 100644 x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts create mode 100644 x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx index 2e737382c67a5..c87e56fe9eff6 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/index.tsx @@ -23,6 +23,10 @@ import { resolveUrlParams } from '../../../../context/UrlParamsContext/resolveUr import { UNIDENTIFIED_SERVICE_NODES_LABEL } from '../../../../../../../../plugins/apm/common/i18n'; import { TraceLink } from '../../TraceLink'; import { CustomizeUI } from '../../Settings/CustomizeUI'; +import { + EditAgentConfigurationRouteHandler, + CreateAgentConfigurationRouteHandler +} from './route_handlers/agent_configuration'; const metricsBreadcrumb = i18n.translate('xpack.apm.breadcrumb.metricsTitle', { defaultMessage: 'Metrics' @@ -101,12 +105,31 @@ export const routes: BreadcrumbRoute[] = [ ), breadcrumb: i18n.translate( 'xpack.apm.breadcrumb.settings.agentConfigurationTitle', - { - defaultMessage: 'Agent Configuration' - } + { defaultMessage: 'Agent Configuration' } ), name: RouteName.AGENT_CONFIGURATION }, + + { + exact: true, + path: '/settings/agent-configuration/create', + breadcrumb: i18n.translate( + 'xpack.apm.breadcrumb.settings.createAgentConfigurationTitle', + { defaultMessage: 'Create Agent Configuration' } + ), + name: RouteName.AGENT_CONFIGURATION_CREATE, + component: () => + }, + { + exact: true, + path: '/settings/agent-configuration/edit', + breadcrumb: i18n.translate( + 'xpack.apm.breadcrumb.settings.editAgentConfigurationTitle', + { defaultMessage: 'Edit Agent Configuration' } + ), + name: RouteName.AGENT_CONFIGURATION_EDIT, + component: () => + }, { exact: true, path: '/services/:serviceName', diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx new file mode 100644 index 0000000000000..58087f0d8be42 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_handlers/agent_configuration.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { useFetcher } from '../../../../../hooks/useFetcher'; +import { history } from '../../../../../utils/history'; +import { Settings } from '../../../Settings'; +import { AgentConfigurationCreateEdit } from '../../../Settings/AgentConfigurations/AgentConfigurationCreateEdit'; +import { toQuery } from '../../../../shared/Links/url_helpers'; + +export function EditAgentConfigurationRouteHandler() { + const { search } = history.location; + + // typescript complains because `pageStop` does not exist in `APMQueryParams` + // Going forward we should move away from globally declared query params and this is a first step + // @ts-ignore + const { name, environment, pageStep } = toQuery(search); + + const res = useFetcher( + callApmApi => { + return callApmApi({ + pathname: '/api/apm/settings/agent-configuration/view', + params: { query: { name, environment } } + }); + }, + [name, environment] + ); + + return ( + + + + ); +} + +export function CreateAgentConfigurationRouteHandler() { + const { search } = history.location; + + // Ignoring here because we specifically DO NOT want to add the query params to the global route handler + // @ts-ignore + const { pageStep } = toQuery(search); + + return ( + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx index db57e8356f39b..33a4990cb549e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Main/route_config/route_names.tsx @@ -20,6 +20,8 @@ export enum RouteName { TRANSACTION_NAME = 'transaction_name', SETTINGS = 'settings', AGENT_CONFIGURATION = 'agent_configuration', + AGENT_CONFIGURATION_CREATE = 'agent_configuration_create', + AGENT_CONFIGURATION_EDIT = 'agent_configuration_edit', INDICES = 'indices', SERVICE_NODES = 'nodes', LINK_TO_TRACE = 'link_to_trace', diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx deleted file mode 100644 index 997df371b51ed..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/DeleteButton.tsx +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useState } from 'react'; -import { EuiButtonEmpty } from '@elastic/eui'; -import { NotificationsStart } from 'kibana/public'; -import { i18n } from '@kbn/i18n'; -import { Config } from '../index'; -import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { callApmApi } from '../../../../../services/rest/createCallApmApi'; -import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; - -interface Props { - onDeleted: () => void; - selectedConfig: Config; -} - -export function DeleteButton({ onDeleted, selectedConfig }: Props) { - const [isDeleting, setIsDeleting] = useState(false); - const { toasts } = useApmPluginContext().core.notifications; - - return ( - { - setIsDeleting(true); - await deleteConfig(selectedConfig, toasts); - setIsDeleting(false); - onDeleted(); - }} - > - {i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.buttonLabel', - { defaultMessage: 'Delete' } - )} - - ); -} - -async function deleteConfig( - selectedConfig: Config, - toasts: NotificationsStart['toasts'] -) { - try { - await callApmApi({ - pathname: '/api/apm/settings/agent-configuration', - method: 'DELETE', - params: { - body: { - service: { - name: selectedConfig.service.name, - environment: selectedConfig.service.environment - } - } - } - }); - - toasts.addSuccess({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededTitle', - { defaultMessage: 'Configuration was deleted' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededText', - { - defaultMessage: - 'You have successfully deleted a configuration for "{serviceName}". It will take some time to propagate to the agents.', - values: { serviceName: getOptionLabel(selectedConfig.service.name) } - } - ) - }); - } catch (error) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedTitle', - { defaultMessage: 'Configuration could not be deleted' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedText', - { - defaultMessage: - 'Something went wrong when deleting a configuration for "{serviceName}". Error: "{errorMessage}"', - values: { - serviceName: getOptionLabel(selectedConfig.service.name), - errorMessage: error.message - } - } - ) - }); - } -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx deleted file mode 100644 index 537bdace50e24..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/ServiceSection.tsx +++ /dev/null @@ -1,159 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { EuiTitle, EuiSpacer, EuiFormRow, EuiText } from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { - omitAllOption, - getOptionLabel -} from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { useFetcher } from '../../../../../hooks/useFetcher'; -import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder'; - -const SELECT_PLACEHOLDER_LABEL = `- ${i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.selectPlaceholder', - { defaultMessage: 'Select' } -)} -`; - -interface Props { - isReadOnly: boolean; - serviceName: string; - onServiceNameChange: (env: string) => void; - environment: string; - onEnvironmentChange: (env: string) => void; -} - -export function ServiceSection({ - isReadOnly, - serviceName, - onServiceNameChange, - environment, - onEnvironmentChange -}: Props) { - const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( - callApmApi => { - if (!isReadOnly) { - return callApmApi({ - pathname: '/api/apm/settings/agent-configuration/services', - forceCache: true - }); - } - }, - [isReadOnly], - { preservePreviousData: false } - ); - const { data: environments = [], status: environmentStatus } = useFetcher( - callApmApi => { - if (!isReadOnly && serviceName) { - return callApmApi({ - pathname: '/api/apm/settings/agent-configuration/environments', - params: { query: { serviceName: omitAllOption(serviceName) } } - }); - } - }, - [isReadOnly, serviceName], - { preservePreviousData: false } - ); - - const ALREADY_CONFIGURED_TRANSLATED = i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.alreadyConfiguredOption', - { defaultMessage: 'already configured' } - ); - - const serviceNameOptions = serviceNames.map(name => ({ - text: getOptionLabel(name), - value: name - })); - const environmentOptions = environments.map( - ({ name, alreadyConfigured }) => ({ - disabled: alreadyConfigured, - text: `${getOptionLabel(name)} ${ - alreadyConfigured ? `(${ALREADY_CONFIGURED_TRANSLATED})` : '' - }`, - value: name - }) - ); - - return ( - <> - -

- {i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.serviceSection.title', - { defaultMessage: 'Service' } - )} -

-
- - - - - {isReadOnly ? ( - {getOptionLabel(serviceName)} - ) : ( - { - e.preventDefault(); - onServiceNameChange(e.target.value); - onEnvironmentChange(''); - }} - /> - )} - - - - {isReadOnly ? ( - {getOptionLabel(environment)} - ) : ( - { - e.preventDefault(); - onEnvironmentChange(e.target.value); - }} - /> - )} - - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx deleted file mode 100644 index 24c8222d4cd99..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/SettingsSection.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { - EuiFormRow, - EuiFieldText, - EuiTitle, - EuiSpacer, - EuiFieldNumber -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash'; -import { SelectWithPlaceholder } from '../../../../shared/SelectWithPlaceholder'; - -interface Props { - isRumService: boolean; - - // sampleRate - sampleRate: string; - setSampleRate: (value: string) => void; - isSampleRateValid?: boolean; - - // captureBody - captureBody: string; - setCaptureBody: (value: string) => void; - - // transactionMaxSpans - transactionMaxSpans: string; - setTransactionMaxSpans: (value: string) => void; - isTransactionMaxSpansValid?: boolean; -} - -export function SettingsSection({ - isRumService, - - // sampleRate - sampleRate, - setSampleRate, - isSampleRateValid, - - // captureBody - captureBody, - setCaptureBody, - - // transactionMaxSpans - transactionMaxSpans, - setTransactionMaxSpans, - isTransactionMaxSpansValid -}: Props) { - return ( - <> - -

- {i18n.translate( - 'xpack.apm.settings.agentConf.flyOut.settingsSection.title', - { defaultMessage: 'Options' } - )} -

-
- - - - - { - e.preventDefault(); - setSampleRate(e.target.value); - }} - /> - - - - - {!isRumService && ( - - { - e.preventDefault(); - setCaptureBody(e.target.value); - }} - /> - - )} - - {!isRumService && ( - - { - e.preventDefault(); - setTransactionMaxSpans(e.target.value); - }} - /> - - )} - - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx deleted file mode 100644 index a034ca543390f..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/index.tsx +++ /dev/null @@ -1,256 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiButton, - EuiButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiFlyout, - EuiFlyoutBody, - EuiFlyoutFooter, - EuiFlyoutHeader, - EuiForm, - EuiPortal, - EuiTitle, - EuiText, - EuiSpacer -} from '@elastic/eui'; -import React, { useState } from 'react'; -import { i18n } from '@kbn/i18n'; -import { isRight } from 'fp-ts/lib/Either'; -import { transactionSampleRateRt } from '../../../../../../../../../plugins/apm/common/runtime_types/transaction_sample_rate_rt'; -import { Config } from '../index'; -import { SettingsSection } from './SettingsSection'; -import { ServiceSection } from './ServiceSection'; -import { DeleteButton } from './DeleteButton'; -import { transactionMaxSpansRt } from '../../../../../../../../../plugins/apm/common/runtime_types/transaction_max_spans_rt'; -import { useFetcher } from '../../../../../hooks/useFetcher'; -import { isRumAgentName } from '../../../../../../../../../plugins/apm/common/agent_name'; -import { ALL_OPTION_VALUE } from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { saveConfig } from './saveConfig'; -import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; -import { useUiTracker } from '../../../../../../../../../plugins/observability/public'; - -const defaultSettings = { - TRANSACTION_SAMPLE_RATE: '1.0', - CAPTURE_BODY: 'off', - TRANSACTION_MAX_SPANS: '500' -}; - -interface Props { - onClose: () => void; - onSaved: () => void; - onDeleted: () => void; - selectedConfig: Config | null; -} - -export function AddEditFlyout({ - onClose, - onSaved, - onDeleted, - selectedConfig -}: Props) { - const { toasts } = useApmPluginContext().core.notifications; - const [isSaving, setIsSaving] = useState(false); - - // get a telemetry UI event tracker - const trackApmEvent = useUiTracker({ app: 'apm' }); - - // config conditions (service) - const [serviceName, setServiceName] = useState( - selectedConfig ? selectedConfig.service.name || ALL_OPTION_VALUE : '' - ); - const [environment, setEnvironment] = useState( - selectedConfig ? selectedConfig.service.environment || ALL_OPTION_VALUE : '' - ); - - const { data: { agentName } = { agentName: undefined } } = useFetcher( - callApmApi => { - if (serviceName === ALL_OPTION_VALUE) { - return Promise.resolve({ agentName: undefined }); - } - - if (serviceName) { - return callApmApi({ - pathname: '/api/apm/settings/agent-configuration/agent_name', - params: { query: { serviceName } } - }); - } - }, - [serviceName], - { preservePreviousData: false } - ); - - // config settings - const [sampleRate, setSampleRate] = useState( - ( - selectedConfig?.settings.transaction_sample_rate || - defaultSettings.TRANSACTION_SAMPLE_RATE - ).toString() - ); - const [captureBody, setCaptureBody] = useState( - selectedConfig?.settings.capture_body || defaultSettings.CAPTURE_BODY - ); - const [transactionMaxSpans, setTransactionMaxSpans] = useState( - ( - selectedConfig?.settings.transaction_max_spans || - defaultSettings.TRANSACTION_MAX_SPANS - ).toString() - ); - - const isRumService = isRumAgentName(agentName); - const isSampleRateValid = isRight(transactionSampleRateRt.decode(sampleRate)); - const isTransactionMaxSpansValid = isRight( - transactionMaxSpansRt.decode(transactionMaxSpans) - ); - - const isFormValid = - !!serviceName && - !!environment && - isSampleRateValid && - // captureBody and isTransactionMaxSpansValid are required except if service is RUM - (isRumService || (!!captureBody && isTransactionMaxSpansValid)) && - // agent name is required, except if serviceName is "all" - (serviceName === ALL_OPTION_VALUE || agentName !== undefined); - - const handleSubmitEvent = async ( - event: - | React.FormEvent - | React.MouseEvent - ) => { - event.preventDefault(); - setIsSaving(true); - - await saveConfig({ - serviceName, - environment, - sampleRate, - captureBody, - transactionMaxSpans, - agentName, - isExistingConfig: Boolean(selectedConfig), - toasts, - trackApmEvent - }); - setIsSaving(false); - onSaved(); - }; - - return ( - - - - -

- {selectedConfig - ? i18n.translate( - 'xpack.apm.settings.agentConf.editConfigTitle', - { defaultMessage: 'Edit configuration' } - ) - : i18n.translate( - 'xpack.apm.settings.agentConf.createConfigTitle', - { defaultMessage: 'Create configuration' } - )} -

-
-
- - - This allows you to fine-tune your agent configuration directly in - Kibana. Best of all, changes are automatically propagated to your - APM agents so there’s no need to redeploy. - - - - - - {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} -
{ - const didClickEnter = e.which === 13; - if (didClickEnter) { - handleSubmitEvent(e); - } - }} - > - - - - - - -
-
- - - - {selectedConfig ? ( - - ) : null} - - - - - - {i18n.translate( - 'xpack.apm.settings.agentConf.cancelButtonLabel', - { defaultMessage: 'Cancel' } - )} - - - - - {i18n.translate( - 'xpack.apm.settings.agentConf.saveConfigurationButtonLabel', - { defaultMessage: 'Save' } - )} - - - - - - -
-
- ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts deleted file mode 100644 index 229394cb5da8c..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AddEditFlyout/saveConfig.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { NotificationsStart } from 'kibana/public'; -import { callApmApi } from '../../../../../services/rest/createCallApmApi'; -import { isRumAgentName } from '../../../../../../../../../plugins/apm/common/agent_name'; -import { - getOptionLabel, - omitAllOption -} from '../../../../../../../../../plugins/apm/common/agent_configuration_constants'; -import { UiTracker } from '../../../../../../../../../plugins/observability/public'; - -interface Settings { - transaction_sample_rate: number; - capture_body?: string; - transaction_max_spans?: number; -} - -export async function saveConfig({ - serviceName, - environment, - sampleRate, - captureBody, - transactionMaxSpans, - agentName, - isExistingConfig, - toasts, - trackApmEvent -}: { - serviceName: string; - environment: string; - sampleRate: string; - captureBody: string; - transactionMaxSpans: string; - agentName?: string; - isExistingConfig: boolean; - toasts: NotificationsStart['toasts']; - trackApmEvent: UiTracker; -}) { - trackApmEvent({ metric: 'save_agent_configuration' }); - - try { - const settings: Settings = { - transaction_sample_rate: Number(sampleRate) - }; - - if (!isRumAgentName(agentName)) { - settings.capture_body = captureBody; - settings.transaction_max_spans = Number(transactionMaxSpans); - } - - const configuration = { - agent_name: agentName, - service: { - name: omitAllOption(serviceName), - environment: omitAllOption(environment) - }, - settings - }; - - await callApmApi({ - pathname: '/api/apm/settings/agent-configuration', - method: 'PUT', - params: { - query: { overwrite: isExistingConfig }, - body: configuration - } - }); - - toasts.addSuccess({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.succeeded.title', - { defaultMessage: 'Configuration saved' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.succeeded.text', - { - defaultMessage: - 'The configuration for "{serviceName}" was saved. It will take some time to propagate to the agents.', - values: { serviceName: getOptionLabel(serviceName) } - } - ) - }); - } catch (error) { - toasts.addDanger({ - title: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.failed.title', - { defaultMessage: 'Configuration could not be saved' } - ), - text: i18n.translate( - 'xpack.apm.settings.agentConf.saveConfig.failed.text', - { - defaultMessage: - 'Something went wrong when saving the configuration for "{serviceName}". Error: "{errorMessage}"', - values: { - serviceName: getOptionLabel(serviceName), - errorMessage: error.message - } - } - ) - }); - } -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx new file mode 100644 index 0000000000000..30d3f9580db48 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/FormRowSelect.tsx @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiDescribedFormGroup, + EuiSelectOption, + EuiFormRow +} from '@elastic/eui'; +import { SelectWithPlaceholder } from '../../../../../shared/SelectWithPlaceholder'; + +interface Props { + title: string; + description: string; + fieldLabel: string; + isLoading: boolean; + options?: EuiSelectOption[]; + value?: string; + disabled: boolean; + onChange: (event: React.ChangeEvent) => void; +} + +export function FormRowSelect({ + title, + description, + fieldLabel, + isLoading, + options, + value, + disabled, + onChange +}: Props) { + return ( + {title}} + description={description} + > + + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx new file mode 100644 index 0000000000000..b9f8fd86d067b --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/ServicePage/ServicePage.tsx @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiTitle, + EuiSpacer, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButton +} from '@elastic/eui'; +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { isString } from 'lodash'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { + omitAllOption, + getOptionLabel +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { useFetcher, FETCH_STATUS } from '../../../../../../hooks/useFetcher'; +import { FormRowSelect } from './FormRowSelect'; +import { APMLink } from '../../../../../shared/Links/apm/APMLink'; + +interface Props { + newConfig: AgentConfigurationIntake; + setNewConfig: React.Dispatch>; + onClickNext: () => void; +} + +export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) { + const { data: serviceNames = [], status: serviceNamesStatus } = useFetcher( + callApmApi => { + return callApmApi({ + pathname: '/api/apm/settings/agent-configuration/services', + forceCache: true + }); + }, + [], + { preservePreviousData: false } + ); + + const { data: environments = [], status: environmentStatus } = useFetcher( + callApmApi => { + if (newConfig.service.name) { + return callApmApi({ + pathname: '/api/apm/settings/agent-configuration/environments', + params: { + query: { serviceName: omitAllOption(newConfig.service.name) } + } + }); + } + }, + [newConfig.service.name], + { preservePreviousData: false } + ); + + const { status: agentNameStatus } = useFetcher( + async callApmApi => { + const serviceName = newConfig.service.name; + + if (!isString(serviceName) || serviceName.length === 0) { + return; + } + + const { agentName } = await callApmApi({ + pathname: '/api/apm/settings/agent-configuration/agent_name', + params: { query: { serviceName } } + }); + + setNewConfig(prev => ({ ...prev, agent_name: agentName })); + }, + [newConfig.service.name, setNewConfig] + ); + + const ALREADY_CONFIGURED_TRANSLATED = i18n.translate( + 'xpack.apm.agentConfig.servicePage.alreadyConfiguredOption', + { defaultMessage: 'already configured' } + ); + + const serviceNameOptions = serviceNames.map(name => ({ + text: getOptionLabel(name), + value: name + })); + const environmentOptions = environments.map( + ({ name, alreadyConfigured }) => ({ + disabled: alreadyConfigured, + text: `${getOptionLabel(name)} ${ + alreadyConfigured ? `(${ALREADY_CONFIGURED_TRANSLATED})` : '' + }`, + value: name + }) + ); + + return ( + + +

+ {i18n.translate('xpack.apm.agentConfig.servicePage.title', { + defaultMessage: 'Choose service' + })} +

+
+ + + + {/* Service name options */} + { + e.preventDefault(); + const name = e.target.value; + setNewConfig(prev => ({ + ...prev, + service: { name, environment: '' } + })); + }} + /> + + {/* Environment options */} + { + e.preventDefault(); + const environment = e.target.value; + setNewConfig(prev => ({ + ...prev, + service: { name: prev.service.name, environment } + })); + }} + /> + + + + + {/* Cancel button */} + + + + {i18n.translate( + 'xpack.apm.agentConfig.servicePage.cancelButton', + { defaultMessage: 'Cancel' } + )} + + + + + {/* Next button */} + + + {i18n.translate( + 'xpack.apm.agentConfig.saveConfigurationButtonLabel', + { defaultMessage: 'Next step' } + )} + + + +
+ ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx new file mode 100644 index 0000000000000..b1959e4d68aa4 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingFormRow.tsx @@ -0,0 +1,178 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { + EuiFormRow, + EuiFieldText, + EuiFieldNumber, + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiCode, + EuiSpacer, + EuiIconTip +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SettingDefinition } from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions/types'; +import { isValid } from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions'; +import { + amountAndUnitToString, + amountAndUnitToObject +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/amount_and_unit'; +import { SelectWithPlaceholder } from '../../../../../shared/SelectWithPlaceholder'; + +function FormRow({ + setting, + value, + onChange +}: { + setting: SettingDefinition; + value?: string; + onChange: (key: string, value: string) => void; +}) { + switch (setting.type) { + case 'float': + case 'text': { + return ( + onChange(setting.key, e.target.value)} + /> + ); + } + + case 'integer': { + return ( + onChange(setting.key, e.target.value)} + /> + ); + } + + case 'select': { + return ( + onChange(setting.key, e.target.value)} + /> + ); + } + + case 'boolean': { + return ( + onChange(setting.key, e.target.value)} + /> + ); + } + + case 'bytes': + case 'duration': { + const { amount, unit } = amountAndUnitToObject(value ?? ''); + + return ( + + + + onChange( + setting.key, + amountAndUnitToString({ amount: e.target.value, unit }) + ) + } + /> + + + ({ text }))} + onChange={e => + onChange( + setting.key, + amountAndUnitToString({ amount, unit: e.target.value }) + ) + } + /> + + + ); + } + + default: + throw new Error(`Unknown type "${(setting as SettingDefinition).type}"`); + } +} + +export function SettingFormRow({ + isUnsaved, + setting, + value, + onChange +}: { + isUnsaved: boolean; + setting: SettingDefinition; + value?: string; + onChange: (key: string, value: string) => void; +}) { + const isInvalid = value != null && value !== '' && !isValid(setting, value); + + return ( + + {setting.label}{' '} + {isUnsaved && ( + + )} + + } + description={ + <> + {setting.description} + + {setting.defaultValue && ( + <> + + Default: {setting.defaultValue} + + )} + + } + > + + + + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx new file mode 100644 index 0000000000000..6d76b69600333 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/SettingsPage.tsx @@ -0,0 +1,301 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiButton, + EuiForm, + EuiTitle, + EuiSpacer, + EuiPanel, + EuiFlexGroup, + EuiFlexItem, + EuiStat, + EuiBottomBar, + EuiText, + EuiHealth, + EuiLoadingSpinner +} from '@elastic/eui'; +import React, { useState, useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiButtonEmpty } from '@elastic/eui'; +import { EuiCallOut } from '@elastic/eui'; +import { FETCH_STATUS } from '../../../../../../hooks/useFetcher'; +import { AgentName } from '../../../../../../../../../../plugins/apm/typings/es_schemas/ui/fields/agent'; +import { history } from '../../../../../../utils/history'; +import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { + filterByAgent, + settingDefinitions, + isValid +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/setting_definitions'; +import { saveConfig } from './saveConfig'; +import { useApmPluginContext } from '../../../../../../hooks/useApmPluginContext'; +import { useUiTracker } from '../../../../../../../../../../plugins/observability/public'; +import { SettingFormRow } from './SettingFormRow'; +import { getOptionLabel } from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; + +function removeEmpty(obj: T): T { + return Object.fromEntries( + Object.entries(obj).filter(([k, v]) => v != null && v !== '') + ); +} + +export function SettingsPage({ + status, + unsavedChanges, + newConfig, + setNewConfig, + resetSettings, + isEditMode, + onClickEdit +}: { + status?: FETCH_STATUS; + unsavedChanges: Record; + newConfig: AgentConfigurationIntake; + setNewConfig: React.Dispatch>; + resetSettings: () => void; + isEditMode: boolean; + onClickEdit: () => void; +}) { + // get a telemetry UI event tracker + const trackApmEvent = useUiTracker({ app: 'apm' }); + const { toasts } = useApmPluginContext().core.notifications; + const [isSaving, setIsSaving] = useState(false); + const unsavedChangesCount = Object.keys(unsavedChanges).length; + const isLoading = status === FETCH_STATUS.LOADING; + + const isFormValid = useMemo(() => { + return ( + settingDefinitions + // only validate settings that are not empty + .filter(({ key }) => { + const value = newConfig.settings[key]; + return value != null && value !== ''; + }) + + // every setting must be valid for the form to be valid + .every(def => { + const value = newConfig.settings[def.key]; + return isValid(def, value); + }) + ); + }, [newConfig.settings]); + + const handleSubmitEvent = async () => { + trackApmEvent({ metric: 'save_agent_configuration' }); + const config = { ...newConfig, settings: removeEmpty(newConfig.settings) }; + + setIsSaving(true); + await saveConfig({ config, isEditMode, toasts }); + setIsSaving(false); + + // go back to overview + history.push({ + pathname: '/settings/agent-configuration', + search: history.location.search + }); + }; + + if (status === FETCH_STATUS.FAILURE) { + return ( + +

+ {i18n.translate( + 'xpack.apm.agentConfig.settingsPage.notFound.message', + { defaultMessage: 'The requested configuration does not exist' } + )} +

+
+ ); + } + + return ( + <> + + {/* Since the submit button is placed outside the form we cannot use `onSubmit` and have to use `onKeyPress` to submit the form on enter */} + {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */} +
{ + const didClickEnter = e.which === 13; + if (didClickEnter && isFormValid) { + e.preventDefault(); + handleSubmitEvent(); + } + }} + > + {/* Selected Service panel */} + + +

+ {i18n.translate('xpack.apm.agentConfig.chooseService.title', { + defaultMessage: 'Choose service' + })} +

+
+ + + + + + + + + + + + {!isEditMode && ( + + {i18n.translate( + 'xpack.apm.agentConfig.chooseService.editButton', + { defaultMessage: 'Edit' } + )} + + )} + + +
+ + + + {/* Settings panel */} + + +

+ {i18n.translate('xpack.apm.agentConfig.settings.title', { + defaultMessage: 'Configuration options' + })} +

+
+ + + + {isLoading ? ( +
+ +
+ ) : ( + renderSettings({ unsavedChanges, newConfig, setNewConfig }) + )} +
+ +
+ + + {/* Bottom bar with save button */} + {unsavedChangesCount > 0 && ( + + + + + + {i18n.translate('xpack.apm.unsavedChanges', { + defaultMessage: + '{unsavedChangesCount, plural, =0{0 unsaved changes} one {1 unsaved change} other {# unsaved changes}} ', + values: { unsavedChangesCount } + })} + + + + + + + {i18n.translate( + 'xpack.apm.agentConfig.settingsPage.discardChangesButton', + { defaultMessage: 'Discard changes' } + )} + + + + + {i18n.translate( + 'xpack.apm.agentConfig.settingsPage.saveButton', + { defaultMessage: 'Save configuration' } + )} + + + + + + + )} + + ); +} + +function renderSettings({ + newConfig, + unsavedChanges, + setNewConfig +}: { + newConfig: AgentConfigurationIntake; + unsavedChanges: Record; + setNewConfig: React.Dispatch>; +}) { + return ( + settingDefinitions + + // filter out agent specific items that are not applicable + // to the selected service + .filter(filterByAgent(newConfig.agent_name as AgentName)) + .map(setting => ( + { + setNewConfig(prev => ({ + ...prev, + settings: { + ...prev.settings, + [key]: value + } + })); + }} + /> + )) + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts new file mode 100644 index 0000000000000..7e3bcd68699be --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/SettingsPage/saveConfig.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { NotificationsStart } from 'kibana/public'; +import { AgentConfigurationIntake } from '../../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { + getOptionLabel, + omitAllOption +} from '../../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { callApmApi } from '../../../../../../services/rest/createCallApmApi'; + +export async function saveConfig({ + config, + isEditMode, + toasts +}: { + config: AgentConfigurationIntake; + agentName?: string; + isEditMode: boolean; + toasts: NotificationsStart['toasts']; +}) { + try { + await callApmApi({ + pathname: '/api/apm/settings/agent-configuration', + method: 'PUT', + params: { + query: { overwrite: isEditMode }, + body: { + ...config, + service: { + name: omitAllOption(config.service.name), + environment: omitAllOption(config.service.environment) + } + } + } + }); + + toasts.addSuccess({ + title: i18n.translate( + 'xpack.apm.agentConfig.saveConfig.succeeded.title', + { defaultMessage: 'Configuration saved' } + ), + text: i18n.translate('xpack.apm.agentConfig.saveConfig.succeeded.text', { + defaultMessage: + 'The configuration for "{serviceName}" was saved. It will take some time to propagate to the agents.', + values: { serviceName: getOptionLabel(config.service.name) } + }) + }); + } catch (error) { + toasts.addDanger({ + title: i18n.translate('xpack.apm.agentConfig.saveConfig.failed.title', { + defaultMessage: 'Configuration could not be saved' + }), + text: i18n.translate('xpack.apm.agentConfig.saveConfig.failed.text', { + defaultMessage: + 'Something went wrong when saving the configuration for "{serviceName}". Error: "{errorMessage}"', + values: { + serviceName: getOptionLabel(config.service.name), + errorMessage: error.message + } + }) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx new file mode 100644 index 0000000000000..531e557b6ef86 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.stories.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { HttpSetup } from 'kibana/public'; +import { AgentConfiguration } from '../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { createCallApmApi } from '../../../../../services/rest/createCallApmApi'; +import { AgentConfigurationCreateEdit } from './index'; +import { + ApmPluginContext, + ApmPluginContextValue +} from '../../../../../context/ApmPluginContext'; + +storiesOf( + 'app/Settings/AgentConfigurations/AgentConfigurationCreateEdit', + module +).add( + 'with config', + () => { + const httpMock = {}; + + // mock + createCallApmApi((httpMock as unknown) as HttpSetup); + + const contextMock = { + core: { + notifications: { toasts: { addWarning: () => {}, addDanger: () => {} } } + } + }; + return ( + + + + ); + }, + { + info: { + source: false + } + } +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx new file mode 100644 index 0000000000000..638e518563f8c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationCreateEdit/index.tsx @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty } from 'lodash'; +import { EuiTitle, EuiText, EuiSpacer } from '@elastic/eui'; +import React, { useState, useEffect, useCallback } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FetcherResult } from '../../../../../hooks/useFetcher'; +import { history } from '../../../../../utils/history'; +import { + AgentConfigurationIntake, + AgentConfiguration +} from '../../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { ServicePage } from './ServicePage/ServicePage'; +import { SettingsPage } from './SettingsPage/SettingsPage'; +import { fromQuery, toQuery } from '../../../../shared/Links/url_helpers'; + +type PageStep = 'choose-service-step' | 'choose-settings-step' | 'review-step'; + +function getInitialNewConfig( + existingConfig: AgentConfigurationIntake | undefined +) { + return { + agent_name: existingConfig?.agent_name, + service: existingConfig?.service || {}, + settings: existingConfig?.settings || {} + }; +} + +function setPage(pageStep: PageStep) { + history.push({ + ...history.location, + search: fromQuery({ + ...toQuery(history.location.search), + pageStep + }) + }); +} + +function getUnsavedChanges({ + newConfig, + existingConfig +}: { + newConfig: AgentConfigurationIntake; + existingConfig?: AgentConfigurationIntake; +}) { + return Object.fromEntries( + Object.entries(newConfig.settings).filter(([key, value]) => { + const existingValue = existingConfig?.settings?.[key]; + + // don't highlight changes that were added and removed + if (value === '' && existingValue == null) { + return false; + } + + return existingValue !== value; + }) + ); +} + +export function AgentConfigurationCreateEdit({ + pageStep, + existingConfigResult +}: { + pageStep: PageStep; + existingConfigResult?: FetcherResult; +}) { + const existingConfig = existingConfigResult?.data; + const isEditMode = Boolean(existingConfigResult); + const [newConfig, setNewConfig] = useState( + getInitialNewConfig(existingConfig) + ); + + const resetSettings = useCallback(() => { + setNewConfig(_newConfig => ({ + ..._newConfig, + settings: existingConfig?.settings || {} + })); + }, [existingConfig]); + + // update newConfig when existingConfig has loaded + useEffect(() => { + setNewConfig(getInitialNewConfig(existingConfig)); + }, [existingConfig]); + + useEffect(() => { + // the user tried to edit the service of an existing config + if (pageStep === 'choose-service-step' && isEditMode) { + setPage('choose-settings-step'); + } + + // the user skipped the first step (select service) + if ( + pageStep === 'choose-settings-step' && + !isEditMode && + isEmpty(newConfig.service) + ) { + setPage('choose-service-step'); + } + }, [isEditMode, newConfig, pageStep]); + + const unsavedChanges = getUnsavedChanges({ newConfig, existingConfig }); + + return ( + <> + +

+ {isEditMode + ? i18n.translate('xpack.apm.agentConfig.editConfigTitle', { + defaultMessage: 'Edit configuration' + }) + : i18n.translate('xpack.apm.agentConfig.createConfigTitle', { + defaultMessage: 'Create configuration' + })} +

+
+ + + {i18n.translate('xpack.apm.agentConfig.newConfig.description', { + defaultMessage: `This allows you to fine-tune your agent configuration directly in + Kibana. Best of all, changes are automatically propagated to your APM + agents so there’s no need to redeploy.` + })} + + + + + {pageStep === 'choose-service-step' && ( + setPage('choose-settings-step')} + /> + )} + + {pageStep === 'choose-settings-step' && ( + setPage('choose-service-step')} + newConfig={newConfig} + setNewConfig={setNewConfig} + resetSettings={resetSettings} + isEditMode={isEditMode} + /> + )} + + {/* + TODO: Add review step + {pageStep === 'review-step' &&
Review will be here
} + */} + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx deleted file mode 100644 index 557945e9ba67a..0000000000000 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/AgentConfigurationList.tsx +++ /dev/null @@ -1,232 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { - EuiEmptyPrompt, - EuiButton, - EuiButtonEmpty, - EuiHealth, - EuiToolTip -} from '@elastic/eui'; -import { isEmpty } from 'lodash'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { FETCH_STATUS } from '../../../../hooks/useFetcher'; -import { ITableColumn, ManagedTable } from '../../../shared/ManagedTable'; -import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AgentConfigurationListAPIResponse } from '../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; -import { Config } from '.'; -import { TimestampTooltip } from '../../../shared/TimestampTooltip'; -import { px, units } from '../../../../style/variables'; -import { getOptionLabel } from '../../../../../../../../plugins/apm/common/agent_configuration_constants'; - -export function AgentConfigurationList({ - status, - data, - setIsFlyoutOpen, - setSelectedConfig -}: { - status: FETCH_STATUS; - data: AgentConfigurationListAPIResponse; - setIsFlyoutOpen: (val: boolean) => void; - setSelectedConfig: (val: Config | null) => void; -}) { - const columns: Array> = [ - { - field: 'applied_by_agent', - align: 'center', - width: px(units.double), - name: '', - sortable: true, - render: (isApplied: boolean) => ( - - - - ) - }, - { - field: 'service.name', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.serviceNameColumnLabel', - { defaultMessage: 'Service name' } - ), - sortable: true, - render: (_, config: Config) => ( - { - setSelectedConfig(config); - setIsFlyoutOpen(true); - }} - > - {getOptionLabel(config.service.name)} - - ) - }, - { - field: 'service.environment', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.environmentColumnLabel', - { defaultMessage: 'Service environment' } - ), - sortable: true, - render: (value: string) => getOptionLabel(value) - }, - { - field: 'settings.transaction_sample_rate', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.sampleRateColumnLabel', - { defaultMessage: 'Sample rate' } - ), - dataType: 'number', - sortable: true, - render: (value: number) => value - }, - { - field: 'settings.capture_body', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.captureBodyColumnLabel', - { defaultMessage: 'Capture body' } - ), - sortable: true, - render: (value: string) => value - }, - { - field: 'settings.transaction_max_spans', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.transactionMaxSpansColumnLabel', - { defaultMessage: 'Transaction max spans' } - ), - dataType: 'number', - sortable: true, - render: (value: number) => value - }, - { - align: 'right', - field: '@timestamp', - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.lastUpdatedColumnLabel', - { defaultMessage: 'Last updated' } - ), - sortable: true, - render: (value: number) => ( - - ) - }, - { - width: px(units.double), - name: '', - actions: [ - { - name: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.editButtonLabel', - { defaultMessage: 'Edit' } - ), - description: i18n.translate( - 'xpack.apm.settings.agentConf.configTable.editButtonDescription', - { defaultMessage: 'Edit this config' } - ), - icon: 'pencil', - color: 'primary', - type: 'icon', - onClick: (config: Config) => { - setSelectedConfig(config); - setIsFlyoutOpen(true); - } - } - ] - } - ]; - - const emptyStatePrompt = ( - - {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.emptyPromptTitle', - { defaultMessage: 'No configurations found.' } - )} - - } - body={ - <> -

- {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.emptyPromptText', - { - defaultMessage: - "Let's change that! You can fine-tune agent configuration directly from Kibana without having to redeploy. Get started by creating your first configuration." - } - )} -

- - } - actions={ - setIsFlyoutOpen(true)}> - {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.createConfigButtonLabel', - { defaultMessage: 'Create configuration' } - )} - - } - /> - ); - - const failurePrompt = ( - -

- {i18n.translate( - 'xpack.apm.settings.agentConf.configTable.configTable.failurePromptText', - { - defaultMessage: - 'The list of agent configurations could not be fetched. Your user may not have the sufficient permissions.' - } - )} -

- - } - /> - ); - - if (status === 'failure') { - return failurePrompt; - } - - if (status === 'success' && isEmpty(data)) { - return emptyStatePrompt; - } - - return ( - } - columns={columns} - items={data} - initialSortField="service.name" - initialSortDirection="asc" - initialPageSize={50} - /> - ); -} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx new file mode 100644 index 0000000000000..267aaddc93f76 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/ConfirmDeleteModal.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { NotificationsStart } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AgentConfigurationListAPIResponse } from '../../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; +import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { callApmApi } from '../../../../../services/rest/createCallApmApi'; +import { useApmPluginContext } from '../../../../../hooks/useApmPluginContext'; + +type Config = AgentConfigurationListAPIResponse[0]; + +interface Props { + config: Config; + onCancel: () => void; + onConfirm: () => void; +} + +export function ConfirmDeleteModal({ config, onCancel, onConfirm }: Props) { + const [isDeleting, setIsDeleting] = useState(false); + const { toasts } = useApmPluginContext().core.notifications; + + return ( + + { + setIsDeleting(true); + await deleteConfig(config, toasts); + setIsDeleting(false); + onConfirm(); + }} + cancelButtonText={i18n.translate( + 'xpack.apm.agentConfig.deleteModal.cancel', + { defaultMessage: `Cancel` } + )} + confirmButtonText={i18n.translate( + 'xpack.apm.agentConfig.deleteModal.confirm', + { defaultMessage: `Delete` } + )} + confirmButtonDisabled={isDeleting} + buttonColor="danger" + defaultFocusedButton="confirm" + > +

+ {i18n.translate('xpack.apm.agentConfig.deleteModal.text', { + defaultMessage: `You are about to delete the configuration for service "{serviceName}" and environment "{environment}".`, + values: { + serviceName: getOptionLabel(config.service.name), + environment: getOptionLabel(config.service.environment) + } + })} +

+
+
+ ); +} + +async function deleteConfig( + config: Config, + toasts: NotificationsStart['toasts'] +) { + try { + await callApmApi({ + pathname: '/api/apm/settings/agent-configuration', + method: 'DELETE', + params: { + body: { + service: { + name: config.service.name, + environment: config.service.environment + } + } + } + }); + + toasts.addSuccess({ + title: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigSucceededTitle', + { defaultMessage: 'Configuration was deleted' } + ), + text: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigSucceededText', + { + defaultMessage: + 'You have successfully deleted a configuration for "{serviceName}". It will take some time to propagate to the agents.', + values: { serviceName: getOptionLabel(config.service.name) } + } + ) + }); + } catch (error) { + toasts.addDanger({ + title: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigFailedTitle', + { defaultMessage: 'Configuration could not be deleted' } + ), + text: i18n.translate( + 'xpack.apm.agentConfig.deleteSection.deleteConfigFailedText', + { + defaultMessage: + 'Something went wrong when deleting a configuration for "{serviceName}". Error: "{errorMessage}"', + values: { + serviceName: getOptionLabel(config.service.name), + errorMessage: error.message + } + } + ) + }); + } +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx new file mode 100644 index 0000000000000..6d5f65121d8fd --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/List/index.tsx @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { + EuiEmptyPrompt, + EuiButton, + EuiButtonEmpty, + EuiHealth, + EuiToolTip, + EuiButtonIcon +} from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import { FETCH_STATUS } from '../../../../../hooks/useFetcher'; +import { ITableColumn, ManagedTable } from '../../../../shared/ManagedTable'; +import { LoadingStatePrompt } from '../../../../shared/LoadingStatePrompt'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AgentConfigurationListAPIResponse } from '../../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; +import { TimestampTooltip } from '../../../../shared/TimestampTooltip'; +import { px, units } from '../../../../../style/variables'; +import { getOptionLabel } from '../../../../../../../../../plugins/apm/common/agent_configuration/all_option'; +import { + createAgentConfigurationHref, + editAgentConfigurationHref +} from '../../../../shared/Links/apm/agentConfigurationLinks'; +import { ConfirmDeleteModal } from './ConfirmDeleteModal'; + +type Config = AgentConfigurationListAPIResponse[0]; + +export function AgentConfigurationList({ + status, + data, + refetch +}: { + status: FETCH_STATUS; + data: Config[]; + refetch: () => void; +}) { + const [configToBeDeleted, setConfigToBeDeleted] = useState( + null + ); + + const emptyStatePrompt = ( + + {i18n.translate( + 'xpack.apm.agentConfig.configTable.emptyPromptTitle', + { defaultMessage: 'No configurations found.' } + )} + + } + body={ + <> +

+ {i18n.translate( + 'xpack.apm.agentConfig.configTable.emptyPromptText', + { + defaultMessage: + "Let's change that! You can fine-tune agent configuration directly from Kibana without having to redeploy. Get started by creating your first configuration." + } + )} +

+ + } + actions={ + + {i18n.translate( + 'xpack.apm.agentConfig.configTable.createConfigButtonLabel', + { defaultMessage: 'Create configuration' } + )} + + } + /> + ); + + const failurePrompt = ( + +

+ {i18n.translate( + 'xpack.apm.agentConfig.configTable.configTable.failurePromptText', + { + defaultMessage: + 'The list of agent configurations could not be fetched. Your user may not have the sufficient permissions.' + } + )} +

+ + } + /> + ); + + if (status === FETCH_STATUS.FAILURE) { + return failurePrompt; + } + + if (status === FETCH_STATUS.SUCCESS && isEmpty(data)) { + return emptyStatePrompt; + } + + const columns: Array> = [ + { + field: 'applied_by_agent', + align: 'center', + width: px(units.double), + name: '', + sortable: true, + render: (isApplied: boolean) => ( + + + + ) + }, + { + field: 'service.name', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.serviceNameColumnLabel', + { defaultMessage: 'Service name' } + ), + sortable: true, + render: (_, config: Config) => ( + + {getOptionLabel(config.service.name)} + + ) + }, + { + field: 'service.environment', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.environmentColumnLabel', + { defaultMessage: 'Service environment' } + ), + sortable: true, + render: (environment: string) => getOptionLabel(environment) + }, + { + align: 'right', + field: '@timestamp', + name: i18n.translate( + 'xpack.apm.agentConfig.configTable.lastUpdatedColumnLabel', + { defaultMessage: 'Last updated' } + ), + sortable: true, + render: (value: number) => ( + + ) + }, + { + width: px(units.double), + name: '', + render: (config: Config) => ( + + ) + }, + { + width: px(units.double), + name: '', + render: (config: Config) => ( + setConfigToBeDeleted(config)} + /> + ) + } + ]; + + return ( + <> + {configToBeDeleted && ( + setConfigToBeDeleted(null)} + onConfirm={() => { + setConfigToBeDeleted(null); + refetch(); + }} + /> + )} + + } + columns={columns} + items={data} + initialSortField="service.name" + initialSortDirection="asc" + initialPageSize={20} + /> + + ); +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx index 35cc68547d337..8171e339adc82 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/AgentConfigurations/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@kbn/i18n'; import { EuiTitle, @@ -16,97 +16,59 @@ import { } from '@elastic/eui'; import { isEmpty } from 'lodash'; import { useFetcher } from '../../../../hooks/useFetcher'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { AgentConfigurationListAPIResponse } from '../../../../../../../../plugins/apm/server/lib/settings/agent_configuration/list_configurations'; -import { AgentConfigurationList } from './AgentConfigurationList'; +import { AgentConfigurationList } from './List'; import { useTrackPageview } from '../../../../../../../../plugins/observability/public'; -import { AddEditFlyout } from './AddEditFlyout'; - -export type Config = AgentConfigurationListAPIResponse[0]; +import { createAgentConfigurationHref } from '../../../shared/Links/apm/agentConfigurationLinks'; export function AgentConfigurations() { - const { data = [], status, refetch } = useFetcher( + const { refetch, data = [], status } = useFetcher( callApmApi => - callApmApi({ pathname: `/api/apm/settings/agent-configuration` }), + callApmApi({ pathname: '/api/apm/settings/agent-configuration' }), [], { preservePreviousData: false } ); - const [selectedConfig, setSelectedConfig] = useState(null); - const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); useTrackPageview({ app: 'apm', path: 'agent_configuration' }); useTrackPageview({ app: 'apm', path: 'agent_configuration', delay: 15000 }); const hasConfigurations = !isEmpty(data); - const onClose = () => { - setSelectedConfig(null); - setIsFlyoutOpen(false); - }; - return ( <> - {isFlyoutOpen && ( - { - onClose(); - refetch(); - }} - onDeleted={() => { - onClose(); - refetch(); - }} - /> - )} -

{i18n.translate( - 'xpack.apm.settings.agentConf.configurationsPanelTitle', + 'xpack.apm.agentConfig.configurationsPanelTitle', { defaultMessage: 'Agent remote configuration' } )}

- {hasConfigurations ? ( - setIsFlyoutOpen(true)} /> - ) : null} + {hasConfigurations ? : null}
- +
); } -function CreateConfigurationButton({ onClick }: { onClick: () => void }) { +function CreateConfigurationButton() { + const href = createAgentConfigurationHref(); return ( - - {i18n.translate( - 'xpack.apm.settings.agentConf.createConfigButtonLabel', - { defaultMessage: 'Create configuration' } - )} + + {i18n.translate('xpack.apm.agentConfig.createConfigButtonLabel', { + defaultMessage: 'Create configuration' + })} diff --git a/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx index eef386731c5c3..f33bb17decd4e 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/Settings/index.tsx @@ -39,23 +39,20 @@ export const Settings: React.FC = props => { id: 0, items: [ { - name: i18n.translate( - 'xpack.apm.settings.agentConfiguration', - { - defaultMessage: 'Agent Configuration' - } - ), + name: i18n.translate('xpack.apm.settings.agentConfig', { + defaultMessage: 'Agent Configuration' + }), id: '1', - // @ts-ignore href: getAPMHref('/settings/agent-configuration', search), - isSelected: pathname === '/settings/agent-configuration' + isSelected: pathname.startsWith( + '/settings/agent-configuration' + ) }, { name: i18n.translate('xpack.apm.settings.indices', { defaultMessage: 'Indices' }), id: '2', - // @ts-ignore href: getAPMHref('/settings/apm-indices', search), isSelected: pathname === '/settings/apm-indices' }, @@ -64,7 +61,6 @@ export const Settings: React.FC = props => { defaultMessage: 'Customize UI' }), id: '3', - // @ts-ignore href: getAPMHref('/settings/customize-ui', search), isSelected: pathname === '/settings/customize-ui' } diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx index eba59f6e3ce44..29b3fff2050c8 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/APMLink.tsx @@ -31,7 +31,7 @@ export const PERSISTENT_APM_PARAMS = [ export function getAPMHref( path: string, - currentSearch: string, // TODO: Replace with passing in URL PARAMS here + currentSearch: string, query: APMQueryParams = {} ) { const currentQuery = toQuery(currentSearch); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx new file mode 100644 index 0000000000000..0c747e0773a69 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/shared/Links/apm/agentConfigurationLinks.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getAPMHref } from './APMLink'; +import { AgentConfigurationIntake } from '../../../../../../../../plugins/apm/common/agent_configuration/configuration_types'; +import { history } from '../../../../utils/history'; + +export function editAgentConfigurationHref( + configService: AgentConfigurationIntake['service'] +) { + const { search } = history.location; + return getAPMHref('/settings/agent-configuration/edit', search, { + // ignoring because `name` has not been added to url params. Related: https://github.com/elastic/kibana/issues/51963 + // @ts-ignore + name: configService.name, + environment: configService.environment + }); +} + +export function createAgentConfigurationHref() { + const { search } = history.location; + return getAPMHref('/settings/agent-configuration/create', search); +} diff --git a/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx b/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx index a8e6bc0a648af..8698978bfe6fb 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/SelectWithPlaceholder/index.tsx @@ -7,33 +7,38 @@ import React from 'react'; import { EuiSelect } from '@elastic/eui'; import { isEmpty } from 'lodash'; +import { i18n } from '@kbn/i18n'; -const NO_SELECTION = 'NO_SELECTION'; +export const NO_SELECTION = '__NO_SELECTION__'; +const DEFAULT_PLACEHOLDER = i18n.translate('xpack.apm.selectPlaceholder', { + defaultMessage: 'Select option:' +}); /** * This component addresses some cross-browser inconsistencies of `EuiSelect` * with `hasNoInitialSelection`. It uses the `placeholder` prop to populate * the first option as the initial, not selected option. */ -export const SelectWithPlaceholder: typeof EuiSelect = props => ( - { - if (props.onChange) { - props.onChange( - Object.assign(e, { +export const SelectWithPlaceholder: typeof EuiSelect = props => { + const placeholder = props.placeholder || DEFAULT_PLACEHOLDER; + return ( + { + if (props.onChange) { + const customEvent = Object.assign(e, { target: Object.assign(e.target, { - value: - e.target.value === NO_SELECTION ? undefined : e.target.value + value: e.target.value === NO_SELECTION ? '' : e.target.value }) - }) - ); - } - }} - /> -); + }); + props.onChange(customEvent); + } + }} + /> + ); +}; diff --git a/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx b/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx index 30e6e02c9fbc1..1e9c20494b42e 100644 --- a/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx +++ b/x-pack/legacy/plugins/apm/public/context/LocationContext.tsx @@ -11,10 +11,8 @@ import { withRouter } from 'react-router-dom'; const initialLocation = {} as Location; const LocationContext = createContext(initialLocation); -const LocationProvider: React.ComponentClass<{}> = withRouter( - ({ location, children }) => { - return ; - } -); +const LocationProvider = withRouter(({ location, children }) => { + return ; +}); export { LocationContext, LocationProvider }; diff --git a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx index c2530d6982c3b..95cebd6b2a465 100644 --- a/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx +++ b/x-pack/legacy/plugins/apm/public/hooks/useFetcher.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable no-console */ + import React, { useContext, useEffect, useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { IHttpFetchError } from 'src/core/public'; @@ -20,7 +22,7 @@ export enum FETCH_STATUS { PENDING = 'pending' } -interface Result { +export interface FetcherResult { data?: Data; status: FETCH_STATUS; error?: Error; @@ -40,13 +42,15 @@ export function useFetcher( options: { preservePreviousData?: boolean; } = {} -): Result> & { refetch: () => void } { +): FetcherResult> & { refetch: () => void } { const { notifications } = useApmPluginContext().core; const { preservePreviousData = true } = options; const { setIsLoading } = useLoadingIndicator(); const { dispatchStatus } = useContext(LoadingIndicatorContext); - const [result, setResult] = useState>>({ + const [result, setResult] = useState< + FetcherResult> + >({ data: undefined, status: FETCH_STATUS.PENDING }); @@ -80,11 +84,27 @@ export function useFetcher( data, status: FETCH_STATUS.SUCCESS, error: undefined - } as Result>); + } as FetcherResult>); } } catch (e) { - const err = e as IHttpFetchError; + const err = e as Error | IHttpFetchError; + if (!didCancel) { + const errorDetails = + 'response' in err ? ( + <> + {err.response?.statusText} ({err.response?.status}) +
+ {i18n.translate('xpack.apm.fetcher.error.url', { + defaultMessage: `URL` + })} +
+ {err.response?.url} + + ) : ( + err.message + ); + notifications.toasts.addWarning({ title: i18n.translate('xpack.apm.fetcher.error.title', { defaultMessage: `Error while fetching resource` @@ -96,13 +116,8 @@ export function useFetcher( defaultMessage: `Error` })} - {err.response?.statusText} ({err.response?.status}) -
- {i18n.translate('xpack.apm.fetcher.error.url', { - defaultMessage: `URL` - })} -
- {err.response?.url} + + {errorDetails} ) }); diff --git a/x-pack/plugins/apm/common/agent_configuration_constants.ts b/x-pack/plugins/apm/common/agent_configuration/all_option.ts similarity index 74% rename from x-pack/plugins/apm/common/agent_configuration_constants.ts rename to x-pack/plugins/apm/common/agent_configuration/all_option.ts index 4ddc65c14a134..7b1c7bdda97a7 100644 --- a/x-pack/plugins/apm/common/agent_configuration_constants.ts +++ b/x-pack/plugins/apm/common/agent_configuration/all_option.ts @@ -8,11 +8,11 @@ import { i18n } from '@kbn/i18n'; export const ALL_OPTION_VALUE = 'ALL_OPTION_VALUE'; -// human-readable label for the option. The "All" option should be translated. +// human-readable label for service and environment. The "All" option should be translated. // Everything else should be returned verbatim export function getOptionLabel(value: string | undefined) { if (value === undefined || value === ALL_OPTION_VALUE) { - return i18n.translate('xpack.apm.settings.agentConf.allOptionLabel', { + return i18n.translate('xpack.apm.agentConfig.allOptionLabel', { defaultMessage: 'All' }); } @@ -20,6 +20,6 @@ export function getOptionLabel(value: string | undefined) { return value; } -export function omitAllOption(value: string) { +export function omitAllOption(value?: string) { return value === ALL_OPTION_VALUE ? undefined : value; } diff --git a/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts b/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts new file mode 100644 index 0000000000000..447e529c9c199 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/amount_and_unit.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +interface AmountAndUnit { + amount: string; + unit: string; +} + +export function amountAndUnitToObject(value: string): AmountAndUnit { + const [, amount = '', unit = ''] = value.match(/(\d+)?(\w+)?/) || []; + return { amount, unit }; +} + +export function amountAndUnitToString({ amount, unit }: AmountAndUnit) { + return `${amount}${unit}`; +} diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/configuration_types.d.ts b/x-pack/plugins/apm/common/agent_configuration/configuration_types.d.ts similarity index 82% rename from x-pack/plugins/apm/server/lib/settings/agent_configuration/configuration_types.d.ts rename to x-pack/plugins/apm/common/agent_configuration/configuration_types.d.ts index ddbe6892c5441..675298c3719f2 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/configuration_types.d.ts +++ b/x-pack/plugins/apm/common/agent_configuration/configuration_types.d.ts @@ -5,11 +5,12 @@ */ import t from 'io-ts'; -import { agentConfigurationIntakeRt } from '../../../../common/runtime_types/agent_configuration_intake_rt'; +import { agentConfigurationIntakeRt } from './runtime_types/agent_configuration_intake_rt'; export type AgentConfigurationIntake = t.TypeOf< typeof agentConfigurationIntakeRt >; + export type AgentConfiguration = { '@timestamp': number; applied_by_agent?: boolean; diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.test.ts new file mode 100644 index 0000000000000..c2a3be784f661 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { agentConfigurationIntakeRt } from './agent_configuration_intake_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('agentConfigurationIntakeRt', () => { + it('is valid when "transaction_sample_rate" is string', () => { + const config = { + service: { name: 'my-service', environment: 'my-environment' }, + settings: { + transaction_sample_rate: '0.5' + } + }; + + expect(isConfigValid(config)).toBe(true); + }); + + it('is invalid when "transaction_sample_rate" is number', () => { + const config = { + service: {}, + settings: { + transaction_sample_rate: 0.5 + } + }; + + expect(isConfigValid(config)).toBe(false); + }); + + it('is valid when unknown setting is string', () => { + const config = { + service: { name: 'my-service', environment: 'my-environment' }, + settings: { + my_unknown_setting: '0.5' + } + }; + + expect(isConfigValid(config)).toBe(true); + }); + + it('is invalid when unknown setting is boolean', () => { + const config = { + service: { name: 'my-service', environment: 'my-environment' }, + settings: { + my_unknown_setting: false + } + }; + + expect(isConfigValid(config)).toBe(false); + }); +}); + +function isConfigValid(config: any) { + return isRight(agentConfigurationIntakeRt.decode(config)); +} diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts new file mode 100644 index 0000000000000..a0b1d5015b9ef --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/agent_configuration_intake_rt.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { settingDefinitions } from '../setting_definitions'; + +// retrieve validation from config definitions settings and validate on the server +const knownSettings = settingDefinitions.reduce< + // TODO: is it possible to get rid of any? + Record> +>((acc, { key, validation }) => { + acc[key] = validation; + return acc; +}, {}); + +export const serviceRt = t.partial({ + name: t.string, + environment: t.string +}); + +export const settingsRt = t.intersection([ + t.record(t.string, t.string), + t.partial(knownSettings) +]); + +export const agentConfigurationIntakeRt = t.intersection([ + t.partial({ agent_name: t.string }), + t.type({ + service: serviceRt, + settings: settingsRt + }) +]); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.test.ts new file mode 100644 index 0000000000000..d4dfbec2344fe --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { booleanRt } from './boolean_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('booleanRt', () => { + describe('it should not accept', () => { + [undefined, null, '', 0, 'foo', true, false].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(booleanRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should accept', () => { + ['true', 'false'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(booleanRt.decode(input))).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.ts new file mode 100644 index 0000000000000..0cd3ac9d6939c --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/boolean_rt.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +export const booleanRt = t.union([t.literal('true'), t.literal('false')]); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts new file mode 100644 index 0000000000000..596037645c002 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.test.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { bytesRt } from './bytes_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('bytesRt', () => { + describe('it should not accept', () => { + [ + undefined, + null, + '', + 0, + 'foo', + true, + false, + '100', + 'mb', + '0kb', + '5gb', + '6tb' + ].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should accept', () => { + ['1b', '2kb', '3mb'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(bytesRt.decode(input))).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts new file mode 100644 index 0000000000000..d189fab89ae5d --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/bytes_rt.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; +import { amountAndUnitToObject } from '../amount_and_unit'; + +export const BYTE_UNITS = ['b', 'kb', 'mb']; + +export const bytesRt = new t.Type( + 'bytesRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const { amount, unit } = amountAndUnitToObject(inputAsString); + const amountAsInt = parseInt(amount, 10); + const isValidUnit = BYTE_UNITS.includes(unit); + const isValid = amountAsInt > 0 && isValidUnit; + + return isValid + ? t.success(inputAsString) + : t.failure( + input, + context, + `Must have numeric amount and a valid unit (${BYTE_UNITS})` + ); + }); + }, + t.identity +); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.test.ts new file mode 100644 index 0000000000000..ba522e6b8ea68 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { captureBodyRt } from './capture_body_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('captureBodyRt', () => { + describe('it should not accept', () => { + [undefined, null, '', 0, 'foo', true, false].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(captureBodyRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should accept', () => { + ['off', 'errors', 'transactions', 'all'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(captureBodyRt.decode(input))).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.ts new file mode 100644 index 0000000000000..8f38a8976beb0 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/capture_body_rt.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; + +export const captureBodyRt = t.union([ + t.literal('off'), + t.literal('errors'), + t.literal('transactions'), + t.literal('all') +]); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts new file mode 100644 index 0000000000000..6b81031542c34 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { durationRt } from './duration_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('durationRt', () => { + describe('it should not accept', () => { + [undefined, null, '', 0, 'foo', true, false, '100', 's', 'm', '0h'].map( + input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(durationRt.decode(input))).toBe(false); + }); + } + ); + }); + + describe('It should accept', () => { + ['1s', '2m', '3h'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(durationRt.decode(input))).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts new file mode 100644 index 0000000000000..99e6a57089dee --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/duration_rt.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; +import { amountAndUnitToObject } from '../amount_and_unit'; + +export const DURATION_UNITS = ['s', 'm', 'h']; + +export const durationRt = new t.Type( + 'durationRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const { amount, unit } = amountAndUnitToObject(inputAsString); + const amountAsInt = parseInt(amount, 10); + const isValidUnit = DURATION_UNITS.includes(unit); + const isValid = amountAsInt > 0 && isValidUnit; + + return isValid + ? t.success(inputAsString) + : t.failure( + input, + context, + `Must have numeric amount and a valid unit (${DURATION_UNITS})` + ); + }); + }, + t.identity +); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts new file mode 100644 index 0000000000000..ef7fbeed4331e --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { integerRt, getIntegerRt } from './integer_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('integerRt', () => { + describe('it should not accept', () => { + [undefined, null, '', 'foo', 0, 55, NaN].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(integerRt.decode(input))).toBe(false); + }); + }); + }); + + describe('it should accept', () => { + ['-1234', '-1', '0', '1000', '32000', '100000'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(integerRt.decode(input))).toBe(true); + }); + }); + }); +}); + +describe('getIntegerRt', () => { + const customIntegerRt = getIntegerRt({ min: 0, max: 32000 }); + describe('it should not accept', () => { + [undefined, null, '', 'foo', 0, 55, '-1', '-55', '33000', NaN].map( + input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customIntegerRt.decode(input))).toBe(false); + }); + } + ); + }); + + describe('it should accept', () => { + ['0', '1000', '32000'].map(input => { + it(`${JSON.stringify(input)}`, () => { + expect(isRight(customIntegerRt.decode(input))).toBe(true); + }); + }); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts new file mode 100644 index 0000000000000..6dbf175c8b4ce --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/integer_rt.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +export function getIntegerRt({ min, max }: { min: number; max: number }) { + return new t.Type( + 'integerRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const inputAsInt = parseInt(inputAsString, 10); + const isValid = inputAsInt >= min && inputAsInt <= max; + return isValid + ? t.success(inputAsString) + : t.failure( + input, + context, + `Number must be a valid number between ${min} and ${max}` + ); + }); + }, + t.identity + ); +} + +export const integerRt = getIntegerRt({ min: -Infinity, max: Infinity }); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts new file mode 100644 index 0000000000000..ece229ca162fb --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { numberFloatRt } from './number_float_rt'; +import { isRight } from 'fp-ts/lib/Either'; + +describe('numberFloatRt', () => { + it('does not accept empty values', () => { + expect(isRight(numberFloatRt.decode(undefined))).toBe(false); + expect(isRight(numberFloatRt.decode(null))).toBe(false); + expect(isRight(numberFloatRt.decode(''))).toBe(false); + }); + + it('should only accept stringified numbers', () => { + expect(isRight(numberFloatRt.decode('0.5'))).toBe(true); + expect(isRight(numberFloatRt.decode(0.5))).toBe(false); + }); + + it('checks if the number falls within 0, 1', () => { + expect(isRight(numberFloatRt.decode('0'))).toBe(true); + expect(isRight(numberFloatRt.decode('0.5'))).toBe(true); + expect(isRight(numberFloatRt.decode('-0.1'))).toBe(false); + expect(isRight(numberFloatRt.decode('1.1'))).toBe(false); + expect(isRight(numberFloatRt.decode(NaN))).toBe(false); + }); + + it('checks whether the number of decimals is 3', () => { + expect(isRight(numberFloatRt.decode('1'))).toBe(true); + expect(isRight(numberFloatRt.decode('0.9'))).toBe(true); + expect(isRight(numberFloatRt.decode('0.99'))).toBe(true); + expect(isRight(numberFloatRt.decode('0.999'))).toBe(true); + expect(isRight(numberFloatRt.decode('0.9999'))).toBe(false); + }); +}); diff --git a/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts new file mode 100644 index 0000000000000..f1890c9851a3d --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/runtime_types/number_float_rt.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { either } from 'fp-ts/lib/Either'; + +export function getNumberFloatRt({ min, max }: { min: number; max: number }) { + return new t.Type( + 'numberFloatRt', + t.string.is, + (input, context) => { + return either.chain(t.string.validate(input, context), inputAsString => { + const inputAsFloat = parseFloat(inputAsString); + const maxThreeDecimals = + parseFloat(inputAsFloat.toFixed(3)) === inputAsFloat; + + const isValid = + inputAsFloat >= min && inputAsFloat <= max && maxThreeDecimals; + + return isValid + ? t.success(inputAsString) + : t.failure( + input, + context, + `Number must be between ${min} and ${max}` + ); + }); + }, + t.identity + ); +} + +export const numberFloatRt = getNumberFloatRt({ min: 0, max: 1 }); diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap new file mode 100644 index 0000000000000..365d8838a24a6 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/__snapshots__/index.test.ts.snap @@ -0,0 +1,191 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`settingDefinitions should have correct default values 1`] = ` +Array [ + Object { + "key": "active", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "api_request_size", + "type": "bytes", + "units": Array [ + "b", + "kb", + "mb", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "bytesRt", + }, + Object { + "key": "api_request_time", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "capture_body", + "options": Array [ + Object { + "text": "off", + }, + Object { + "text": "errors", + }, + Object { + "text": "transactions", + }, + Object { + "text": "all", + }, + ], + "type": "select", + "validationName": "(\\"off\\" | \\"errors\\" | \\"transactions\\" | \\"all\\")", + }, + Object { + "key": "capture_headers", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "circuit_breaker_enabled", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "enable_log_correlation", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "log_level", + "type": "text", + "validationName": "string", + }, + Object { + "key": "profiling_inferred_spans_enabled", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "profiling_inferred_spans_excluded_classes", + "type": "text", + "validationName": "string", + }, + Object { + "key": "profiling_inferred_spans_included_classes", + "type": "text", + "validationName": "string", + }, + Object { + "key": "profiling_inferred_spans_min_duration", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "profiling_inferred_spans_sampling_interval", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "server_timeout", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "span_frames_min_duration", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "stack_trace_limit", + "type": "integer", + "validationError": "Must be an integer", + "validationName": "integerRt", + }, + Object { + "key": "stress_monitor_cpu_duration_threshold", + "type": "duration", + "units": Array [ + "s", + "m", + "h", + ], + "validationError": "Please specify an integer and a unit", + "validationName": "durationRt", + }, + Object { + "key": "stress_monitor_gc_relief_threshold", + "type": "float", + "validationError": "Must be a number between 0.000 and 1", + "validationName": "numberFloatRt", + }, + Object { + "key": "stress_monitor_gc_stress_threshold", + "type": "boolean", + "validationName": "(\\"true\\" | \\"false\\")", + }, + Object { + "key": "stress_monitor_system_cpu_relief_threshold", + "type": "float", + "validationError": "Must be a number between 0.000 and 1", + "validationName": "numberFloatRt", + }, + Object { + "key": "stress_monitor_system_cpu_stress_threshold", + "type": "float", + "validationError": "Must be a number between 0.000 and 1", + "validationName": "numberFloatRt", + }, + Object { + "key": "trace_methods_duration_threshold", + "type": "integer", + "validationError": "Must be an integer", + "validationName": "integerRt", + }, + Object { + "key": "transaction_max_spans", + "max": 32000, + "min": 0, + "type": "integer", + "validationError": "Must be between 0 and 32000", + "validationName": "integerRt", + }, + Object { + "key": "transaction_sample_rate", + "type": "float", + "validationError": "Must be a number between 0.000 and 1", + "validationName": "numberFloatRt", + }, +] +`; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts new file mode 100644 index 0000000000000..b6eb40305dae7 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -0,0 +1,219 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { getIntegerRt } from '../runtime_types/integer_rt'; +import { captureBodyRt } from '../runtime_types/capture_body_rt'; +import { RawSettingDefinition } from './types'; + +/* + * Settings added here will show up in the UI and will be validated on the client and server + */ +export const generalSettings: RawSettingDefinition[] = [ + // Active + { + key: 'active', + type: 'boolean', + defaultValue: 'true', + label: i18n.translate('xpack.apm.agentConfig.active.label', { + defaultMessage: 'Active' + }), + description: i18n.translate('xpack.apm.agentConfig.active.description', { + defaultMessage: + 'A boolean specifying if the agent should be active or not.\nWhen active, the agent instruments incoming HTTP requests, tracks errors and collects and sends metrics.\nWhen inactive, the agent works as a noop, not collecting data and not communicating with the APM Server.\nAs this is a reversible switch, agent threads are not being killed when inactivated, but they will be \nmostly idle in this state, so the overhead should be negligible.\n\nYou can use this setting to dynamically disable Elastic APM at runtime.' + }), + excludeAgents: ['js-base', 'rum-js', 'python', 'dotnet'] + }, + + // API Request Size + { + key: 'api_request_size', + type: 'bytes', + defaultValue: '768kb', + label: i18n.translate('xpack.apm.agentConfig.apiRequestSize.label', { + defaultMessage: 'API Request Size' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.apiRequestSize.description', + { + defaultMessage: + 'The maximum total compressed size of the request body which is sent to the APM server intake api via a chunked encoding (HTTP streaming).\nNote that a small overshoot is possible.\n\nAllowed byte units are `b`, `kb` and `mb`. `1kb` is equal to `1024b`.' + } + ), + excludeAgents: ['js-base', 'rum-js', 'dotnet'] + }, + + // API Request Time + { + key: 'api_request_time', + type: 'duration', + defaultValue: '10s', + label: i18n.translate('xpack.apm.agentConfig.apiRequestTime.label', { + defaultMessage: 'API Request Time' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.apiRequestTime.description', + { + defaultMessage: + "Maximum time to keep an HTTP request to the APM Server open for.\n\nNOTE: This value has to be lower than the APM Server's `read_timeout` setting." + } + ), + excludeAgents: ['js-base', 'rum-js', 'dotnet'] + }, + + // Capture headers + { + key: 'capture_headers', + type: 'boolean', + defaultValue: 'true', + label: i18n.translate('xpack.apm.agentConfig.captureHeaders.label', { + defaultMessage: 'Capture Headers' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.captureHeaders.description', + { + defaultMessage: + 'If set to `true`, the agent will capture request and response headers, including cookies.\n\nNOTE: Setting this to `false` reduces network bandwidth, disk space and object allocations.' + } + ), + excludeAgents: ['js-base', 'rum-js'] + }, + + // Capture body + { + key: 'capture_body', + validation: captureBodyRt, + type: 'select', + defaultValue: 'off', + label: i18n.translate('xpack.apm.agentConfig.captureBody.label', { + defaultMessage: 'Capture body' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.captureBody.description', + { + defaultMessage: + 'For transactions that are HTTP requests, the agent can optionally capture the request body (e.g. POST variables). Default is "off".' + } + ), + options: [ + { text: 'off' }, + { text: 'errors' }, + { text: 'transactions' }, + { text: 'all' } + ], + excludeAgents: ['js-base', 'rum-js', 'dotnet'] + }, + + // LOG_LEVEL + { + key: 'log_level', + type: 'text', + defaultValue: 'info', + label: i18n.translate('xpack.apm.agentConfig.logLevel.label', { + defaultMessage: 'Log level' + }), + description: i18n.translate('xpack.apm.agentConfig.logLevel.description', { + defaultMessage: 'Sets the logging level for the agent' + }), + excludeAgents: ['js-base', 'rum-js', 'python'] + }, + + // SERVER_TIMEOUT + { + key: 'server_timeout', + type: 'duration', + defaultValue: '5s', + label: i18n.translate('xpack.apm.agentConfig.serverTimeout.label', { + defaultMessage: 'Server Timeout' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.serverTimeout.description', + { + defaultMessage: + 'If a request to the APM server takes longer than the configured timeout,\nthe request is cancelled and the event (exception or transaction) is discarded.\nSet to 0 to disable timeouts.\n\nWARNING: If timeouts are disabled or set to a high value, your app could experience memory issues if the APM server times out.' + } + ), + includeAgents: ['nodejs', 'java', 'go'] + }, + + // SPAN_FRAMES_MIN_DURATION + { + key: 'span_frames_min_duration', + type: 'duration', + defaultValue: '5ms', + label: i18n.translate('xpack.apm.agentConfig.spanFramesMinDuration.label', { + defaultMessage: 'Span frames minimum duration' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.spanFramesMinDuration.description', + { + defaultMessage: + 'In its default settings, the APM agent will collect a stack trace with every recorded span.\nWhile this is very helpful to find the exact place in your code that causes the span, collecting this stack trace does have some overhead. \nWhen setting this option to a negative value, like `-1ms`, stack traces will be collected for all spans. Setting it to a positive value, e.g. `5ms`, will limit stack trace collection to spans with durations equal to or longer than the given value, e.g. 5 milliseconds.\n\nTo disable stack trace collection for spans completely, set the value to `0ms`.' + } + ), + excludeAgents: ['js-base', 'rum-js', 'nodejs'] + }, + + // STACK_TRACE_LIMIT + { + key: 'stack_trace_limit', + type: 'integer', + defaultValue: '50', + label: i18n.translate('xpack.apm.agentConfig.stackTraceLimit.label', { + defaultMessage: 'Stack trace limit' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.stackTraceLimit.description', + { + defaultMessage: + 'Setting it to 0 will disable stack trace collection. Any positive integer value will be used as the maximum number of frames to collect. Setting it -1 means that all frames will be collected.' + } + ), + includeAgents: ['nodejs', 'java', 'dotnet', 'go'] + }, + + // Transaction sample rate + { + key: 'transaction_sample_rate', + type: 'float', + defaultValue: '1.0', + label: i18n.translate('xpack.apm.agentConfig.transactionSampleRate.label', { + defaultMessage: 'Transaction sample rate' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.transactionSampleRate.description', + { + defaultMessage: + 'By default, the agent will sample every transaction (e.g. request to your service). To reduce overhead and storage requirements, you can set the sample rate to a value between 0.0 and 1.0. We still record overall time and the result for unsampled transactions, but no context information, labels, or spans.' + } + ) + }, + + // Transaction max spans + { + key: 'transaction_max_spans', + type: 'integer', + validation: getIntegerRt({ min: 0, max: 32000 }), + validationError: i18n.translate( + 'xpack.apm.agentConfig.transactionMaxSpans.errorText', + { defaultMessage: 'Must be between 0 and 32000' } + ), + defaultValue: '500', + label: i18n.translate('xpack.apm.agentConfig.transactionMaxSpans.label', { + defaultMessage: 'Transaction max spans' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.transactionMaxSpans.description', + { + defaultMessage: + 'Limits the amount of spans that are recorded per transaction. Default is 500.' + } + ), + min: 0, + max: 32000, + excludeAgents: ['js-base', 'rum-js'] + } +]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts new file mode 100644 index 0000000000000..0d1113d74c98b --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -0,0 +1,187 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { omit } from 'lodash'; +import { filterByAgent, settingDefinitions } from '../setting_definitions'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +import { SettingDefinition } from './types'; + +describe('filterByAgent', () => { + describe('when `excludeAgents` is dotnet and nodejs', () => { + const setting = { + key: 'my-setting', + excludeAgents: ['dotnet', 'nodejs'] + } as SettingDefinition; + + it('should not include dotnet', () => { + expect(filterByAgent('dotnet')(setting)).toBe(false); + }); + + it('should include go', () => { + expect(filterByAgent('go')(setting)).toBe(true); + }); + }); + + describe('when `includeAgents` is dotnet and nodejs', () => { + const setting = { + key: 'my-setting', + includeAgents: ['dotnet', 'nodejs'] + } as SettingDefinition; + + it('should not include go', () => { + expect(filterByAgent('go')(setting)).toBe(false); + }); + + it('should include dotnet', () => { + expect(filterByAgent('dotnet')(setting)).toBe(true); + }); + }); + + describe('options per agent', () => { + it('go', () => { + expect(getSettingKeysForAgent('go')).toEqual([ + 'active', + 'api_request_size', + 'api_request_time', + 'capture_body', + 'capture_headers', + 'log_level', + 'server_timeout', + 'span_frames_min_duration', + 'stack_trace_limit', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('java', () => { + expect(getSettingKeysForAgent('java')).toEqual([ + 'active', + 'api_request_size', + 'api_request_time', + 'capture_body', + 'capture_headers', + 'circuit_breaker_enabled', + 'enable_log_correlation', + 'log_level', + 'profiling_inferred_spans_enabled', + 'profiling_inferred_spans_excluded_classes', + 'profiling_inferred_spans_included_classes', + 'profiling_inferred_spans_min_duration', + 'profiling_inferred_spans_sampling_interval', + 'server_timeout', + 'span_frames_min_duration', + 'stack_trace_limit', + 'stress_monitor_cpu_duration_threshold', + 'stress_monitor_gc_relief_threshold', + 'stress_monitor_gc_stress_threshold', + 'stress_monitor_system_cpu_relief_threshold', + 'stress_monitor_system_cpu_stress_threshold', + 'trace_methods_duration_threshold', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('js-base', () => { + expect(getSettingKeysForAgent('js-base')).toEqual([ + 'transaction_sample_rate' + ]); + }); + + it('rum-js', () => { + expect(getSettingKeysForAgent('rum-js')).toEqual([ + 'transaction_sample_rate' + ]); + }); + + it('nodejs', () => { + expect(getSettingKeysForAgent('nodejs')).toEqual([ + 'active', + 'api_request_size', + 'api_request_time', + 'capture_body', + 'capture_headers', + 'log_level', + 'server_timeout', + 'stack_trace_limit', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('python', () => { + expect(getSettingKeysForAgent('python')).toEqual([ + 'api_request_size', + 'api_request_time', + 'capture_body', + 'capture_headers', + 'span_frames_min_duration', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('dotnet', () => { + expect(getSettingKeysForAgent('dotnet')).toEqual([ + 'capture_headers', + 'log_level', + 'span_frames_min_duration', + 'stack_trace_limit', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('ruby', () => { + expect(getSettingKeysForAgent('ruby')).toEqual([ + 'active', + 'api_request_size', + 'api_request_time', + 'capture_body', + 'capture_headers', + 'log_level', + 'span_frames_min_duration', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + + it('"All" services (no agent name)', () => { + expect(getSettingKeysForAgent(undefined)).toEqual([ + 'capture_headers', + 'transaction_max_spans', + 'transaction_sample_rate' + ]); + }); + }); +}); + +describe('settingDefinitions', () => { + it('should have correct default values', () => { + expect( + settingDefinitions.map(def => { + return { + ...omit(def, [ + 'category', + 'defaultValue', + 'description', + 'excludeAgents', + 'includeAgents', + 'label', + 'validation' + ]), + validationName: def.validation.name + }; + }) + ).toMatchSnapshot(); + }); +}); + +function getSettingKeysForAgent(agentName: AgentName | undefined) { + const definitions = settingDefinitions.filter(filterByAgent(agentName)); + return definitions.map(def => def.key); +} diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.ts new file mode 100644 index 0000000000000..8786a94be096d --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { sortBy } from 'lodash'; +import { isRight } from 'fp-ts/lib/Either'; +import { i18n } from '@kbn/i18n'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; +import { booleanRt } from '../runtime_types/boolean_rt'; +import { integerRt } from '../runtime_types/integer_rt'; +import { isRumAgentName } from '../../agent_name'; +import { numberFloatRt } from '../runtime_types/number_float_rt'; +import { bytesRt, BYTE_UNITS } from '../runtime_types/bytes_rt'; +import { durationRt, DURATION_UNITS } from '../runtime_types/duration_rt'; +import { RawSettingDefinition, SettingDefinition } from './types'; +import { generalSettings } from './general_settings'; +import { javaSettings } from './java_settings'; + +function getDefaultsByType(settingDefinition: RawSettingDefinition) { + switch (settingDefinition.type) { + case 'boolean': + return { validation: booleanRt }; + case 'text': + return { validation: t.string }; + case 'integer': + return { + validation: integerRt, + validationError: i18n.translate( + 'xpack.apm.agentConfig.integer.errorText', + { defaultMessage: 'Must be an integer' } + ) + }; + case 'float': + return { + validation: numberFloatRt, + validationError: i18n.translate( + 'xpack.apm.agentConfig.float.errorText', + { defaultMessage: 'Must be a number between 0.000 and 1' } + ) + }; + case 'bytes': + return { + validation: bytesRt, + units: BYTE_UNITS, + validationError: i18n.translate( + 'xpack.apm.agentConfig.bytes.errorText', + { defaultMessage: 'Please specify an integer and a unit' } + ) + }; + case 'duration': + return { + validation: durationRt, + units: DURATION_UNITS, + validationError: i18n.translate( + 'xpack.apm.agentConfig.bytes.errorText', + { defaultMessage: 'Please specify an integer and a unit' } + ) + }; + } +} + +export function filterByAgent(agentName?: AgentName) { + return (setting: SettingDefinition) => { + // agentName is missing if "All" was selected + if (!agentName) { + // options that only apply to certain agents will be filtered out + if (setting.includeAgents) { + return false; + } + + // only options that apply to every agent (ignoring RUM) should be returned + if (setting.excludeAgents) { + return setting.excludeAgents.every(isRumAgentName); + } + + return true; + } + + if (setting.includeAgents) { + return setting.includeAgents.includes(agentName); + } + + if (setting.excludeAgents) { + return !setting.excludeAgents.includes(agentName); + } + + return true; + }; +} + +export function isValid(setting: SettingDefinition, value: unknown) { + return isRight(setting.validation.decode(value)); +} + +export const settingDefinitions = sortBy( + [...generalSettings, ...javaSettings].map(def => { + const defWithDefaults = { + ...getDefaultsByType(def), + ...def + }; + + // ensure every option has validation + if (!defWithDefaults.validation) { + throw new Error(`Missing validation for ${def.key}`); + } + + return defWithDefaults as SettingDefinition; + }), + 'key' +); diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts new file mode 100644 index 0000000000000..1a480c131e853 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/java_settings.ts @@ -0,0 +1,256 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { RawSettingDefinition } from './types'; + +export const javaSettings: RawSettingDefinition[] = [ + // ENABLE_LOG_CORRELATION + { + key: 'enable_log_correlation', + type: 'boolean', + defaultValue: 'false', + label: i18n.translate('xpack.apm.agentConfig.enableLogCorrelation.label', { + defaultMessage: 'Enable log correlation' + }), + description: i18n.translate( + 'xpack.apm.agentConfig.enableLogCorrelation.description', + { + defaultMessage: + "A boolean specifying if the agent should integrate into SLF4J's https://www.slf4j.org/api/org/slf4j/MDC.html[MDC] to enable trace-log correlation.\nIf set to `true`, the agent will set the `trace.id` and `transaction.id` for the currently active spans and transactions to the MDC.\nSee <> for more details.\n\nNOTE: While it's allowed to enable this setting at runtime, you can't disable it without a restart." + } + ), + includeAgents: ['java'] + }, + + // TRACE_METHODS_DURATION_THRESHOLD + { + key: 'trace_methods_duration_threshold', + type: 'integer', + label: i18n.translate( + 'xpack.apm.agentConfig.traceMethodsDurationThreshold.label', + { + defaultMessage: 'Trace methods duration threshold' + } + ), + description: i18n.translate( + 'xpack.apm.agentConfig.traceMethodsDurationThreshold.description', + { + defaultMessage: + 'If trace_methods config option is set, provides a threshold to limit spans based on duration. When set to a value greater than 0, spans representing methods traced based on trace_methods will be discarded by default.' + } + ), + includeAgents: ['java'] + }, + + /* + * Circuit-Breaker + **/ + { + key: 'circuit_breaker_enabled', + label: i18n.translate('xpack.apm.agentConfig.circuitBreakerEnabled.label', { + defaultMessage: 'Cirtcuit breaker enabled' + }), + type: 'boolean', + category: 'Circuit-Breaker', + defaultValue: 'false', + description: i18n.translate( + 'xpack.apm.agentConfig.circuitBreakerEnabled.description', + { + defaultMessage: + 'A boolean specifying whether the circuit breaker should be enabled or not. \nWhen enabled, the agent periodically polls stress monitors to detect system/process/JVM stress state. \nIf ANY of the monitors detects a stress indication, the agent will become inactive, as if the \n<> configuration option has been set to `false`, thus reducing resource consumption to a minimum. \nWhen inactive, the agent continues polling the same monitors in order to detect whether the stress state \nhas been relieved. If ALL monitors approve that the system/process/JVM is not under stress anymore, the \nagent will resume and become fully functional.' + } + ), + includeAgents: ['java'] + }, + { + key: 'stress_monitor_gc_stress_threshold', + label: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorGcStressThreshold.label', + { defaultMessage: 'Stress monitor gc stress threshold' } + ), + type: 'boolean', + category: 'Circuit-Breaker', + defaultValue: '0.95', + description: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorGcStressThreshold.description', + { + defaultMessage: + 'The threshold used by the GC monitor to rely on for identifying heap stress.\nThe same threshold will be used for all heap pools, so that if ANY has a usage percentage that crosses it, \nthe agent will consider it as a heap stress. The GC monitor relies only on memory consumption measured \nafter a recent GC.' + } + ), + includeAgents: ['java'] + }, + { + key: 'stress_monitor_gc_relief_threshold', + label: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorGcReliefThreshold.label', + { defaultMessage: 'Stress monitor gc relief threshold' } + ), + + type: 'float', + category: 'Circuit-Breaker', + defaultValue: '0.75', + description: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorGcReliefThreshold.description', + { + defaultMessage: + 'The threshold used by the GC monitor to rely on for identifying when the heap is not under stress .\nIf `stress_monitor_gc_stress_threshold` has been crossed, the agent will consider it a heap-stress state. \nIn order to determine that the stress state is over, percentage of occupied memory in ALL heap pools should \nbe lower than this threshold. The GC monitor relies only on memory consumption measured after a recent GC.' + } + ), + includeAgents: ['java'] + }, + { + key: 'stress_monitor_cpu_duration_threshold', + label: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorCpuDurationThreshold.label', + { defaultMessage: 'Stress monitor cpu duration threshold' } + ), + type: 'duration', + category: 'Circuit-Breaker', + defaultValue: '1m', + description: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorCpuDurationThreshold.description', + { + defaultMessage: + 'The minimal time required in order to determine whether the system is \neither currently under stress, or that the stress detected previously has been relieved. \nAll measurements during this time must be consistent in comparison to the relevant threshold in \norder to detect a change of stress state. Must be at least `1m`.' + } + ), + includeAgents: ['java'] + }, + { + key: 'stress_monitor_system_cpu_stress_threshold', + label: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.label', + { defaultMessage: 'Stress monitor system cpu stress threshold' } + ), + type: 'float', + category: 'Circuit-Breaker', + defaultValue: '0.95', + description: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorSystemCpuStressThreshold.description', + { + defaultMessage: + 'The threshold used by the system CPU monitor to detect system CPU stress. \nIf the system CPU crosses this threshold for a duration of at least `stress_monitor_cpu_duration_threshold`, \nthe monitor considers this as a stress state.' + } + ), + includeAgents: ['java'] + }, + { + key: 'stress_monitor_system_cpu_relief_threshold', + label: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorSystemCpuReliefThreshold.label', + { defaultMessage: 'Stress monitor system cpu relief threshold' } + ), + type: 'float', + category: 'Circuit-Breaker', + defaultValue: '0.8', + description: i18n.translate( + 'xpack.apm.agentConfig.stressMonitorSystemCpuReliefThreshold.description', + { + defaultMessage: + 'The threshold used by the system CPU monitor to determine that the system is \nnot under CPU stress. If the monitor detected a CPU stress, the measured system CPU needs to be below \nthis threshold for a duration of at least `stress_monitor_cpu_duration_threshold` in order for the \nmonitor to decide that the CPU stress has been relieved.' + } + ), + includeAgents: ['java'] + }, + + /* + * Profiling + **/ + + { + key: 'profiling_inferred_spans_enabled', + label: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansEnabled.label', + { defaultMessage: 'Profiling inferred spans enabled' } + ), + type: 'boolean', + category: 'Profiling', + defaultValue: 'false', + description: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansEnabled.description', + { + defaultMessage: + 'Set to `true` to make the agent create spans for method executions based on\nhttps://github.com/jvm-profiling-tools/async-profiler[async-profiler], a sampling aka statistical profiler.\n\nDue to the nature of how sampling profilers work,\nthe duration of the inferred spans are not exact, but only estimations.\nThe <> lets you fine tune the trade-off between accuracy and overhead.\n\nThe inferred spans are created after a profiling session has ended.\nThis means there is a delay between the regular and the inferred spans being visible in the UI.\n\nNOTE: This feature is not available on Windows' + } + ), + includeAgents: ['java'] + }, + { + key: 'profiling_inferred_spans_sampling_interval', + label: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansSamplingInterval.label', + { defaultMessage: 'Profiling inferred spans sampling interval' } + ), + type: 'duration', + category: 'Profiling', + defaultValue: '50ms', + description: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansSamplingInterval.description', + { + defaultMessage: + 'The frequency at which stack traces are gathered within a profiling session.\nThe lower you set it, the more accurate the durations will be.\nThis comes at the expense of higher overhead and more spans for potentially irrelevant operations.\nThe minimal duration of a profiling-inferred span is the same as the value of this setting.' + } + ), + includeAgents: ['java'] + }, + { + key: 'profiling_inferred_spans_min_duration', + label: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansMinDuration.label', + { defaultMessage: 'Profiling inferred spans min duration' } + ), + type: 'duration', + category: 'Profiling', + defaultValue: '0ms', + description: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansMinDuration.description', + { + defaultMessage: + 'The minimum duration of an inferred span.\nNote that the min duration is also implicitly set by the sampling interval.\nHowever, increasing the sampling interval also decreases the accuracy of the duration of inferred spans.' + } + ), + includeAgents: ['java'] + }, + { + key: 'profiling_inferred_spans_included_classes', + label: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansIncludedClasses.label', + { defaultMessage: 'Profiling inferred spans included classes' } + ), + type: 'text', + category: 'Profiling', + defaultValue: '*', + description: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansIncludedClasses.description', + { + defaultMessage: + 'If set, the agent will only create inferred spans for methods which match this list.\nSetting a value may slightly increase performance and can reduce clutter by only creating spans for the classes you are interested in.\nExample: `org.example.myapp.*`\n\nThis option supports the wildcard `*`, which matches zero or more characters.\nExamples: `/foo/*/bar/*/baz*`, `*foo*`.\nMatching is case insensitive by default.\nPrepending an element with `(?-i)` makes the matching case sensitive.' + } + ), + includeAgents: ['java'] + }, + { + key: 'profiling_inferred_spans_excluded_classes', + label: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansExcludedClasses.label', + { defaultMessage: 'Profiling inferred spans excluded classes' } + ), + type: 'text', + category: 'Profiling', + defaultValue: + '(?-i)java.*,(?-i)javax.*,(?-i)sun.*,(?-i)com.sun.*,(?-i)jdk.*,(?-i)org.apache.tomcat.*,(?-i)org.apache.catalina.*,(?-i)org.apache.coyote.*,(?-i)org.jboss.as.*,(?-i)org.glassfish.*,(?-i)org.eclipse.jetty.*,(?-i)com.ibm.websphere.*,(?-i)io.undertow.*', + description: i18n.translate( + 'xpack.apm.agentConfig.profilingInferredSpansExcludedClasses.description', + { + defaultMessage: + 'Excludes classes for which no profiler-inferred spans should be created.\n\nThis option supports the wildcard `*`, which matches zero or more characters.\nExamples: `/foo/*/bar/*/baz*`, `*foo*`.\nMatching is case insensitive by default.\nPrepending an element with `(?-i)` makes the matching case sensitive.' + } + ), + includeAgents: ['java'] + } +]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts new file mode 100644 index 0000000000000..6b584fc7e2048 --- /dev/null +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/types.d.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; + +interface BaseSetting { + /** + * UI: unique key to identify setting + */ + key: string; + + /** + * UI: Human readable name of setting + */ + label: string; + + /** + * UI: Human readable name of setting + * Not used yet + */ + category?: string; + + /** + * UI: + */ + defaultValue?: string; + + /** + * UI: description of setting + */ + description: string; + + /** + * UI: placeholder to show in input field + */ + placeholder?: string; + + /** + * runtime validation of the input + */ + validation?: t.Type; + + /** + * UI: error shown when the runtime validation fails + */ + validationError?: string; + + /** + * Limits the setting to no agents, except those specified in `includeAgents` + */ + includeAgents?: AgentName[]; + + /** + * Limits the setting to all agents, except those specified in `excludeAgents` + */ + excludeAgents?: AgentName[]; +} + +interface TextSetting extends BaseSetting { + type: 'text'; +} + +interface IntegerSetting extends BaseSetting { + type: 'integer'; + min?: number; + max?: number; +} + +interface FloatSetting extends BaseSetting { + type: 'float'; +} + +interface SelectSetting extends BaseSetting { + type: 'select'; + options: Array<{ text: string }>; +} + +interface BooleanSetting extends BaseSetting { + type: 'boolean'; +} + +interface BytesSetting extends BaseSetting { + type: 'bytes'; + units?: string[]; +} + +interface DurationSetting extends BaseSetting { + type: 'duration'; + units?: string[]; +} + +export type RawSettingDefinition = + | TextSetting + | FloatSetting + | IntegerSetting + | SelectSetting + | BooleanSetting + | BytesSetting + | DurationSetting; + +export type SettingDefinition = RawSettingDefinition & { + validation: NonNullable; +}; diff --git a/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.test.ts deleted file mode 100644 index 4c9dc78eb41e9..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { agentConfigurationIntakeRt } from './index'; -import { isRight } from 'fp-ts/lib/Either'; - -describe('agentConfigurationIntakeRt', () => { - it('is valid when required parameters are given', () => { - const config = { - service: {}, - settings: {} - }; - - expect(isConfigValid(config)).toBe(true); - }); - - it('is valid when required and optional parameters are given', () => { - const config = { - service: { name: 'my-service', environment: 'my-environment' }, - settings: { - transaction_sample_rate: 0.5, - capture_body: 'foo', - transaction_max_spans: 10 - } - }; - - expect(isConfigValid(config)).toBe(true); - }); - - it('is invalid when required parameters are not given', () => { - const config = {}; - expect(isConfigValid(config)).toBe(false); - }); -}); - -function isConfigValid(config: any) { - return isRight(agentConfigurationIntakeRt.decode(config)); -} diff --git a/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.ts deleted file mode 100644 index 32a2832b5eaf3..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/agent_configuration_intake_rt/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as t from 'io-ts'; -import { transactionSampleRateRt } from '../transaction_sample_rate_rt'; -import { transactionMaxSpansRt } from '../transaction_max_spans_rt'; - -export const serviceRt = t.partial({ - name: t.string, - environment: t.string -}); - -export const agentConfigurationIntakeRt = t.intersection([ - t.partial({ agent_name: t.string }), - t.type({ - service: serviceRt, - settings: t.partial({ - transaction_sample_rate: transactionSampleRateRt, - capture_body: t.string, - transaction_max_spans: transactionMaxSpansRt - }) - }) -]); diff --git a/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.test.ts deleted file mode 100644 index b62251b6974d9..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { transactionMaxSpansRt } from './index'; -import { isRight } from 'fp-ts/lib/Either'; - -describe('transactionMaxSpans', () => { - it('does not accept empty values', () => { - expect(isRight(transactionMaxSpansRt.decode(undefined))).toBe(false); - expect(isRight(transactionMaxSpansRt.decode(null))).toBe(false); - expect(isRight(transactionMaxSpansRt.decode(''))).toBe(false); - }); - - it('accepts both strings and numbers as values', () => { - expect(isRight(transactionMaxSpansRt.decode('55'))).toBe(true); - expect(isRight(transactionMaxSpansRt.decode(55))).toBe(true); - }); - - it('checks if the number falls within 0, 32000', () => { - expect(isRight(transactionMaxSpansRt.decode(0))).toBe(true); - expect(isRight(transactionMaxSpansRt.decode(32000))).toBe(true); - expect(isRight(transactionMaxSpansRt.decode(-55))).toBe(false); - expect(isRight(transactionMaxSpansRt.decode(NaN))).toBe(false); - }); -}); diff --git a/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.ts deleted file mode 100644 index 251161c21babe..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/transaction_max_spans_rt/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as t from 'io-ts'; - -export const transactionMaxSpansRt = new t.Type( - 'transactionMaxSpans', - t.number.is, - (input, context) => { - const value = parseInt(input as string, 10); - return value >= 0 && value <= 32000 - ? t.success(value) - : t.failure(input, context); - }, - t.identity -); diff --git a/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts b/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts deleted file mode 100644 index 6930a69f0870a..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { transactionSampleRateRt } from './index'; -import { isRight } from 'fp-ts/lib/Either'; - -describe('transactionSampleRateRt', () => { - it('does not accept empty values', () => { - expect(isRight(transactionSampleRateRt.decode(undefined))).toBe(false); - expect(isRight(transactionSampleRateRt.decode(null))).toBe(false); - expect(isRight(transactionSampleRateRt.decode(''))).toBe(false); - }); - - it('accepts both strings and numbers as values', () => { - expect(isRight(transactionSampleRateRt.decode('0.5'))).toBe(true); - expect(isRight(transactionSampleRateRt.decode(0.5))).toBe(true); - }); - - it('checks if the number falls within 0, 1', () => { - expect(isRight(transactionSampleRateRt.decode(0))).toBe(true); - - expect(isRight(transactionSampleRateRt.decode(0.5))).toBe(true); - - expect(isRight(transactionSampleRateRt.decode(-0.1))).toBe(false); - expect(isRight(transactionSampleRateRt.decode(1.1))).toBe(false); - - expect(isRight(transactionSampleRateRt.decode(NaN))).toBe(false); - }); - - it('checks whether the number of decimals is 3', () => { - expect(isRight(transactionSampleRateRt.decode(1))).toBe(true); - expect(isRight(transactionSampleRateRt.decode(0.99))).toBe(true); - expect(isRight(transactionSampleRateRt.decode(0.999))).toBe(true); - expect(isRight(transactionSampleRateRt.decode(0.998))).toBe(true); - }); -}); diff --git a/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts b/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts deleted file mode 100644 index 90c60d16f7b59..0000000000000 --- a/x-pack/plugins/apm/common/runtime_types/transaction_sample_rate_rt/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import * as t from 'io-ts'; - -export const transactionSampleRateRt = new t.Type( - 'TransactionSampleRate', - t.number.is, - (input, context) => { - const value = parseFloat(input as string); - return value >= 0 && value <= 1 && parseFloat(value.toFixed(3)) === value - ? t.success(value) - : t.failure(input, context); - }, - t.identity -); diff --git a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts index cc01c990bf985..91a595c0900be 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_or_update_index.ts @@ -9,8 +9,9 @@ import { CallCluster } from 'src/legacy/core_plugins/elasticsearch'; export type Mappings = | { - dynamic?: boolean; + dynamic?: boolean | 'strict'; properties: Record; + dynamic_templates?: any[]; } | { type: string; diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts new file mode 100644 index 0000000000000..ab01a68733a7a --- /dev/null +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/convert_settings_to_string.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ESSearchHit } from '../../../../typings/elasticsearch'; +import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; + +// needed for backwards compatability +// All settings except `transaction_sample_rate` and `transaction_max_spans` are stored as strings (they are stored as float and integer respectively) +export function convertConfigSettingsToString( + hit: ESSearchHit +) { + const config = hit._source; + + if (config.settings?.transaction_sample_rate) { + config.settings.transaction_sample_rate = config.settings.transaction_sample_rate.toString(); + } + + if (config.settings?.transaction_max_spans) { + config.settings.transaction_max_spans = config.settings.transaction_max_spans.toString(); + } + + return hit; +} diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts index bc03138e0c247..b2dc22ceb2918 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_agent_config_index.ts @@ -26,6 +26,19 @@ export async function createApmAgentConfigurationIndex({ } const mappings: Mappings = { + dynamic: 'strict', + dynamic_templates: [ + { + // force string to keyword (instead of default of text + keyword) + strings: { + match_mapping_type: 'string', + mapping: { + type: 'keyword', + ignore_above: 1024 + } + } + } + ], properties: { '@timestamp': { type: 'date' @@ -43,21 +56,9 @@ const mappings: Mappings = { } }, settings: { - properties: { - transaction_sample_rate: { - type: 'scaled_float', - scaling_factor: 1000, - ignore_malformed: true, - coerce: false - }, - capture_body: { - type: 'keyword', - ignore_above: 1024 - }, - transaction_max_spans: { - type: 'short' - } - } + // allowing dynamic fields without specifying anything specific + dynamic: true, + properties: {} }, applied_by_agent: { type: 'boolean' diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts index 74fcc61dde863..aa5ddb288b2d7 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/create_or_update_configuration.ts @@ -9,7 +9,7 @@ import { Setup } from '../../helpers/setup_request'; import { AgentConfiguration, AgentConfigurationIntake -} from './configuration_types'; +} from '../../../../common/agent_configuration/configuration_types'; import { APMIndexDocumentParams } from '../../helpers/es_client'; export async function createOrUpdateConfiguration({ diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts index eea409882f876..e3455cdbc5a1c 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/find_exact_configuration.ts @@ -9,8 +9,9 @@ import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; import { Setup } from '../../helpers/setup_request'; -import { AgentConfiguration } from './configuration_types'; +import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; import { ESSearchHit } from '../../../../typings/elasticsearch'; +import { convertConfigSettingsToString } from './convert_settings_to_string'; export async function findExactConfiguration({ service, @@ -42,5 +43,11 @@ export async function findExactConfiguration({ params ); - return resp.hits.hits[0] as ESSearchHit | undefined; + const hit = resp.hits.hits[0] as ESSearchHit | undefined; + + if (!hit) { + return; + } + + return convertConfigSettingsToString(hit); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_all_environments.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_all_environments.ts index 52a3422f8e6b7..a70f7965d0e59 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_all_environments.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_all_environments.ts @@ -10,7 +10,7 @@ import { SERVICE_NAME, SERVICE_ENVIRONMENT } from '../../../../../common/elasticsearch_fieldnames'; -import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration_constants'; +import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration/all_option'; export async function getAllEnvironments({ serviceName, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts index f54217461510f..754c698af8753 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_environments/get_existing_environments_for_service.ts @@ -9,7 +9,7 @@ import { SERVICE_NAME, SERVICE_ENVIRONMENT } from '../../../../../common/elasticsearch_fieldnames'; -import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration_constants'; +import { ALL_OPTION_VALUE } from '../../../../../common/agent_configuration/all_option'; export async function getExistingEnvironmentsForService({ serviceName, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts index 9b9acbb1e0ad5..352bbe1b6a294 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/get_service_names.ts @@ -10,7 +10,7 @@ import { PROCESSOR_EVENT, SERVICE_NAME } from '../../../../common/elasticsearch_fieldnames'; -import { ALL_OPTION_VALUE } from '../../../../common/agent_configuration_constants'; +import { ALL_OPTION_VALUE } from '../../../../common/agent_configuration/all_option'; export type AgentConfigurationServicesAPIResponse = PromiseReturnType< typeof getServiceNames diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts index 585de740bc88d..c44d7b41f532b 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/list_configurations.ts @@ -6,7 +6,8 @@ import { PromiseReturnType } from '../../../../typings/common'; import { Setup } from '../../helpers/setup_request'; -import { AgentConfiguration } from './configuration_types'; +import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; +import { convertConfigSettingsToString } from './convert_settings_to_string'; export type AgentConfigurationListAPIResponse = PromiseReturnType< typeof listConfigurations @@ -20,8 +21,7 @@ export async function listConfigurations({ setup }: { setup: Setup }) { }; const resp = await internalClient.search(params); - return resp.hits.hits.map(item => ({ - id: item._id, - ...item._source - })); + return resp.hits.hits + .map(convertConfigSettingsToString) + .map(hit => hit._source); } diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts index b6aecd1d7f0ca..5157c94a74768 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/mark_applied_by_agent.ts @@ -5,7 +5,7 @@ */ import { Setup } from '../../helpers/setup_request'; -import { AgentConfiguration } from './configuration_types'; +import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; export async function markAppliedByAgent({ id, diff --git a/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts b/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts index 9bbdc96a3a797..fa38e8fabfb69 100644 --- a/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts +++ b/x-pack/plugins/apm/server/lib/settings/agent_configuration/search_configurations.ts @@ -9,7 +9,8 @@ import { SERVICE_ENVIRONMENT } from '../../../../common/elasticsearch_fieldnames'; import { Setup } from '../../helpers/setup_request'; -import { AgentConfiguration } from './configuration_types'; +import { AgentConfiguration } from '../../../../common/agent_configuration/configuration_types'; +import { convertConfigSettingsToString } from './convert_settings_to_string'; export async function searchConfigurations({ service, @@ -66,5 +67,11 @@ export async function searchConfigurations({ params ); - return resp.hits.hits[0] as ESSearchHit | undefined; + const hit = resp.hits.hits[0] as ESSearchHit | undefined; + + if (!hit) { + return; + } + + return convertConfigSettingsToString(hit); } diff --git a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts index 1583e15bdecd5..42b99b34beea7 100644 --- a/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts +++ b/x-pack/plugins/apm/server/lib/settings/custom_link/create_custom_link_index.ts @@ -26,6 +26,7 @@ export const createApmCustomLinkIndex = async ({ }; const mappings: Mappings = { + dynamic: 'strict', properties: { '@timestamp': { type: 'date' diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 50a794067bfad..57b3f282852c4 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -22,6 +22,7 @@ import { } from './services'; import { agentConfigurationRoute, + getSingleAgentConfigurationRoute, agentConfigurationSearchRoute, deleteAgentConfigurationRoute, listAgentConfigurationEnvironmentsRoute, @@ -86,6 +87,7 @@ const createApmApi = () => { .add(serviceAnnotationsRoute) // Agent configuration + .add(getSingleAgentConfigurationRoute) .add(agentConfigurationAgentNameRoute) .add(agentConfigurationRoute) .add(agentConfigurationSearchRoute) diff --git a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts index 83b845b1fc436..8cfd736a336c2 100644 --- a/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts +++ b/x-pack/plugins/apm/server/routes/settings/agent_configuration.ts @@ -20,7 +20,7 @@ import { markAppliedByAgent } from '../../lib/settings/agent_configuration/mark_ import { serviceRt, agentConfigurationIntakeRt -} from '../../../common/runtime_types/agent_configuration_intake_rt'; +} from '../../../common/agent_configuration/runtime_types/agent_configuration_intake_rt'; import { jsonRt } from '../../../common/runtime_types/json_rt'; // get list of configurations @@ -32,6 +32,31 @@ export const agentConfigurationRoute = createRoute(core => ({ } })); +// get a single configuration +export const getSingleAgentConfigurationRoute = createRoute(() => ({ + path: '/api/apm/settings/agent-configuration/view', + params: { + query: serviceRt + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { name, environment } = context.params.query; + + const service = { name, environment }; + const config = await findExactConfiguration({ service, setup }); + + if (!config) { + context.logger.info( + `Config was not found for ${service.name}/${service.environment}` + ); + + throw Boom.notFound(); + } + + return config._source; + } +})); + // delete configuration export const deleteAgentConfigurationRoute = createRoute(() => ({ method: 'DELETE', @@ -68,45 +93,7 @@ export const deleteAgentConfigurationRoute = createRoute(() => ({ } })); -// get list of services -export const listAgentConfigurationServicesRoute = createRoute(() => ({ - method: 'GET', - path: '/api/apm/settings/agent-configuration/services', - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - return await getServiceNames({ - setup - }); - } -})); - -// get environments for service -export const listAgentConfigurationEnvironmentsRoute = createRoute(() => ({ - path: '/api/apm/settings/agent-configuration/environments', - params: { - query: t.partial({ serviceName: t.string }) - }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; - return await getEnvironments({ serviceName, setup }); - } -})); - -// get agentName for service -export const agentConfigurationAgentNameRoute = createRoute(() => ({ - path: '/api/apm/settings/agent-configuration/agent_name', - params: { - query: t.type({ serviceName: t.string }) - }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { serviceName } = context.params.query; - const agentName = await getAgentNameByService({ serviceName, setup }); - return { agentName }; - } -})); - +// create/update configuration export const createOrUpdateAgentConfigurationRoute = createRoute(() => ({ method: 'PUT', path: '/api/apm/settings/agent-configuration', @@ -154,10 +141,10 @@ export const agentConfigurationSearchRoute = createRoute(core => ({ method: 'POST', path: '/api/apm/settings/agent-configuration/search', params: { - body: t.type({ - service: serviceRt, - etag: t.string - }) + body: t.intersection([ + t.type({ service: serviceRt }), + t.partial({ etag: t.string }) + ]) }, handler: async ({ context, request }) => { const { service, etag } = context.params.body; @@ -188,3 +175,46 @@ export const agentConfigurationSearchRoute = createRoute(core => ({ return config; } })); + +/* + * Utility endpoints (not documented as part of the public API) + */ + +// get list of services +export const listAgentConfigurationServicesRoute = createRoute(() => ({ + method: 'GET', + path: '/api/apm/settings/agent-configuration/services', + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + return await getServiceNames({ + setup + }); + } +})); + +// get environments for service +export const listAgentConfigurationEnvironmentsRoute = createRoute(() => ({ + path: '/api/apm/settings/agent-configuration/environments', + params: { + query: t.partial({ serviceName: t.string }) + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { serviceName } = context.params.query; + return await getEnvironments({ serviceName, setup }); + } +})); + +// get agentName for service +export const agentConfigurationAgentNameRoute = createRoute(() => ({ + path: '/api/apm/settings/agent-configuration/agent_name', + params: { + query: t.type({ serviceName: t.string }) + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { serviceName } = context.params.query; + const agentName = await getAgentNameByService({ serviceName, setup }); + return { agentName }; + } +})); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bec3ffd147964..ce78847a8e8b3 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3860,48 +3860,6 @@ "xpack.apm.servicesTable.transactionsPerMinuteColumnLabel": "1 分あたりのトランザクション", "xpack.apm.servicesTable.transactionsPerMinuteUnitLabel": "1分あたりトランザクション数", "xpack.apm.servicesTable.UpgradeAssistantLink": "Kibana アップグレードアシスタントで詳細をご覧ください", - "xpack.apm.settings.agentConf.allOptionLabel": "すべて", - "xpack.apm.settings.agentConf.cancelButtonLabel": "キャンセル", - "xpack.apm.settings.agentConf.configTable.appliedTooltipMessage": "1 つまたは複数のエージェントにより適用されました。", - "xpack.apm.settings.agentConf.configTable.captureBodyColumnLabel": "キャプチャ本文", - "xpack.apm.settings.agentConf.configTable.configTable.failurePromptText": "エージェントの構成一覧を取得できませんでした。ユーザーに十分なパーミッションがない可能性があります。", - "xpack.apm.settings.agentConf.configTable.createConfigButtonLabel": "構成の作成", - "xpack.apm.settings.agentConf.configTable.editButtonDescription": "この構成を編集します", - "xpack.apm.settings.agentConf.configTable.editButtonLabel": "編集", - "xpack.apm.settings.agentConf.configTable.emptyPromptText": "変更しましょう。直接 Kibana からエージェント構成を微調整できます。再展開する必要はありません。まず、最初の構成を作成します。", - "xpack.apm.settings.agentConf.configTable.emptyPromptTitle": "構成が見つかりません。", - "xpack.apm.settings.agentConf.configTable.environmentColumnLabel": "サービス環境", - "xpack.apm.settings.agentConf.configTable.lastUpdatedColumnLabel": "最終更新", - "xpack.apm.settings.agentConf.configTable.notAppliedTooltipMessage": "まだエージェントにより適用されていません", - "xpack.apm.settings.agentConf.configTable.sampleRateColumnLabel": "サンプルレート", - "xpack.apm.settings.agentConf.configTable.serviceNameColumnLabel": "サービス名", - "xpack.apm.settings.agentConf.configTable.transactionMaxSpansColumnLabel": "トランザクションの最大範囲", - "xpack.apm.settings.agentConf.configurationsPanelTitle": "構成", - "xpack.apm.settings.agentConf.createConfigButtonLabel": "構成の作成", - "xpack.apm.settings.agentConf.createConfigTitle": "構成の作成", - "xpack.apm.settings.agentConf.editConfigTitle": "構成の編集", - "xpack.apm.settings.agentConf.flyout.deleteSection.buttonLabel": "削除", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedText": "{serviceName} の構成を削除中にエラーが発生しました。エラー「{errorMessage}」", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedTitle": "構成を削除できませんでした", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededText": "「{serviceName}」の構成が正常に削除されました。エージェントに反映されるまでに少し時間がかかります。", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededTitle": "構成が削除されました", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputHelpText": "HTTP リクエストのトランザクションの場合、エージェントはリクエスト本文 (POST 変数など) をキャプチャすることができます。デフォルトは「off」です。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputLabel": "本文をキャプチャ", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputPlaceholderText": "オプションを選択", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputErrorText": "サンプルレートは 0.000 ~ 1 の範囲でなければなりません", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputHelpText": "0.000 ~ 1.0 の範囲のレートを選択してください。デフォルトは 1.0 (トレースの 100%) です。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputLabel": "トランザクションのサンプルレート", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputPlaceholderText": "サンプルレートを設定", - "xpack.apm.settings.agentConf.flyOut.settingsSection.title": "オプション", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputErrorText": "0 と 32000 の間でなければなりません", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputHelpText": "トランザクションごとに記録される範囲を制限します。デフォルトは 500 です。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputLabel": "トランザクションの最大範囲", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputPlaceholderText": "トランザクションの最大範囲を設定", - "xpack.apm.settings.agentConf.saveConfig.failed.text": "「{serviceName}」の構成を保存中にエラーが発生しました。エラー「{errorMessage}」", - "xpack.apm.settings.agentConf.saveConfig.failed.title": "構成を保存できませんでした", - "xpack.apm.settings.agentConf.saveConfig.succeeded.text": "「{serviceName}」の構成が保存されました。エージェントに反映されるまでに少し時間がかかります。", - "xpack.apm.settings.agentConf.saveConfig.succeeded.title": "構成が保存されました", - "xpack.apm.settings.agentConf.saveConfigurationButtonLabel": "保存", "xpack.apm.settingsLinkLabel": "設定", "xpack.apm.setupInstructionsButtonLabel": "セットアップの手順", "xpack.apm.stacktraceTab.localVariablesToogleButtonLabel": "ローカル変数", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index f472247232cb8..d5d0a2f9e7aff 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3861,48 +3861,6 @@ "xpack.apm.servicesTable.transactionsPerMinuteColumnLabel": "每分钟事务数", "xpack.apm.servicesTable.transactionsPerMinuteUnitLabel": "tpm", "xpack.apm.servicesTable.UpgradeAssistantLink": "通过访问 Kibana 升级助手来了解详情", - "xpack.apm.settings.agentConf.allOptionLabel": "全部", - "xpack.apm.settings.agentConf.cancelButtonLabel": "取消", - "xpack.apm.settings.agentConf.configTable.appliedTooltipMessage": "已至少由一个代理应用", - "xpack.apm.settings.agentConf.configTable.captureBodyColumnLabel": "捕获正文", - "xpack.apm.settings.agentConf.configTable.configTable.failurePromptText": "无法获取代理配置列表。您的用户可能没有足够的权限。", - "xpack.apm.settings.agentConf.configTable.createConfigButtonLabel": "创建配置", - "xpack.apm.settings.agentConf.configTable.editButtonDescription": "编辑此配置", - "xpack.apm.settings.agentConf.configTable.editButtonLabel": "编辑", - "xpack.apm.settings.agentConf.configTable.emptyPromptText": "让我们改动一下!可以直接从 Kibana 微调代理配置,无需重新部署。首先创建您的第一个配置。", - "xpack.apm.settings.agentConf.configTable.emptyPromptTitle": "未找到任何配置。", - "xpack.apm.settings.agentConf.configTable.environmentColumnLabel": "服务环境", - "xpack.apm.settings.agentConf.configTable.lastUpdatedColumnLabel": "最后更新时间", - "xpack.apm.settings.agentConf.configTable.notAppliedTooltipMessage": "尚未由任何代理应用", - "xpack.apm.settings.agentConf.configTable.sampleRateColumnLabel": "采样速率", - "xpack.apm.settings.agentConf.configTable.serviceNameColumnLabel": "服务名称", - "xpack.apm.settings.agentConf.configTable.transactionMaxSpansColumnLabel": "事务最大跨度数", - "xpack.apm.settings.agentConf.configurationsPanelTitle": "代理远程配置", - "xpack.apm.settings.agentConf.createConfigButtonLabel": "创建配置", - "xpack.apm.settings.agentConf.createConfigTitle": "创建配置", - "xpack.apm.settings.agentConf.editConfigTitle": "编辑配置", - "xpack.apm.settings.agentConf.flyout.deleteSection.buttonLabel": "删除", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedText": "为“{serviceName}”删除配置时出现问题。错误:“{errorMessage}”", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigFailedTitle": "配置无法删除", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededText": "您已成功为“{serviceName}”删除配置。将需要一些时间才能传播到代理。", - "xpack.apm.settings.agentConf.flyout.deleteSection.deleteConfigSucceededTitle": "配置已删除", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputHelpText": "有关属于 HTTP 请求的事务,代理可以选择性地捕获请求正文(例如 POST 变量)。默认为“off”。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputLabel": "捕获正文", - "xpack.apm.settings.agentConf.flyOut.settingsSection.captureBodyInputPlaceholderText": "选择选项", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputErrorText": "采样速率必须介于 0.000 和 1 之间", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputHelpText": "选择 0.000 和 1.0 之间的速率。默认为 1.0(100% 的跟踪)。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputLabel": "事务采样速率", - "xpack.apm.settings.agentConf.flyOut.settingsSection.sampleRateConfigurationInputPlaceholderText": "设置采样速率", - "xpack.apm.settings.agentConf.flyOut.settingsSection.title": "选项", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputErrorText": "必须介于 0 和 32000 之间", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputHelpText": "限制每个事务记录的跨度数量。默认值为 500。", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputLabel": "事务最大跨度数", - "xpack.apm.settings.agentConf.flyOut.settingsSection.transactionMaxSpansConfigInputPlaceholderText": "设置事务最大跨度数", - "xpack.apm.settings.agentConf.saveConfig.failed.text": "编辑“{serviceName}”的配置时出现问题。错误:“{errorMessage}”", - "xpack.apm.settings.agentConf.saveConfig.failed.title": "配置无法编辑", - "xpack.apm.settings.agentConf.saveConfig.succeeded.text": "“{serviceName}”的配置已保存。将需要一些时间才能传播到代理。", - "xpack.apm.settings.agentConf.saveConfig.succeeded.title": "配置已保存", - "xpack.apm.settings.agentConf.saveConfigurationButtonLabel": "保存", "xpack.apm.settingsLinkLabel": "璁剧疆", "xpack.apm.setupInstructionsButtonLabel": "设置说明", "xpack.apm.stacktraceTab.localVariablesToogleButtonLabel": "本地变量", diff --git a/x-pack/test/api_integration/apis/apm/agent_configuration.ts b/x-pack/test/api_integration/apis/apm/agent_configuration.ts index 959a0c97acfa3..8cabac523791c 100644 --- a/x-pack/test/api_integration/apis/apm/agent_configuration.ts +++ b/x-pack/test/api_integration/apis/apm/agent_configuration.ts @@ -5,7 +5,7 @@ */ import expect from '@kbn/expect'; -import { AgentConfigurationIntake } from '../../../../plugins/apm/server/lib/settings/agent_configuration/configuration_types'; +import { AgentConfigurationIntake } from '../../../../plugins/apm/common/agent_configuration/configuration_types'; import { FtrProviderContext } from '../../ftr_provider_context'; export default function agentConfigurationTests({ getService }: FtrProviderContext) { @@ -70,7 +70,7 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte describe('when creating one configuration', () => { const newConfig = { service: {}, - settings: { transaction_sample_rate: 0.55 }, + settings: { transaction_sample_rate: '0.55' }, }; const searchParams = { @@ -86,16 +86,16 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte const { statusCode, body } = await searchConfigurations(searchParams); expect(statusCode).to.equal(200); expect(body._source.service).to.eql({}); - expect(body._source.settings).to.eql({ transaction_sample_rate: 0.55 }); + expect(body._source.settings).to.eql({ transaction_sample_rate: '0.55' }); }); it('can update the created config', async () => { - await updateConfiguration({ service: {}, settings: { transaction_sample_rate: 0.85 } }); + await updateConfiguration({ service: {}, settings: { transaction_sample_rate: '0.85' } }); const { statusCode, body } = await searchConfigurations(searchParams); expect(statusCode).to.equal(200); expect(body._source.service).to.eql({}); - expect(body._source.settings).to.eql({ transaction_sample_rate: 0.85 }); + expect(body._source.settings).to.eql({ transaction_sample_rate: '0.85' }); }); it('can delete the created config', async () => { @@ -109,23 +109,23 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte const configs = [ { service: {}, - settings: { transaction_sample_rate: 0.1 }, + settings: { transaction_sample_rate: '0.1' }, }, { service: { name: 'my_service' }, - settings: { transaction_sample_rate: 0.2 }, + settings: { transaction_sample_rate: '0.2' }, }, { service: { name: 'my_service', environment: 'development' }, - settings: { transaction_sample_rate: 0.3 }, + settings: { transaction_sample_rate: '0.3' }, }, { service: { environment: 'production' }, - settings: { transaction_sample_rate: 0.4 }, + settings: { transaction_sample_rate: '0.4' }, }, { service: { environment: 'development' }, - settings: { transaction_sample_rate: 0.5 }, + settings: { transaction_sample_rate: '0.5' }, }, ]; @@ -140,27 +140,27 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte const agentsRequests = [ { service: { name: 'non_existing_service', environment: 'non_existing_env' }, - expectedSettings: { transaction_sample_rate: 0.1 }, + expectedSettings: { transaction_sample_rate: '0.1' }, }, { service: { name: 'my_service', environment: 'non_existing_env' }, - expectedSettings: { transaction_sample_rate: 0.2 }, + expectedSettings: { transaction_sample_rate: '0.2' }, }, { service: { name: 'my_service', environment: 'production' }, - expectedSettings: { transaction_sample_rate: 0.2 }, + expectedSettings: { transaction_sample_rate: '0.2' }, }, { service: { name: 'my_service', environment: 'development' }, - expectedSettings: { transaction_sample_rate: 0.3 }, + expectedSettings: { transaction_sample_rate: '0.3' }, }, { service: { name: 'non_existing_service', environment: 'production' }, - expectedSettings: { transaction_sample_rate: 0.4 }, + expectedSettings: { transaction_sample_rate: '0.4' }, }, { service: { name: 'non_existing_service', environment: 'development' }, - expectedSettings: { transaction_sample_rate: 0.5 }, + expectedSettings: { transaction_sample_rate: '0.5' }, }, ]; @@ -180,8 +180,9 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte describe('when an agent retrieves a configuration', () => { const config = { service: { name: 'myservice', environment: 'development' }, - settings: { transaction_sample_rate: 0.9 }, + settings: { transaction_sample_rate: '0.9' }, }; + let etag: string; before(async () => { log.debug('creating agent configuration'); @@ -192,20 +193,27 @@ export default function agentConfigurationTests({ getService }: FtrProviderConte await deleteConfiguration(config); }); - it(`should have 'applied_by_agent=false' on first request`, async () => { - const { body } = await searchConfigurations({ + it(`should have 'applied_by_agent=false' before supplying etag`, async () => { + const res1 = await searchConfigurations({ service: { name: 'myservice', environment: 'development' }, - etag: '7312bdcc34999629a3d39df24ed9b2a7553c0c39', }); - expect(body._source.applied_by_agent).to.be(false); + etag = res1.body._source.etag; + + const res2 = await searchConfigurations({ + service: { name: 'myservice', environment: 'development' }, + etag, + }); + + expect(res1.body._source.applied_by_agent).to.be(false); + expect(res2.body._source.applied_by_agent).to.be(false); }); - it(`should have 'applied_by_agent=true' on second request`, async () => { + it(`should have 'applied_by_agent=true' after supplying etag`, async () => { async function getAppliedByAgent() { const { body } = await searchConfigurations({ service: { name: 'myservice', environment: 'development' }, - etag: '7312bdcc34999629a3d39df24ed9b2a7553c0c39', + etag, }); return body._source.applied_by_agent; diff --git a/x-pack/test/api_integration/apis/apm/feature_controls.ts b/x-pack/test/api_integration/apis/apm/feature_controls.ts index 3c5314d0d3261..afe68f21d9e39 100644 --- a/x-pack/test/api_integration/apis/apm/feature_controls.ts +++ b/x-pack/test/api_integration/apis/apm/feature_controls.ts @@ -244,7 +244,7 @@ export default function featureControlsTests({ getService }: FtrProviderContext) describe('apm feature controls', () => { const config = { service: { name: 'test-service' }, - settings: { transaction_sample_rate: 0.5 }, + settings: { transaction_sample_rate: '0.5' }, }; before(async () => { log.info(`Creating agent configuration`); From ef48205f15130f4dd6251a2c3cdf42000becc472 Mon Sep 17 00:00:00 2001 From: Justin Kambic Date: Mon, 23 Mar 2020 15:18:11 -0400 Subject: [PATCH 03/34] [Uptime] Add configurable page size to monitor list (#60573) * Add configurable page size to monitor list. * Add functional tests for new feature. * Update outdated snapshots. * Extract UI concerns for size select component to dedicated function. * Add missing props to resolve type check errors. * Add unit test for new UI functionality. * Refresh snapshots after additional changes. * Introduce new parameter to API test function. * Update flex behavior for new UI component. * Clean up code in functional page object file. * Refresh snapshots that were broken by previous feedback implementation. * Fix async error introduced to test framework by other patch. --- .../plugins/uptime/common/graphql/types.ts | 2 + .../__snapshots__/monitor_list.test.tsx.snap | 160 +++++++++++++----- .../monitor_list_pagination.test.tsx.snap | 70 ++++++-- .../__tests__/monitor_list.test.tsx | 6 + .../monitor_list_page_size_select.test.tsx | 45 +++++ .../monitor_list_pagination.test.tsx | 4 + .../functional/monitor_list/monitor_list.tsx | 32 ++-- .../monitor_list_page_size_select.tsx | 132 +++++++++++++++ .../monitor_list/overview_page_link.tsx | 9 +- .../plugins/uptime/public/hooks/index.ts | 2 +- .../uptime/public/hooks/use_url_params.ts | 6 +- .../plugins/uptime/public/pages/overview.tsx | 22 ++- .../public/queries/monitor_states_query.ts | 3 +- .../graphql/monitor_states/resolvers.ts | 3 +- .../graphql/monitor_states/schema.gql.ts | 1 + .../server/lib/requests/get_monitor_states.ts | 5 +- .../apis/uptime/graphql/monitor_states.ts | 1 + .../api_integration/apis/uptime/rest/index.ts | 11 +- .../test/functional/apps/uptime/overview.ts | 90 +++++++++- .../functional/page_objects/uptime_page.ts | 15 +- x-pack/test/functional/services/uptime.ts | 9 + 21 files changed, 533 insertions(+), 95 deletions(-) create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.tsx create mode 100644 x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.tsx diff --git a/x-pack/legacy/plugins/uptime/common/graphql/types.ts b/x-pack/legacy/plugins/uptime/common/graphql/types.ts index 1a37ce0b18c73..bd017e6cfaf4c 100644 --- a/x-pack/legacy/plugins/uptime/common/graphql/types.ts +++ b/x-pack/legacy/plugins/uptime/common/graphql/types.ts @@ -552,6 +552,8 @@ export interface GetMonitorStatesQueryArgs { filters?: string | null; statusFilter?: string | null; + + pageSize: number; } // ==================================================== diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap index 5f1d790430bdd..2b8bc0bb06ddf 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap @@ -80,25 +80,42 @@ exports[`MonitorList component renders a no items message when no data is provid size="m" /> - - + + + + + + + + @@ -110,6 +127,10 @@ exports[`MonitorList component renders the monitor list 1`] = ` padding-left: 17px; } +.c3 { + padding-top: 12px; +} + .c2 { white-space: nowrap; overflow: hidden; @@ -570,41 +591,81 @@ exports[`MonitorList component renders the monitor list 1`] = ` class="euiSpacer euiSpacer--m" />
- + class="euiPopover__anchor" + > + +
+
- + class="euiFlexItem euiFlexItem--flexGrowZero" + > + +
+
+ +
+ @@ -752,25 +813,42 @@ exports[`MonitorList component shallow renders the monitor list 1`] = ` size="m" /> - - + + + + + + + + diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap index 29ab7a8455fe6..db5bfa72deb36 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/__snapshots__/monitor_list_pagination.test.tsx.snap @@ -80,25 +80,42 @@ exports[`MonitorListPagination component renders a no items message when no data size="m" /> - - + + + + + + + + @@ -247,25 +264,42 @@ exports[`MonitorListPagination component renders the monitor list 1`] = ` size="m" /> - - + + + + + + + + diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx index 5cdd1772a7f24..d2030155d0092 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list.test.tsx @@ -87,6 +87,8 @@ describe('MonitorList component', () => { data={{ monitorStates: result }} hasActiveFilters={false} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" /> ); @@ -101,6 +103,8 @@ describe('MonitorList component', () => { data={{}} hasActiveFilters={false} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" /> ); @@ -114,6 +118,8 @@ describe('MonitorList component', () => { data={{ monitorStates: result }} hasActiveFilters={false} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" /> ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.tsx new file mode 100644 index 0000000000000..0642712d951fe --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_page_size_select.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { MonitorListPageSizeSelectComponent } from '../monitor_list_page_size_select'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; + +describe('MonitorListPageSizeSelect', () => { + it('updates the state when selection changes', () => { + const setSize = jest.fn(); + const setUrlParams = jest.fn(); + const wrapper = mountWithIntl( + + ); + wrapper + .find('[data-test-subj="xpack.uptime.monitorList.pageSizeSelect.popoverOpen"]') + .first() + .simulate('click'); + wrapper + .find('[data-test-subj="xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem25"]') + .first() + .simulate('click'); + expect(setSize).toHaveBeenCalledTimes(1); + expect(setSize.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + 25, + ], + ] + `); + expect(setUrlParams).toHaveBeenCalledTimes(1); + expect(setUrlParams.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "pagination": undefined, + }, + ], + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx index 1aef9281a3066..b08b8b3fabc3e 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/__tests__/monitor_list_pagination.test.tsx @@ -99,6 +99,8 @@ describe('MonitorListPagination component', () => { dangerColor="danger" data={{ monitorStates: result }} loading={false} + pageSize={25} + setPageSize={jest.fn()} successColor="primary" hasActiveFilters={false} /> @@ -114,6 +116,8 @@ describe('MonitorListPagination component', () => { data={{}} loading={false} successColor="primary" + pageSize={25} + setPageSize={jest.fn()} hasActiveFilters={false} /> ); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx index 58250222e1330..a9fb1ce2f4be1 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list.tsx @@ -33,6 +33,7 @@ import { MonitorPageLink } from './monitor_page_link'; import { OverviewPageLink } from './overview_page_link'; import * as labels from './translations'; import { MonitorListDrawer } from '../../connected'; +import { MonitorListPageSizeSelect } from './monitor_list_page_size_select'; interface MonitorListQueryResult { monitorStates?: MonitorSummaryResult; @@ -43,6 +44,8 @@ interface MonitorListProps { hasActiveFilters: boolean; successColor: string; linkParameters?: string; + pageSize: number; + setPageSize: (size: number) => void; } type Props = UptimeGraphQLQueryProps & MonitorListProps; @@ -185,20 +188,27 @@ export const MonitorListComponent = (props: Props) => { columns={columns} /> - + - + - + + + + + + + + diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.tsx new file mode 100644 index 0000000000000..abfc1384bb1af --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/monitor_list_page_size_select.tsx @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonEmpty, EuiContextMenuPanel, EuiContextMenuItem, EuiPopover } from '@elastic/eui'; +import React, { useState, useEffect } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { useUrlParams, UpdateUrlParams } from '../../../hooks'; + +interface PopoverButtonProps { + setIsOpen: (isOpen: boolean) => any; + size: number; +} + +const PopoverButton: React.FC = ({ setIsOpen, size }) => ( + setIsOpen(true)} + > + + +); + +interface ContextItemProps { + 'data-test-subj': string; + key: string; + numRows: number; +} + +const items: ContextItemProps[] = [ + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem10', + key: '10 rows', + numRows: 10, + }, + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem25', + key: '25 rows', + numRows: 25, + }, + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem50', + key: '50 rows', + numRows: 50, + }, + { + 'data-test-subj': 'xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem100', + key: '100 rows', + numRows: 100, + }, +]; + +const LOCAL_STORAGE_KEY = 'xpack.uptime.monitorList.pageSize'; + +interface MonitorListPageSizeSelectProps { + size: number; + setSize: (value: number) => void; +} + +/** + * This component wraps the underlying UI functionality to make the component more testable. + * The features leveraged in this function are tested elsewhere, and are not novel to this component. + */ +export const MonitorListPageSizeSelect: React.FC = ({ + size, + setSize, +}) => { + const [, setUrlParams] = useUrlParams(); + + useEffect(() => { + localStorage.setItem(LOCAL_STORAGE_KEY, size.toString()); + }, [size]); + + return ( + + ); +}; + +interface ComponentProps extends MonitorListPageSizeSelectProps { + setUrlParams: UpdateUrlParams; +} + +/** + * This function contains the UI functionality for the page select feature. It's agnostic to any + * external services/features, and focuses only on providing the UI and handling user interaction. + */ +export const MonitorListPageSizeSelectComponent: React.FC = ({ + size, + setSize, + setUrlParams, +}) => { + const [isOpen, setIsOpen] = useState(false); + return ( + setIsOpen(value)} size={size} />} + isOpen={isOpen} + closePopover={() => setIsOpen(false)} + anchorPosition="upLeft" + > + ( + { + setSize(numRows); + // reset pagination because the page size has changed + setUrlParams({ pagination: undefined }); + setIsOpen(false); + }} + > + + + ))} + /> + + ); +}; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx index 9d8f28cdb34c3..f79da4c98dfed 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_list/overview_page_link.tsx @@ -7,8 +7,13 @@ import { EuiButtonIcon } from '@elastic/eui'; import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; +import styled from 'styled-components'; import { useUrlParams } from '../../../hooks'; +const OverviewPageLinkButtonIcon = styled(EuiButtonIcon)` + padding-top: 12px; +`; + interface OverviewPageLinkProps { dataTestSubj: string; direction: string; @@ -38,8 +43,8 @@ export const OverviewPageLink: FunctionComponent = ({ }); return ( - { updateUrlParams({ pagination }); }} diff --git a/x-pack/legacy/plugins/uptime/public/hooks/index.ts b/x-pack/legacy/plugins/uptime/public/hooks/index.ts index cfb8d71f783a6..e022248df407a 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/index.ts +++ b/x-pack/legacy/plugins/uptime/public/hooks/index.ts @@ -4,6 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { useUrlParams } from './use_url_params'; +export * from './use_url_params'; export * from './use_telemetry'; export * from './update_kuery_string'; diff --git a/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts b/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts index dc309943d7cf9..20063b2c1bc93 100644 --- a/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts +++ b/x-pack/legacy/plugins/uptime/public/hooks/use_url_params.ts @@ -8,8 +8,10 @@ import { parse, stringify } from 'query-string'; import { useLocation, useHistory } from 'react-router-dom'; import { UptimeUrlParams, getSupportedUrlParams } from '../lib/helper'; -type GetUrlParams = () => UptimeUrlParams; -type UpdateUrlParams = (updatedParams: { [key: string]: string | number | boolean }) => void; +export type GetUrlParams = () => UptimeUrlParams; +export type UpdateUrlParams = (updatedParams: { + [key: string]: string | number | boolean | undefined; +}) => void; export type UptimeUrlParamsHook = () => [GetUrlParams, UpdateUrlParams]; diff --git a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx index a8a35fd2681b6..943dbd6bd57ba 100644 --- a/x-pack/legacy/plugins/uptime/public/pages/overview.tsx +++ b/x-pack/legacy/plugins/uptime/public/pages/overview.tsx @@ -5,7 +5,7 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import React, { useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useState } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { @@ -40,9 +40,26 @@ const EuiFlexItemStyled = styled(EuiFlexItem)` } `; +// TODO: these values belong deeper down in the monitor +// list pagination control, but are here temporarily until we +// are done removing GraphQL +const DEFAULT_PAGE_SIZE = 10; +const LOCAL_STORAGE_KEY = 'xpack.uptime.monitorList.pageSize'; +const getMonitorListPageSizeValue = () => { + const value = parseInt(localStorage.getItem(LOCAL_STORAGE_KEY) ?? '', 10); + if (isNaN(value)) { + return DEFAULT_PAGE_SIZE; + } + return value; +}; + export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFilters }: Props) => { const { colors } = useContext(UptimeThemeContext); const [getUrlParams] = useUrlParams(); + // TODO: this is temporary until we migrate the monitor list to our Redux implementation + const [monitorListPageSize, setMonitorListPageSize] = useState( + getMonitorListPageSizeValue() + ); const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = getUrlParams(); const { dateRangeStart, @@ -106,10 +123,13 @@ export const OverviewPageComponent = ({ autocomplete, indexPattern, setEsKueryFi hasActiveFilters={!!esFilters} implementsCustomErrorState={true} linkParameters={linkParameters} + pageSize={monitorListPageSize} + setPageSize={setMonitorListPageSize} successColor={colors.success} variables={{ ...sharedProps, pagination, + pageSize: monitorListPageSize, }} /> diff --git a/x-pack/legacy/plugins/uptime/public/queries/monitor_states_query.ts b/x-pack/legacy/plugins/uptime/public/queries/monitor_states_query.ts index 9e609786094d5..676e638c239de 100644 --- a/x-pack/legacy/plugins/uptime/public/queries/monitor_states_query.ts +++ b/x-pack/legacy/plugins/uptime/public/queries/monitor_states_query.ts @@ -7,13 +7,14 @@ import gql from 'graphql-tag'; export const monitorStatesQueryString = ` -query MonitorStates($dateRangeStart: String!, $dateRangeEnd: String!, $pagination: String, $filters: String, $statusFilter: String) { +query MonitorStates($dateRangeStart: String!, $dateRangeEnd: String!, $pagination: String, $filters: String, $statusFilter: String, $pageSize: Int) { monitorStates: getMonitorStates( dateRangeStart: $dateRangeStart dateRangeEnd: $dateRangeEnd pagination: $pagination filters: $filters statusFilter: $statusFilter + pageSize: $pageSize ) { prevPagePagination nextPagePagination diff --git a/x-pack/plugins/uptime/server/graphql/monitor_states/resolvers.ts b/x-pack/plugins/uptime/server/graphql/monitor_states/resolvers.ts index 08973b217b96c..479c06234ca66 100644 --- a/x-pack/plugins/uptime/server/graphql/monitor_states/resolvers.ts +++ b/x-pack/plugins/uptime/server/graphql/monitor_states/resolvers.ts @@ -32,7 +32,7 @@ export const createMonitorStatesResolvers: CreateUMGraphQLResolvers = ( Query: { async getMonitorStates( _resolver, - { dateRangeStart, dateRangeEnd, filters, pagination, statusFilter }, + { dateRangeStart, dateRangeEnd, filters, pagination, statusFilter, pageSize }, { APICaller, savedObjectsClient } ): Promise { const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( @@ -53,6 +53,7 @@ export const createMonitorStatesResolvers: CreateUMGraphQLResolvers = ( dateRangeStart, dateRangeEnd, pagination: decodedPagination, + pageSize, filters, // this is added to make typescript happy, // this sort of reassignment used to be further downstream but I've moved it here diff --git a/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts b/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts index d088aed951204..6ab564fdeb532 100644 --- a/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts +++ b/x-pack/plugins/uptime/server/graphql/monitor_states/schema.gql.ts @@ -177,6 +177,7 @@ export const monitorStatesSchema = gql` pagination: String filters: String statusFilter: String + pageSize: Int ): MonitorSummaryResult } `; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts index bfccb34ab94de..d7842d1a0b4aa 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_states.ts @@ -24,6 +24,7 @@ export interface GetMonitorStatesParams { dateRangeStart: string; dateRangeEnd: string; pagination?: CursorPagination; + pageSize: number; filters?: string | null; statusFilter?: string; } @@ -53,12 +54,12 @@ export const getMonitorStates: UMElasticsearchQueryFn< dateRangeStart, dateRangeEnd, pagination, + pageSize, filters, statusFilter, }) => { pagination = pagination || CONTEXT_DEFAULTS.CURSOR_PAGINATION; statusFilter = statusFilter === null ? undefined : statusFilter; - const size = 10; const queryContext = new QueryContext( callES, @@ -67,7 +68,7 @@ export const getMonitorStates: UMElasticsearchQueryFn< dateRangeEnd, pagination, filters && filters !== '' ? JSON.parse(filters) : null, - size, + pageSize, statusFilter ); diff --git a/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts b/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts index 511cdb6d004fa..a293426195d23 100644 --- a/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts +++ b/x-pack/test/api_integration/apis/uptime/graphql/monitor_states.ts @@ -22,6 +22,7 @@ export default function({ getService }: FtrProviderContext) { variables: { dateRangeStart, dateRangeEnd, + pageSize: 10, ...variables, }, }; diff --git a/x-pack/test/api_integration/apis/uptime/rest/index.ts b/x-pack/test/api_integration/apis/uptime/rest/index.ts index 712a8bc40c41c..b2236e1bb6308 100644 --- a/x-pack/test/api_integration/apis/uptime/rest/index.ts +++ b/x-pack/test/api_integration/apis/uptime/rest/index.ts @@ -17,15 +17,20 @@ export default function({ getService, loadTestFile }: FtrProviderContext) { describe('uptime REST endpoints', () => { beforeEach('clear settings', async () => { try { - server.savedObjects.delete({ + await server.savedObjects.delete({ type: settingsObjectType, id: settingsObjectId, }); } catch (e) { // a 404 just means the doc is already missing - if (e.statuscode !== 404) { + if (e.response.status !== 404) { + const { status, statusText, data, headers, config } = e.response; throw new Error( - `error attempting to delete settings (${e.statuscode}): ${JSON.stringify(e)}` + `error attempting to delete settings:\n${JSON.stringify( + { status, statusText, data, headers, config }, + null, + 2 + )}` ); } } diff --git a/x-pack/test/functional/apps/uptime/overview.ts b/x-pack/test/functional/apps/uptime/overview.ts index 9a879032fadc1..f3b587b9bc1e2 100644 --- a/x-pack/test/functional/apps/uptime/overview.ts +++ b/x-pack/test/functional/apps/uptime/overview.ts @@ -65,10 +65,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '0018-up', '0019-up', ]); - await retry.tryForTime(12000, async () => { - // there should now be pagination data in the URL - await pageObjects.uptime.pageUrlContains('pagination'); - }); + // there should now be pagination data in the URL + await pageObjects.uptime.pageUrlContains('pagination'); await pageObjects.uptime.setStatusFilter('up'); await pageObjects.uptime.pageHasExpectedIds([ '0000-intermittent', @@ -82,10 +80,86 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '0008-up', '0009-up', ]); - await retry.tryForTime(12000, async () => { - // ensure that pagination is removed from the URL - await pageObjects.uptime.pageUrlContains('pagination', false); - }); + // ensure that pagination is removed from the URL + await pageObjects.uptime.pageUrlContains('pagination', false); + }); + + it('clears pagination parameters when size changes', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); + await pageObjects.uptime.changePage('next'); + await pageObjects.uptime.pageUrlContains('pagination'); + await pageObjects.uptime.setMonitorListPageSize(50); + // the pagination parameter should be cleared after a size change + await pageObjects.uptime.pageUrlContains('pagination', false); + }); + + it('pagination size updates to reflect current selection', async () => { + await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); + await pageObjects.uptime.pageHasExpectedIds([ + '0000-intermittent', + '0001-up', + '0002-up', + '0003-up', + '0004-up', + '0005-up', + '0006-up', + '0007-up', + '0008-up', + '0009-up', + ]); + await pageObjects.uptime.setMonitorListPageSize(50); + await pageObjects.uptime.pageHasExpectedIds([ + '0000-intermittent', + '0001-up', + '0002-up', + '0003-up', + '0004-up', + '0005-up', + '0006-up', + '0007-up', + '0008-up', + '0009-up', + '0010-down', + '0011-up', + '0012-up', + '0013-up', + '0014-up', + '0015-intermittent', + '0016-up', + '0017-up', + '0018-up', + '0019-up', + '0020-down', + '0021-up', + '0022-up', + '0023-up', + '0024-up', + '0025-up', + '0026-up', + '0027-up', + '0028-up', + '0029-up', + '0030-intermittent', + '0031-up', + '0032-up', + '0033-up', + '0034-up', + '0035-up', + '0036-up', + '0037-up', + '0038-up', + '0039-up', + '0040-down', + '0041-up', + '0042-up', + '0043-up', + '0044-up', + '0045-intermittent', + '0046-up', + '0047-up', + '0048-up', + '0049-up', + ]); }); describe('snapshot counts', () => { diff --git a/x-pack/test/functional/page_objects/uptime_page.ts b/x-pack/test/functional/page_objects/uptime_page.ts index e18c7d4154728..0b8e994ba8095 100644 --- a/x-pack/test/functional/page_objects/uptime_page.ts +++ b/x-pack/test/functional/page_objects/uptime_page.ts @@ -66,12 +66,14 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo return await uptimeService.pageHasDataMissing(); } - public async pageHasExpectedIds(monitorIdsToCheck: string[]) { - await Promise.all(monitorIdsToCheck.map(id => uptimeService.monitorPageLinkExists(id))); + public async pageHasExpectedIds(monitorIdsToCheck: string[]): Promise { + return retry.tryForTime(15000, async () => { + await Promise.all(monitorIdsToCheck.map(id => uptimeService.monitorPageLinkExists(id))); + }); } - public async pageUrlContains(value: string, expected: boolean = true) { - await retry.try(async () => { + public async pageUrlContains(value: string, expected: boolean = true): Promise { + return retry.tryForTime(12000, async () => { expect(await uptimeService.urlContains(value)).to.eql(expected); }); } @@ -144,5 +146,10 @@ export function UptimePageProvider({ getPageObjects, getService }: FtrProviderCo await alerts.setLocationsSelectable(); await alerts.clickSaveAlertButtion(); } + + public async setMonitorListPageSize(size: number): Promise { + await uptimeService.openPageSizeSelectPopover(); + return uptimeService.clickPageSizeSelectPopoverItem(size); + } })(); } diff --git a/x-pack/test/functional/services/uptime.ts b/x-pack/test/functional/services/uptime.ts index 57beedc5e0f29..f9902d0142b96 100644 --- a/x-pack/test/functional/services/uptime.ts +++ b/x-pack/test/functional/services/uptime.ts @@ -194,5 +194,14 @@ export function UptimeProvider({ getService }: FtrProviderContext) { timeout: 3000, }); }, + async openPageSizeSelectPopover(): Promise { + return testSubjects.click('xpack.uptime.monitorList.pageSizeSelect.popoverOpen', 5000); + }, + async clickPageSizeSelectPopoverItem(size: number = 10): Promise { + return testSubjects.click( + `xpack.uptime.monitorList.pageSizeSelect.sizeSelectItem${size.toString()}`, + 5000 + ); + }, }; } From afca33b5206e1389af18da81a91682904f2b4c79 Mon Sep 17 00:00:00 2001 From: Lee Drengenberg Date: Mon, 23 Mar 2020 19:18:35 +0000 Subject: [PATCH 04/34] add relationship test on Saved Objects (#59968) * just a demo of function to return saved object table elements * fix esArchive data, extend import objects test case for relationships * improved data-test-subjs * update snapshot for jest test * unskip other half of the tests * removed commented-out code * use new findByTestSubject methods Co-authored-by: Elastic Machine --- .../__snapshots__/relationships.test.js.snap | 12 ++ .../components/relationships/relationships.js | 16 ++- .../objects_table/components/table/table.js | 1 + .../apps/management/_import_objects.js | 109 ++++++++---------- .../es_archiver/management/data.json.gz | Bin 1295 -> 1297 bytes test/functional/page_objects/settings_page.ts | 65 +++++++++-- 6 files changed, 125 insertions(+), 78 deletions(-) diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap index c1241d5d7c1e5..728944f3ccbfe 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/__jest__/__snapshots__/relationships.test.js.snap @@ -53,6 +53,7 @@ exports[`Relationships should render dashboards normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -72,6 +73,7 @@ exports[`Relationships should render dashboards normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -117,6 +119,7 @@ exports[`Relationships should render dashboards normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ @@ -263,6 +266,7 @@ exports[`Relationships should render index patterns normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -282,6 +286,7 @@ exports[`Relationships should render index patterns normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -327,6 +332,7 @@ exports[`Relationships should render index patterns normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ @@ -429,6 +435,7 @@ exports[`Relationships should render searches normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -448,6 +455,7 @@ exports[`Relationships should render searches normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -493,6 +501,7 @@ exports[`Relationships should render searches normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ @@ -595,6 +604,7 @@ exports[`Relationships should render visualizations normally 1`] = ` "width": "50px", }, Object { + "data-test-subj": "directRelationship", "dataType": "string", "field": "relationship", "name": "Direct relationship", @@ -614,6 +624,7 @@ exports[`Relationships should render visualizations normally 1`] = ` "actions": Array [ Object { "available": [Function], + "data-test-subj": "relationshipsTableAction-inspect", "description": "Inspect this saved object", "icon": "inspect", "name": "Inspect", @@ -659,6 +670,7 @@ exports[`Relationships should render visualizations normally 1`] = ` } pagination={true} responsive={true} + rowProps={[Function]} search={ Object { "filters": Array [ diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js index ee9fb70e31fb2..ce3415ad2f0e7 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/relationships/relationships.js @@ -135,6 +135,7 @@ export class Relationships extends Component { aria-label={getSavedObjectLabel(type)} type={object.meta.icon || 'apps'} size="s" + data-test-subj="relationshipsObjectType" /> ); @@ -149,6 +150,7 @@ export class Relationships extends Component { dataType: 'string', sortable: false, width: '125px', + 'data-test-subj': 'directRelationship', render: relationship => { if (relationship === 'parent') { return ( @@ -187,10 +189,16 @@ export class Relationships extends Component { const { path } = object.meta.inAppUrl || {}; const canGoInApp = this.props.canGoInApp(object); if (!canGoInApp) { - return {title || getDefaultTitle(object)}; + return ( + + {title || getDefaultTitle(object)} + + ); } return ( - {title || getDefaultTitle(object)} + + {title || getDefaultTitle(object)} + ); }, }, @@ -211,6 +219,7 @@ export class Relationships extends Component { ), type: 'icon', icon: 'inspect', + 'data-test-subj': 'relationshipsTableAction-inspect', onClick: object => goInspectObject(object), available: object => !!object.meta.editUrl, }, @@ -295,6 +304,9 @@ export class Relationships extends Component { columns={columns} pagination={true} search={search} + rowProps={() => ({ + 'data-test-subj': `relationshipsTableRow`, + })} /> ); diff --git a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js index 386b35399b754..5342693113bca 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/objects/components/objects_table/components/table/table.js @@ -186,6 +186,7 @@ export class Table extends PureComponent { aria-label={getSavedObjectLabel(type)} type={object.meta.icon || 'apps'} size="s" + data-test-subj="objectType" /> ); diff --git a/test/functional/apps/management/_import_objects.js b/test/functional/apps/management/_import_objects.js index 8aabe42ccc3db..cd39f1cf25ccc 100644 --- a/test/functional/apps/management/_import_objects.js +++ b/test/functional/apps/management/_import_objects.js @@ -19,12 +19,14 @@ import expect from '@kbn/expect'; import path from 'path'; +import { indexBy } from 'lodash'; export default function({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const esArchiver = getService('esArchiver'); const PageObjects = getPageObjects(['common', 'settings', 'header']); const testSubjects = getService('testSubjects'); + const log = getService('log'); describe('import objects', function describeIndexTests() { describe('.ndjson file', () => { @@ -33,6 +35,7 @@ export default function({ getService, getPageObjects }) { await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); await esArchiver.load('management'); + await PageObjects.settings.clickKibanaSavedObjects(); }); afterEach(async function() { @@ -40,20 +43,31 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); - const objects = await PageObjects.settings.getSavedObjectsInTable(); - const isSavedObjectImported = objects.includes('Log Agents'); - expect(isSavedObjectImported).to.be(true); + + // get all the elements in the table, and index them by the 'title' visible text field + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + log.debug("check that 'Log Agents' is in table as a visualization"); + expect(elements['Log Agents'].objectType).to.eql('visualization'); + + await elements['logstash-*'].relationshipsElement.click(); + const flyout = indexBy(await PageObjects.settings.getRelationshipFlyout(), 'title'); + log.debug( + "check that 'Shared-Item Visualization AreaChart' shows 'logstash-*' as it's Parent" + ); + expect(flyout['Shared-Item Visualization AreaChart'].relationship).to.eql('Parent'); + log.debug("check that 'Log Agents' shows 'logstash-*' as it's Parent"); + expect(flyout['Log Agents'].relationship).to.eql('Parent'); }); it('should provide dialog to allow the importing of saved objects with index pattern conflicts', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_conflicts.ndjson') ); @@ -65,15 +79,12 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.clickConfirmChanges(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object with index pattern conflict'); expect(isSavedObjectImported).to.be(true); }); it('should allow the user to override duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can override the existing visualization. await PageObjects.settings.importFile( @@ -93,8 +104,6 @@ export default function({ getService, getPageObjects }) { }); it('should allow the user to cancel overriding duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can be prompted to override the existing visualization. await PageObjects.settings.importFile( @@ -114,21 +123,17 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects linked to saved searches', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_saved_search.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -136,14 +141,11 @@ export default function({ getService, getPageObjects }) { }); it('should not import saved objects linked to saved searches when saved search does not exist', async function() { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.ndjson') ); await PageObjects.settings.checkNoneImported(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -151,12 +153,13 @@ export default function({ getService, getPageObjects }) { }); it('should not import saved objects linked to saved searches when saved search index pattern does not exist', async function() { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_saved_search.ndjson') ); @@ -164,7 +167,6 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.checkImportConflictsWarning(); await PageObjects.settings.clickConfirmChanges(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -173,14 +175,11 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns already exists', async () => { // First, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); @@ -189,19 +188,19 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns does not exists', async () => { // First, we need to delete the index pattern - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); // Then, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.ndjson') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); @@ -215,6 +214,7 @@ export default function({ getService, getPageObjects }) { await kibanaServer.uiSettings.replace({}); await PageObjects.settings.navigateTo(); await esArchiver.load('management'); + await PageObjects.settings.clickKibanaSavedObjects(); }); afterEach(async function() { @@ -222,20 +222,17 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('Log Agents'); expect(isSavedObjectImported).to.be(true); }); it('should provide dialog to allow the importing of saved objects with index pattern conflicts', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects-conflicts.json') ); @@ -248,15 +245,12 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.clickConfirmChanges(); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object with index pattern conflict'); expect(isSavedObjectImported).to.be(true); }); it('should allow the user to override duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can override the existing visualization. await PageObjects.settings.importFile( @@ -277,8 +271,6 @@ export default function({ getService, getPageObjects }) { }); it('should allow the user to cancel overriding duplicate saved objects', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); - // This data has already been loaded by the "visualize" esArchive. We'll load it again // so that we can be prompted to override the existing visualization. await PageObjects.settings.importFile( @@ -299,21 +291,17 @@ export default function({ getService, getPageObjects }) { }); it('should import saved objects linked to saved searches', async function() { - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_saved_search.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -321,14 +309,11 @@ export default function({ getService, getPageObjects }) { }); it('should not import saved objects linked to saved searches when saved search does not exist', async function() { - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json') ); await PageObjects.settings.checkImportFailedWarning(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -337,7 +322,6 @@ export default function({ getService, getPageObjects }) { it('should not import saved objects linked to saved searches when saved search index pattern does not exist', async function() { // First, import the saved search - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_saved_search.json') ); @@ -346,21 +330,21 @@ export default function({ getService, getPageObjects }) { await PageObjects.settings.clickImportDone(); // Second, we need to delete the index pattern - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); // Last, import a saved object connected to the saved search // This should NOT show the conflicts - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_connected_to_saved_search.json') ); // Wait for all the saves to happen await PageObjects.settings.checkNoneImported(); await PageObjects.settings.clickImportDone(); - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object connected to saved search'); @@ -369,14 +353,11 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns already exists', async () => { // First, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json') ); await PageObjects.settings.checkImportFailedWarning(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); @@ -385,19 +366,19 @@ export default function({ getService, getPageObjects }) { it('should import saved objects with index patterns when index patterns does not exists', async () => { // First, we need to delete the index pattern - await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickKibanaIndexPatterns(); - await PageObjects.settings.removeLogstashIndexPatternIfExist(); + const elements = indexBy( + await PageObjects.settings.getSavedObjectElementsInTable(), + 'title' + ); + await elements['logstash-*'].checkbox.click(); + await PageObjects.settings.clickSavedObjectsDelete(); // Then, import the objects - await PageObjects.settings.clickKibanaSavedObjects(); await PageObjects.settings.importFile( path.join(__dirname, 'exports', '_import_objects_with_index_patterns.json') ); await PageObjects.settings.checkImportSucceeded(); await PageObjects.settings.clickImportDone(); - // Wait for all the saves to happen - await PageObjects.settings.waitUntilSavedObjectsTableIsNotLoading(); const objects = await PageObjects.settings.getSavedObjectsInTable(); const isSavedObjectImported = objects.includes('saved object imported with index pattern'); diff --git a/test/functional/fixtures/es_archiver/management/data.json.gz b/test/functional/fixtures/es_archiver/management/data.json.gz index e4b8f7e954477592d8715764032c849e6751f9b0..cfb08a5ee3a609048ffa9743b5adc8db2b5cd7a3 100644 GIT binary patch literal 1297 zcmV+s1@8JEiwFP!000023e8(lZ`(Kwe($dc@-kp(f~LLOVR`B>+yOgm-9rxphNdtS zi?MZ;El-l0bV2_6QL^mXX@G07pwvTvn6$-Dq(n*-wLKY)#`d9t@q9GqDjlCX!ab9< zqipd39|d<@QF7Q!DrPO{a=x0uZ|VEl*@T_LyUA<@e@vFki?hl3#l;fdFX0`V{q7qZ zg)dzb(>t+bC2Q$M)jEBYGuo5UV<2lKKyu?+x!EqpB`aVto-f84R-i#I#-|J44^K2! zi!$@HRMn+L;u-!osTP$5j*lxQ4Is%2^c1UWM?iS;;pgBTR7>JDV!~^?(>?;0fs=52 z`GGPJ@4p+$*B}`-`cah@flO647X@OPuLZYxRJa-(WPmotOf_2*4wW1jM`_eBo?3=O zBT1-|&_D%PHmwclsbt1Bnnls7>o=Sll5!Dbvc&2d2O#sh9WI5%^ zB3Fh|e8JLOlz{WLAdCZ2ly`c4G5d@rD0F zae6#$c0&LJGhC0bayp~Lkv=lhr9O-+p z_cz|0;>2%v=vrDbY}o@ zpfZ&z4K<9*0*s+wG_(mYWui^0$L5iVEw(X~rt+!EIQpo896&8m~s} z>s(LAf$*GTs6TwNd z!pU*!bAJVaNtHfA(dREffK7$&#%+^Ejfj_`3FdD%=v@)b>l{n<-d^HFBlsJ%{WlNbedm#Xn$XSy>hK>yDAVg`g872_~G!Q z26tRF--f?QJ10AeZ||*tISiF#EhKLWI~zZxS<9=5{yku9ob{=~_-}$x)$RVd#2OP1cX zM-Fbrd36Uk`22ma10140gkEmFNm-V83EQSj5CP1F>Iy!} zcPWR0wQCtI(mgHiyc-OyoIj0$NiK|4SzC+QYtBGcv^QY-R`C~h4lQB5BGETA2Cc1X z+-Z&N7oYmP^XB?WA5)PD3(_W5iw}k%u1aoTf37BMS#zL+kum-(md+f2)(3 H_A~$hxXyvs literal 1295 zcmV+q1@QVGiwFP!000026U|#uZ`(Eye$THk{8C_ujX2p_)K4jj4d~FO4;cm&aUjs* zS>i&8DoMpo4gdEYDN2zX15HpP$AaXr3rmaXN%q`1^qnCl2V!UiT1IVt0BkYEE z;$fUNWCl_(!4nlD(IixP0&7KH176y=>E^n&1ooxjB8j(WFJkBJcz!x84ql<$N4h46y#O1GiJ?w15$`3FBW(qaH%Iy` z79Y39k(+!2%v=vrDbaw!8 zpfZ&z4K<9*0*s;GG_(mYWui^0$HvIS5!)EbQ2A74oV-H3QW~8SUzyR5&2IA@4XaVJ zyi0BqU$4YBTMgw<=fZhR2W5R+Dhm{wQ2E`e8yZ{80WoORWGG5IrSBBA~#_?aSVEzjGy>M5+f{+MTutY3AN21v zxJ#+|HvF5kGq02Qx0;8;fJoLt@}_*VVUr!|Rzg}gK<78NyK#qTcPQvR-;V~qA06y> zEv_w+Vb_~oUwIGOG)|lKrNa1kLhr*y6Z>%m>!HcvEJIw(ODd*WVHz(O2lv~DjWE5b z!TCQ*Nng9!lYNE^%Kl_5l(P9}Q9jNWKSi1B5@!%9Pz5dxx>C~9{!mpY-m)S~@7jw6 zH?O?A104MQh3PvVgKoOQiq+D7`_rXh>;MBCl0Jl9Zhc5umU#=?rWg7&AS>GMT>4S*pXD6dsCq{t12h4xt!hZK z#`cR(ecgHUkxC)T#L-$ytFzOPvf{WdFHy)t4|dMexL%_Q2VEoUSgTJ<{{ycKSPa}W F000#3i^u=~ diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index e0f64340ca7dc..25706fda74925 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -47,6 +47,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider async clickKibanaSavedObjects() { await testSubjects.click('objects'); + await this.waitUntilSavedObjectsTableIsNotLoading(); } async clickKibanaIndexPatterns() { @@ -648,6 +649,7 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider async clickImportDone() { await testSubjects.click('importSavedObjectsDoneBtn'); + await this.waitUntilSavedObjectsTableIsNotLoading(); } async clickConfirmChanges() { @@ -681,9 +683,38 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider }); } + async getSavedObjectElementsInTable() { + const rows = await testSubjects.findAll('~savedObjectsTableRow'); + return mapAsync(rows, async row => { + const checkbox = await row.findByCssSelector('[data-test-subj*="checkboxSelectRow"]'); + // return the object type aria-label="index patterns" + const objectType = await row.findByTestSubject('objectType'); + const titleElement = await row.findByTestSubject('savedObjectsTableRowTitle'); + // not all rows have inspect button - Advanced Settings objects don't + let inspectElement; + const innerHtml = await row.getAttribute('innerHTML'); + if (innerHtml.includes('Inspect')) { + inspectElement = await row.findByTestSubject('savedObjectsTableAction-inspect'); + } else { + inspectElement = null; + } + const relationshipsElement = await row.findByTestSubject( + 'savedObjectsTableAction-relationships' + ); + return { + checkbox, + objectType: await objectType.getAttribute('aria-label'), + titleElement, + title: await titleElement.getVisibleText(), + inspectElement, + relationshipsElement, + }; + }); + } + async getSavedObjectsInTable() { const table = await testSubjects.find('savedObjectsTable'); - const cells = await table.findAllByCssSelector('td:nth-child(3)'); + const cells = await table.findAllByTestSubject('savedObjectsTableRowTitle'); const objects = []; for (const cell of cells) { @@ -693,6 +724,23 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider return objects; } + async getRelationshipFlyout() { + const rows = await testSubjects.findAll('relationshipsTableRow'); + return mapAsync(rows, async row => { + const objectType = await row.findByTestSubject('relationshipsObjectType'); + const relationship = await row.findByTestSubject('directRelationship'); + const titleElement = await row.findByTestSubject('relationshipsTitle'); + const inspectElement = await row.findByTestSubject('relationshipsTableAction-inspect'); + return { + objectType: await objectType.getAttribute('aria-label'), + relationship: await relationship.getVisibleText(), + titleElement, + title: await titleElement.getVisibleText(), + inspectElement, + }; + }); + } + async getSavedObjectsTableSummary() { const table = await testSubjects.find('savedObjectsTable'); const rows = await table.findAllByCssSelector('tbody tr'); @@ -723,17 +771,10 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider return await deleteButton.isEnabled(); } - async canSavedObjectBeDeleted(id: string) { - const allCheckBoxes = await testSubjects.findAll('checkboxSelectRow*'); - for (const checkBox of allCheckBoxes) { - if (await checkBox.isSelected()) { - await checkBox.click(); - } - } - - const checkBox = await testSubjects.find(`checkboxSelectRow-${id}`); - await checkBox.click(); - return await this.canSavedObjectsBeDeleted(); + async clickSavedObjectsDelete() { + await testSubjects.click('savedObjectsManagementDelete'); + await testSubjects.click('confirmModalConfirmButton'); + await this.waitUntilSavedObjectsTableIsNotLoading(); } } From 73deba16cc2c57d75f44a1a35b183e5a79736d00 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 23 Mar 2020 12:22:26 -0700 Subject: [PATCH 05/34] Ensure that the default datasources use the default config's namespace (#60823) Co-authored-by: Elastic Machine --- .../common/services/package_to_config.test.ts | 25 +++++++++++++++++++ .../common/services/package_to_config.ts | 6 ++++- .../ingest_manager/server/services/setup.ts | 18 ++++--------- 3 files changed, 35 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts index 5025c9b5288b9..357f407811880 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.test.ts @@ -298,6 +298,31 @@ describe('Ingest Manager - packageToConfig', () => { }, }); }); + it('returns datasource with namespace and description', () => { + expect( + packageToConfigDatasource( + mockPackage, + '1', + '2', + 'ds-1', + 'mock-namespace', + 'Test description' + ) + ).toEqual({ + config_id: '1', + enabled: true, + inputs: [], + name: 'ds-1', + namespace: 'mock-namespace', + description: 'Test description', + output_id: '2', + package: { + name: 'mock-package', + title: 'Mock package', + version: '0.0.0', + }, + }); + }); it('returns datasource with inputs', () => { const mockPackageWithDatasources = ({ ...mockPackage, diff --git a/x-pack/plugins/ingest_manager/common/services/package_to_config.ts b/x-pack/plugins/ingest_manager/common/services/package_to_config.ts index 8848fa6a9cf48..fa3479a69e39d 100644 --- a/x-pack/plugins/ingest_manager/common/services/package_to_config.ts +++ b/x-pack/plugins/ingest_manager/common/services/package_to_config.ts @@ -88,10 +88,14 @@ export const packageToConfigDatasource = ( packageInfo: PackageInfo, configId: string, outputId: string, - datasourceName?: string + datasourceName?: string, + namespace?: string, + description?: string ): NewDatasource => { return { name: datasourceName || `${packageInfo.name}-1`, + namespace, + description, package: { name: packageInfo.name, title: packageInfo.title, diff --git a/x-pack/plugins/ingest_manager/server/services/setup.ts b/x-pack/plugins/ingest_manager/server/services/setup.ts index 7311c46320f40..224355ced7cb1 100644 --- a/x-pack/plugins/ingest_manager/server/services/setup.ts +++ b/x-pack/plugins/ingest_manager/server/services/setup.ts @@ -11,7 +11,7 @@ import { agentConfigService } from './agent_config'; import { outputService } from './output'; import { ensureInstalledDefaultPackages } from './epm/packages/install'; import { - packageToConfigDatasourceInputs, + packageToConfigDatasource, Datasource, AgentConfig, Installation, @@ -122,16 +122,8 @@ async function addPackageToConfig( savedObjectsClient: soClient, pkgkey: `${packageToInstall.name}-${packageToInstall.version}`, }); - await datasourceService.create(soClient, { - name: `${packageInfo.name}-1`, - enabled: true, - package: { - name: packageInfo.name, - title: packageInfo.title, - version: packageInfo.version, - }, - inputs: packageToConfigDatasourceInputs(packageInfo), - config_id: config.id, - output_id: defaultOutput.id, - }); + await datasourceService.create( + soClient, + packageToConfigDatasource(packageInfo, config.id, defaultOutput.id, undefined, config.namespace) + ); } From d32c4c8390fe7db4c5d0ebad99389be8c90d1d60 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 23 Mar 2020 13:40:24 -0600 Subject: [PATCH 06/34] [Maps] fix point to point source regression (#60930) * [Maps] fix pew pew regression * add functional test for pew pew source --- .../es_search_source/es_search_source.js | 5 ++- .../maps/public/layers/sources/es_source.js | 4 +- .../functional/apps/maps/es_pew_pew_source.js | 39 +++++++++++++++++++ x-pack/test/functional/apps/maps/index.js | 1 + 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 x-pack/test/functional/apps/maps/es_pew_pew_source.js diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js index 440b9aa89a945..cd44ef49623fa 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/es_search_source.js @@ -149,11 +149,14 @@ export class ESSearchSource extends AbstractESSource { } renderSourceSettingsEditor({ onChange }) { + const getGeoField = () => { + return this._getGeoField(); + }; return ( { + async _getGeoField() { const indexPattern = await this.getIndexPattern(); const geoField = indexPattern.fields.getByName(this._descriptor.geoField); if (!geoField) { @@ -243,7 +243,7 @@ export class AbstractESSource extends AbstractVectorSource { ); } return geoField; - }; + } async getDisplayName() { try { diff --git a/x-pack/test/functional/apps/maps/es_pew_pew_source.js b/x-pack/test/functional/apps/maps/es_pew_pew_source.js new file mode 100644 index 0000000000000..45ae1c69640cf --- /dev/null +++ b/x-pack/test/functional/apps/maps/es_pew_pew_source.js @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; + +export default function({ getPageObjects, getService }) { + const PageObjects = getPageObjects(['maps']); + const inspector = getService('inspector'); + + const VECTOR_SOURCE_ID = '67c1de2c-2fc5-4425-8983-094b589afe61'; + + describe('point to point source', () => { + before(async () => { + await PageObjects.maps.loadSavedMap('pew pew demo'); + }); + + it('should request source clusters for destination locations', async () => { + await inspector.open(); + await inspector.openInspectorRequestsView(); + const requestStats = await inspector.getTableData(); + const hits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits'); + const totalHits = PageObjects.maps.getInspectorStatRowHit(requestStats, 'Hits (total)'); + await inspector.close(); + + expect(hits).to.equal('0'); + expect(totalHits).to.equal('4'); + }); + + it('should render lines', async () => { + const mapboxStyle = await PageObjects.maps.getMapboxStyle(); + const features = mapboxStyle.sources[VECTOR_SOURCE_ID].data.features; + expect(features.length).to.equal(2); + expect(features[0].geometry.type).to.equal('LineString'); + }); + }); +} diff --git a/x-pack/test/functional/apps/maps/index.js b/x-pack/test/functional/apps/maps/index.js index ae7de986cf867..58c211724b287 100644 --- a/x-pack/test/functional/apps/maps/index.js +++ b/x-pack/test/functional/apps/maps/index.js @@ -41,6 +41,7 @@ export default function({ loadTestFile, getService }) { describe('', function() { this.tags('ciGroup10'); loadTestFile(require.resolve('./es_geo_grid_source')); + loadTestFile(require.resolve('./es_pew_pew_source')); loadTestFile(require.resolve('./joins')); loadTestFile(require.resolve('./add_layer_panel')); loadTestFile(require.resolve('./import_geojson')); From 88d41fa352552fa63940de39418dd85553c63d20 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Mon, 23 Mar 2020 16:09:09 -0400 Subject: [PATCH 07/34] [Maps] Remove client-side scaling of ordinal values (#58528) This removes the rescaling of ordinal values to the [0,1] domain, and modifies the creation of the mapbox-style rules to use the actual RangeStyleMeta-data. This is an important prerequisite for Maps handling tile vector sources. For these sources, Maps does not have access to the raw underlying GeoJson and needs to use the stylemeta directly. --- .../maps/public/layers/heatmap_layer.js | 2 +- .../maps/public/layers/styles/color_utils.js | 25 ++- .../public/layers/styles/color_utils.test.js | 22 +-- .../layers/styles/heatmap/heatmap_style.js | 7 +- .../properties/dynamic_color_property.js | 80 ++++++---- .../properties/dynamic_color_property.test.js | 143 +++++++++++------- .../properties/dynamic_icon_property.js | 4 +- .../dynamic_orientation_property.js | 4 - .../properties/dynamic_size_property.js | 51 ++++--- .../properties/dynamic_style_property.d.ts | 2 - .../properties/dynamic_style_property.js | 34 +---- .../properties/dynamic_text_property.js | 4 - .../properties/label_border_size_property.js | 30 ++-- .../public/layers/styles/vector/style_util.js | 36 +++-- .../layers/styles/vector/style_util.test.js | 28 +--- .../layers/styles/vector/vector_style.js | 14 +- .../functional/apps/maps/mapbox_styles.js | 101 +++++++++---- 17 files changed, 335 insertions(+), 252 deletions(-) diff --git a/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js b/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js index ef78b5afe3a3a..22f7a92c17c51 100644 --- a/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js +++ b/x-pack/legacy/plugins/maps/public/layers/heatmap_layer.js @@ -72,7 +72,7 @@ export class HeatmapLayer extends VectorLayer { const propertyKey = this._getPropKeyOfSelectedMetric(); const dataBoundToMap = AbstractLayer.getBoundDataForSource(mbMap, this.getId()); if (featureCollection !== dataBoundToMap) { - let max = 0; + let max = 1; //max will be at least one, since counts or sums will be at least one. for (let i = 0; i < featureCollection.features.length; i++) { max = Math.max(featureCollection.features[i].properties[propertyKey], max); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js index a619eaba21aef..09c7d76db1691 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.js @@ -78,17 +78,26 @@ export function getColorRampCenterColor(colorRampName) { // Returns an array of color stops // [ stop_input_1: number, stop_output_1: color, stop_input_n: number, stop_output_n: color ] -export function getOrdinalColorRampStops(colorRampName, numberColors = GRADIENT_INTERVALS) { +export function getOrdinalColorRampStops(colorRampName, min, max) { if (!colorRampName) { return null; } - return getHexColorRangeStrings(colorRampName, numberColors).reduce( - (accu, stopColor, idx, srcArr) => { - const stopNumber = idx / srcArr.length; // number between 0 and 1, increasing as index increases - return [...accu, stopNumber, stopColor]; - }, - [] - ); + + if (min > max) { + return null; + } + + const hexColors = getHexColorRangeStrings(colorRampName, GRADIENT_INTERVALS); + if (max === min) { + //just return single stop value + return [max, hexColors[hexColors.length - 1]]; + } + + const delta = max - min; + return hexColors.reduce((accu, stopColor, idx, srcArr) => { + const stopNumber = min + (delta * idx) / srcArr.length; + return [...accu, stopNumber, stopColor]; + }, []); } export const COLOR_GRADIENTS = Object.keys(vislibColorMaps).map(colorRampName => ({ diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js index 5a8289ba903f3..9a5ece01d5206 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/color_utils.test.js @@ -60,26 +60,30 @@ describe('getColorRampCenterColor', () => { }); describe('getColorRampStops', () => { - it('Should create color stops for color ramp', () => { - expect(getOrdinalColorRampStops('Blues')).toEqual([ + it('Should create color stops for custom range', () => { + expect(getOrdinalColorRampStops('Blues', 0, 1000)).toEqual([ 0, '#f7faff', - 0.125, + 125, '#ddeaf7', - 0.25, + 250, '#c5daee', - 0.375, + 375, '#9dc9e0', - 0.5, + 500, '#6aadd5', - 0.625, + 625, '#4191c5', - 0.75, + 750, '#2070b4', - 0.875, + 875, '#072f6b', ]); }); + + it('Should snap to end of color stops for identical range', () => { + expect(getOrdinalColorRampStops('Blues', 23, 23)).toEqual([23, '#072f6b']); + }); }); describe('getLinearGradient', () => { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js index dc3cfc3ffbdb8..d769fe0da9ec2 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/heatmap/heatmap_style.js @@ -14,6 +14,11 @@ import { getOrdinalColorRampStops } from '../color_utils'; import { i18n } from '@kbn/i18n'; import { EuiIcon } from '@elastic/eui'; +//The heatmap range chosen hear runs from 0 to 1. It is arbitrary. +//Weighting is on the raw count/sum values. +const MIN_RANGE = 0; +const MAX_RANGE = 1; + export class HeatmapStyle extends AbstractStyle { static type = LAYER_STYLE_TYPE.HEATMAP; @@ -80,7 +85,7 @@ export class HeatmapStyle extends AbstractStyle { const { colorRampName } = this._descriptor; if (colorRampName && colorRampName !== DEFAULT_HEATMAP_COLOR_RAMP_NAME) { - const colorStops = getOrdinalColorRampStops(colorRampName); + const colorStops = getOrdinalColorRampStops(colorRampName, MIN_RANGE, MAX_RANGE); mbMap.setPaintProperty(layerId, 'heatmap-color', [ 'interpolate', ['linear'], diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js index 417426f12fc98..146bc40aa8531 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js @@ -5,8 +5,7 @@ */ import { DynamicStyleProperty } from './dynamic_style_property'; -import _ from 'lodash'; -import { getComputedFieldName, getOtherCategoryLabel } from '../style_util'; +import { getOtherCategoryLabel, makeMbClampedNumberExpression } from '../style_util'; import { getOrdinalColorRampStops, getColorPalette } from '../../color_utils'; import { ColorGradient } from '../../components/color_gradient'; import React from 'react'; @@ -23,6 +22,7 @@ import { COLOR_MAP_TYPE } from '../../../../../common/constants'; import { isCategoricalStopsInvalid } from '../components/color/color_stops_utils'; const EMPTY_STOPS = { stops: [], defaultColor: null }; +const RGBA_0000 = 'rgba(0,0,0,0)'; export class DynamicColorProperty extends DynamicStyleProperty { syncCircleColorWithMb(mbLayerId, mbMap, alpha) { @@ -70,6 +70,17 @@ export class DynamicColorProperty extends DynamicStyleProperty { mbMap.setPaintProperty(mbLayerId, 'text-halo-color', color); } + supportsFieldMeta() { + if (!this.isComplete() || !this._field.supportsFieldMeta()) { + return false; + } + + return ( + (this.isCategorical() && !this._options.useCustomColorPalette) || + (this.isOrdinal() && !this._options.useCustomColorRamp) + ); + } + isOrdinal() { return ( typeof this._options.type === 'undefined' || this._options.type === COLOR_MAP_TYPE.ORDINAL @@ -80,28 +91,20 @@ export class DynamicColorProperty extends DynamicStyleProperty { return this._options.type === COLOR_MAP_TYPE.CATEGORICAL; } - isCustomOrdinalColorRamp() { - return this._options.useCustomColorRamp; - } - supportsMbFeatureState() { return true; } - isOrdinalScaled() { - return this.isOrdinal() && !this.isCustomOrdinalColorRamp(); - } - isOrdinalRanged() { - return this.isOrdinal() && !this.isCustomOrdinalColorRamp(); + return this.isOrdinal() && !this._options.useCustomColorRamp; } hasOrdinalBreaks() { - return (this.isOrdinal() && this.isCustomOrdinalColorRamp()) || this.isCategorical(); + return (this.isOrdinal() && this._options.useCustomColorRamp) || this.isCategorical(); } _getMbColor() { - if (!_.get(this._options, 'field.name')) { + if (!this._field || !this._field.getName()) { return null; } @@ -111,7 +114,7 @@ export class DynamicColorProperty extends DynamicStyleProperty { } _getOrdinalColorMbExpression() { - const targetName = getComputedFieldName(this._styleName, this._options.field.name); + const targetName = this._field.getName(); if (this._options.useCustomColorRamp) { if (!this._options.customColorRamp || !this._options.customColorRamp.length) { // custom color ramp config is not complete @@ -122,27 +125,44 @@ export class DynamicColorProperty extends DynamicStyleProperty { return [...accumulatedStops, nextStop.stop, nextStop.color]; }, []); const firstStopValue = colorStops[0]; - const lessThenFirstStopValue = firstStopValue - 1; + const lessThanFirstStopValue = firstStopValue - 1; return [ 'step', - ['coalesce', ['feature-state', targetName], lessThenFirstStopValue], - 'rgba(0,0,0,0)', // MB will assign the base value to any features that is below the first stop value + ['coalesce', ['feature-state', targetName], lessThanFirstStopValue], + RGBA_0000, // MB will assign the base value to any features that is below the first stop value ...colorStops, ]; - } + } else { + const rangeFieldMeta = this.getRangeFieldMeta(); + if (!rangeFieldMeta) { + return null; + } - const colorStops = getOrdinalColorRampStops(this._options.color); - if (!colorStops) { - return null; + const colorStops = getOrdinalColorRampStops( + this._options.color, + rangeFieldMeta.min, + rangeFieldMeta.max + ); + if (!colorStops) { + return null; + } + + const lessThanFirstStopValue = rangeFieldMeta.min - 1; + return [ + 'interpolate', + ['linear'], + makeMbClampedNumberExpression({ + minValue: rangeFieldMeta.min, + maxValue: rangeFieldMeta.max, + lookupFunction: 'feature-state', + fallback: lessThanFirstStopValue, + fieldName: targetName, + }), + lessThanFirstStopValue, + RGBA_0000, + ...colorStops, + ]; } - return [ - 'interpolate', - ['linear'], - ['coalesce', ['feature-state', targetName], -1], - -1, - 'rgba(0,0,0,0)', - ...colorStops, - ]; } _getColorPaletteStops() { @@ -220,7 +240,7 @@ export class DynamicColorProperty extends DynamicStyleProperty { } mbStops.push(defaultColor); //last color is default color - return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + return ['match', ['to-string', ['get', this._field.getName()]], ...mbStops]; } renderRangeLegendHeader() { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js index 755fc72d52798..b19c25b369848 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -75,16 +75,10 @@ class MockLayer { } } -const makeProperty = options => { - return new DynamicColorProperty( - options, - VECTOR_STYLES.LINE_COLOR, - mockField, - new MockLayer(), - () => { - return x => x + '_format'; - } - ); +const makeProperty = (options, field = mockField) => { + return new DynamicColorProperty(options, VECTOR_STYLES.LINE_COLOR, field, new MockLayer(), () => { + return x => x + '_format'; + }); }; const defaultLegendParams = { @@ -236,7 +230,72 @@ test('Should pluck the categorical style-meta from fieldmeta', async () => { }); }); -describe('get mapbox color expression', () => { +describe('supportsFieldMeta', () => { + test('should support it when field does for ordinals', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(true); + }); + + test('should support it when field does for categories', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.CATEGORICAL, + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(true); + }); + + test('should not support it when field does not', () => { + const field = Object.create(mockField); + field.supportsFieldMeta = function() { + return false; + }; + + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + }; + const styleProp = makeProperty(dynamicStyleOptions, field); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); + + test('should not support it when field config not complete', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + }; + const styleProp = makeProperty(dynamicStyleOptions, null); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); + + test('should not support it when using custom ramp for ordinals', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + useCustomColorRamp: true, + customColorRamp: [], + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); + + test('should not support it when using custom palette for categories', () => { + const dynamicStyleOptions = { + type: COLOR_MAP_TYPE.CATEGORICAL, + useCustomColorPalette: true, + customColorPalette: [], + }; + const styleProp = makeProperty(dynamicStyleOptions); + + expect(styleProp.supportsFieldMeta()).toEqual(false); + }); +}); + +describe('get mapbox color expression (via internal _getMbColor)', () => { describe('ordinal color ramp', () => { test('should return null when field is not provided', async () => { const dynamicStyleOptions = { @@ -259,44 +318,46 @@ describe('get mapbox color expression', () => { test('should return null when color ramp is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toBeNull(); }); - test('should return mapbox expression for color ramp', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, color: 'Blues', }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'interpolate', ['linear'], - ['coalesce', ['feature-state', '__kbn__dynamic__myField__lineColor'], -1], + [ + 'coalesce', + [ + 'case', + ['==', ['feature-state', 'foobar'], null], + -1, + ['max', ['min', ['to-number', ['feature-state', 'foobar']], 100], 0], + ], + -1, + ], -1, 'rgba(0,0,0,0)', 0, '#f7faff', - 0.125, + 12.5, '#ddeaf7', - 0.25, + 25, '#c5daee', - 0.375, + 37.5, '#9dc9e0', - 0.5, + 50, '#6aadd5', - 0.625, + 62.5, '#4191c5', - 0.75, + 75, '#2070b4', - 0.875, + 87.5, '#072f6b', ]); }); @@ -306,9 +367,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorRamp is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, useCustomColorRamp: true, }; const colorProperty = makeProperty(dynamicStyleOptions); @@ -318,9 +376,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorRamp is empty', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, useCustomColorRamp: true, customColorRamp: [], }; @@ -331,9 +386,6 @@ describe('get mapbox color expression', () => { test('should return mapbox expression for custom color ramp', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.ORDINAL, - field: { - name: 'myField', - }, useCustomColorRamp: true, customColorRamp: [ { stop: 10, color: '#f7faff' }, @@ -343,7 +395,7 @@ describe('get mapbox color expression', () => { const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'step', - ['coalesce', ['feature-state', '__kbn__dynamic__myField__lineColor'], 9], + ['coalesce', ['feature-state', 'foobar'], 9], 'rgba(0,0,0,0)', 10, '#f7faff', @@ -376,9 +428,6 @@ describe('get mapbox color expression', () => { test('should return null when color palette is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toBeNull(); @@ -387,15 +436,12 @@ describe('get mapbox color expression', () => { test('should return mapbox expression for color palette', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, colorCategory: 'palette_0', }; const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'match', - ['to-string', ['get', 'myField']], + ['to-string', ['get', 'foobar']], 'US', '#54B399', 'CN', @@ -409,9 +455,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorPalette is not provided', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, useCustomColorPalette: true, }; const colorProperty = makeProperty(dynamicStyleOptions); @@ -421,9 +464,6 @@ describe('get mapbox color expression', () => { test('should return null when customColorPalette is empty', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, useCustomColorPalette: true, customColorPalette: [], }; @@ -434,9 +474,6 @@ describe('get mapbox color expression', () => { test('should return mapbox expression for custom color palette', async () => { const dynamicStyleOptions = { type: COLOR_MAP_TYPE.CATEGORICAL, - field: { - name: 'myField', - }, useCustomColorPalette: true, customColorPalette: [ { stop: null, color: '#f7faff' }, @@ -446,7 +483,7 @@ describe('get mapbox color expression', () => { const colorProperty = makeProperty(dynamicStyleOptions); expect(colorProperty._getMbColor()).toEqual([ 'match', - ['to-string', ['get', 'myField']], + ['to-string', ['get', 'foobar']], 'MX', '#072f6b', '#f7faff', diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js index c492efbdf4ba3..05e2ad06842ce 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_icon_property.js @@ -81,7 +81,7 @@ export class DynamicIconProperty extends DynamicStyleProperty { mbStops.push(getMakiIconId(style, iconPixelSize)); }); mbStops.push(getMakiIconId(fallback, iconPixelSize)); //last item is fallback style for anything that does not match provided stops - return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + return ['match', ['to-string', ['get', this._field.getName()]], ...mbStops]; } _getMbIconAnchorExpression() { @@ -98,7 +98,7 @@ export class DynamicIconProperty extends DynamicStyleProperty { mbStops.push(getMakiSymbolAnchor(style)); }); mbStops.push(getMakiSymbolAnchor(fallback)); //last item is fallback style for anything that does not match provided stops - return ['match', ['to-string', ['get', this._options.field.name]], ...mbStops]; + return ['match', ['to-string', ['get', this._field.getName()]], ...mbStops]; } _isIconDynamicConfigComplete() { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js index 96408b3d2229e..ae4d935e2457b 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_orientation_property.js @@ -25,8 +25,4 @@ export class DynamicOrientationProperty extends DynamicStyleProperty { supportsMbFeatureState() { return false; } - - isOrdinalScaled() { - return false; - } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js index 7626f8c9b4158..8b3f670bfa528 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js @@ -5,7 +5,7 @@ */ import { DynamicStyleProperty } from './dynamic_style_property'; - +import { makeMbClampedNumberExpression } from '../style_util'; import { HALF_LARGE_MAKI_ICON_SIZE, LARGE_MAKI_ICON_SIZE, @@ -74,17 +74,24 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } syncIconSizeWithMb(symbolLayerId, mbMap) { - if (this._isSizeDynamicConfigComplete(this._options)) { + const rangeFieldMeta = this.getRangeFieldMeta(); + if (this._isSizeDynamicConfigComplete(this._options) && rangeFieldMeta) { const halfIconPixels = this.getIconPixelSize() / 2; - const targetName = this.getComputedFieldName(); + const targetName = this.getFieldName(); // Using property state instead of feature-state because layout properties do not support feature-state mbMap.setLayoutProperty(symbolLayerId, 'icon-size', [ 'interpolate', ['linear'], - ['coalesce', ['get', targetName], 0], - 0, + makeMbClampedNumberExpression({ + minValue: rangeFieldMeta.min, + maxValue: rangeFieldMeta.max, + fallback: 0, + lookupFunction: 'get', + fieldName: targetName, + }), + rangeFieldMeta.min, this._options.minSize / halfIconPixels, - 1, + rangeFieldMeta.max, this._options.maxSize / halfIconPixels, ]); } else { @@ -113,25 +120,35 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } getMbSizeExpression() { - if (this._isSizeDynamicConfigComplete(this._options)) { - return this._getMbDataDrivenSize({ - targetName: this.getComputedFieldName(), - minSize: this._options.minSize, - maxSize: this._options.maxSize, - }); + const rangeFieldMeta = this.getRangeFieldMeta(); + if (!this._isSizeDynamicConfigComplete(this._options) || !rangeFieldMeta) { + return null; } - return null; + + return this._getMbDataDrivenSize({ + targetName: this.getFieldName(), + minSize: this._options.minSize, + maxSize: this._options.maxSize, + minValue: rangeFieldMeta.min, + maxValue: rangeFieldMeta.max, + }); } - _getMbDataDrivenSize({ targetName, minSize, maxSize }) { + _getMbDataDrivenSize({ targetName, minSize, maxSize, minValue, maxValue }) { const lookup = this.supportsMbFeatureState() ? 'feature-state' : 'get'; return [ 'interpolate', ['linear'], - ['coalesce', [lookup, targetName], 0], - 0, + makeMbClampedNumberExpression({ + lookupFunction: lookup, + maxValue, + minValue, + fieldName: targetName, + fallback: 0, + }), + minValue, minSize, - 1, + maxValue, maxSize, ]; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts index 25063944b8891..a83dd55c0c175 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.d.ts @@ -12,8 +12,6 @@ import { DynamicStylePropertyOptions, } from '../../../../../common/style_property_descriptor_types'; import { IField } from '../../../fields/field'; -import { IVectorLayer } from '../../../vector_layer'; -import { IVectorSource } from '../../../sources/vector_source'; import { CategoryFieldMeta, RangeFieldMeta } from '../../../../../common/descriptor_types'; export interface IDynamicStyleProperty extends IStyleProperty { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index 68e06bacfa7b7..ea521f8749d80 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -13,7 +13,6 @@ import { SOURCE_META_ID_ORIGIN, FIELD_ORIGIN, } from '../../../../../common/constants'; -import { scaleValue, getComputedFieldName } from '../style_util'; import React from 'react'; import { OrdinalLegend } from './components/ordinal_legend'; import { CategoricalLegend } from './components/categorical_legend'; @@ -109,13 +108,6 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return this._field ? this._field.getName() : ''; } - getComputedFieldName() { - if (!this.isComplete()) { - return null; - } - return getComputedFieldName(this._styleName, this.getField().getName()); - } - isDynamic() { return true; } @@ -150,13 +142,7 @@ export class DynamicStyleProperty extends AbstractStyleProperty { } supportsFieldMeta() { - if (this.isOrdinal()) { - return this.isComplete() && this.isOrdinalScaled() && this._field.supportsFieldMeta(); - } else if (this.isCategorical()) { - return this.isComplete() && this._field.supportsFieldMeta(); - } else { - return false; - } + return this.isComplete() && this._field.supportsFieldMeta(); } async getFieldMetaRequest() { @@ -173,10 +159,6 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return true; } - isOrdinalScaled() { - return true; - } - getFieldMetaOptions() { return _.get(this.getOptions(), 'fieldMetaOptions', {}); } @@ -296,19 +278,9 @@ export class DynamicStyleProperty extends AbstractStyleProperty { } } - getMbValue(value) { - if (!this.isOrdinal()) { - return this.formatField(value); - } - + getNumericalMbFeatureStateValue(value) { const valueAsFloat = parseFloat(value); - if (this.isOrdinalScaled()) { - return scaleValue(valueAsFloat, this.getRangeFieldMeta()); - } - if (isNaN(valueAsFloat)) { - return 0; - } - return valueAsFloat; + return isNaN(valueAsFloat) ? null : valueAsFloat; } renderBreakedLegend() { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js index c561ec128dec5..de868f3f92650 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_text_property.js @@ -28,8 +28,4 @@ export class DynamicTextProperty extends DynamicStyleProperty { supportsMbFeatureState() { return false; } - - isOrdinalScaled() { - return false; - } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js index 7119b659c1232..3016b15d0a05c 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js @@ -31,21 +31,27 @@ export class LabelBorderSizeProperty extends AbstractStyleProperty { } syncLabelBorderSizeWithMb(mbLayerId, mbMap) { - const widthRatio = getWidthRatio(this.getOptions().size); - if (this.getOptions().size === LABEL_BORDER_SIZES.NONE) { mbMap.setPaintProperty(mbLayerId, 'text-halo-width', 0); - } else if (this._labelSizeProperty.isDynamic() && this._labelSizeProperty.isComplete()) { + return; + } + + const widthRatio = getWidthRatio(this.getOptions().size); + + if (this._labelSizeProperty.isDynamic() && this._labelSizeProperty.isComplete()) { const labelSizeExpression = this._labelSizeProperty.getMbSizeExpression(); - mbMap.setPaintProperty(mbLayerId, 'text-halo-width', [ - 'max', - ['*', labelSizeExpression, widthRatio], - 1, - ]); - } else { - const labelSize = _.get(this._labelSizeProperty.getOptions(), 'size', DEFAULT_LABEL_SIZE); - const labelBorderSize = Math.max(labelSize * widthRatio, 1); - mbMap.setPaintProperty(mbLayerId, 'text-halo-width', labelBorderSize); + if (labelSizeExpression) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', [ + 'max', + ['*', labelSizeExpression, widthRatio], + 1, + ]); + return; + } } + + const labelSize = _.get(this._labelSizeProperty.getOptions(), 'size', DEFAULT_LABEL_SIZE); + const labelBorderSize = Math.max(labelSize * widthRatio, 1); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', labelBorderSize); } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js index 2859b8c0e5a56..0820568468439 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.js @@ -34,22 +34,6 @@ export function isOnlySingleFeatureType(featureType, supportedFeatures, hasFeatu }, true); } -export function scaleValue(value, range) { - if (isNaN(value) || !range) { - return -1; //Nothing to scale, put outside scaled range - } - - if (range.delta === 0 || value >= range.max) { - return 1; //snap to end of scaled range - } - - if (value <= range.min) { - return 0; //snap to beginning of scaled range - } - - return (value - range.min) / range.delta; -} - export function assignCategoriesToPalette({ categories, paletteValues }) { const stops = []; let fallback = null; @@ -70,3 +54,23 @@ export function assignCategoriesToPalette({ categories, paletteValues }) { fallback, }; } + +export function makeMbClampedNumberExpression({ + lookupFunction, + fieldName, + minValue, + maxValue, + fallback, +}) { + const clamp = ['max', ['min', ['to-number', [lookupFunction, fieldName]], maxValue], minValue]; + return [ + 'coalesce', + [ + 'case', + ['==', [lookupFunction, fieldName], null], + minValue - 1, //== does a JS-y like check where returns true for null and undefined + clamp, + ], + fallback, + ]; +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js index 2be31c0107193..76bbfc84e3892 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/style_util.test.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isOnlySingleFeatureType, scaleValue, assignCategoriesToPalette } from './style_util'; +import { isOnlySingleFeatureType, assignCategoriesToPalette } from './style_util'; import { VECTOR_SHAPE_TYPES } from '../../sources/vector_feature_types'; describe('isOnlySingleFeatureType', () => { @@ -62,32 +62,6 @@ describe('isOnlySingleFeatureType', () => { }); }); -describe('scaleValue', () => { - test('Should scale value between 0 and 1', () => { - expect(scaleValue(5, { min: 0, max: 10, delta: 10 })).toBe(0.5); - }); - - test('Should snap value less then range min to 0', () => { - expect(scaleValue(-1, { min: 0, max: 10, delta: 10 })).toBe(0); - }); - - test('Should snap value greater then range max to 1', () => { - expect(scaleValue(11, { min: 0, max: 10, delta: 10 })).toBe(1); - }); - - test('Should snap value to 1 when tere is not range delta', () => { - expect(scaleValue(10, { min: 10, max: 10, delta: 0 })).toBe(1); - }); - - test('Should put value as -1 when value is not provided', () => { - expect(scaleValue(undefined, { min: 0, max: 10, delta: 10 })).toBe(-1); - }); - - test('Should put value as -1 when range is not provided', () => { - expect(scaleValue(5, undefined)).toBe(-1); - }); -}); - describe('assignCategoriesToPalette', () => { test('Categories and palette values have same length', () => { const categories = [{ key: 'alpah' }, { key: 'bravo' }, { key: 'charlie' }, { key: 'delta' }]; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js index b5a0b51727936..ae5d148e43cfd 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js @@ -503,11 +503,19 @@ export class VectorStyle extends AbstractStyle { const dynamicStyleProp = dynamicStyleProps[j]; const name = dynamicStyleProp.getField().getName(); const computedName = getComputedFieldName(dynamicStyleProp.getStyleName(), name); - const styleValue = dynamicStyleProp.getMbValue(feature.properties[name]); + const rawValue = feature.properties[name]; if (dynamicStyleProp.supportsMbFeatureState()) { - tmpFeatureState[computedName] = styleValue; + tmpFeatureState[name] = dynamicStyleProp.getNumericalMbFeatureStateValue(rawValue); //the same value will be potentially overridden multiple times, if the name remains identical } else { - feature.properties[computedName] = styleValue; + //in practice, a new system property will only be created for: + // - label text: this requires the value to be formatted first. + // - icon orientation: this is a lay-out property which do not support feature-state (but we're still coercing to a number) + + const formattedValue = dynamicStyleProp.isOrdinal() + ? dynamicStyleProp.getNumericalMbFeatureStateValue(rawValue) + : dynamicStyleProp.formatField(rawValue); + + feature.properties[computedName] = formattedValue; } } tmpFeatureIdentifier.source = mbSourceId; diff --git a/x-pack/test/functional/apps/maps/mapbox_styles.js b/x-pack/test/functional/apps/maps/mapbox_styles.js index d3280bae8582e..508a019db1764 100644 --- a/x-pack/test/functional/apps/maps/mapbox_styles.js +++ b/x-pack/test/functional/apps/maps/mapbox_styles.js @@ -16,9 +16,7 @@ export const MAPBOX_STYLES = { ['==', ['get', '__kbn_isvisibleduetojoin__'], true], ['any', ['==', ['geometry-type'], 'Point'], ['==', ['geometry-type'], 'MultiPoint']], ], - layout: { - visibility: 'visible', - }, + layout: { visibility: 'visible' }, paint: { 'circle-color': [ 'interpolate', @@ -26,32 +24,53 @@ export const MAPBOX_STYLES = { [ 'coalesce', [ - 'feature-state', - '__kbn__dynamic____kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name__fillColor', + 'case', + [ + '==', + ['feature-state', '__kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name'], + null, + ], + 2, + [ + 'max', + [ + 'min', + [ + 'to-number', + [ + 'feature-state', + '__kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name', + ], + ], + 12, + ], + 3, + ], ], - -1, + 2, ], - -1, + 2, 'rgba(0,0,0,0)', - 0, + 3, '#f7faff', - 0.125, + 4.125, '#ddeaf7', - 0.25, + 5.25, '#c5daee', - 0.375, + 6.375, '#9dc9e0', - 0.5, + 7.5, '#6aadd5', - 0.625, + 8.625, '#4191c5', - 0.75, + 9.75, '#2070b4', - 0.875, + 10.875, '#072f6b', ], - 'circle-opacity': 0.75, // Obtained dynamically - /* 'circle-stroke-color': '' */ 'circle-stroke-opacity': 0.75, + 'circle-opacity': 0.75, + 'circle-stroke-color': '#41937c', + 'circle-stroke-opacity': 0.75, 'circle-stroke-width': 1, 'circle-radius': 10, }, @@ -67,9 +86,7 @@ export const MAPBOX_STYLES = { ['==', ['get', '__kbn_isvisibleduetojoin__'], true], ['any', ['==', ['geometry-type'], 'Polygon'], ['==', ['geometry-type'], 'MultiPolygon']], ], - layout: { - visibility: 'visible', - }, + layout: { visibility: 'visible' }, paint: { 'fill-color': [ 'interpolate', @@ -77,28 +94,48 @@ export const MAPBOX_STYLES = { [ 'coalesce', [ - 'feature-state', - '__kbn__dynamic____kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name__fillColor', + 'case', + [ + '==', + ['feature-state', '__kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name'], + null, + ], + 2, + [ + 'max', + [ + 'min', + [ + 'to-number', + [ + 'feature-state', + '__kbnjoin__max_of_prop1_groupby_meta_for_geo_shapes*.shape_name', + ], + ], + 12, + ], + 3, + ], ], - -1, + 2, ], - -1, + 2, 'rgba(0,0,0,0)', - 0, + 3, '#f7faff', - 0.125, + 4.125, '#ddeaf7', - 0.25, + 5.25, '#c5daee', - 0.375, + 6.375, '#9dc9e0', - 0.5, + 7.5, '#6aadd5', - 0.625, + 8.625, '#4191c5', - 0.75, + 9.75, '#2070b4', - 0.875, + 10.875, '#072f6b', ], 'fill-opacity': 0.75, From 3eeb8df172d66911b51e66009b1b53ed39aa91da Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 23 Mar 2020 16:40:33 -0400 Subject: [PATCH 08/34] [Fleet] add Agent config details yaml view (#60943) --- .../ingest_manager/common/services/routes.ts | 4 + .../enrollment_instructions/index.tsx | 0 .../enrollment_instructions/manual/index.tsx | 0 .../enrollment_instructions/shell/index.tsx | 14 ++-- .../hooks/use_request/agent_config.ts | 7 ++ .../details_page/components/yaml/index.tsx | 78 +++++++++++++++++++ .../agent_config/details_page/index.tsx | 4 +- .../agent_enrollment_flyout/instructions.tsx | 5 +- 8 files changed, 103 insertions(+), 9 deletions(-) rename x-pack/plugins/ingest_manager/public/applications/ingest_manager/{sections/fleet/agent_list_page => }/components/enrollment_instructions/index.tsx (100%) rename x-pack/plugins/ingest_manager/public/applications/ingest_manager/{sections/fleet/agent_list_page => }/components/enrollment_instructions/manual/index.tsx (100%) rename x-pack/plugins/ingest_manager/public/applications/ingest_manager/{sections/fleet/agent_list_page => }/components/enrollment_instructions/shell/index.tsx (86%) create mode 100644 x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx diff --git a/x-pack/plugins/ingest_manager/common/services/routes.ts b/x-pack/plugins/ingest_manager/common/services/routes.ts index 01b3b1983486c..7cc6fc3c66afb 100644 --- a/x-pack/plugins/ingest_manager/common/services/routes.ts +++ b/x-pack/plugins/ingest_manager/common/services/routes.ts @@ -82,6 +82,10 @@ export const agentConfigRouteService = { getDeletePath: () => { return AGENT_CONFIG_API_ROUTES.DELETE_PATTERN; }, + + getInfoFullPath: (agentConfigId: string) => { + return AGENT_CONFIG_API_ROUTES.FULL_INFO_PATTERN.replace('{agentConfigId}', agentConfigId); + }, }; export const fleetSetupRouteService = { diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx similarity index 100% rename from x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/index.tsx rename to x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/index.tsx diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/manual/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx similarity index 100% rename from x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/manual/index.tsx rename to x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/manual/index.tsx diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/shell/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx similarity index 86% rename from x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/shell/index.tsx rename to x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx index 04e84902bc9d4..e6990927b926e 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/fleet/agent_list_page/components/enrollment_instructions/shell/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/components/enrollment_instructions/shell/index.tsx @@ -12,7 +12,7 @@ import { EuiFieldText, EuiPopover, } from '@elastic/eui'; -import { EnrollmentAPIKey } from '../../../../../../types'; +import { EnrollmentAPIKey } from '../../../types'; // No need for i18n as these are platform names const PLATFORMS = { @@ -37,11 +37,13 @@ export const ShellEnrollmentInstructions: React.FunctionComponent = ({ const [isPlatformOptionsOpen, setIsPlatformOptionsOpen] = useState(false); // Build quick installation command - const quickInstallInstructions = `${ - kibanaCASha256 ? `CA_SHA256=${kibanaCASha256} ` : '' - }API_KEY=${ - apiKey.api_key - } sh -c "$(curl ${kibanaUrl}/api/ingest_manager/fleet/install/${currentPlatform})"`; + // const quickInstallInstructions = `${ + // kibanaCASha256 ? `CA_SHA256=${kibanaCASha256} ` : '' + // }API_KEY=${ + // apiKey.api_key + // } sh -c "$(curl ${kibanaUrl}/api/ingest_manager/fleet/install/${currentPlatform})"`; + + const quickInstallInstructions = `./agent enroll ${kibanaUrl} ${apiKey.api_key}`; return ( <> diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts index d44cc67e2dc4c..d16d835f8c701 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/hooks/use_request/agent_config.ts @@ -32,6 +32,13 @@ export const useGetOneAgentConfig = (agentConfigId: string) => { }); }; +export const useGetOneAgentConfigFull = (agentConfigId: string) => { + return useRequest({ + path: agentConfigRouteService.getInfoFullPath(agentConfigId), + method: 'get', + }); +}; + export const sendGetOneAgentConfig = (agentConfigId: string) => { return sendRequest({ path: agentConfigRouteService.getInfoPath(agentConfigId), diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx new file mode 100644 index 0000000000000..57031a3d72abe --- /dev/null +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/components/yaml/index.tsx @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { dump } from 'js-yaml'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiSpacer, + EuiText, + EuiCodeBlock, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { AgentConfig } from '../../../../../../../../common/types/models'; +import { + useGetOneAgentConfigFull, + useGetEnrollmentAPIKeys, + useGetOneEnrollmentAPIKey, + useCore, +} from '../../../../../hooks'; +import { ShellEnrollmentInstructions } from '../../../../../components/enrollment_instructions'; +import { Loading } from '../../../../../components'; + +const CONFIG_KEYS_ORDER = ['id', 'revision', 'outputs', 'datasources']; + +export const ConfigYamlView = memo<{ config: AgentConfig }>(({ config }) => { + const core = useCore(); + + const fullConfigRequest = useGetOneAgentConfigFull(config.id); + const apiKeysRequest = useGetEnrollmentAPIKeys(); + const apiKeyRequest = useGetOneEnrollmentAPIKey(apiKeysRequest.data?.list?.[0].id as string); + + if (fullConfigRequest.isLoading && !fullConfigRequest.data) { + return ; + } + + return ( + + + + {dump(fullConfigRequest.data.item, { + sortKeys: (keyA: string, keyB: string) => { + return CONFIG_KEYS_ORDER.indexOf(keyA) - CONFIG_KEYS_ORDER.indexOf(keyB); + }, + })} + + + + +

+ +

+
+ + + + + + {apiKeyRequest.data && ( + + )} +
+
+ ); +}); diff --git a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx index efb96f6459254..450f86df5c03a 100644 --- a/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx +++ b/x-pack/plugins/ingest_manager/public/applications/ingest_manager/sections/agent_config/details_page/index.tsx @@ -31,6 +31,7 @@ import { LinkedAgentCount } from '../components'; import { useAgentConfigLink } from './hooks/use_details_uri'; import { DETAILS_ROUTER_PATH, DETAILS_ROUTER_SUB_PATH } from './constants'; import { ConfigDatasourcesView } from './components/datasources'; +import { ConfigYamlView } from './components/yaml'; const Divider = styled.div` width: 0; @@ -282,8 +283,7 @@ export const AgentConfigDetailsLayout: React.FunctionComponent = () => { { - // TODO: YAML implementation tracked via https://github.com/elastic/kibana/issues/57958 - return
YAML placeholder
; + return ; }} /> Date: Mon, 23 Mar 2020 16:44:57 -0400 Subject: [PATCH 09/34] [Lens] Use new charts APIs to simplify series naming (#60708) * build: update @elastic/charts to v18.1.0 * tests: fix breaking-change on legendItem className * fix: type changes and ml custom tooltip data * tests: fix snapshot test * [Lens] Use new charts APIs to simplify series naming * Fix types * Fix naming * Remove accidental file * Update snapshots Co-authored-by: Marco Vettorello Co-authored-by: Elastic Machine --- .../__snapshots__/xy_expression.test.tsx.snap | 119 +++++++++--------- .../xy_visualization/xy_expression.test.tsx | 48 +++++-- .../public/xy_visualization/xy_expression.tsx | 83 ++++-------- 3 files changed, 121 insertions(+), 129 deletions(-) diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap b/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap index 44398c929a2b9..4b19ad288ddaa 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/__snapshots__/xy_expression.test.tsx.snap @@ -29,24 +29,23 @@ exports[`xy_expression XYChart component it renders area 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -58,8 +57,8 @@ exports[`xy_expression XYChart component it renders area 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -96,24 +95,23 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -125,8 +123,8 @@ exports[`xy_expression XYChart component it renders bar 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -163,24 +161,23 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -192,8 +189,8 @@ exports[`xy_expression XYChart component it renders horizontal bar 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -230,24 +227,23 @@ exports[`xy_expression XYChart component it renders line 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -259,8 +255,8 @@ exports[`xy_expression XYChart component it renders line 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -297,24 +293,23 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -330,8 +325,8 @@ exports[`xy_expression XYChart component it renders stacked area 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -368,24 +363,23 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -401,8 +395,8 @@ exports[`xy_expression XYChart component it renders stacked bar 1`] = ` xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" @@ -439,24 +433,23 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = data={ Array [ Object { - "Label A": 1, - "Label B": 2, - "Label D": "Foo", + "a": 1, + "b": 2, "c": "I", "d": "Foo", }, Object { - "Label A": 1, - "Label B": 5, - "Label D": "Bar", + "a": 1, + "b": 5, "c": "J", "d": "Bar", }, ] } enableHistogramMode={false} - id="Label D" + id="d" key="0" + name={[Function]} splitSeriesAccessors={ Array [ "d", @@ -472,8 +465,8 @@ exports[`xy_expression XYChart component it renders stacked horizontal bar 1`] = xScaleType="ordinal" yAccessors={ Array [ - "Label A", - "Label B", + "a", + "b", ] } yScaleType="linear" diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx index 6323a063c988b..adf64fece2942 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.test.tsx @@ -12,6 +12,7 @@ import { LineSeries, Settings, ScaleType, + SeriesNameFn, } from '@elastic/charts'; import { xyChart, XYChart } from './xy_expression'; import { LensMultiTable } from '../types'; @@ -367,7 +368,7 @@ describe('xy_expression', () => { expect(component.find(BarSeries).prop('enableHistogramMode')).toEqual(false); }); - test('it rewrites the rows based on provided labels', () => { + test('it names the series for multiple accessors', () => { const { data, args } = sampleArgs(); const component = shallow( @@ -379,25 +380,56 @@ describe('xy_expression', () => { chartTheme={{}} /> ); - expect(component.find(LineSeries).prop('data')).toEqual([ - { 'Label A': 1, 'Label B': 2, c: 'I', 'Label D': 'Foo', d: 'Foo' }, - { 'Label A': 1, 'Label B': 5, c: 'J', 'Label D': 'Bar', d: 'Bar' }, - ]); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + expect( + nameFn( + { + seriesKeys: ['a', 'b', 'c', 'd'], + key: '', + specId: 'a', + yAccessor: '', + splitAccessors: new Map(), + }, + false + ) + ).toEqual('Label A - Label B - c - Label D'); }); - test('it uses labels as Y accessors', () => { + test('it names the series for a single accessor', () => { const { data, args } = sampleArgs(); const component = shallow( ); - expect(component.find(LineSeries).prop('yAccessors')).toEqual(['Label A', 'Label B']); + const nameFn = component.find(LineSeries).prop('name') as SeriesNameFn; + + expect( + nameFn( + { + seriesKeys: ['a', 'b', 'c', 'd'], + key: '', + specId: 'a', + yAccessor: '', + splitAccessors: new Map(), + }, + false + ) + ).toEqual('Label A'); }); test('it set the scale of the x axis according to the args prop', () => { diff --git a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx index cc30e5d18a7f7..ce966ee6150a0 100644 --- a/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx +++ b/x-pack/legacy/plugins/lens/public/xy_visualization/xy_expression.tsx @@ -18,7 +18,6 @@ import { } from '@elastic/charts'; import { I18nProvider } from '@kbn/i18n/react'; import { - KibanaDatatable, IInterpreterRenderHandlers, ExpressionRenderDefinition, ExpressionFunctionDefinition, @@ -33,6 +32,11 @@ import { XYArgs, SeriesType, visualizationTypes } from './types'; import { VisualizationContainer } from '../visualization_container'; import { isHorizontalChart } from './state_helpers'; +type InferPropType = T extends React.FunctionComponent ? P : T; +type SeriesSpec = InferPropType & + InferPropType & + InferPropType; + export interface XYChartProps { data: LensMultiTable; args: XYArgs; @@ -247,80 +251,43 @@ export function XYChart({ data, args, formatFactory, timeZone, chartTheme }: XYC return; } - const columnToLabelMap = columnToLabel ? JSON.parse(columnToLabel) : {}; - const splitAccessorLabel = splitAccessor ? columnToLabelMap[splitAccessor] : ''; - const yAccessors = accessors.map(accessor => columnToLabelMap[accessor] || accessor); - const idForLegend = splitAccessorLabel || yAccessors; - const sanitized = sanitizeRows({ - splitAccessor, - formatFactory, - columnToLabelMap, - table: data.tables[layerId], - }); - - const seriesProps = { - key: index, - splitSeriesAccessors: sanitized.splitAccessor ? [sanitized.splitAccessor] : [], + const columnToLabelMap: Record = columnToLabel + ? JSON.parse(columnToLabel) + : {}; + const table = data.tables[layerId]; + const seriesProps: SeriesSpec = { + splitSeriesAccessors: splitAccessor ? [splitAccessor] : [], stackAccessors: seriesType.includes('stacked') ? [xAccessor] : [], - id: idForLegend, + id: splitAccessor || accessors.join(','), xAccessor, - yAccessors, - data: sanitized.rows, + yAccessors: accessors, + data: table.rows, xScaleType, yScaleType, enableHistogramMode: isHistogram && (seriesType.includes('stacked') || !splitAccessor), timeZone, + name(d) { + if (accessors.length > 1) { + return d.seriesKeys + .map((key: string | number) => columnToLabelMap[key] || key) + .join(' - '); + } + return columnToLabelMap[d.seriesKeys[0]] ?? d.seriesKeys[0]; + }, }; return seriesType === 'line' ? ( - + ) : seriesType === 'bar' || seriesType === 'bar_stacked' || seriesType === 'bar_horizontal' || seriesType === 'bar_horizontal_stacked' ? ( - + ) : ( - + ); } )} ); } - -/** - * Renames the columns to match the user-configured accessors in - * columnToLabelMap. If a splitAccessor is provided, formats the - * values in that column. - */ -function sanitizeRows({ - splitAccessor, - table, - formatFactory, - columnToLabelMap, -}: { - splitAccessor?: string; - table: KibanaDatatable; - formatFactory: FormatFactory; - columnToLabelMap: Record; -}) { - const column = table.columns.find(c => c.id === splitAccessor); - const formatter = formatFactory(column && column.formatHint); - - return { - splitAccessor: column && column.id, - rows: table.rows.map(r => { - const newRow: typeof r = {}; - - if (column) { - newRow[column.id] = formatter.convert(r[column.id]); - } - - Object.keys(r).forEach(key => { - const newKey = columnToLabelMap[key] || key; - newRow[newKey] = r[key]; - }); - return newRow; - }), - }; -} From ec2972c224a7360decf948c1368e1f8f08ba2f58 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Mon, 23 Mar 2020 17:03:01 -0400 Subject: [PATCH 10/34] Dashboard/add panel flow (#59918) Added an emphasize prop to the top nav menu item and used it for a new 'Create new' button which redirects to the 'new visualization' modal. Co-authored-by: Ryan Keairns --- .../np_ready/dashboard_app_controller.tsx | 5 +- .../np_ready/top_nav/get_top_nav_config.ts | 22 ++++++- .../dashboard/np_ready/top_nav/top_nav_ids.ts | 2 +- .../top_nav_menu_item.test.tsx.snap | 19 ++++++ .../public/top_nav_menu/_index.scss | 8 ++- .../public/top_nav_menu/top_nav_menu.tsx | 7 ++- .../public/top_nav_menu/top_nav_menu_data.tsx | 5 ++ .../top_nav_menu/top_nav_menu_item.test.tsx | 60 +++++++++++++++---- .../public/top_nav_menu/top_nav_menu_item.tsx | 26 ++++---- .../dashboard/create_and_add_embeddables.js | 17 +++++- .../services/dashboard/add_panel.js | 7 +++ 11 files changed, 148 insertions(+), 30 deletions(-) create mode 100644 src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx index d1e4c9d2d2a0c..f1e1f20de1ce6 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/dashboard_app_controller.tsx @@ -754,7 +754,7 @@ export class DashboardAppController { * When de-angularizing this code, please call the underlaying action function * directly and not via the top nav object. **/ - navActions[TopNavIds.ADD](); + navActions[TopNavIds.ADD_EXISTING](); }; $scope.enterEditMode = () => { dashboardStateManager.setFullScreenMode(false); @@ -847,7 +847,8 @@ export class DashboardAppController { showCloneModal(onClone, currentTitle); }; - navActions[TopNavIds.ADD] = () => { + + navActions[TopNavIds.ADD_EXISTING] = () => { if (dashboardContainer && !isErrorEmbeddable(dashboardContainer)) { openAddPanelFlyout({ embeddable: dashboardContainer, diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts index 7188fab19d6f2..7a3cb4b7dad56 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/get_top_nav_config.ts @@ -48,9 +48,10 @@ export function getTopNavConfig( ]; case ViewMode.EDIT: return [ + getCreateNewConfig(actions[TopNavIds.VISUALIZE]), getSaveConfig(actions[TopNavIds.SAVE]), getViewConfig(actions[TopNavIds.EXIT_EDIT_MODE]), - getAddConfig(actions[TopNavIds.ADD]), + getAddConfig(actions[TopNavIds.ADD_EXISTING]), getOptionsConfig(actions[TopNavIds.OPTIONS]), getShareConfig(actions[TopNavIds.SHARE]), ]; @@ -161,6 +162,25 @@ function getAddConfig(action: NavAction) { }; } +/** + * @returns {kbnTopNavConfig} + */ +function getCreateNewConfig(action: NavAction) { + return { + emphasize: true, + iconType: 'plusInCircle', + id: 'addNew', + label: i18n.translate('kbn.dashboard.topNave.addNewButtonAriaLabel', { + defaultMessage: 'Create new', + }), + description: i18n.translate('kbn.dashboard.topNave.addNewConfigDescription', { + defaultMessage: 'Create a new panel on this dashboard', + }), + testId: 'dashboardAddNewPanelButton', + run: action, + }; +} + /** * @returns {kbnTopNavConfig} */ diff --git a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts index c67d6891c18e7..748bfaaab6141 100644 --- a/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts +++ b/src/legacy/core_plugins/kibana/public/dashboard/np_ready/top_nav/top_nav_ids.ts @@ -18,7 +18,6 @@ */ export const TopNavIds = { - ADD: 'add', SHARE: 'share', OPTIONS: 'options', SAVE: 'save', @@ -27,4 +26,5 @@ export const TopNavIds = { CLONE: 'clone', FULL_SCREEN: 'fullScreenMode', VISUALIZE: 'visualize', + ADD_EXISTING: 'addExisting', }; diff --git a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap new file mode 100644 index 0000000000000..0d54d5d3e9c4a --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_menu_item.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TopNavMenu Should render emphasized item which should be clickable 1`] = ` + + Test + +`; diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 4a0e6af3f7f70..5befe4789dd6c 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,7 +1,11 @@ .kbnTopNavMenu__wrapper { z-index: 5; - .kbnTopNavMenu { - padding: $euiSizeS 0px $euiSizeXS; + .kbnTopNavMenu { + padding: $euiSizeS 0; + + .kbnTopNavItemEmphasized { + padding: 0 $euiSizeS; + } } } diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 80d1a53cd417f..14ad40f13e388 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -46,7 +46,11 @@ export function TopNavMenu(props: TopNavMenuProps) { if (!config) return; return config.map((menuItem: TopNavMenuData, i: number) => { return ( - + ); @@ -66,6 +70,7 @@ export function TopNavMenu(props: TopNavMenuProps) { void; export interface TopNavMenuData { @@ -28,6 +30,9 @@ export interface TopNavMenuData { className?: string; disableButton?: boolean | (() => boolean); tooltip?: string | (() => string); + emphasize?: boolean; + iconType?: string; + iconSide?: ButtonIconSide; } export interface RegisteredTopNavMenuData extends TopNavMenuData { diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx index 4816ef3c95869..9ba58379c5ce1 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx @@ -23,6 +23,15 @@ import { TopNavMenuData } from './top_nav_menu_data'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; describe('TopNavMenu', () => { + const ensureMenuItemDisabled = (data: TopNavMenuData) => { + const component = shallowWithIntl(); + expect(component.prop('isDisabled')).toEqual(true); + + const event = { currentTarget: { value: 'a' } }; + component.simulate('click', event); + expect(data.run).toHaveBeenCalledTimes(0); + }; + it('Should render and click an item', () => { const data: TopNavMenuData = { id: 'test', @@ -60,35 +69,62 @@ describe('TopNavMenu', () => { expect(data.run).toHaveBeenCalled(); }); - it('Should render disabled item and it shouldnt be clickable', () => { + it('Should render emphasized item which should be clickable', () => { const data: TopNavMenuData = { id: 'test', label: 'test', - disableButton: true, + iconType: 'beaker', + iconSide: 'right', + emphasize: true, run: jest.fn(), }; const component = shallowWithIntl(); - expect(component.prop('isDisabled')).toEqual(true); - const event = { currentTarget: { value: 'a' } }; component.simulate('click', event); - expect(data.run).toHaveBeenCalledTimes(0); + expect(data.run).toHaveBeenCalledTimes(1); + expect(component).toMatchSnapshot(); + }); + + it('Should render disabled item and it shouldnt be clickable', () => { + ensureMenuItemDisabled({ + id: 'test', + label: 'test', + disableButton: true, + run: jest.fn(), + }); }); it('Should render item with disable function and it shouldnt be clickable', () => { - const data: TopNavMenuData = { + ensureMenuItemDisabled({ id: 'test', label: 'test', disableButton: () => true, run: jest.fn(), - }; + }); + }); - const component = shallowWithIntl(); - expect(component.prop('isDisabled')).toEqual(true); + it('Should render disabled emphasized item which shouldnt be clickable', () => { + ensureMenuItemDisabled({ + id: 'test', + label: 'test', + iconType: 'beaker', + iconSide: 'right', + emphasize: true, + disableButton: true, + run: jest.fn(), + }); + }); - const event = { currentTarget: { value: 'a' } }; - component.simulate('click', event); - expect(data.run).toHaveBeenCalledTimes(0); + it('Should render emphasized item with disable function and it shouldnt be clickable', () => { + ensureMenuItemDisabled({ + id: 'test', + label: 'test', + iconType: 'beaker', + iconSide: 'right', + emphasize: true, + disableButton: () => true, + run: jest.fn(), + }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index 4d3b72bae6411..92e267f17d08e 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -21,6 +21,7 @@ import { capitalize, isFunction } from 'lodash'; import React, { MouseEvent } from 'react'; import { EuiButtonEmpty, EuiToolTip } from '@elastic/eui'; +import { EuiButton } from '@elastic/eui'; import { TopNavMenuData } from './top_nav_menu_data'; export function TopNavMenuItem(props: TopNavMenuData) { @@ -39,14 +40,20 @@ export function TopNavMenuItem(props: TopNavMenuData) { props.run(e.currentTarget); } - const btn = ( - + const commonButtonProps = { + isDisabled: isDisabled(), + onClick: handleClick, + iconType: props.iconType, + iconSide: props.iconSide, + 'data-test-subj': props.testId, + }; + + const btn = props.emphasize ? ( + + {capitalize(props.label || props.id!)} + + ) : ( + {capitalize(props.label || props.id!)} ); @@ -54,9 +61,8 @@ export function TopNavMenuItem(props: TopNavMenuData) { const tooltip = getTooltip(); if (tooltip) { return {btn}; - } else { - return btn; } + return btn; } TopNavMenuItem.defaultProps = { diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 5ebb9fdf6330f..3ce8e353e61fc 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -41,9 +41,24 @@ export default function({ getService, getPageObjects }) { }); describe('add new visualization link', () => { - it('adds a new visualization', async () => { + it('adds new visualiztion via the top nav link', async () => { const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await PageObjects.dashboard.switchToEditMode(); + await dashboardAddPanel.clickCreateNewLink(); + await PageObjects.visualize.clickAreaChart(); + await PageObjects.visualize.clickNewSearch(); + await PageObjects.visualize.saveVisualizationExpectSuccess( + 'visualization from top nav add new panel' + ); + await retry.try(async () => { + const panelCount = await PageObjects.dashboard.getPanelCount(); + expect(panelCount).to.eql(originalPanelCount + 1); + }); + await PageObjects.dashboard.waitForRenderComplete(); + }); + + it('adds a new visualization', async () => { + const originalPanelCount = await PageObjects.dashboard.getPanelCount(); await dashboardAddPanel.ensureAddPanelIsShowing(); await dashboardAddPanel.clickAddNewEmbeddableLink('visualization'); await PageObjects.visualize.clickAreaChart(); diff --git a/test/functional/services/dashboard/add_panel.js b/test/functional/services/dashboard/add_panel.js index 91e7c15c4f1d9..6259203982161 100644 --- a/test/functional/services/dashboard/add_panel.js +++ b/test/functional/services/dashboard/add_panel.js @@ -32,6 +32,13 @@ export function DashboardAddPanelProvider({ getService, getPageObjects }) { await PageObjects.common.sleep(500); } + async clickCreateNewLink() { + log.debug('DashboardAddPanel.clickAddNewPanelButton'); + await testSubjects.click('dashboardAddNewPanelButton'); + // Give some time for the animation to complete + await PageObjects.common.sleep(500); + } + async clickAddNewEmbeddableLink(type) { await testSubjects.click('createNew'); await testSubjects.click(`createNew-${type}`); From f7a30498439c7cce2a856cd480ab8afed7eafdac Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Mon, 23 Mar 2020 17:11:09 -0400 Subject: [PATCH 11/34] [Lens] Improve suggestions when dragging field for the second time (#60687) * [Lens] Improve suggestions when dragging into an existing visualization * Include 0 metrics case Co-authored-by: Elastic Machine --- .../indexpattern_suggestions.test.tsx | 90 ++++++++++++++++--- .../indexpattern_suggestions.ts | 62 +++++++++---- 2 files changed, 120 insertions(+), 32 deletions(-) diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx index 4e48d0c0987b5..e36622f876acd 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.test.tsx @@ -780,7 +780,7 @@ describe('IndexPattern Data Source suggestions', () => { expect(suggestions[0].table.columns[0].operation.isBucketed).toBeFalsy(); }); - it('prepends a terms column on string field', () => { + it('appends a terms column on string field', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'dest', @@ -795,7 +795,7 @@ describe('IndexPattern Data Source suggestions', () => { layers: { previousLayer: initialState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['id1', 'cola', 'colb'], + columnOrder: ['cola', 'id1', 'colb'], columns: { ...initialState.layers.currentLayer.columns, id1: expect.objectContaining({ @@ -810,7 +810,7 @@ describe('IndexPattern Data Source suggestions', () => { ); }); - it('appends a metric column on a number field', () => { + it('replaces a metric column on a number field if only one other metric is already set', () => { const initialState = stateWithNonEmptyTables(); const suggestions = getDatasourceSuggestionsForField(initialState, '1', { name: 'memory', @@ -819,15 +819,57 @@ describe('IndexPattern Data Source suggestions', () => { searchable: true, }); + expect(suggestions).toContainEqual( + expect.objectContaining({ + state: expect.objectContaining({ + layers: expect.objectContaining({ + currentLayer: expect.objectContaining({ + columnOrder: ['cola', 'id1'], + columns: { + cola: initialState.layers.currentLayer.columns.cola, + id1: expect.objectContaining({ + operationType: 'avg', + sourceField: 'memory', + }), + }, + }), + }), + }), + }) + ); + }); + + it('adds a metric column on a number field if no other metrics set', () => { + const initialState = stateWithNonEmptyTables(); + const modifiedState: IndexPatternPrivateState = { + ...initialState, + layers: { + ...initialState.layers, + currentLayer: { + ...initialState.layers.currentLayer, + columns: { + cola: initialState.layers.currentLayer.columns.cola, + }, + columnOrder: ['cola'], + }, + }, + }; + const suggestions = getDatasourceSuggestionsForField(modifiedState, '1', { + name: 'memory', + type: 'number', + aggregatable: true, + searchable: true, + }); + expect(suggestions).toContainEqual( expect.objectContaining({ state: expect.objectContaining({ layers: { - previousLayer: initialState.layers.previousLayer, + previousLayer: modifiedState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['cola', 'colb', 'id1'], + columnOrder: ['cola', 'id1'], columns: { - ...initialState.layers.currentLayer.columns, + ...modifiedState.layers.currentLayer.columns, id1: expect.objectContaining({ operationType: 'avg', sourceField: 'memory', @@ -840,10 +882,30 @@ describe('IndexPattern Data Source suggestions', () => { ); }); - it('appends a metric column with a different operation on a number field if field is already in use', () => { + it('adds a metric column on a number field if 2 or more other metric', () => { const initialState = stateWithNonEmptyTables(); - const suggestions = getDatasourceSuggestionsForField(initialState, '1', { - name: 'bytes', + const modifiedState: IndexPatternPrivateState = { + ...initialState, + layers: { + ...initialState.layers, + currentLayer: { + ...initialState.layers.currentLayer, + columns: { + ...initialState.layers.currentLayer.columns, + colc: { + dataType: 'number', + isBucketed: false, + sourceField: 'dest', + label: 'Unique count of dest', + operationType: 'cardinality', + }, + }, + columnOrder: ['cola', 'colb', 'colc'], + }, + }, + }; + const suggestions = getDatasourceSuggestionsForField(modifiedState, '1', { + name: 'memory', type: 'number', aggregatable: true, searchable: true, @@ -853,14 +915,14 @@ describe('IndexPattern Data Source suggestions', () => { expect.objectContaining({ state: expect.objectContaining({ layers: { - previousLayer: initialState.layers.previousLayer, + previousLayer: modifiedState.layers.previousLayer, currentLayer: expect.objectContaining({ - columnOrder: ['cola', 'colb', 'id1'], + columnOrder: ['cola', 'colb', 'colc', 'id1'], columns: { - ...initialState.layers.currentLayer.columns, + ...modifiedState.layers.currentLayer.columns, id1: expect.objectContaining({ - operationType: 'sum', - sourceField: 'bytes', + operationType: 'avg', + sourceField: 'memory', }), }, }), diff --git a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts index 35e99fc4fe98d..96127caa67bb4 100644 --- a/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts +++ b/x-pack/legacy/plugins/lens/public/indexpattern_datasource/indexpattern_suggestions.ts @@ -197,16 +197,29 @@ function addFieldAsMetricOperation( field, }); const newColumnId = generateId(); - const updatedColumns = { - ...layer.columns, - [newColumnId]: newColumn, - }; - const updatedColumnOrder = [...layer.columnOrder, newColumnId]; + + const [, metrics] = separateBucketColumns(layer); + + // Add metrics if there are 0 or > 1 metric + if (metrics.length !== 1) { + return { + indexPatternId: indexPattern.id, + columns: { + ...layer.columns, + [newColumnId]: newColumn, + }, + columnOrder: [...layer.columnOrder, newColumnId], + }; + } + + // If only one metric, replace instead of add + const newColumns = { ...layer.columns, [newColumnId]: newColumn }; + delete newColumns[metrics[0]]; return { indexPatternId: indexPattern.id, - columns: updatedColumns, - columnOrder: updatedColumnOrder, + columns: newColumns, + columnOrder: [...layer.columnOrder.filter(c => c !== metrics[0]), newColumnId], }; } @@ -231,21 +244,34 @@ function addFieldAsBucketOperation( ...layer.columns, [newColumnId]: newColumn, }; + + const oldDateHistogramIndex = layer.columnOrder.findIndex( + columnId => layer.columns[columnId].operationType === 'date_histogram' + ); + const oldDateHistogramId = + oldDateHistogramIndex > -1 ? layer.columnOrder[oldDateHistogramIndex] : null; + let updatedColumnOrder: string[] = []; - if (applicableBucketOperation === 'terms') { - updatedColumnOrder = [newColumnId, ...buckets, ...metrics]; - } else { - const oldDateHistogramColumn = layer.columnOrder.find( - columnId => layer.columns[columnId].operationType === 'date_histogram' - ); - if (oldDateHistogramColumn) { - delete updatedColumns[oldDateHistogramColumn]; + if (oldDateHistogramId) { + if (applicableBucketOperation === 'terms') { + // Insert the new terms bucket above the first date histogram + updatedColumnOrder = [ + ...buckets.slice(0, oldDateHistogramIndex), + newColumnId, + ...buckets.slice(oldDateHistogramIndex, buckets.length), + ...metrics, + ]; + } else if (applicableBucketOperation === 'date_histogram') { + // Replace date histogram with new date histogram + delete updatedColumns[oldDateHistogramId]; updatedColumnOrder = layer.columnOrder.map(columnId => - columnId !== oldDateHistogramColumn ? columnId : newColumnId + columnId !== oldDateHistogramId ? columnId : newColumnId ); - } else { - updatedColumnOrder = [...buckets, newColumnId, ...metrics]; } + } else { + // Insert the new bucket after existing buckets. Users will see the same data + // they already had, with an extra level of detail. + updatedColumnOrder = [...buckets, newColumnId, ...metrics]; } return { indexPatternId: indexPattern.id, From d5c13c043b9346233c91401091fd19fd607f7193 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 23 Mar 2020 22:13:32 +0100 Subject: [PATCH 12/34] [APM] use span.destination.service.resource (#60908) * [APM] use span.destination.service.resource Closes #60405. * update snapshots Co-authored-by: Nathan L Smith --- .../app/ServiceMap/cytoscapeOptions.ts | 8 +- .../elasticsearch_fieldnames.test.ts.snap | 6 + .../apm/common/elasticsearch_fieldnames.ts | 2 + x-pack/plugins/apm/common/service_map.ts | 6 +- .../dedupe_connections/index.test.ts | 143 ++++++++++++++++++ .../index.ts} | 104 ++++++++++--- .../get_service_map_from_trace_ids.ts | 16 +- .../lib/service_map/get_trace_sample_ids.ts | 25 +-- 8 files changed, 250 insertions(+), 60 deletions(-) create mode 100644 x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts rename x-pack/plugins/apm/server/lib/service_map/{dedupe_connections.ts => dedupe_connections/index.ts} (54%) diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index e19cb8ae4b646..e92a4fe797855 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -7,8 +7,8 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import cytoscape from 'cytoscape'; import { CSSProperties } from 'react'; import { - DESTINATION_ADDRESS, - SERVICE_NAME + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; import { defaultIcon, iconForNode } from './icons'; @@ -59,7 +59,9 @@ const style: cytoscape.Stylesheet[] = [ 'ghost-opacity': 0.15, height: nodeHeight, label: (el: cytoscape.NodeSingular) => - isService(el) ? el.data(SERVICE_NAME) : el.data(DESTINATION_ADDRESS), + isService(el) + ? el.data(SERVICE_NAME) + : el.data(SPAN_DESTINATION_SERVICE_RESOURCE), 'min-zoomed-font-size': theme.euiSizeL, 'overlay-opacity': 0, shape: (el: cytoscape.NodeSingular) => diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 9a557532aae93..897d4e979fce3 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -76,6 +76,8 @@ exports[`Error SERVICE_VERSION 1`] = `undefined`; exports[`Error SPAN_ACTION 1`] = `undefined`; +exports[`Error SPAN_DESTINATION_SERVICE_RESOURCE 1`] = `undefined`; + exports[`Error SPAN_DURATION 1`] = `undefined`; exports[`Error SPAN_ID 1`] = `undefined`; @@ -188,6 +190,8 @@ exports[`Span SERVICE_VERSION 1`] = `undefined`; exports[`Span SPAN_ACTION 1`] = `"my action"`; +exports[`Span SPAN_DESTINATION_SERVICE_RESOURCE 1`] = `undefined`; + exports[`Span SPAN_DURATION 1`] = `1337`; exports[`Span SPAN_ID 1`] = `"span id"`; @@ -300,6 +304,8 @@ exports[`Transaction SERVICE_VERSION 1`] = `undefined`; exports[`Transaction SPAN_ACTION 1`] = `undefined`; +exports[`Transaction SPAN_DESTINATION_SERVICE_RESOURCE 1`] = `undefined`; + exports[`Transaction SPAN_DURATION 1`] = `undefined`; exports[`Transaction SPAN_ID 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 8f1b306a34eb0..822201baddd88 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -39,6 +39,8 @@ export const SPAN_SELF_TIME_SUM = 'span.self_time.sum.us'; export const SPAN_ACTION = 'span.action'; export const SPAN_NAME = 'span.name'; export const SPAN_ID = 'span.id'; +export const SPAN_DESTINATION_SERVICE_RESOURCE = + 'span.destination.service.resource'; // Parent ID for a transaction or span export const PARENT_ID = 'parent.id'; diff --git a/x-pack/plugins/apm/common/service_map.ts b/x-pack/plugins/apm/common/service_map.ts index 8c749cd00bd32..4a8608199c037 100644 --- a/x-pack/plugins/apm/common/service_map.ts +++ b/x-pack/plugins/apm/common/service_map.ts @@ -8,12 +8,12 @@ import { i18n } from '@kbn/i18n'; import { ILicense } from '../../licensing/public'; import { AGENT_NAME, - DESTINATION_ADDRESS, SERVICE_ENVIRONMENT, SERVICE_FRAMEWORK_NAME, SERVICE_NAME, SPAN_SUBTYPE, - SPAN_TYPE + SPAN_TYPE, + SPAN_DESTINATION_SERVICE_RESOURCE } from './elasticsearch_fieldnames'; export interface ServiceConnectionNode { @@ -23,7 +23,7 @@ export interface ServiceConnectionNode { [AGENT_NAME]: string; } export interface ExternalConnectionNode { - [DESTINATION_ADDRESS]: string; + [SPAN_DESTINATION_SERVICE_RESOURCE]: string; [SPAN_TYPE]: string; [SPAN_SUBTYPE]: string; } diff --git a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts new file mode 100644 index 0000000000000..01d6a2e2e81bc --- /dev/null +++ b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ServiceMapResponse } from './'; +import { + SPAN_DESTINATION_SERVICE_RESOURCE, + SERVICE_NAME, + SERVICE_ENVIRONMENT, + SERVICE_FRAMEWORK_NAME, + AGENT_NAME, + SPAN_TYPE, + SPAN_SUBTYPE +} from '../../../../common/elasticsearch_fieldnames'; +import { dedupeConnections } from './'; + +const nodejsService = { + [SERVICE_NAME]: 'opbeans-node', + [SERVICE_ENVIRONMENT]: 'production', + [SERVICE_FRAMEWORK_NAME]: null, + [AGENT_NAME]: 'nodejs' +}; + +const nodejsExternal = { + [SPAN_DESTINATION_SERVICE_RESOURCE]: 'opbeans-node', + [SPAN_TYPE]: 'external', + [SPAN_SUBTYPE]: 'aa' +}; + +const javaService = { + [SERVICE_NAME]: 'opbeans-java', + [SERVICE_ENVIRONMENT]: 'production', + [SERVICE_FRAMEWORK_NAME]: null, + [AGENT_NAME]: 'java' +}; + +describe('dedupeConnections', () => { + it('maps external destinations to internal services', () => { + const response: ServiceMapResponse = { + services: [nodejsService, javaService], + discoveredServices: [ + { + from: nodejsExternal, + to: nodejsService + } + ], + connections: [ + { + source: javaService, + destination: nodejsExternal + } + ] + }; + + const { elements } = dedupeConnections(response); + + const connection = elements.find( + element => 'source' in element.data && 'target' in element.data + ); + + // @ts-ignore + expect(connection?.data.target).toBe('opbeans-node'); + + expect( + elements.find(element => element.data.id === '>opbeans-node') + ).toBeUndefined(); + }); + + it('collapses external destinations based on span.destination.resource.name', () => { + const response: ServiceMapResponse = { + services: [nodejsService, javaService], + discoveredServices: [ + { + from: nodejsExternal, + to: nodejsService + } + ], + connections: [ + { + source: javaService, + destination: nodejsExternal + }, + { + source: javaService, + destination: { + ...nodejsExternal, + [SPAN_TYPE]: 'foo' + } + } + ] + }; + + const { elements } = dedupeConnections(response); + + const connections = elements.filter(element => 'source' in element.data); + + expect(connections.length).toBe(1); + + const nodes = elements.filter(element => !('source' in element.data)); + + expect(nodes.length).toBe(2); + }); + + it('picks the first span.type/subtype in an alphabetically sorted list', () => { + const response: ServiceMapResponse = { + services: [javaService], + discoveredServices: [], + connections: [ + { + source: javaService, + destination: nodejsExternal + }, + { + source: javaService, + destination: { + ...nodejsExternal, + [SPAN_TYPE]: 'foo' + } + }, + { + source: javaService, + destination: { + ...nodejsExternal, + [SPAN_SUBTYPE]: 'bb' + } + } + ] + }; + + const { elements } = dedupeConnections(response); + + const nodes = elements.filter(element => !('source' in element.data)); + + const nodejsNode = nodes.find(node => node.data.id === '>opbeans-node'); + + // @ts-ignore + expect(nodejsNode?.data[SPAN_TYPE]).toBe('external'); + // @ts-ignore + expect(nodejsNode?.data[SPAN_SUBTYPE]).toBe('aa'); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections.ts b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts similarity index 54% rename from x-pack/plugins/apm/server/lib/service_map/dedupe_connections.ts rename to x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts index 485958cc17afd..d256f657bb778 100644 --- a/x-pack/plugins/apm/server/lib/service_map/dedupe_connections.ts +++ b/x-pack/plugins/apm/server/lib/service_map/dedupe_connections/index.ts @@ -6,23 +6,25 @@ import { isEqual, sortBy } from 'lodash'; import { ValuesType } from 'utility-types'; import { - DESTINATION_ADDRESS, - SERVICE_NAME -} from '../../../common/elasticsearch_fieldnames'; + SERVICE_NAME, + SPAN_DESTINATION_SERVICE_RESOURCE, + SPAN_TYPE, + SPAN_SUBTYPE +} from '../../../../common/elasticsearch_fieldnames'; import { Connection, ConnectionNode, - ExternalConnectionNode, - ServiceConnectionNode -} from '../../../common/service_map'; -import { ConnectionsResponse, ServicesResponse } from './get_service_map'; + ServiceConnectionNode, + ExternalConnectionNode +} from '../../../../common/service_map'; +import { ConnectionsResponse, ServicesResponse } from '../get_service_map'; function getConnectionNodeId(node: ConnectionNode): string { - if (DESTINATION_ADDRESS in node) { + if ('span.destination.service.resource' in node) { // use a prefix to distinguish exernal destination ids from services - return `>${(node as ExternalConnectionNode)[DESTINATION_ADDRESS]}`; + return `>${node[SPAN_DESTINATION_SERVICE_RESOURCE]}`; } - return (node as ServiceConnectionNode)[SERVICE_NAME]; + return node[SERVICE_NAME]; } function getConnectionId(connection: Connection) { @@ -31,32 +33,86 @@ function getConnectionId(connection: Connection) { )}`; } -type ServiceMapResponse = ConnectionsResponse & { services: ServicesResponse }; +export type ServiceMapResponse = ConnectionsResponse & { + services: ServicesResponse; +}; export function dedupeConnections(response: ServiceMapResponse) { const { discoveredServices, services, connections } = response; - const serviceNodes = services.map(service => ({ - ...service, - id: service[SERVICE_NAME] - })); + const allNodes = connections + .flatMap(connection => [connection.source, connection.destination]) + .map(node => ({ ...node, id: getConnectionNodeId(node) })) + .concat( + services.map(service => ({ + ...service, + id: service[SERVICE_NAME] + })) + ); - // maps destination.address to service.name if possible - function getConnectionNode(node: ConnectionNode) { - let mappedNode: ConnectionNode | undefined; + const serviceNodes = allNodes.filter(node => SERVICE_NAME in node) as Array< + ServiceConnectionNode & { + id: string; + } + >; - if (DESTINATION_ADDRESS in node) { - mappedNode = discoveredServices.find(map => isEqual(map.from, node))?.to; + const externalNodes = allNodes.filter( + node => SPAN_DESTINATION_SERVICE_RESOURCE in node + ) as Array< + ExternalConnectionNode & { + id: string; + } + >; + + // 1. maps external nodes to internal services + // 2. collapses external nodes into one node based on span.destination.service.resource + // 3. picks the first available span.type/span.subtype in an alphabetically sorted list + const nodeMap = allNodes.reduce((map, node) => { + if (map[node.id]) { + return map; } - if (!mappedNode) { - mappedNode = node; + const service = + discoveredServices.find(({ from }) => { + if ('span.destination.service.resource' in node) { + return ( + node[SPAN_DESTINATION_SERVICE_RESOURCE] === + from[SPAN_DESTINATION_SERVICE_RESOURCE] + ); + } + return false; + })?.to ?? serviceNodes.find(serviceNode => serviceNode.id === node.id); + + if (service) { + return { + ...map, + [node.id]: { + id: service[SERVICE_NAME], + ...service + } + }; } + const allMatchedExternalNodes = externalNodes.filter(n => n.id === node.id); + + const firstMatchedNode = allMatchedExternalNodes[0]; + return { - ...mappedNode, - id: getConnectionNodeId(mappedNode) + ...map, + [node.id]: { + ...firstMatchedNode, + label: firstMatchedNode[SPAN_DESTINATION_SERVICE_RESOURCE], + [SPAN_TYPE]: allMatchedExternalNodes.map(n => n[SPAN_TYPE]).sort()[0], + [SPAN_SUBTYPE]: allMatchedExternalNodes + .map(n => n[SPAN_SUBTYPE]) + .sort()[0] + } }; + }, {} as Record); + + // maps destination.address to service.name if possible + function getConnectionNode(node: ConnectionNode) { + return nodeMap[getConnectionNodeId(node)]; } // build connections with mapped nodes diff --git a/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts index d6d9e9b875408..eaf3d63521a98 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts @@ -65,7 +65,7 @@ export async function getServiceMapFromTraceIds({ 'parent.id', 'service.name', 'service.environment', - 'destination.address', + 'span.destination.service.resource', 'trace.id', 'processor.event', 'span.type', @@ -103,7 +103,7 @@ export async function getServiceMapFromTraceIds({ source: ` def getDestination ( def event ) { def destination = new HashMap(); - destination['destination.address'] = event['destination.address']; + destination['span.destination.service.resource'] = event['span.destination.service.resource']; destination['span.type'] = event['span.type']; destination['span.subtype'] = event['span.subtype']; return destination; @@ -138,13 +138,11 @@ export async function getServiceMapFromTraceIds({ /* flag parent path for removal, as it has children */ context.locationsToRemove.add(parent.path); - /* if the parent has 'destination.address' set, and the service is different, + /* if the parent has 'span.destination.service.resource' set, and the service is different, we've discovered a service */ - if (parent['destination.address'] != null - && parent['destination.address'] != "" - && (parent['span.type'] == 'external' - || parent['span.type'] == 'messaging') + if (parent['span.destination.service.resource'] != null + && parent['span.destination.service.resource'] != "" && (parent['service.name'] != event['service.name'] || parent['service.environment'] != event['service.environment'] ) @@ -165,8 +163,8 @@ export async function getServiceMapFromTraceIds({ } /* if there is an outgoing span, create a new path */ - if (event['destination.address'] != null - && event['destination.address'] != '') { + if (event['span.destination.service.resource'] != null + && event['span.destination.service.resource'] != '') { def outgoingLocation = getDestination(event); def outgoingPath = new ArrayList(basePath); outgoingPath.add(outgoingLocation); diff --git a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts index f4e12df5d6a66..9cb1a61e1d76f 100644 --- a/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts +++ b/x-pack/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -16,9 +16,7 @@ import { SERVICE_NAME, SERVICE_ENVIRONMENT, TRACE_ID, - DESTINATION_ADDRESS, - SPAN_TYPE, - SPAN_SUBTYPE + SPAN_DESTINATION_SERVICE_RESOURCE } from '../../../common/elasticsearch_fieldnames'; const MAX_TRACES_TO_INSPECT = 1000; @@ -46,7 +44,7 @@ export async function getTraceSampleIds({ }, { exists: { - field: DESTINATION_ADDRESS + field: SPAN_DESTINATION_SERVICE_RESOURCE } }, rangeQuery @@ -82,9 +80,9 @@ export async function getTraceSampleIds({ composite: { sources: [ { - [DESTINATION_ADDRESS]: { + [SPAN_DESTINATION_SERVICE_RESOURCE]: { terms: { - field: DESTINATION_ADDRESS + field: SPAN_DESTINATION_SERVICE_RESOURCE } } }, @@ -102,21 +100,6 @@ export async function getTraceSampleIds({ missing_bucket: true } } - }, - { - [SPAN_TYPE]: { - terms: { - field: SPAN_TYPE - } - } - }, - { - [SPAN_SUBTYPE]: { - terms: { - field: SPAN_SUBTYPE, - missing_bucket: true - } - } } ], size: fingerprintBucketSize From a0a85dbb90a6aca8dc17081ed9168219ab90de36 Mon Sep 17 00:00:00 2001 From: Nathan L Smith Date: Mon, 23 Mar 2020 16:13:56 -0500 Subject: [PATCH 13/34] Simplify service map layout (#60949) Clean up the cytoscape component and event handlers to simplify the layout logic. Make all centering animations animated. Add logging of cytoscape events when we're in debug mode. Add Elasticsearch icon. --- .../app/ServiceMap/Cytoscape.stories.tsx | 8 ++ .../components/app/ServiceMap/Cytoscape.tsx | 94 +++++++++---------- .../app/ServiceMap/Popover/index.tsx | 6 +- .../app/ServiceMap/cytoscapeOptions.ts | 5 - .../public/components/app/ServiceMap/icons.ts | 9 +- .../app/ServiceMap/icons/elasticsearch.svg | 1 + 6 files changed, 69 insertions(+), 54 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 155695f7596dd..7a066b520cc3b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -77,6 +77,14 @@ storiesOf('app/ServiceMap/Cytoscape', module) { data: { id: 'default' } }, { data: { id: 'cache', label: 'cache', 'span.type': 'cache' } }, { data: { id: 'database', label: 'database', 'span.type': 'db' } }, + { + data: { + id: 'elasticsearch', + label: 'elasticsearch', + 'span.type': 'db', + 'span.subtype': 'elasticsearch' + } + }, { data: { id: 'external', label: 'external', 'span.type': 'external' } }, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index e0a188b4915a2..a4cd6f4ed09a9 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -9,7 +9,6 @@ import React, { createContext, CSSProperties, ReactNode, - useCallback, useEffect, useRef, useState @@ -109,23 +108,26 @@ export function Cytoscape({ serviceName, style }: CytoscapeProps) { - const initialElements = elements.map(element => ({ - ...element, - // prevents flash of unstyled elements - classes: [element.classes, 'invisible'].join(' ').trim() - })); - const [ref, cy] = useCytoscape({ ...cytoscapeOptions, - elements: initialElements + elements }); // Add the height to the div style. The height is a separate prop because it // is required and can trigger rendering when changed. const divStyle = { ...style, height }; - const resetConnectedEdgeStyle = useCallback( - (node?: cytoscape.NodeSingular) => { + // Trigger a custom "data" event when data changes + useEffect(() => { + if (cy && elements.length > 0) { + cy.add(elements); + cy.trigger('data'); + } + }, [cy, elements]); + + // Set up cytoscape event handlers + useEffect(() => { + const resetConnectedEdgeStyle = (node?: cytoscape.NodeSingular) => { if (cy) { cy.edges().removeClass('highlight'); @@ -133,12 +135,9 @@ export function Cytoscape({ node.connectedEdges().addClass('highlight'); } } - }, - [cy] - ); + }; - const dataHandler = useCallback( - event => { + const dataHandler: cytoscape.EventHandler = event => { if (cy) { if (serviceName) { resetConnectedEdgeStyle(cy.getElementById(serviceName)); @@ -150,36 +149,25 @@ export function Cytoscape({ } else { resetConnectedEdgeStyle(); } - if (event.cy.elements().length > 0) { - const selectedRoots = selectRoots(event.cy); - const layout = cy.layout( - getLayoutOptions(selectedRoots, height, width) - ); - layout.one('layoutstop', () => { - if (serviceName) { - const focusedNode = cy.getElementById(serviceName); - cy.center(focusedNode); - } - // show elements after layout is applied - cy.elements().removeClass('invisible'); - }); - layout.run(); - } - } - }, - [cy, resetConnectedEdgeStyle, serviceName, height, width] - ); - // Trigger a custom "data" event when data changes - useEffect(() => { - if (cy) { - cy.add(elements); - cy.trigger('data'); - } - }, [cy, elements]); + const selectedRoots = selectRoots(event.cy); + const layout = cy.layout( + getLayoutOptions(selectedRoots, height, width) + ); - // Set up cytoscape event handlers - useEffect(() => { + layout.run(); + } + }; + const layoutstopHandler: cytoscape.EventHandler = event => { + event.cy.animate({ + ...animationOptions, + center: { + eles: serviceName + ? event.cy.getElementById(serviceName) + : event.cy.collection() + } + }); + }; const mouseoverHandler: cytoscape.EventHandler = event => { event.target.addClass('hover'); event.target.connectedEdges().addClass('nodeHover'); @@ -194,10 +182,18 @@ export function Cytoscape({ const unselectHandler: cytoscape.EventHandler = event => { resetConnectedEdgeStyle(); }; + const debugHandler: cytoscape.EventHandler = event => { + const debugEnabled = sessionStorage.getItem('apm_debug') === 'true'; + if (debugEnabled) { + // eslint-disable-next-line no-console + console.debug('cytoscape:', event); + } + }; if (cy) { + cy.on('data layoutstop select unselect', debugHandler); cy.on('data', dataHandler); - cy.ready(dataHandler); + cy.on('layoutstop', layoutstopHandler); cy.on('mouseover', 'edge, node', mouseoverHandler); cy.on('mouseout', 'edge, node', mouseoutHandler); cy.on('select', 'node', selectHandler); @@ -207,15 +203,19 @@ export function Cytoscape({ return () => { if (cy) { cy.removeListener( - 'data', + 'data layoutstop select unselect', undefined, - dataHandler as cytoscape.EventHandler + debugHandler ); + cy.removeListener('data', undefined, dataHandler); + cy.removeListener('layoutstop', undefined, layoutstopHandler); cy.removeListener('mouseover', 'edge, node', mouseoverHandler); cy.removeListener('mouseout', 'edge, node', mouseoutHandler); + cy.removeListener('select', 'node', selectHandler); + cy.removeListener('unselect', 'node', unselectHandler); } }; - }, [cy, dataHandler, resetConnectedEdgeStyle, serviceName]); + }, [cy, height, serviceName, width]); return ( diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx index 13aa53a8cf4b2..102b135f3cd1f 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Popover/index.tsx @@ -17,6 +17,7 @@ import React, { import { SERVICE_NAME } from '../../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; import { CytoscapeContext } from '../Cytoscape'; import { Contents } from './Contents'; +import { animationOptions } from '../cytoscapeOptions'; interface PopoverProps { focusedServiceName?: string; @@ -88,7 +89,10 @@ export function Popover({ focusedServiceName }: PopoverProps) { const centerSelectedNode = useCallback(() => { if (cy) { - cy.center(cy.getElementById(selectedNodeServiceName)); + cy.animate({ + ...animationOptions, + center: { eles: cy.getElementById(selectedNodeServiceName) } + }); } }, [cy, selectedNodeServiceName]); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index e92a4fe797855..413458f336e6f 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -115,11 +115,6 @@ const style: cytoscape.Stylesheet[] = [ selector: 'edge[isInverseEdge]', style: { visibility: 'hidden' } }, - // @ts-ignore - { - selector: '.invisible', - style: { visibility: 'hidden' } - }, { selector: 'edge.nodeHover', style: { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts index 5102dfc02f757..4925ffba310b5 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -8,12 +8,14 @@ import cytoscape from 'cytoscape'; import { AGENT_NAME, SERVICE_NAME, - SPAN_TYPE + SPAN_TYPE, + SPAN_SUBTYPE } from '../../../../../../../plugins/apm/common/elasticsearch_fieldnames'; import databaseIcon from './icons/database.svg'; import defaultIconImport from './icons/default.svg'; import documentsIcon from './icons/documents.svg'; import dotNetIcon from './icons/dot-net.svg'; +import elasticsearchIcon from './icons/elasticsearch.svg'; import globeIcon from './icons/globe.svg'; import goIcon from './icons/go.svg'; import javaIcon from './icons/java.svg'; @@ -63,6 +65,11 @@ export function iconForNode(node: cytoscape.NodeSingular) { return serviceIcons[node.data(AGENT_NAME) as string]; } else if (isIE11) { return defaultIcon; + } else if ( + node.data(SPAN_TYPE) === 'db' && + node.data(SPAN_SUBTYPE) === 'elasticsearch' + ) { + return elasticsearchIcon; } else if (icons[type]) { return icons[type]; } else { diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg new file mode 100644 index 0000000000000..4f9fda36ba06a --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/icons/elasticsearch.svg @@ -0,0 +1 @@ + From dd93a14fefebd70a64165adfae3dcc3512f8e623 Mon Sep 17 00:00:00 2001 From: Brent Kimmel Date: Mon, 23 Mar 2020 17:17:52 -0400 Subject: [PATCH 14/34] Resolver/nodedesign 25 (#60630) * PR base * adds designed resolver nodes * adjust distance between nodes * WIP remove stroke * WIP changes to meet mocks * new boxes * remove animation * new box assets * baby resolver running nodes complete * cleanup defs, add running trigger cube * added 2 more defs for process cubes * adding switched for assets on node component * vacuuming defs file * adjusting types and references to new event model * switch background to full shade for contrast * switch background to full shade for contrast * cube, animation and a11y changes to 25% nodes * PR base * adds designed resolver nodes * adjust distance between nodes * WIP remove stroke * WIP changes to meet mocks * new boxes * remove animation * new box assets * baby resolver running nodes complete * cleanup defs, add running trigger cube * added 2 more defs for process cubes * adding switched for assets on node component * vacuuming defs file * adjusting types and references to new event model * switch background to full shade for contrast * cube, animation and a11y changes to 25% nodes * merge upstream * change from Legacy to new Resolver event * cleaning up unused styles * fix adjacency map issues * fix process type to cube mapping * fix typing on selctor * set viewport to strict * remove unused types * fixes ci / testing issues * feedback from Jon Buttner * fix index from Jon Buttner comment * reset focus state on nodes * Robert review: changing adjacency map property names for better semantics * Robert Austin review: changing var name * Robert Austin review: rearrange code for readability * Robert Austin review: change const name * Robert Austin review: rearranging code for readability * Robert Austin review: adjustments to process_event_dot * Robert Austin review: replace level getter * Robert Austin review: removing unnecessary casting * Robert Austin review: adjust selector * Robert Austin review: fix setting parent map * Robert Austin review: replace function with consts * K Qualters review: change return type of function Co-authored-by: Elastic Machine --- .../resolver/models/indexed_process_tree.ts | 82 +++- .../embeddables/resolver/store/actions.ts | 15 +- .../data/__snapshots__/graphing.test.ts.snap | 116 +++--- .../resolver/store/data/selectors.ts | 42 +- .../embeddables/resolver/store/reducer.ts | 16 +- .../embeddables/resolver/store/selectors.ts | 5 + .../public/embeddables/resolver/types.ts | 36 ++ .../public/embeddables/resolver/view/defs.tsx | 381 ++++++++++++++++++ .../embeddables/resolver/view/edge_line.tsx | 3 +- .../embeddables/resolver/view/index.tsx | 63 ++- .../resolver/view/process_event_dot.tsx | 245 ++++++++++- 11 files changed, 887 insertions(+), 117 deletions(-) create mode 100644 x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts index c9a03f0a47968..967a2c10f14c3 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/models/indexed_process_tree.ts @@ -5,7 +5,7 @@ */ import { uniquePidForProcess, uniqueParentPidForProcess } from './process_event'; -import { IndexedProcessTree } from '../types'; +import { IndexedProcessTree, AdjacentProcessMap } from '../types'; import { ResolverEvent } from '../../../../common/types'; import { levelOrder as baseLevelOrder } from '../lib/tree_sequencers'; @@ -15,21 +15,89 @@ import { levelOrder as baseLevelOrder } from '../lib/tree_sequencers'; export function factory(processes: ResolverEvent[]): IndexedProcessTree { const idToChildren = new Map(); const idToValue = new Map(); + const idToAdjacent = new Map(); + + function emptyAdjacencyMap(id: string): AdjacentProcessMap { + return { + self: id, + parent: null, + firstChild: null, + previousSibling: null, + nextSibling: null, + level: 1, + }; + } + + const roots: ResolverEvent[] = []; for (const process of processes) { - idToValue.set(uniquePidForProcess(process), process); + const uniqueProcessPid = uniquePidForProcess(process); + idToValue.set(uniqueProcessPid, process); + + const currentProcessAdjacencyMap: AdjacentProcessMap = + idToAdjacent.get(uniqueProcessPid) || emptyAdjacencyMap(uniqueProcessPid); + idToAdjacent.set(uniqueProcessPid, currentProcessAdjacencyMap); + const uniqueParentPid = uniqueParentPidForProcess(process); - const processChildren = idToChildren.get(uniqueParentPid); - if (processChildren) { - processChildren.push(process); + const currentProcessSiblings = idToChildren.get(uniqueParentPid); + + if (currentProcessSiblings) { + const previousProcessId = uniquePidForProcess( + currentProcessSiblings[currentProcessSiblings.length - 1] + ); + currentProcessSiblings.push(process); + /** + * Update adjacency maps for current and previous entries + */ + idToAdjacent.get(previousProcessId)!.nextSibling = uniqueProcessPid; + currentProcessAdjacencyMap.previousSibling = previousProcessId; + if (uniqueParentPid) { + currentProcessAdjacencyMap.parent = uniqueParentPid; + } } else { idToChildren.set(uniqueParentPid, [process]); + + if (uniqueParentPid) { + /** + * Get the parent's map, otherwise set an empty one + */ + const parentAdjacencyMap = + idToAdjacent.get(uniqueParentPid) || + (idToAdjacent.set(uniqueParentPid, emptyAdjacencyMap(uniqueParentPid)), + idToAdjacent.get(uniqueParentPid))!; + // set firstChild for parent + parentAdjacencyMap.firstChild = uniqueProcessPid; + // set parent for current + currentProcessAdjacencyMap.parent = uniqueParentPid || null; + } else { + // In this case (no unique parent id), it must be a root + roots.push(process); + } } } + /** + * Scan adjacency maps from the top down and assign levels + */ + function traverseLevels(currentProcessMap: AdjacentProcessMap, level: number = 1): void { + const nextLevel = level + 1; + if (currentProcessMap.nextSibling) { + traverseLevels(idToAdjacent.get(currentProcessMap.nextSibling)!, level); + } + if (currentProcessMap.firstChild) { + traverseLevels(idToAdjacent.get(currentProcessMap.firstChild)!, nextLevel); + } + currentProcessMap.level = level; + } + + for (const treeRoot of roots) { + traverseLevels(idToAdjacent.get(uniquePidForProcess(treeRoot))!); + } + return { idToChildren, idToProcess: idToValue, + idToAdjacent, }; } @@ -38,8 +106,8 @@ export function factory(processes: ResolverEvent[]): IndexedProcessTree { */ export function children(tree: IndexedProcessTree, process: ResolverEvent): ResolverEvent[] { const id = uniquePidForProcess(process); - const processChildren = tree.idToChildren.get(id); - return processChildren === undefined ? [] : processChildren; + const currentProcessSiblings = tree.idToChildren.get(id); + return currentProcessSiblings === undefined ? [] : currentProcessSiblings; } /** diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts index fec2078cc60c9..ceb5da2ca9098 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts @@ -43,10 +43,23 @@ interface UserChangedSelectedEvent { interface AppRequestedResolverData { readonly type: 'appRequestedResolverData'; } +/** + * When the user switches the active descendent of the Resolver. + */ +interface UserFocusedOnResolverNode { + readonly type: 'userFocusedOnResolverNode'; + readonly payload: { + /** + * Used to identify the process node that should be brought into view. + */ + readonly nodeId: string; + }; +} export type ResolverAction = | CameraAction | DataAction | UserBroughtProcessIntoView | UserChangedSelectedEvent - | AppRequestedResolverData; + | AppRequestedResolverData + | UserFocusedOnResolverNode; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap index b88652097eb5c..00abc27b25a83 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/__snapshots__/graphing.test.ts.snap @@ -41,128 +41,128 @@ Object { -0.8164965809277259, ], Array [ - 35.35533905932738, - -21.228911104120876, + 70.71067811865476, + -41.641325627314025, ], ], Array [ Array [ - -35.35533905932738, - -62.053740150507174, + -70.71067811865476, + -123.29098372008661, ], Array [ - 106.06601717798213, - 19.595917942265423, + 212.13203435596427, + 40.00833246545857, ], ], Array [ Array [ - -35.35533905932738, - -62.053740150507174, + -70.71067811865476, + -123.29098372008661, ], Array [ 0, - -82.46615467370032, + -164.1158127664729, ], ], Array [ Array [ - 106.06601717798213, - 19.595917942265423, + 212.13203435596427, + 40.00833246545857, ], Array [ - 141.4213562373095, + 282.842712474619, -0.8164965809277259, ], ], Array [ Array [ 0, - -82.46615467370032, + -164.1158127664729, ], Array [ - 35.35533905932738, - -102.87856919689347, + 70.71067811865476, + -204.9406418128592, ], ], Array [ Array [ 0, - -123.2909837200866, + -245.76547085924548, ], Array [ - 70.71067811865476, - -82.46615467370032, + 141.4213562373095, + -164.1158127664729, ], ], Array [ Array [ 0, - -123.2909837200866, + -245.76547085924548, ], Array [ - 35.35533905932738, - -143.70339824327976, + 70.71067811865476, + -286.5902999056318, ], ], Array [ Array [ - 70.71067811865476, - -82.46615467370032, + 141.4213562373095, + -164.1158127664729, ], Array [ - 106.06601717798213, - -102.87856919689347, + 212.13203435596427, + -204.9406418128592, ], ], Array [ Array [ - 141.4213562373095, + 282.842712474619, -0.8164965809277259, ], Array [ - 176.7766952966369, - -21.22891110412087, + 353.5533905932738, + -41.64132562731401, ], ], Array [ Array [ - 141.4213562373095, - -41.64132562731402, + 282.842712474619, + -82.4661546737003, ], Array [ - 212.13203435596427, + 424.26406871192853, -0.8164965809277259, ], ], Array [ Array [ - 141.4213562373095, - -41.64132562731402, + 282.842712474619, + -82.4661546737003, ], Array [ - 176.7766952966369, - -62.053740150507174, + 353.5533905932738, + -123.29098372008661, ], ], Array [ Array [ - 212.13203435596427, + 424.26406871192853, -0.8164965809277259, ], Array [ - 247.48737341529164, - -21.228911104120883, + 494.9747468305833, + -41.64132562731404, ], ], Array [ Array [ - 247.48737341529164, - -21.228911104120883, + 494.9747468305833, + -41.64132562731404, ], Array [ - 318.1980515339464, - -62.05374015050717, + 636.3961030678928, + -123.2909837200866, ], ], ], @@ -199,7 +199,7 @@ Object { }, } => Array [ 0, - -82.46615467370032, + -164.1158127664729, ], Object { "@timestamp": 1582233383000, @@ -215,7 +215,7 @@ Object { "unique_ppid": 0, }, } => Array [ - 141.4213562373095, + 282.842712474619, -0.8164965809277259, ], Object { @@ -232,8 +232,8 @@ Object { "unique_ppid": 1, }, } => Array [ - 35.35533905932738, - -143.70339824327976, + 70.71067811865476, + -286.5902999056318, ], Object { "@timestamp": 1582233383000, @@ -249,8 +249,8 @@ Object { "unique_ppid": 1, }, } => Array [ - 106.06601717798213, - -102.87856919689347, + 212.13203435596427, + -204.9406418128592, ], Object { "@timestamp": 1582233383000, @@ -266,8 +266,8 @@ Object { "unique_ppid": 2, }, } => Array [ - 176.7766952966369, - -62.053740150507174, + 353.5533905932738, + -123.29098372008661, ], Object { "@timestamp": 1582233383000, @@ -283,8 +283,8 @@ Object { "unique_ppid": 2, }, } => Array [ - 247.48737341529164, - -21.228911104120883, + 494.9747468305833, + -41.64132562731404, ], Object { "@timestamp": 1582233383000, @@ -300,8 +300,8 @@ Object { "unique_ppid": 6, }, } => Array [ - 318.1980515339464, - -62.05374015050717, + 636.3961030678928, + -123.2909837200866, ], }, } @@ -316,8 +316,8 @@ Object { -0.8164965809277259, ], Array [ - 70.71067811865476, - -41.641325627314025, + 141.4213562373095, + -82.46615467370032, ], ], ], @@ -353,8 +353,8 @@ Object { "unique_ppid": 0, }, } => Array [ - 70.71067811865476, - -41.641325627314025, + 141.4213562373095, + -82.46615467370032, ], }, } diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts index e8007f82e30c2..5dda54d4ed029 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts @@ -13,11 +13,12 @@ import { EdgeLineSegment, ProcessWithWidthMetadata, Matrix3, + AdjacentProcessMap, } from '../../types'; import { ResolverEvent } from '../../../../../common/types'; import { Vector2 } from '../../types'; import { add as vector2Add, applyMatrix3 } from '../../lib/vector2'; -import { isGraphableProcess } from '../../models/process_event'; +import { isGraphableProcess, uniquePidForProcess } from '../../models/process_event'; import { factory as indexedProcessTreeFactory, children as indexedProcessTreeChildren, @@ -27,7 +28,7 @@ import { } from '../../models/indexed_process_tree'; const unit = 100; -const distanceBetweenNodesInUnits = 1; +const distanceBetweenNodesInUnits = 2; export function isLoading(state: DataState) { return state.isLoading; @@ -392,17 +393,42 @@ function processPositions( return positions; } -export const processNodePositionsAndEdgeLineSegments = createSelector( +export const indexedProcessTree = createSelector(graphableProcesses, function indexedTree( + /* eslint-disable no-shadow */ + graphableProcesses + /* eslint-enable no-shadow */ +) { + return indexedProcessTreeFactory(graphableProcesses); +}); + +export const processAdjacencies = createSelector( + indexedProcessTree, graphableProcesses, - function processNodePositionsAndEdgeLineSegments( + function selectProcessAdjacencies( /* eslint-disable no-shadow */ + indexedProcessTree, graphableProcesses /* eslint-enable no-shadow */ ) { - /** - * Index the tree, creating maps from id -> node and id -> children - */ - const indexedProcessTree = indexedProcessTreeFactory(graphableProcesses); + const processToAdjacencyMap = new Map(); + const { idToAdjacent } = indexedProcessTree; + + for (const graphableProcess of graphableProcesses) { + const processPid = uniquePidForProcess(graphableProcess); + const adjacencyMap = idToAdjacent.get(processPid)!; + processToAdjacencyMap.set(graphableProcess, adjacencyMap); + } + return { processToAdjacencyMap }; + } +); + +export const processNodePositionsAndEdgeLineSegments = createSelector( + indexedProcessTree, + function processNodePositionsAndEdgeLineSegments( + /* eslint-disable no-shadow */ + indexedProcessTree + /* eslint-enable no-shadow */ + ) { /** * Walk the tree in reverse level order, calculating the 'width' of subtrees. */ diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts index 20c490b8998f9..1c66a998a4c22 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/reducer.ts @@ -7,11 +7,25 @@ import { Reducer, combineReducers } from 'redux'; import { animateProcessIntoView } from './methods'; import { cameraReducer } from './camera/reducer'; import { dataReducer } from './data/reducer'; -import { ResolverState, ResolverAction } from '../types'; +import { ResolverState, ResolverAction, ResolverUIState } from '../types'; + +const uiReducer: Reducer = ( + uiState = { activeDescendentId: null }, + action +) => { + if (action.type === 'userFocusedOnResolverNode') { + return { + activeDescendentId: action.payload.nodeId, + }; + } else { + return uiState; + } +}; const concernReducers = combineReducers({ camera: cameraReducer, data: dataReducer, + ui: uiReducer, }); export const resolverReducer: Reducer = (state, action) => { diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts index 708eb684ebd3e..37482916496e7 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts @@ -54,6 +54,11 @@ export const processNodePositionsAndEdgeLineSegments = composeSelectors( dataSelectors.processNodePositionsAndEdgeLineSegments ); +export const processAdjacencies = composeSelectors( + dataStateSelector, + dataSelectors.processAdjacencies +); + /** * Returns the camera state from within ResolverState */ diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts index 4380d3ab98999..674553aba0937 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts @@ -23,6 +23,21 @@ export interface ResolverState { * Contains the state associated with event data (process events and possibly other event types). */ readonly data: DataState; + + /** + * Contains the state needed to maintain Resolver UI elements. + */ + readonly ui: ResolverUIState; +} + +/** + * Piece of redux state that models an animation for the camera. + */ +export interface ResolverUIState { + /** + * The ID attribute of the resolver's aria-activedescendent. + */ + readonly activeDescendentId: string | null; } /** @@ -174,9 +189,26 @@ export interface ProcessEvent { source_id?: number; process_name: string; process_path: string; + signature_status?: string; }; } +/** + * A map of Process Ids that indicate which processes are adjacent to a given process along + * directions in two axes: up/down and previous/next. + */ +export interface AdjacentProcessMap { + readonly self: string; + parent: string | null; + firstChild: string | null; + previousSibling: string | null; + nextSibling: string | null; + /** + * To support aria-level, this must be >= 1 + */ + level: number; +} + /** * A represention of a process tree with indices for O(1) access to children and values by id. */ @@ -189,6 +221,10 @@ export interface IndexedProcessTree { * Map of ID to process */ idToProcess: Map; + /** + * Map of ID to adjacent processes + */ + idToAdjacent: Map; } /** diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx new file mode 100644 index 0000000000000..799f67123ba26 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/defs.tsx @@ -0,0 +1,381 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { memo } from 'react'; +import { saturate, lighten } from 'polished'; + +import { + htmlIdGenerator, + euiPaletteForTemperature, + euiPaletteForStatus, + colorPalette, +} from '@elastic/eui'; + +/** + * Generating from `colorPalette` function: This could potentially + * pick up a palette shift and decouple from raw hex + */ +const [euiColorEmptyShade, , , , , euiColor85Shade, euiColorFullShade] = colorPalette( + ['#ffffff', '#000000'], + 7 +); + +/** + * Base Colors - sourced from EUI + */ +const resolverPalette: Record = { + temperatures: euiPaletteForTemperature(7), + statii: euiPaletteForStatus(7), + fullShade: euiColorFullShade, + emptyShade: euiColorEmptyShade, +}; + +/** + * Defines colors by semantics like so: + * `danger`, `attention`, `enabled`, `disabled` + * Or by function like: + * `colorBlindBackground`, `subMenuForeground` + */ +type ResolverColorNames = + | 'ok' + | 'empty' + | 'full' + | 'warning' + | 'strokeBehindEmpty' + | 'resolverBackground' + | 'runningProcessStart' + | 'runningProcessEnd' + | 'runningTriggerStart' + | 'runningTriggerEnd' + | 'activeNoWarning' + | 'activeWarning' + | 'fullLabelBackground' + | 'inertDescription'; + +export const NamedColors: Record = { + ok: saturate(0.5, resolverPalette.temperatures[0]), + empty: euiColorEmptyShade, + full: euiColorFullShade, + strokeBehindEmpty: euiColor85Shade, + warning: resolverPalette.statii[3], + resolverBackground: euiColorFullShade, + runningProcessStart: '#006BB4', + runningProcessEnd: '#017D73', + runningTriggerStart: '#BD281E', + runningTriggerEnd: '#DD0A73', + activeNoWarning: '#0078FF', + activeWarning: '#C61F38', + fullLabelBackground: '#3B3C41', + inertDescription: '#747474', +}; + +const idGenerator = htmlIdGenerator(); + +/** + * Ids of paint servers to be referenced by fill and stroke attributes + */ +export const PaintServerIds = { + runningProcess: idGenerator('psRunningProcess'), + runningTrigger: idGenerator('psRunningTrigger'), + runningProcessCube: idGenerator('psRunningProcessCube'), + runningTriggerCube: idGenerator('psRunningTriggerCube'), + terminatedProcessCube: idGenerator('psTerminatedProcessCube'), + terminatedTriggerCube: idGenerator('psTerminatedTriggerCube'), +}; + +/** + * PaintServers: Where color palettes, grandients, patterns and other similar concerns + * are exposed to the component + */ +const PaintServers = memo(() => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + +)); + +/** + * Ids of symbols to be linked by elements + */ +export const SymbolIds = { + processNode: idGenerator('nodeSymbol'), + solidHexagon: idGenerator('hexagon'), + runningProcessCube: idGenerator('runningCube'), + runningTriggerCube: idGenerator('runningTriggerCube'), + terminatedProcessCube: idGenerator('terminatedCube'), + terminatedTriggerCube: idGenerator('terminatedTriggerCube'), +}; + +/** + * Defs entries that define shapes, masks and other spatial elements + */ +const SymbolsAndShapes = memo(() => ( + <> + + + + + + + + + + Running Process + + + + + + + + + + + + + Running Trigger Process + + + + + + + + + + + + + Terminated Process + + + + + + + + + + Terminated Trigger Process + + + + + + + + + +)); + +/** + * This element is used to define the reusable assets for the Resolver + * It confers sevral advantages, including but not limited to: + * 1) Freedom of form for creative assets (beyond box-model constraints) + * 2) Separation of concerns between creative assets and more functional areas of the app + * 3) elements can be handled by compositor (faster) + */ +export const SymbolDefinitions = memo(() => ( + + + + + + +)); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx index 3386ed4a448d5..fbd40dda9adfd 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/edge_line.tsx @@ -66,7 +66,7 @@ export const EdgeLine = styled( */ transform: `translateY(-50%) rotateZ(${angle(screenStart, screenEnd)}rad)`, }; - return
; + return
; } ) )` @@ -74,4 +74,5 @@ export const EdgeLine = styled( height: 3px; background-color: #d4d4d4; color: #333333; + contain: strict; `; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx index eab22f993d0a8..22e9d05ad98ff 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx @@ -14,6 +14,7 @@ import { Panel } from './panel'; import { GraphControls } from './graph_controls'; import { ProcessEventDot } from './process_event_dot'; import { useCamera } from './use_camera'; +import { SymbolDefinitions, NamedColors } from './defs'; import { ResolverAction } from '../types'; import { ResolverEvent } from '../../../../common/types'; @@ -33,6 +34,14 @@ const StyledGraphControls = styled(GraphControls)` right: 5px; `; +const StyledResolverContainer = styled.div` + display: flex; + flex-grow: 1; + contain: layout; +`; + +const bgColor = NamedColors.resolverBackground; + export const Resolver = styled( React.memo(function Resolver({ className, @@ -46,6 +55,8 @@ export const Resolver = styled( ); const dispatch: (action: ResolverAction) => unknown = useDispatch(); + const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); + const { projectionMatrix, ref, onMouseDown } = useCamera(); const isLoading = useSelector(selectors.isLoading); @@ -62,29 +73,35 @@ export const Resolver = styled(
) : ( - <> -
- {Array.from(processNodePositions).map(([processEvent, position], index) => ( - - ))} - {edgeLineSegments.map(([startPosition, endPosition], index) => ( - - ))} -
- - - + + {edgeLineSegments.map(([startPosition, endPosition], index) => ( + + ))} + {Array.from(processNodePositions).map(([processEvent, position], index) => ( + + ))} + )} + + +
); }) @@ -111,4 +128,6 @@ export const Resolver = styled( * Prevent partially visible components from showing up outside the bounds of Resolver. */ overflow: hidden; + contain: strict; + background-color: ${bgColor}; `; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx index 2241df97291ae..f3086be1598a8 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx @@ -4,12 +4,52 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; +import { htmlIdGenerator, EuiKeyboardAccessible } from '@elastic/eui'; import { applyMatrix3 } from '../lib/vector2'; -import { Vector2, Matrix3 } from '../types'; +import { Vector2, Matrix3, AdjacentProcessMap, ResolverProcessType } from '../types'; +import { SymbolIds, NamedColors, PaintServerIds } from './defs'; import { ResolverEvent } from '../../../../common/types'; +import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../../common/models/event'; +import * as processModel from '../models/process_event'; + +const nodeAssets = { + runningProcessCube: { + cubeSymbol: `#${SymbolIds.runningProcessCube}`, + labelFill: `url(#${PaintServerIds.runningProcess})`, + descriptionFill: NamedColors.activeNoWarning, + descriptionText: i18n.translate('xpack.endpoint.resolver.runningProcess', { + defaultMessage: 'Running Process', + }), + }, + runningTriggerCube: { + cubeSymbol: `#${SymbolIds.runningTriggerCube}`, + labelFill: `url(#${PaintServerIds.runningTrigger})`, + descriptionFill: NamedColors.activeWarning, + descriptionText: i18n.translate('xpack.endpoint.resolver.runningTrigger', { + defaultMessage: 'Running Trigger', + }), + }, + terminatedProcessCube: { + cubeSymbol: `#${SymbolIds.terminatedProcessCube}`, + labelFill: NamedColors.fullLabelBackground, + descriptionFill: NamedColors.inertDescription, + descriptionText: i18n.translate('xpack.endpoint.resolver.terminatedProcess', { + defaultMessage: 'Terminated Process', + }), + }, + terminatedTriggerCube: { + cubeSymbol: `#${SymbolIds.terminatedTriggerCube}`, + labelFill: NamedColors.fullLabelBackground, + descriptionFill: NamedColors.inertDescription, + descriptionText: i18n.translate('xpack.endpoint.resolver.terminatedTrigger', { + defaultMessage: 'Terminated Trigger', + }), + }, +}; /** * A placeholder view for a process node. @@ -21,6 +61,7 @@ export const ProcessEventDot = styled( position, event, projectionMatrix, + adjacentNodeMap, }: { /** * A `className` string provided by `styled` @@ -38,39 +79,205 @@ export const ProcessEventDot = styled( * projectionMatrix which can be used to convert `position` to screen coordinates. */ projectionMatrix: Matrix3; + /** + * map of what nodes are "adjacent" to this one in "up, down, previous, next" directions + */ + adjacentNodeMap?: AdjacentProcessMap; }) => { /** * Convert the position, which is in 'world' coordinates, to screen coordinates. */ const [left, top] = applyMatrix3(position, projectionMatrix); - const style = { - left: (left - 20).toString() + 'px', - top: (top - 20).toString() + 'px', - }; + + const [magFactorX] = projectionMatrix; + + const selfId = adjacentNodeMap?.self; + + const nodeViewportStyle = useMemo( + () => ({ + left: `${left}px`, + top: `${top}px`, + // Width of symbol viewport scaled to fit + width: `${360 * magFactorX}px`, + // Height according to symbol viewbox AR + height: `${120 * magFactorX}px`, + // Adjusted to position/scale with camera + transform: `translateX(-${0.172413 * 360 * magFactorX + 10}px) translateY(-${0.73684 * + 120 * + magFactorX}px)`, + }), + [left, magFactorX, top] + ); + + const markerBaseSize = 15; + const markerSize = markerBaseSize; + const markerPositionOffset = -markerBaseSize / 2; + + const labelYOffset = markerPositionOffset + 0.25 * markerSize - 0.5; + + const labelYHeight = markerSize / 1.7647; + + const levelAttribute = adjacentNodeMap?.level + ? { + 'aria-level': adjacentNodeMap.level, + } + : {}; + + const flowToAttribute = adjacentNodeMap?.nextSibling + ? { + 'aria-flowto': adjacentNodeMap.nextSibling, + } + : {}; + + const nodeType = getNodeType(event); + const clickTargetRef: { current: SVGAnimationElement | null } = React.createRef(); + const { cubeSymbol, labelFill, descriptionFill, descriptionText } = nodeAssets[nodeType]; + const resolverNodeIdGenerator = htmlIdGenerator('resolverNode'); + const [nodeId, labelId, descriptionId] = [ + !!selfId ? resolverNodeIdGenerator(String(selfId)) : resolverNodeIdGenerator(), + resolverNodeIdGenerator(), + resolverNodeIdGenerator(), + ] as string[]; + + const dispatch = useResolverDispatch(); + + const handleFocus = useCallback( + (focusEvent: React.FocusEvent) => { + dispatch({ + type: 'userFocusedOnResolverNode', + payload: { + nodeId, + }, + }); + focusEvent.currentTarget.setAttribute('aria-current', 'true'); + }, + [dispatch, nodeId] + ); + + const handleClick = useCallback( + (clickEvent: React.MouseEvent) => { + if (clickTargetRef.current !== null) { + (clickTargetRef.current as any).beginElement(); + } + }, + [clickTargetRef] + ); + return ( - - name: {eventModel.eventName(event)} -
- x: {position[0]} -
- y: {position[1]} -
+ + + + + + + + + {eventModel.eventName(event)} + + + {descriptionText} + + + + ); } ) )` position: absolute; - width: 40px; - height: 40px; + display: block; text-align: left; font-size: 10px; - /** - * Give the element a button-like appearance. - */ user-select: none; - border: 1px solid black; box-sizing: border-box; border-radius: 10%; padding: 4px; white-space: nowrap; + will-change: left, top, width, height; + contain: strict; `; + +const processTypeToCube: Record = { + processCreated: 'terminatedProcessCube', + processRan: 'runningProcessCube', + processTerminated: 'terminatedProcessCube', + unknownProcessEvent: 'runningProcessCube', + processCausedAlert: 'runningTriggerCube', + unknownEvent: 'runningProcessCube', +}; + +function getNodeType(processEvent: ResolverEvent): keyof typeof nodeAssets { + const processType = processModel.eventType(processEvent); + + if (processType in processTypeToCube) { + return processTypeToCube[processType]; + } + return 'runningProcessCube'; +} From fa69765e4bda11701f70e6a8dd34e0ff07c9c3da Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 23 Mar 2020 22:45:26 +0100 Subject: [PATCH 15/34] Implement Kibana Login Selector (#53010) --- x-pack/legacy/plugins/security/index.ts | 5 +- x-pack/plugins/security/common/login_state.ts | 20 + .../common/model/authenticated_user.mock.ts | 2 +- .../security/common/parse_next.test.ts | 17 + x-pack/plugins/security/common/parse_next.ts | 2 +- .../__snapshots__/login_page.test.tsx.snap | 113 ++- .../basic_login_form.test.tsx.snap | 95 --- .../basic_login_form.test.tsx | 111 --- .../basic_login_form/basic_login_form.tsx | 219 ----- .../authentication/login/components/index.ts | 2 +- .../__snapshots__/login_form.test.tsx.snap | 240 ++++++ .../{basic_login_form => login_form}/index.ts | 2 +- .../components/login_form/login_form.test.tsx | 272 +++++++ .../components/login_form/login_form.tsx | 343 ++++++++ .../authentication/login/login_app.test.ts | 6 +- .../public/authentication/login/login_app.ts | 4 +- .../authentication/login/login_page.test.tsx | 56 +- .../authentication/login/login_page.tsx | 180 +++-- .../authentication/authenticator.test.ts | 522 ++++++++++-- .../server/authentication/authenticator.ts | 278 ++++--- .../server/authentication/index.mock.ts | 2 +- .../server/authentication/index.test.ts | 28 +- .../security/server/authentication/index.ts | 4 +- .../authentication/providers/base.mock.ts | 3 +- .../server/authentication/providers/base.ts | 11 +- .../authentication/providers/basic.test.ts | 19 +- .../server/authentication/providers/basic.ts | 23 +- .../authentication/providers/http.test.ts | 2 +- .../server/authentication/providers/index.ts | 4 +- .../authentication/providers/kerberos.test.ts | 178 ++-- .../authentication/providers/kerberos.ts | 33 +- .../authentication/providers/oidc.test.ts | 195 ++++- .../server/authentication/providers/oidc.ts | 118 ++- .../authentication/providers/pki.test.ts | 313 ++++---- .../server/authentication/providers/pki.ts | 31 +- .../authentication/providers/saml.test.ts | 275 +++++-- .../server/authentication/providers/saml.ts | 173 ++-- .../authentication/providers/token.test.ts | 45 +- .../server/authentication/providers/token.ts | 33 +- x-pack/plugins/security/server/config.test.ts | 757 +++++++++++++++++- x-pack/plugins/security/server/config.ts | 246 +++++- x-pack/plugins/security/server/index.ts | 26 +- x-pack/plugins/security/server/plugin.test.ts | 6 +- x-pack/plugins/security/server/plugin.ts | 20 +- .../routes/authentication/basic.test.ts | 14 +- .../server/routes/authentication/basic.ts | 7 +- .../routes/authentication/common.test.ts | 264 +++++- .../server/routes/authentication/common.ts | 66 +- .../server/routes/authentication/index.ts | 6 +- .../server/routes/authentication/oidc.ts | 21 +- .../server/routes/authentication/saml.test.ts | 12 +- .../server/routes/authentication/saml.ts | 28 +- .../security/server/routes/index.mock.ts | 8 +- .../routes/users/change_password.test.ts | 6 +- .../server/routes/users/change_password.ts | 2 +- .../server/routes/views/index.test.ts | 30 +- .../security/server/routes/views/index.ts | 6 +- .../server/routes/views/login.test.ts | 197 ++++- .../security/server/routes/views/login.ts | 36 +- x-pack/scripts/functional_tests.js | 1 + .../apis/security/kerberos_login.ts | 10 +- .../fixtures/kerberos_tools.ts | 13 + .../apis/index.ts | 14 + .../apis/login_selector.ts | 545 +++++++++++++ .../login_selector_api_integration/config.ts | 141 ++++ .../ftr_provider_context.d.ts} | 9 +- .../services.ts | 14 + .../apis/security/pki_auth.ts | 1 - x-pack/test/pki_api_integration/config.ts | 1 - .../apis/security/saml_login.ts | 43 +- x-pack/test/saml_api_integration/config.ts | 2 +- .../fixtures/idp_metadata.xml | 2 +- .../fixtures/idp_metadata_2.xml | 41 + .../fixtures/saml_tools.ts | 17 +- 74 files changed, 5201 insertions(+), 1390 deletions(-) create mode 100644 x-pack/plugins/security/common/login_state.ts delete mode 100644 x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap delete mode 100644 x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx delete mode 100644 x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx create mode 100644 x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap rename x-pack/plugins/security/public/authentication/login/components/{basic_login_form => login_form}/index.ts (82%) create mode 100644 x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx create mode 100644 x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx create mode 100644 x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts create mode 100644 x-pack/test/login_selector_api_integration/apis/index.ts create mode 100644 x-pack/test/login_selector_api_integration/apis/login_selector.ts create mode 100644 x-pack/test/login_selector_api_integration/config.ts rename x-pack/{plugins/security/public/authentication/login/login_state.ts => test/login_selector_api_integration/ftr_provider_context.d.ts} (56%) create mode 100644 x-pack/test/login_selector_api_integration/services.ts create mode 100644 x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml diff --git a/x-pack/legacy/plugins/security/index.ts b/x-pack/legacy/plugins/security/index.ts index deebbccf5aa49..5b2218af1fd52 100644 --- a/x-pack/legacy/plugins/security/index.ts +++ b/x-pack/legacy/plugins/security/index.ts @@ -51,10 +51,7 @@ export const security = (kibana: Record) => uiExports: { hacks: ['plugins/security/hacks/legacy'], injectDefaultVars: (server: Server) => { - return { - secureCookies: getSecurityPluginSetup(server).__legacyCompat.config.secureCookies, - enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled'), - }; + return { enableSpaceAwarePrivileges: server.config().get('xpack.spaces.enabled') }; }, }, diff --git a/x-pack/plugins/security/common/login_state.ts b/x-pack/plugins/security/common/login_state.ts new file mode 100644 index 0000000000000..4342e82d2f90b --- /dev/null +++ b/x-pack/plugins/security/common/login_state.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LoginLayout } from './licensing'; + +export interface LoginSelector { + enabled: boolean; + providers: Array<{ type: string; name: string; description?: string }>; +} + +export interface LoginState { + layout: LoginLayout; + allowLogin: boolean; + showLoginForm: boolean; + requiresSecureConnection: boolean; + selector: LoginSelector; +} diff --git a/x-pack/plugins/security/common/model/authenticated_user.mock.ts b/x-pack/plugins/security/common/model/authenticated_user.mock.ts index 220b284e76591..f8b0d27efcbf4 100644 --- a/x-pack/plugins/security/common/model/authenticated_user.mock.ts +++ b/x-pack/plugins/security/common/model/authenticated_user.mock.ts @@ -15,7 +15,7 @@ export function mockAuthenticatedUser(user: Partial = {}) { enabled: true, authentication_realm: { name: 'native1', type: 'native' }, lookup_realm: { name: 'native1', type: 'native' }, - authentication_provider: 'basic', + authentication_provider: 'basic1', ...user, }; } diff --git a/x-pack/plugins/security/common/parse_next.test.ts b/x-pack/plugins/security/common/parse_next.test.ts index b5e6c7dca41d8..11a843d397ded 100644 --- a/x-pack/plugins/security/common/parse_next.test.ts +++ b/x-pack/plugins/security/common/parse_next.test.ts @@ -34,6 +34,15 @@ describe('parseNext', () => { expect(parseNext(href, basePath)).toEqual(`${next}#${hash}`); }); + it('should properly handle multiple next with hash', () => { + const basePath = '/iqf'; + const next1 = `${basePath}/app/kibana`; + const next2 = `${basePath}/app/ml`; + const hash = '/discover/New-Saved-Search'; + const href = `${basePath}/login?next=${next1}&next=${next2}#${hash}`; + expect(parseNext(href, basePath)).toEqual(`${next1}#${hash}`); + }); + it('should properly decode special characters', () => { const basePath = '/iqf'; const next = `${encodeURIComponent(basePath)}%2Fapp%2Fkibana`; @@ -118,6 +127,14 @@ describe('parseNext', () => { expect(parseNext(href)).toEqual(`${next}#${hash}`); }); + it('should properly handle multiple next with hash', () => { + const next1 = '/app/kibana'; + const next2 = '/app/ml'; + const hash = '/discover/New-Saved-Search'; + const href = `/login?next=${next1}&next=${next2}#${hash}`; + expect(parseNext(href)).toEqual(`${next1}#${hash}`); + }); + it('should properly decode special characters', () => { const next = '%2Fapp%2Fkibana'; const hash = '/discover/New-Saved-Search'; diff --git a/x-pack/plugins/security/common/parse_next.ts b/x-pack/plugins/security/common/parse_next.ts index 834acd783abbe..7cbe335825a5a 100644 --- a/x-pack/plugins/security/common/parse_next.ts +++ b/x-pack/plugins/security/common/parse_next.ts @@ -40,5 +40,5 @@ export function parseNext(href: string, basePath = '') { return `${basePath}/`; } - return query.next + (hash || ''); + return next + (hash || ''); } diff --git a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap index 30715be1db232..ecbdfedac1dd3 100644 --- a/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap +++ b/x-pack/plugins/security/public/authentication/login/__snapshots__/login_page.test.tsx.snap @@ -23,7 +23,7 @@ exports[`LoginPage disabled form states renders as expected when an unknown logi @@ -38,6 +38,25 @@ exports[`LoginPage disabled form states renders as expected when an unknown logi /> `; +exports[`LoginPage disabled form states renders as expected when login is not enabled 1`] = ` + + } + title={ + + } +/> +`; + exports[`LoginPage disabled form states renders as expected when secure connection is required but not present 1`] = ` `; exports[`LoginPage enabled form state renders as expected when info message is set 1`] = ` - `; exports[`LoginPage enabled form state renders as expected when loginAssistanceMessage is set 1`] = ` - `; @@ -172,7 +254,7 @@ exports[`LoginPage page renders as expected 1`] = ` gutterSize="l" > -
diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap deleted file mode 100644 index b09f398ed5ed9..0000000000000 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/__snapshots__/basic_login_form.test.tsx.snap +++ /dev/null @@ -1,95 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`BasicLoginForm renders as expected 1`] = ` - - - - - -
- - } - labelType="label" - > - - - - } - labelType="label" - > - - - - - -
-
-
-`; diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx deleted file mode 100644 index e62fd7191dfae..0000000000000 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { act } from '@testing-library/react'; -import { EuiButton, EuiCallOut } from '@elastic/eui'; -import { mountWithIntl, nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { BasicLoginForm } from './basic_login_form'; - -import { coreMock } from '../../../../../../../../src/core/public/mocks'; - -describe('BasicLoginForm', () => { - beforeAll(() => { - Object.defineProperty(window, 'location', { - value: { href: 'https://some-host/bar' }, - writable: true, - }); - }); - - afterAll(() => { - delete (window as any).location; - }); - - it('renders as expected', () => { - expect( - shallowWithIntl( - - ) - ).toMatchSnapshot(); - }); - - it('renders an info message when provided.', () => { - const wrapper = shallowWithIntl( - - ); - - expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message'); - }); - - it('renders an invalid credentials message', async () => { - const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; - mockHTTP.post.mockRejectedValue({ response: { status: 401 } }); - - const wrapper = mountWithIntl(); - - wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); - wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); - wrapper.find(EuiButton).simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(EuiCallOut).props().title).toEqual( - `Invalid username or password. Please try again.` - ); - }); - - it('renders unknown error message', async () => { - const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; - mockHTTP.post.mockRejectedValue({ response: { status: 500 } }); - - const wrapper = mountWithIntl(); - - wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); - wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); - wrapper.find(EuiButton).simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(wrapper.find(EuiCallOut).props().title).toEqual(`Oops! Error. Try again.`); - }); - - it('properly redirects after successful login', async () => { - window.location.href = `https://some-host/login?next=${encodeURIComponent( - '/some-base-path/app/kibana#/home?_g=()' - )}`; - const mockHTTP = coreMock.createStart({ basePath: '/some-base-path' }).http; - mockHTTP.post.mockResolvedValue({}); - - const wrapper = mountWithIntl(); - - wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username1' } }); - wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password1' } }); - wrapper.find(EuiButton).simulate('click'); - - await act(async () => { - await nextTick(); - wrapper.update(); - }); - - expect(mockHTTP.post).toHaveBeenCalledTimes(1); - expect(mockHTTP.post).toHaveBeenCalledWith('/internal/security/login', { - body: JSON.stringify({ username: 'username1', password: 'password1' }), - }); - - expect(window.location.href).toBe('/some-base-path/app/kibana#/home?_g=()'); - expect(wrapper.find(EuiCallOut).exists()).toBe(false); - }); -}); diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx deleted file mode 100644 index 7302ee9bf9851..0000000000000 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/basic_login_form.tsx +++ /dev/null @@ -1,219 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { ChangeEvent, Component, FormEvent, Fragment, MouseEvent } from 'react'; -import ReactMarkdown from 'react-markdown'; -import { - EuiButton, - EuiCallOut, - EuiFieldText, - EuiFormRow, - EuiPanel, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { HttpStart, IHttpFetchError } from 'src/core/public'; -import { parseNext } from '../../../../../common/parse_next'; - -interface Props { - http: HttpStart; - infoMessage?: string; - loginAssistanceMessage: string; -} - -interface State { - hasError: boolean; - isLoading: boolean; - username: string; - password: string; - message: string; -} - -export class BasicLoginForm extends Component { - public state = { - hasError: false, - isLoading: false, - username: '', - password: '', - message: '', - }; - - public render() { - return ( - - {this.renderLoginAssistanceMessage()} - {this.renderMessage()} - -
- - } - > - - - - - } - > - - - - - - -
-
-
- ); - } - - private renderLoginAssistanceMessage = () => { - return ( - - - {this.props.loginAssistanceMessage} - - - ); - }; - - private renderMessage = () => { - if (this.state.message) { - return ( - - - - - ); - } - - if (this.props.infoMessage) { - return ( - - - - - ); - } - - return null; - }; - - private setUsernameInputRef(ref: HTMLInputElement) { - if (ref) { - ref.focus(); - } - } - - private isFormValid = () => { - const { username, password } = this.state; - - return username && password; - }; - - private onUsernameChange = (e: ChangeEvent) => { - this.setState({ - username: e.target.value, - }); - }; - - private onPasswordChange = (e: ChangeEvent) => { - this.setState({ - password: e.target.value, - }); - }; - - private submit = async (e: MouseEvent | FormEvent) => { - e.preventDefault(); - - if (!this.isFormValid()) { - return; - } - - this.setState({ - isLoading: true, - message: '', - }); - - const { http } = this.props; - const { username, password } = this.state; - - try { - await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) }); - window.location.href = parseNext(window.location.href, http.basePath.serverBasePath); - } catch (error) { - const message = - (error as IHttpFetchError).response?.status === 401 - ? i18n.translate( - 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', - { defaultMessage: 'Invalid username or password. Please try again.' } - ) - : i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', { - defaultMessage: 'Oops! Error. Try again.', - }); - - this.setState({ - hasError: true, - message, - isLoading: false, - }); - } - }; -} diff --git a/x-pack/plugins/security/public/authentication/login/components/index.ts b/x-pack/plugins/security/public/authentication/login/components/index.ts index 5f267f7c4caa2..3113a177d433e 100644 --- a/x-pack/plugins/security/public/authentication/login/components/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/index.ts @@ -4,5 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export { BasicLoginForm } from './basic_login_form'; +export { LoginForm } from './login_form'; export { DisabledLoginForm } from './disabled_login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap new file mode 100644 index 0000000000000..a25498a637c2f --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/__snapshots__/login_form.test.tsx.snap @@ -0,0 +1,240 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`LoginForm login selector renders as expected with login form 1`] = ` + + + Login w/SAML + + + + Login w/PKI + + + + ―――   + +   ――― + + + +
+ + } + labelType="label" + > + + + + } + labelType="label" + > + + + + + +
+
+
+`; + +exports[`LoginForm login selector renders as expected without login form for providers with and without description 1`] = ` + + + Login w/SAML + + + + + + + +`; + +exports[`LoginForm renders as expected 1`] = ` + + +
+ + } + labelType="label" + > + + + + } + labelType="label" + > + + + + + +
+
+
+`; diff --git a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts similarity index 82% rename from x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts rename to x-pack/plugins/security/public/authentication/login/components/login_form/index.ts index 3c1350ba590a6..c09a8dad4945c 100644 --- a/x-pack/plugins/security/public/authentication/login/components/basic_login_form/index.ts +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { BasicLoginForm } from './basic_login_form'; +export { LoginForm } from './login_form'; diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx new file mode 100644 index 0000000000000..c17c10a2c5148 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.test.tsx @@ -0,0 +1,272 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { act } from '@testing-library/react'; +import { EuiButton, EuiCallOut } from '@elastic/eui'; +import { mountWithIntl, nextTick, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { LoginForm } from './login_form'; + +import { coreMock } from '../../../../../../../../src/core/public/mocks'; + +describe('LoginForm', () => { + beforeAll(() => { + Object.defineProperty(window, 'location', { + value: { href: 'https://some-host/bar' }, + writable: true, + }); + }); + + afterAll(() => { + delete (window as any).location; + }); + + it('renders as expected', () => { + const coreStartMock = coreMock.createStart(); + expect( + shallowWithIntl( + + ) + ).toMatchSnapshot(); + }); + + it('renders an info message when provided.', () => { + const coreStartMock = coreMock.createStart(); + const wrapper = shallowWithIntl( + + ); + + expect(wrapper.find(EuiCallOut).props().title).toEqual('Hey this is an info message'); + }); + + it('renders an invalid credentials message', async () => { + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockRejectedValue({ response: { status: 401 } }); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(EuiCallOut).props().title).toEqual( + `Invalid username or password. Please try again.` + ); + }); + + it('renders unknown error message', async () => { + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockRejectedValue({ response: { status: 500 } }); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(EuiCallOut).props().title).toEqual(`Oops! Error. Try again.`); + }); + + it('properly redirects after successful login', async () => { + window.location.href = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockResolvedValue({}); + + const wrapper = mountWithIntl( + + ); + + wrapper.find('input[name="username"]').simulate('change', { target: { value: 'username1' } }); + wrapper.find('input[name="password"]').simulate('change', { target: { value: 'password1' } }); + wrapper.find(EuiButton).simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login', { + body: JSON.stringify({ username: 'username1', password: 'password1' }), + }); + + expect(window.location.href).toBe('/some-base-path/app/kibana#/home?_g=()'); + expect(wrapper.find(EuiCallOut).exists()).toBe(false); + }); + + describe('login selector', () => { + it('renders as expected with login form', async () => { + const coreStartMock = coreMock.createStart(); + expect( + shallowWithIntl( + + ) + ).toMatchSnapshot(); + }); + + it('renders as expected without login form for providers with and without description', async () => { + const coreStartMock = coreMock.createStart(); + expect( + shallowWithIntl( + + ) + ).toMatchSnapshot(); + }); + + it('properly redirects after successful login', async () => { + const currentURL = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockResolvedValue({ + location: 'https://external-idp/login?optional-arg=2#optional-hash', + }); + + window.location.href = currentURL; + const wrapper = mountWithIntl( + + ); + + wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), + }); + + expect(window.location.href).toBe('https://external-idp/login?optional-arg=2#optional-hash'); + expect(wrapper.find(EuiCallOut).exists()).toBe(false); + expect(coreStartMock.notifications.toasts.addError).not.toHaveBeenCalled(); + }); + + it('shows error toast if login fails', async () => { + const currentURL = `https://some-host/login?next=${encodeURIComponent( + '/some-base-path/app/kibana#/home?_g=()' + )}`; + + const failureReason = new Error('Oh no!'); + const coreStartMock = coreMock.createStart({ basePath: '/some-base-path' }); + coreStartMock.http.post.mockRejectedValue(failureReason); + + window.location.href = currentURL; + const wrapper = mountWithIntl( + + ); + + wrapper.findWhere(node => node.key() === 'saml1').simulate('click'); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(coreStartMock.http.post).toHaveBeenCalledTimes(1); + expect(coreStartMock.http.post).toHaveBeenCalledWith('/internal/security/login_with', { + body: JSON.stringify({ providerType: 'saml', providerName: 'saml1', currentURL }), + }); + + expect(window.location.href).toBe(currentURL); + expect(coreStartMock.notifications.toasts.addError).toHaveBeenCalledWith(failureReason, { + title: 'Could not perform login.', + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx new file mode 100644 index 0000000000000..a028eb1ba4b70 --- /dev/null +++ b/x-pack/plugins/security/public/authentication/login/components/login_form/login_form.tsx @@ -0,0 +1,343 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { ChangeEvent, Component, FormEvent, Fragment, MouseEvent } from 'react'; +import ReactMarkdown from 'react-markdown'; +import { + EuiButton, + EuiCallOut, + EuiFieldText, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { HttpStart, IHttpFetchError, NotificationsStart } from 'src/core/public'; +import { parseNext } from '../../../../../common/parse_next'; +import { LoginSelector } from '../../../../../common/login_state'; + +interface Props { + http: HttpStart; + notifications: NotificationsStart; + selector: LoginSelector; + showLoginForm: boolean; + infoMessage?: string; + loginAssistanceMessage: string; +} + +interface State { + loadingState: + | { type: LoadingStateType.None } + | { type: LoadingStateType.Form } + | { type: LoadingStateType.Selector; providerName: string }; + username: string; + password: string; + message: + | { type: MessageType.None } + | { type: MessageType.Danger | MessageType.Info; content: string }; +} + +enum LoadingStateType { + None, + Form, + Selector, +} + +enum MessageType { + None, + Info, + Danger, +} + +export class LoginForm extends Component { + public state: State = { + loadingState: { type: LoadingStateType.None }, + username: '', + password: '', + message: this.props.infoMessage + ? { type: MessageType.Info, content: this.props.infoMessage } + : { type: MessageType.None }, + }; + + public render() { + return ( + + {this.renderLoginAssistanceMessage()} + {this.renderMessage()} + {this.renderSelector()} + {this.renderLoginForm()} + + ); + } + + private renderLoginForm = () => { + if (!this.props.showLoginForm) { + return null; + } + + return ( + +
+ + } + > + + + + + } + > + + + + + + +
+
+ ); + }; + + private renderLoginAssistanceMessage = () => { + if (!this.props.loginAssistanceMessage) { + return null; + } + + return ( + + + {this.props.loginAssistanceMessage} + + + ); + }; + + private renderMessage = () => { + const { message } = this.state; + if (message.type === MessageType.Danger) { + return ( + + + + + ); + } + + if (message.type === MessageType.Info) { + return ( + + + + + ); + } + + return null; + }; + + private renderSelector = () => { + const showLoginSelector = + this.props.selector.enabled && this.props.selector.providers.length > 0; + if (!showLoginSelector) { + return null; + } + + const loginSelectorAndLoginFormSeparator = showLoginSelector && this.props.showLoginForm && ( + <> + + ―――   + +   ――― + + + + ); + + return ( + <> + {this.props.selector.providers.map((provider, index) => ( + + this.loginWithSelector(provider.type, provider.name)} + > + {provider.description ?? ( + + )} + + + + ))} + {loginSelectorAndLoginFormSeparator} + + ); + }; + + private setUsernameInputRef(ref: HTMLInputElement) { + if (ref) { + ref.focus(); + } + } + + private isFormValid = () => { + const { username, password } = this.state; + + return username && password; + }; + + private onUsernameChange = (e: ChangeEvent) => { + this.setState({ + username: e.target.value, + }); + }; + + private onPasswordChange = (e: ChangeEvent) => { + this.setState({ + password: e.target.value, + }); + }; + + private submitLoginForm = async ( + e: MouseEvent | FormEvent + ) => { + e.preventDefault(); + + if (!this.isFormValid()) { + return; + } + + this.setState({ + loadingState: { type: LoadingStateType.Form }, + message: { type: MessageType.None }, + }); + + const { http } = this.props; + const { username, password } = this.state; + + try { + await http.post('/internal/security/login', { body: JSON.stringify({ username, password }) }); + window.location.href = parseNext(window.location.href, http.basePath.serverBasePath); + } catch (error) { + const message = + (error as IHttpFetchError).response?.status === 401 + ? i18n.translate( + 'xpack.security.login.basicLoginForm.invalidUsernameOrPasswordErrorMessage', + { defaultMessage: 'Invalid username or password. Please try again.' } + ) + : i18n.translate('xpack.security.login.basicLoginForm.unknownErrorMessage', { + defaultMessage: 'Oops! Error. Try again.', + }); + + this.setState({ + message: { type: MessageType.Danger, content: message }, + loadingState: { type: LoadingStateType.None }, + }); + } + }; + + private loginWithSelector = async (providerType: string, providerName: string) => { + this.setState({ + loadingState: { type: LoadingStateType.Selector, providerName }, + message: { type: MessageType.None }, + }); + + try { + const { location } = await this.props.http.post<{ location: string }>( + '/internal/security/login_with', + { body: JSON.stringify({ providerType, providerName, currentURL: window.location.href }) } + ); + + window.location.href = location; + } catch (err) { + this.props.notifications.toasts.addError(err, { + title: i18n.translate('xpack.security.loginPage.loginSelectorErrorMessage', { + defaultMessage: 'Could not perform login.', + }), + }); + + this.setState({ loadingState: { type: LoadingStateType.None } }); + } + }; + + private isLoadingState(type: LoadingStateType.None | LoadingStateType.Form): boolean; + private isLoadingState(type: LoadingStateType.Selector, providerName: string): boolean; + private isLoadingState(type: LoadingStateType, providerName?: string) { + const { loadingState } = this.state; + if (loadingState.type !== type) { + return false; + } + + return ( + loadingState.type !== LoadingStateType.Selector || loadingState.providerName === providerName + ); + } +} diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts index 051f08058ed8d..2597a935f45df 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -38,7 +38,6 @@ describe('loginApp', () => { it('properly renders application', async () => { const coreSetupMock = coreMock.createSetup(); const coreStartMock = coreMock.createStart(); - coreStartMock.injectedMetadata.getInjectedVar.mockReturnValue(true); coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}]); const containerMock = document.createElement('div'); @@ -55,16 +54,13 @@ describe('loginApp', () => { history: (scopedHistoryMock.create() as unknown) as ScopedHistory, }); - expect(coreStartMock.injectedMetadata.getInjectedVar).toHaveBeenCalledTimes(1); - expect(coreStartMock.injectedMetadata.getInjectedVar).toHaveBeenCalledWith('secureCookies'); - const mockRenderApp = jest.requireMock('./login_page').renderLoginPage; expect(mockRenderApp).toHaveBeenCalledTimes(1); expect(mockRenderApp).toHaveBeenCalledWith(coreStartMock.i18n, containerMock, { http: coreStartMock.http, + notifications: coreStartMock.notifications, fatalErrors: coreStartMock.fatalErrors, loginAssistanceMessage: 'some-message', - requiresSecureConnection: true, }); }); }); diff --git a/x-pack/plugins/security/public/authentication/login/login_app.ts b/x-pack/plugins/security/public/authentication/login/login_app.ts index 4f4bf3903a1fa..1642aba51c1ae 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.ts @@ -31,11 +31,9 @@ export const loginApp = Object.freeze({ ]); return renderLoginPage(coreStart.i18n, element, { http: coreStart.http, + notifications: coreStart.notifications, fatalErrors: coreStart.fatalErrors, loginAssistanceMessage: config.loginAssistanceMessage, - requiresSecureConnection: coreStart.injectedMetadata.getInjectedVar( - 'secureCookies' - ) as boolean, }); }, }); diff --git a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx index 294434cd08ebc..c4be57d8d7db7 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.test.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.test.tsx @@ -8,15 +8,18 @@ import React from 'react'; import { shallow } from 'enzyme'; import { act } from '@testing-library/react'; import { nextTick } from 'test_utils/enzyme_helpers'; -import { LoginState } from './login_state'; +import { LoginState } from '../../../common/login_state'; import { LoginPage } from './login_page'; import { coreMock } from '../../../../../../src/core/public/mocks'; -import { DisabledLoginForm, BasicLoginForm } from './components'; +import { DisabledLoginForm, LoginForm } from './components'; const createLoginState = (options?: Partial) => { return { allowLogin: true, layout: 'form', + requiresSecureConnection: false, + showLoginForm: true, + selector: { enabled: false, providers: [] }, ...options, } as LoginState; }; @@ -55,9 +58,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -74,14 +77,14 @@ describe('LoginPage', () => { describe('disabled form states', () => { it('renders as expected when secure connection is required but not present', async () => { const coreStartMock = coreMock.createStart(); - httpMock.get.mockResolvedValue(createLoginState()); + httpMock.get.mockResolvedValue(createLoginState({ requiresSecureConnection: true })); const wrapper = shallow( ); @@ -100,9 +103,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -121,9 +124,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -144,9 +147,30 @@ describe('LoginPage', () => { const wrapper = shallow( + ); + + await act(async () => { + await nextTick(); + wrapper.update(); + }); + + expect(wrapper.find(DisabledLoginForm)).toMatchSnapshot(); + }); + + it('renders as expected when login is not enabled', async () => { + const coreStartMock = coreMock.createStart(); + httpMock.get.mockResolvedValue(createLoginState({ showLoginForm: false })); + + const wrapper = shallow( + ); @@ -167,9 +191,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -179,7 +203,7 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); it('renders as expected when info message is set', async () => { @@ -190,9 +214,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -202,7 +226,7 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); it('renders as expected when loginAssistanceMessage is set', async () => { @@ -212,9 +236,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -224,7 +248,7 @@ describe('LoginPage', () => { resetHttpMock(); // so the calls don't show in the BasicLoginForm snapshot }); - expect(wrapper.find(BasicLoginForm)).toMatchSnapshot(); + expect(wrapper.find(LoginForm)).toMatchSnapshot(); }); }); @@ -236,9 +260,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); @@ -261,9 +285,9 @@ describe('LoginPage', () => { const wrapper = shallow( ); diff --git a/x-pack/plugins/security/public/authentication/login/login_page.tsx b/x-pack/plugins/security/public/authentication/login/login_page.tsx index d38dee74faedf..70f8f76ee0a9c 100644 --- a/x-pack/plugins/security/public/authentication/login/login_page.tsx +++ b/x-pack/plugins/security/public/authentication/login/login_page.tsx @@ -12,16 +12,15 @@ import { parse } from 'url'; import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { CoreStart, FatalErrorsStart, HttpStart } from 'src/core/public'; -import { LoginLayout } from '../../../common/licensing'; -import { BasicLoginForm, DisabledLoginForm } from './components'; -import { LoginState } from './login_state'; +import { CoreStart, FatalErrorsStart, HttpStart, NotificationsStart } from 'src/core/public'; +import { LoginState } from '../../../common/login_state'; +import { LoginForm, DisabledLoginForm } from './components'; interface Props { http: HttpStart; + notifications: NotificationsStart; fatalErrors: FatalErrorsStart; loginAssistanceMessage: string; - requiresSecureConnection: boolean; } interface State { @@ -44,7 +43,7 @@ const infoMessageMap = new Map([ ]); export class LoginPage extends Component { - state = { loginState: null }; + state = { loginState: null } as State; public async componentDidMount() { const loadingCount$ = new BehaviorSubject(1); @@ -67,12 +66,10 @@ export class LoginPage extends Component { } const isSecureConnection = !!window.location.protocol.match(/^https/); - const { allowLogin, layout } = loginState; + const { allowLogin, layout, requiresSecureConnection } = loginState; const loginIsSupported = - this.props.requiresSecureConnection && !isSecureConnection - ? false - : allowLogin && layout === 'form'; + requiresSecureConnection && !isSecureConnection ? false : allowLogin && layout === 'form'; const contentHeaderClasses = classNames('loginWelcome__content', 'eui-textCenter', { ['loginWelcome__contentDisabledForm']: !loginIsSupported, @@ -111,7 +108,7 @@ export class LoginPage extends Component {
- {this.getLoginForm({ isSecureConnection, layout })} + {this.getLoginForm({ ...loginState, isSecureConnection })}
@@ -119,13 +116,34 @@ export class LoginPage extends Component { } private getLoginForm = ({ - isSecureConnection, layout, - }: { - isSecureConnection: boolean; - layout: LoginLayout; - }) => { - if (this.props.requiresSecureConnection && !isSecureConnection) { + requiresSecureConnection, + isSecureConnection, + selector, + showLoginForm, + }: LoginState & { isSecureConnection: boolean }) => { + const isLoginExplicitlyDisabled = + !showLoginForm && (!selector.enabled || selector.providers.length === 0); + if (isLoginExplicitlyDisabled) { + return ( + + } + message={ + + } + /> + ); + } + + if (requiresSecureConnection && !isSecureConnection) { return ( { ); } - switch (layout) { - case 'form': - return ( - - ); - case 'error-es-unavailable': - return ( - - } - message={ - - } - /> - ); - case 'error-xpack-unavailable': - return ( - - } - message={ - - } - /> - ); - default: - return ( - - } - message={ - - } - /> - ); + if (layout === 'error-es-unavailable') { + return ( + + } + message={ + + } + /> + ); + } + + if (layout === 'error-xpack-unavailable') { + return ( + + } + message={ + + } + /> + ); + } + + if (layout !== 'form') { + return ( + + } + message={ + + } + /> + ); } + + return ( + + ); }; } diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index af019ff10dedc..a595b63faaf9b 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -5,6 +5,7 @@ */ jest.mock('./providers/basic'); +jest.mock('./providers/token'); jest.mock('./providers/saml'); jest.mock('./providers/http'); @@ -20,33 +21,32 @@ import { sessionStorageMock, } from '../../../../../src/core/server/mocks'; import { mockAuthenticatedUser } from '../../common/model/authenticated_user.mock'; +import { ConfigSchema, createConfig } from '../config'; import { AuthenticationResult } from './authentication_result'; import { Authenticator, AuthenticatorOptions, ProviderSession } from './authenticator'; import { DeauthenticationResult } from './deauthentication_result'; -import { BasicAuthenticationProvider } from './providers'; +import { BasicAuthenticationProvider, SAMLAuthenticationProvider } from './providers'; function getMockOptions({ session, providers, http = {}, + selector, }: { session?: AuthenticatorOptions['config']['session']; - providers?: string[]; + providers?: Record | string[]; http?: Partial; + selector?: AuthenticatorOptions['config']['authc']['selector']; } = {}) { return { clusterClient: elasticsearchServiceMock.createClusterClient(), basePath: httpServiceMock.createSetupContract().basePath, loggers: loggingServiceMock.create(), - config: { - session: { idleTimeout: null, lifespan: null, ...(session || {}) }, - authc: { - providers: providers || [], - oidc: {}, - saml: {}, - http: { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'], ...http }, - }, - }, + config: createConfig( + ConfigSchema.validate({ session, authc: { selector, providers, http } }), + loggingServiceMock.create().get(), + { isTLSEnabled: false } + ), sessionStorageFactory: sessionStorageMock.createFactory(), }; } @@ -56,26 +56,36 @@ describe('Authenticator', () => { beforeEach(() => { mockBasicAuthenticationProvider = { login: jest.fn(), - authenticate: jest.fn(), - logout: jest.fn(), + authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + logout: jest.fn().mockResolvedValue(DeauthenticationResult.notHandled()), getHTTPAuthenticationScheme: jest.fn(), }; jest.requireMock('./providers/http').HTTPAuthenticationProvider.mockImplementation(() => ({ + type: 'http', authenticate: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + logout: jest.fn().mockResolvedValue(DeauthenticationResult.notHandled()), + })); + + jest.requireMock('./providers/basic').BasicAuthenticationProvider.mockImplementation(() => ({ + type: 'basic', + ...mockBasicAuthenticationProvider, })); - jest - .requireMock('./providers/basic') - .BasicAuthenticationProvider.mockImplementation(() => mockBasicAuthenticationProvider); + jest.requireMock('./providers/saml').SAMLAuthenticationProvider.mockImplementation(() => ({ + type: 'saml', + getHTTPAuthenticationScheme: jest.fn(), + })); }); afterEach(() => jest.clearAllMocks()); describe('initialization', () => { it('fails if authentication providers are not configured.', () => { - expect(() => new Authenticator(getMockOptions())).toThrowError( - 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' + expect( + () => new Authenticator(getMockOptions({ providers: {}, http: { enabled: false } })) + ).toThrowError( + 'No authentication provider is configured. Verify `xpack.security.authc.*` config value.' ); }); @@ -85,11 +95,19 @@ describe('Authenticator', () => { ); }); + it('fails if any of the user specified provider uses reserved __http__ name.', () => { + expect( + () => + new Authenticator(getMockOptions({ providers: { basic: { __http__: { order: 0 } } } })) + ).toThrowError('Provider name "__http__" is reserved.'); + }); + describe('HTTP authentication provider', () => { beforeEach(() => { jest .requireMock('./providers/basic') .BasicAuthenticationProvider.mockImplementation(() => ({ + type: 'basic', getHTTPAuthenticationScheme: jest.fn().mockReturnValue('basic'), })); }); @@ -97,9 +115,9 @@ describe('Authenticator', () => { afterEach(() => jest.resetAllMocks()); it('enabled by default', () => { - const authenticator = new Authenticator(getMockOptions({ providers: ['basic'] })); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(true); + const authenticator = new Authenticator(getMockOptions()); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -110,11 +128,13 @@ describe('Authenticator', () => { it('includes all required schemes if `autoSchemesEnabled` is enabled', () => { const authenticator = new Authenticator( - getMockOptions({ providers: ['basic', 'kerberos'] }) + getMockOptions({ + providers: { basic: { basic1: { order: 0 } }, kerberos: { kerberos1: { order: 1 } } }, + }) ); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('kerberos')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(true); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('kerberos')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -125,11 +145,14 @@ describe('Authenticator', () => { it('does not include additional schemes if `autoSchemesEnabled` is disabled', () => { const authenticator = new Authenticator( - getMockOptions({ providers: ['basic', 'kerberos'], http: { autoSchemesEnabled: false } }) + getMockOptions({ + providers: { basic: { basic1: { order: 0 } }, kerberos: { kerberos1: { order: 1 } } }, + http: { autoSchemesEnabled: false }, + }) ); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('kerberos')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(true); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('kerberos')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(true); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -138,10 +161,13 @@ describe('Authenticator', () => { it('disabled if explicitly disabled', () => { const authenticator = new Authenticator( - getMockOptions({ providers: ['basic'], http: { enabled: false } }) + getMockOptions({ + providers: { basic: { basic1: { order: 0 } } }, + http: { enabled: false }, + }) ); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('http')).toBe(false); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('http')).toBe(false); expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider @@ -156,14 +182,15 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, lifespanExpiration: null, state: { authorization: 'Basic xxx' }, - provider: 'basic', + provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, }; @@ -176,17 +203,26 @@ describe('Authenticator', () => { ); }); - it('fails if login attempt is not provided.', async () => { + it('fails if login attempt is not provided or invalid.', async () => { await expect( authenticator.login(httpServerMock.createKibanaRequest(), undefined as any) ).rejects.toThrowError( - 'Login attempt should be an object with non-empty "provider" property.' + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' ); await expect( authenticator.login(httpServerMock.createKibanaRequest(), {} as any) ).rejects.toThrowError( - 'Login attempt should be an object with non-empty "provider" property.' + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' + ); + + await expect( + authenticator.login(httpServerMock.createKibanaRequest(), { + provider: 'basic', + value: {}, + } as any) + ).rejects.toThrowError( + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' ); }); @@ -198,9 +234,9 @@ describe('Authenticator', () => { AuthenticationResult.failed(failureReason) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); }); it('returns user that authentication provider returns.', async () => { @@ -211,7 +247,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual( AuthenticationResult.succeeded(user, { authHeaders: { authorization: 'Basic .....' } }) ); }); @@ -225,9 +263,9 @@ describe('Authenticator', () => { AuthenticationResult.succeeded(user, { state: { authorization } }) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( - AuthenticationResult.succeeded(user, { state: { authorization } }) - ); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user, { state: { authorization } })); expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); expect(mockSessionStorage.set).toHaveBeenCalledWith({ @@ -238,9 +276,171 @@ describe('Authenticator', () => { it('returns `notHandled` if login attempt is targeted to not configured provider.', async () => { const request = httpServerMock.createKibanaRequest(); - await expect(authenticator.login(request, { provider: 'token', value: {} })).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect( + authenticator.login(request, { provider: { type: 'token' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + await expect( + authenticator.login(request, { provider: { name: 'basic2' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + + describe('multi-provider scenarios', () => { + let mockSAMLAuthenticationProvider1: jest.Mocked>; + let mockSAMLAuthenticationProvider2: jest.Mocked>; + + beforeEach(() => { + mockSAMLAuthenticationProvider1 = { + login: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + authenticate: jest.fn(), + logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), + }; + + mockSAMLAuthenticationProvider2 = { + login: jest.fn().mockResolvedValue(AuthenticationResult.notHandled()), + authenticate: jest.fn(), + logout: jest.fn(), + getHTTPAuthenticationScheme: jest.fn(), + }; + + jest + .requireMock('./providers/saml') + .SAMLAuthenticationProvider.mockImplementationOnce(() => ({ + type: 'saml', + ...mockSAMLAuthenticationProvider1, + })) + .mockImplementationOnce(() => ({ + type: 'saml', + ...mockSAMLAuthenticationProvider2, + })); + + mockOptions = getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { + saml1: { realm: 'saml1-realm', order: 1 }, + saml2: { realm: 'saml2-realm', order: 2 }, + }, + }, + }); + mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); + + it('tries to login only with the provider that has specified name', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + mockSAMLAuthenticationProvider2.login.mockResolvedValue( + AuthenticationResult.succeeded(user, { state: { token: 'access-token' } }) + ); + + await expect( + authenticator.login(request, { provider: { name: 'saml2' }, value: {} }) + ).resolves.toEqual( + AuthenticationResult.succeeded(user, { state: { token: 'access-token' } }) + ); + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(1); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml2' }, + state: { token: 'access-token' }, + }); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider1.login).not.toHaveBeenCalled(); + }); + + it('tries to login only with the provider that has specified type', async () => { + const request = httpServerMock.createKibanaRequest(); + + await expect( + authenticator.login(request, { provider: { type: 'saml' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider1.login.mock.invocationCallOrder[0]).toBeLessThan( + mockSAMLAuthenticationProvider2.login.mock.invocationCallOrder[0] + ); + }); + + it('returns as soon as provider handles request', async () => { + const request = httpServerMock.createKibanaRequest(); + + const authenticationResults = [ + AuthenticationResult.failed(new Error('Fail')), + AuthenticationResult.succeeded(mockAuthenticatedUser(), { state: { result: '200' } }), + AuthenticationResult.redirectTo('/some/url', { state: { result: '302' } }), + ]; + + for (const result of authenticationResults) { + mockSAMLAuthenticationProvider1.login.mockResolvedValue(result); + + await expect( + authenticator.login(request, { provider: { type: 'saml' }, value: {} }) + ).resolves.toEqual(result); + } + + expect(mockSessionStorage.set).toHaveBeenCalledTimes(2); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml1' }, + state: { result: '200' }, + }); + expect(mockSessionStorage.set).toHaveBeenCalledWith({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml1' }, + state: { result: '302' }, + }); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider2.login).not.toHaveBeenCalled(); + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(3); + }); + + it('provides session only if provider name matches', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'saml', name: 'saml2' }, + }); + + const loginAttemptValue = Symbol('attempt'); + await expect( + authenticator.login(request, { provider: { type: 'saml' }, value: loginAttemptValue }) + ).resolves.toEqual(AuthenticationResult.notHandled()); + + expect(mockBasicAuthenticationProvider.login).not.toHaveBeenCalled(); + + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider1.login).toHaveBeenCalledWith( + request, + loginAttemptValue, + null + ); + + expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledTimes(1); + expect(mockSAMLAuthenticationProvider2.login).toHaveBeenCalledWith( + request, + loginAttemptValue, + mockSessVal.state + ); + + // Presence of the session has precedence over order. + expect(mockSAMLAuthenticationProvider2.login.mock.invocationCallOrder[0]).toBeLessThan( + mockSAMLAuthenticationProvider1.login.mock.invocationCallOrder[0] + ); + }); }); it('clears session if it belongs to a different provider.', async () => { @@ -249,10 +449,13 @@ describe('Authenticator', () => { const request = httpServerMock.createKibanaRequest(); mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'token', name: 'token1' }, + }); await expect( - authenticator.login(request, { provider: 'basic', value: credentials }) + authenticator.login(request, { provider: { type: 'basic' }, value: credentials }) ).resolves.toEqual(AuthenticationResult.succeeded(user)); expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith( @@ -265,17 +468,67 @@ describe('Authenticator', () => { expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + it('clears session if it belongs to a provider with the name that is registered but has different type.', async () => { + const user = mockAuthenticatedUser(); + const credentials = { username: 'user', password: 'password' }; + const request = httpServerMock.createKibanaRequest(); + + // Re-configure authenticator with `token` provider that uses the name of `basic`. + const loginMock = jest.fn().mockResolvedValue(AuthenticationResult.succeeded(user)); + jest.requireMock('./providers/token').TokenAuthenticationProvider.mockImplementation(() => ({ + type: 'token', + login: loginMock, + getHTTPAuthenticationScheme: jest.fn(), + })); + mockOptions = getMockOptions({ providers: { token: { basic1: { order: 0 } } } }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + authenticator = new Authenticator(mockOptions); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + mockSessionStorage.get.mockResolvedValue(mockSessVal); + + await expect( + authenticator.login(request, { provider: { name: 'basic1' }, value: credentials }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); + + expect(loginMock).toHaveBeenCalledWith(request, credentials, null); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + it('clears session if provider asked to do so.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest(); + mockSessionStorage.get.mockResolvedValue(mockSessVal); mockBasicAuthenticationProvider.login.mockResolvedValue( AuthenticationResult.succeeded(user, { state: null }) ); - await expect(authenticator.login(request, { provider: 'basic', value: {} })).resolves.toEqual( - AuthenticationResult.succeeded(user, { state: null }) - ); + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user, { state: null })); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + + it('clears legacy session.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + // Use string format for the `provider` session value field to emulate legacy session. + mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + + mockBasicAuthenticationProvider.login.mockResolvedValue(AuthenticationResult.succeeded(user)); + + await expect( + authenticator.login(request, { provider: { type: 'basic' }, value: {} }) + ).resolves.toEqual(AuthenticationResult.succeeded(user)); + + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.login).toHaveBeenCalledWith(request, {}, null); expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); @@ -288,14 +541,15 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); + mockSessionStorage.get.mockResolvedValue(null); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, lifespanExpiration: null, state: { authorization: 'Basic xxx' }, - provider: 'basic', + provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, }; @@ -430,7 +684,7 @@ describe('Authenticator', () => { idleTimeout: duration(3600 * 24), lifespan: null, }, - providers: ['basic'], + providers: { basic: { basic1: { order: 0 } } }, }); mockSessionStorage = sessionStorageMock.create(); @@ -469,7 +723,7 @@ describe('Authenticator', () => { idleTimeout: duration(hr * 2), lifespan: duration(hr * 8), }, - providers: ['basic'], + providers: { basic: { basic1: { order: 0 } } }, }); mockSessionStorage = sessionStorageMock.create(); @@ -521,7 +775,7 @@ describe('Authenticator', () => { idleTimeout: null, lifespan, }, - providers: ['basic'], + providers: { basic: { basic1: { order: 0 } } }, }); mockSessionStorage = sessionStorageMock.create(); @@ -703,14 +957,33 @@ describe('Authenticator', () => { expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + it('clears legacy session.', async () => { + const user = mockAuthenticatedUser(); + const request = httpServerMock.createKibanaRequest(); + + // Use string format for the `provider` session value field to emulate legacy session. + mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'basic' }); + + mockBasicAuthenticationProvider.authenticate.mockResolvedValue( + AuthenticationResult.succeeded(user) + ); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.succeeded(user) + ); + + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledTimes(1); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalledWith(request, null); + + expect(mockSessionStorage.set).not.toHaveBeenCalled(); + expect(mockSessionStorage.clear).toHaveBeenCalled(); + }); + it('does not clear session if provider can not handle system API request authentication with active session.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-system-request': 'true' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); mockSessionStorage.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( @@ -726,9 +999,6 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); mockSessionStorage.get.mockResolvedValue(mockSessVal); await expect(authenticator.authenticate(request)).resolves.toEqual( @@ -744,10 +1014,10 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'true' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'token', name: 'token1' }, + }); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() @@ -762,10 +1032,10 @@ describe('Authenticator', () => { headers: { 'kbn-system-request': 'false' }, }); - mockBasicAuthenticationProvider.authenticate.mockResolvedValue( - AuthenticationResult.notHandled() - ); - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + provider: { type: 'token', name: 'token1' }, + }); await expect(authenticator.authenticate(request)).resolves.toEqual( AuthenticationResult.notHandled() @@ -774,6 +1044,70 @@ describe('Authenticator', () => { expect(mockSessionStorage.set).not.toHaveBeenCalled(); expect(mockSessionStorage.clear).toHaveBeenCalled(); }); + + describe('with Login Selector', () => { + beforeEach(() => { + mockOptions = getMockOptions({ + selector: { enabled: true }, + providers: { basic: { basic1: { order: 0 } } }, + }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + + authenticator = new Authenticator(mockOptions); + }); + + it('does not redirect to Login Selector if there is an active session', async () => { + const request = httpServerMock.createKibanaRequest(); + mockSessionStorage.get.mockResolvedValue(mockSessVal); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect AJAX requests to Login Selector', async () => { + const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect to Login Selector if request has `Authorization` header', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Basic ***' }, + }); + + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('does not redirect to Login Selector if it is not enabled', async () => { + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); + mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); + authenticator = new Authenticator(mockOptions); + + const request = httpServerMock.createKibanaRequest(); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + expect(mockBasicAuthenticationProvider.authenticate).toHaveBeenCalled(); + }); + + it('redirects to the Login Selector when needed.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(authenticator.authenticate(request)).resolves.toEqual( + AuthenticationResult.redirectTo( + '/mock-server-basepath/login?next=%2Fmock-server-basepath%2Fpath' + ) + ); + expect(mockBasicAuthenticationProvider.authenticate).not.toHaveBeenCalled(); + }); + }); }); describe('`logout` method', () => { @@ -782,14 +1116,14 @@ describe('Authenticator', () => { let mockSessionStorage: jest.Mocked>; let mockSessVal: any; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); mockSessVal = { idleTimeoutExpiration: null, lifespanExpiration: null, state: { authorization: 'Basic xxx' }, - provider: 'basic', + provider: { type: 'basic', name: 'basic1' }, path: mockOptions.basePath.serverBasePath, }; @@ -805,6 +1139,7 @@ describe('Authenticator', () => { it('returns `notHandled` if session does not exist.', async () => { const request = httpServerMock.createKibanaRequest(); mockSessionStorage.get.mockResolvedValue(null); + mockBasicAuthenticationProvider.logout.mockResolvedValue(DeauthenticationResult.notHandled()); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.notHandled() @@ -829,7 +1164,7 @@ describe('Authenticator', () => { }); it('if session does not exist but provider name is valid, returns whatever authentication provider returns.', async () => { - const request = httpServerMock.createKibanaRequest({ query: { provider: 'basic' } }); + const request = httpServerMock.createKibanaRequest({ query: { provider: 'basic1' } }); mockSessionStorage.get.mockResolvedValue(null); mockBasicAuthenticationProvider.logout.mockResolvedValue( @@ -855,16 +1190,20 @@ describe('Authenticator', () => { expect(mockSessionStorage.clear).not.toHaveBeenCalled(); }); - it('only clears session if it belongs to not configured provider.', async () => { + it('clears session if it belongs to not configured provider.', async () => { const request = httpServerMock.createKibanaRequest(); const state = { authorization: 'Bearer xxx' }; - mockSessionStorage.get.mockResolvedValue({ ...mockSessVal, state, provider: 'token' }); + mockSessionStorage.get.mockResolvedValue({ + ...mockSessVal, + state, + provider: { type: 'token', name: 'token1' }, + }); await expect(authenticator.logout(request)).resolves.toEqual( DeauthenticationResult.notHandled() ); - expect(mockBasicAuthenticationProvider.logout).not.toHaveBeenCalled(); + expect(mockBasicAuthenticationProvider.logout).toHaveBeenCalledTimes(1); expect(mockSessionStorage.clear).toHaveBeenCalled(); }); }); @@ -874,7 +1213,7 @@ describe('Authenticator', () => { let mockOptions: ReturnType; let mockSessionStorage: jest.Mocked>; beforeEach(() => { - mockOptions = getMockOptions({ providers: ['basic'] }); + mockOptions = getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }); mockSessionStorage = sessionStorageMock.create(); mockOptions.sessionStorageFactory.asScoped.mockReturnValue(mockSessionStorage); @@ -889,13 +1228,13 @@ describe('Authenticator', () => { now: currentDate, idleTimeoutExpiration: currentDate + 60000, lifespanExpiration: currentDate + 120000, - provider: 'basic', + provider: 'basic1', }; mockSessionStorage.get.mockResolvedValue({ idleTimeoutExpiration: mockInfo.idleTimeoutExpiration, lifespanExpiration: mockInfo.lifespanExpiration, state, - provider: mockInfo.provider, + provider: { type: 'basic', name: mockInfo.provider }, path: mockOptions.basePath.serverBasePath, }); jest.spyOn(Date, 'now').mockImplementation(() => currentDate); @@ -917,13 +1256,22 @@ describe('Authenticator', () => { describe('`isProviderEnabled` method', () => { it('returns `true` only if specified provider is enabled', () => { - let authenticator = new Authenticator(getMockOptions({ providers: ['basic'] })); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('saml')).toBe(false); - - authenticator = new Authenticator(getMockOptions({ providers: ['basic', 'saml'] })); - expect(authenticator.isProviderEnabled('basic')).toBe(true); - expect(authenticator.isProviderEnabled('saml')).toBe(true); + let authenticator = new Authenticator( + getMockOptions({ providers: { basic: { basic1: { order: 0 } } } }) + ); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('saml')).toBe(false); + + authenticator = new Authenticator( + getMockOptions({ + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'test' } }, + }, + }) + ); + expect(authenticator.isProviderTypeEnabled('basic')).toBe(true); + expect(authenticator.isProviderTypeEnabled('saml')).toBe(true); }); }); }); diff --git a/x-pack/plugins/security/server/authentication/authenticator.ts b/x-pack/plugins/security/server/authentication/authenticator.ts index e2e2d12917394..caf5b485d05e3 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.ts @@ -28,21 +28,22 @@ import { OIDCAuthenticationProvider, PKIAuthenticationProvider, HTTPAuthenticationProvider, - isSAMLRequestQuery, } from './providers'; import { AuthenticationResult } from './authentication_result'; import { DeauthenticationResult } from './deauthentication_result'; import { Tokens } from './tokens'; import { SessionInfo } from '../../public'; +import { canRedirectRequest } from './can_redirect_request'; +import { HTTPAuthorizationHeader } from './http_authentication'; /** * The shape of the session that is actually stored in the cookie. */ export interface ProviderSession { /** - * Name/type of the provider this session belongs to. + * Name and type of the provider this session belongs to. */ - provider: string; + provider: { type: string; name: string }; /** * The Unix time in ms when the session should be considered expired. If `null`, session will stay @@ -73,9 +74,9 @@ export interface ProviderSession { */ export interface ProviderLoginAttempt { /** - * Name/type of the provider this login attempt is targeted for. + * Name or type of the provider this login attempt is targeted for. */ - provider: string; + provider: { name: string } | { type: string }; /** * Login attempt can have any form and defined by the specific provider. @@ -115,11 +116,42 @@ function assertRequest(request: KibanaRequest) { } function assertLoginAttempt(attempt: ProviderLoginAttempt) { - if (!attempt || !attempt.provider || typeof attempt.provider !== 'string') { - throw new Error('Login attempt should be an object with non-empty "provider" property.'); + if (!isLoginAttemptWithProviderType(attempt) && !isLoginAttemptWithProviderName(attempt)) { + throw new Error( + 'Login attempt should be an object with non-empty "provider.type" or "provider.name" property.' + ); } } +function isLoginAttemptWithProviderName( + attempt: unknown +): attempt is { value: unknown; provider: { name: string } } { + return ( + typeof attempt === 'object' && + (attempt as any)?.provider?.name && + typeof (attempt as any)?.provider?.name === 'string' + ); +} + +function isLoginAttemptWithProviderType( + attempt: unknown +): attempt is { value: unknown; provider: { type: string } } { + return ( + typeof attempt === 'object' && + (attempt as any)?.provider?.type && + typeof (attempt as any)?.provider?.type === 'string' + ); +} + +/** + * Determines if session value was created by the previous Kibana versions which had a different + * session value format. + * @param sessionValue The session value to check. + */ +function isLegacyProviderSession(sessionValue: any) { + return typeof sessionValue?.provider === 'string'; +} + /** * Instantiates authentication provider based on the provider key from config. * @param providerType Provider type key. @@ -194,29 +226,22 @@ export class Authenticator { }), }; - const authProviders = this.options.config.authc.providers; - if (authProviders.length === 0) { - throw new Error( - 'No authentication provider is configured. Verify `xpack.security.authc.providers` config value.' - ); - } - this.providers = new Map( - authProviders.map(providerType => { - const providerSpecificOptions = this.options.config.authc.hasOwnProperty(providerType) - ? (this.options.config.authc as Record)[providerType] - : undefined; - - this.logger.debug(`Enabling "${providerType}" authentication provider.`); + this.options.config.authc.sortedProviders.map(({ type, name }) => { + this.logger.debug(`Enabling "${name}" (${type}) authentication provider.`); return [ - providerType, + name, instantiateProvider( - providerType, - Object.freeze({ ...providerCommonOptions, logger: options.loggers.get(providerType) }), - providerSpecificOptions + type, + Object.freeze({ + ...providerCommonOptions, + name, + logger: options.loggers.get(type, name), + }), + this.options.config.authc.providers[type]?.[name] ), - ] as [string, BaseAuthenticationProvider]; + ]; }) ); @@ -225,11 +250,18 @@ export class Authenticator { this.setupHTTPAuthenticationProvider( Object.freeze({ ...providerCommonOptions, + name: '__http__', logger: options.loggers.get(HTTPAuthenticationProvider.type), }) ); } + if (this.providers.size === 0) { + throw new Error( + 'No authentication provider is configured. Verify `xpack.security.authc.*` config value.' + ); + } + this.serverBasePath = this.options.basePath.serverBasePath || '/'; this.idleTimeout = this.options.config.session.idleTimeout; @@ -245,60 +277,58 @@ export class Authenticator { assertRequest(request); assertLoginAttempt(attempt); - // If there is an attempt to login with a provider that isn't enabled, we should fail. - const provider = this.providers.get(attempt.provider); - if (provider === undefined) { + const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + const existingSession = await this.getSessionValue(sessionStorage); + + // Login attempt can target specific provider by its name (e.g. chosen at the Login Selector UI) + // or a group of providers with the specified type (e.g. in case of 3rd-party initiated login + // attempts we may not know what provider exactly can handle that attempt and we have to try + // every enabled provider of the specified type). + const providers: Array<[string, BaseAuthenticationProvider]> = + isLoginAttemptWithProviderName(attempt) && this.providers.has(attempt.provider.name) + ? [[attempt.provider.name, this.providers.get(attempt.provider.name)!]] + : isLoginAttemptWithProviderType(attempt) + ? [...this.providerIterator(existingSession)].filter( + ([, { type }]) => type === attempt.provider.type + ) + : []; + + if (providers.length === 0) { this.logger.debug( - `Login attempt for provider "${attempt.provider}" is detected, but it isn't enabled.` + `Login attempt for provider with ${ + isLoginAttemptWithProviderName(attempt) + ? `name ${attempt.provider.name}` + : `type "${(attempt.provider as Record).type}"` + } is detected, but it isn't enabled.` ); return AuthenticationResult.notHandled(); } - this.logger.debug(`Performing login using "${attempt.provider}" provider.`); - - const sessionStorage = this.options.sessionStorageFactory.asScoped(request); + for (const [providerName, provider] of providers) { + // Check if current session has been set by this provider. + const ownsSession = + existingSession?.provider.name === providerName && + existingSession?.provider.type === provider.type; - // If we detect an existing session that belongs to a different provider than the one requested - // to perform a login we should clear such session. - let existingSession = await this.getSessionValue(sessionStorage); - if (existingSession && existingSession.provider !== attempt.provider) { - this.logger.debug( - `Clearing existing session of another ("${existingSession.provider}") provider.` + const authenticationResult = await provider.login( + request, + attempt.value, + ownsSession ? existingSession!.state : null ); - sessionStorage.clear(); - existingSession = null; - } - - const authenticationResult = await provider.login( - request, - attempt.value, - existingSession && existingSession.state - ); - // There are two possible cases when we'd want to clear existing state: - // 1. If provider owned the state (e.g. intermediate state used for multi step login), but failed - // to login, that likely means that state is not valid anymore and we should clear it. - // 2. Also provider can specifically ask to clear state by setting it to `null` even if - // authentication attempt didn't fail (e.g. custom realm could "pin" client/request identity to - // a server-side only session established during multi step login that relied on intermediate - // client-side state which isn't needed anymore). - const shouldClearSession = - authenticationResult.shouldClearState() || - (authenticationResult.failed() && getErrorStatusCode(authenticationResult.error) === 401); - if (existingSession && shouldClearSession) { - sessionStorage.clear(); - } else if (authenticationResult.shouldUpdateState()) { - const { idleTimeoutExpiration, lifespanExpiration } = this.calculateExpiry(existingSession); - sessionStorage.set({ - state: authenticationResult.state, - provider: attempt.provider, - idleTimeoutExpiration, - lifespanExpiration, - path: this.serverBasePath, + this.updateSessionValue(sessionStorage, { + provider: { type: provider.type, name: providerName }, + isSystemRequest: request.isSystemRequest, + authenticationResult, + existingSession: ownsSession ? existingSession : null, }); + + if (!authenticationResult.notHandled()) { + return authenticationResult; + } } - return authenticationResult; + return AuthenticationResult.notHandled(); } /** @@ -311,33 +341,46 @@ export class Authenticator { const sessionStorage = this.options.sessionStorageFactory.asScoped(request); const existingSession = await this.getSessionValue(sessionStorage); - let authenticationResult = AuthenticationResult.notHandled(); - for (const [providerType, provider] of this.providerIterator(existingSession)) { + // If request doesn't have any session information, isn't attributed with HTTP Authorization + // header and Login Selector is enabled, we must redirect user to the login selector. + const useLoginSelector = + !existingSession && + this.options.config.authc.selector.enabled && + canRedirectRequest(request) && + HTTPAuthorizationHeader.parseFromRequest(request) == null; + if (useLoginSelector) { + this.logger.debug('Redirecting request to Login Selector.'); + return AuthenticationResult.redirectTo( + `${this.options.basePath.serverBasePath}/login?next=${encodeURIComponent( + `${this.options.basePath.get(request)}${request.url.path}` + )}` + ); + } + + for (const [providerName, provider] of this.providerIterator(existingSession)) { // Check if current session has been set by this provider. - const ownsSession = existingSession && existingSession.provider === providerType; + const ownsSession = + existingSession?.provider.name === providerName && + existingSession?.provider.type === provider.type; - authenticationResult = await provider.authenticate( + const authenticationResult = await provider.authenticate( request, ownsSession ? existingSession!.state : null ); this.updateSessionValue(sessionStorage, { - providerType, + provider: { type: provider.type, name: providerName }, isSystemRequest: request.isSystemRequest, authenticationResult, existingSession: ownsSession ? existingSession : null, }); - if ( - authenticationResult.failed() || - authenticationResult.succeeded() || - authenticationResult.redirected() - ) { + if (!authenticationResult.notHandled()) { return authenticationResult; } } - return authenticationResult; + return AuthenticationResult.notHandled(); } /** @@ -349,28 +392,33 @@ export class Authenticator { const sessionStorage = this.options.sessionStorageFactory.asScoped(request); const sessionValue = await this.getSessionValue(sessionStorage); - const providerName = this.getProviderName(request.query); if (sessionValue) { sessionStorage.clear(); - return this.providers.get(sessionValue.provider)!.logout(request, sessionValue.state); - } else if (providerName) { + return this.providers.get(sessionValue.provider.name)!.logout(request, sessionValue.state); + } + + const providerName = this.getProviderName(request.query); + if (providerName) { // provider name is passed in a query param and sourced from the browser's local storage; // hence, we can't assume that this provider exists, so we have to check it const provider = this.providers.get(providerName); if (provider) { return provider.logout(request, null); } - } - - // Normally when there is no active session in Kibana, `logout` method shouldn't do anything - // and user will eventually be redirected to the home page to log in. But if SAML is supported there - // is a special case when logout is initiated by the IdP or another SP, then IdP will request _every_ - // SP associated with the current user session to do the logout. So if Kibana (without active session) - // receives such a request it shouldn't redirect user to the home page, but rather redirect back to IdP - // with correct logout response and only Elasticsearch knows how to do that. - if (isSAMLRequestQuery(request.query) && this.providers.has('saml')) { - return this.providers.get('saml')!.logout(request); + } else { + // In case logout is called and we cannot figure out what provider is supposed to handle it, + // we should iterate through all providers and let them decide if they can perform a logout. + // This can be necessary if some 3rd-party initiates logout. And even if user doesn't have an + // active session already some providers can still properly respond to the 3rd-party logout + // request. For example SAML provider can process logout request encoded in `SAMLRequest` + // query string parameter. + for (const [, provider] of this.providerIterator(null)) { + const deauthenticationResult = await provider.logout(request); + if (!deauthenticationResult.notHandled()) { + return deauthenticationResult; + } + } } return DeauthenticationResult.notHandled(); @@ -393,7 +441,7 @@ export class Authenticator { now: Date.now(), idleTimeoutExpiration: sessionValue.idleTimeoutExpiration, lifespanExpiration: sessionValue.lifespanExpiration, - provider: sessionValue.provider, + provider: sessionValue.provider.name, }; } return null; @@ -403,8 +451,8 @@ export class Authenticator { * Checks whether specified provider type is currently enabled. * @param providerType Type of the provider (`basic`, `saml`, `pki` etc.). */ - isProviderEnabled(providerType: string) { - return this.providers.has(providerType); + isProviderTypeEnabled(providerType: string) { + return [...this.providers.values()].some(provider => provider.type === providerType); } /** @@ -428,10 +476,11 @@ export class Authenticator { } } - this.providers.set( - HTTPAuthenticationProvider.type, - new HTTPAuthenticationProvider(options, { supportedSchemes }) - ); + if (this.providers.has(options.name)) { + throw new Error(`Provider name "${options.name}" is reserved.`); + } + + this.providers.set(options.name, new HTTPAuthenticationProvider(options, { supportedSchemes })); } /** @@ -447,11 +496,11 @@ export class Authenticator { if (!sessionValue) { yield* this.providers; } else { - yield [sessionValue.provider, this.providers.get(sessionValue.provider)!]; + yield [sessionValue.provider.name, this.providers.get(sessionValue.provider.name)!]; - for (const [providerType, provider] of this.providers) { - if (providerType !== sessionValue.provider) { - yield [providerType, provider]; + for (const [providerName, provider] of this.providers) { + if (providerName !== sessionValue.provider.name) { + yield [providerName, provider]; } } } @@ -463,14 +512,19 @@ export class Authenticator { * @param sessionStorage Session storage instance. */ private async getSessionValue(sessionStorage: SessionStorage) { - let sessionValue = await sessionStorage.get(); + const sessionValue = await sessionStorage.get(); - // If for some reason we have a session stored for the provider that is not available - // (e.g. when user was logged in with one provider, but then configuration has changed - // and that provider is no longer available), then we should clear session entirely. - if (sessionValue && !this.providers.has(sessionValue.provider)) { + // If we detect that session is in incompatible format or for some reason we have a session + // stored for the provider that is not available anymore (e.g. when user was logged in with one + // provider, but then configuration has changed and that provider is no longer available), then + // we should clear session entirely. + if ( + sessionValue && + (isLegacyProviderSession(sessionValue) || + this.providers.get(sessionValue.provider.name)?.type !== sessionValue.provider.type) + ) { sessionStorage.clear(); - sessionValue = null; + return null; } return sessionValue; @@ -479,12 +533,12 @@ export class Authenticator { private updateSessionValue( sessionStorage: SessionStorage, { - providerType, + provider, authenticationResult, existingSession, isSystemRequest, }: { - providerType: string; + provider: { type: string; name: string }; authenticationResult: AuthenticationResult; existingSession: ProviderSession | null; isSystemRequest: boolean; @@ -515,7 +569,7 @@ export class Authenticator { state: authenticationResult.shouldUpdateState() ? authenticationResult.state : existingSession!.state, - provider: providerType, + provider, idleTimeoutExpiration, lifespanExpiration, path: this.serverBasePath, diff --git a/x-pack/plugins/security/server/authentication/index.mock.ts b/x-pack/plugins/security/server/authentication/index.mock.ts index 43892753f0d3f..8092c1c81017b 100644 --- a/x-pack/plugins/security/server/authentication/index.mock.ts +++ b/x-pack/plugins/security/server/authentication/index.mock.ts @@ -10,7 +10,7 @@ export const authenticationMock = { create: (): jest.Mocked => ({ login: jest.fn(), logout: jest.fn(), - isProviderEnabled: jest.fn(), + isProviderTypeEnabled: jest.fn(), createAPIKey: jest.fn(), getCurrentUser: jest.fn(), grantAPIKeyAsInternalUser: jest.fn(), diff --git a/x-pack/plugins/security/server/authentication/index.test.ts b/x-pack/plugins/security/server/authentication/index.test.ts index 21e5f18bc0282..6609f8707976b 100644 --- a/x-pack/plugins/security/server/authentication/index.test.ts +++ b/x-pack/plugins/security/server/authentication/index.test.ts @@ -10,7 +10,6 @@ jest.mock('./api_keys'); jest.mock('./authenticator'); import Boom from 'boom'; -import { first } from 'rxjs/operators'; import { loggingServiceMock, @@ -31,7 +30,7 @@ import { ScopedClusterClient, } from '../../../../../src/core/server'; import { AuthenticatedUser } from '../../common/model'; -import { ConfigType, createConfig$ } from '../config'; +import { ConfigSchema, ConfigType, createConfig } from '../config'; import { AuthenticationResult } from './authentication_result'; import { Authentication, setupAuthentication } from '.'; import { @@ -51,23 +50,18 @@ describe('setupAuthentication()', () => { license: jest.Mocked; }; let mockScopedClusterClient: jest.Mocked>; - beforeEach(async () => { - const mockConfig$ = createConfig$( - coreMock.createPluginInitializerContext({ - encryptionKey: 'ab'.repeat(16), - secureCookies: true, - session: { - idleTimeout: null, - lifespan: null, - }, - cookieName: 'my-sid-cookie', - authc: { providers: ['basic'], http: { enabled: true } }, - }), - true - ); + beforeEach(() => { mockSetupAuthenticationParams = { http: coreMock.createSetup().http, - config: await mockConfig$.pipe(first()).toPromise(), + config: createConfig( + ConfigSchema.validate({ + encryptionKey: 'ab'.repeat(16), + secureCookies: true, + cookieName: 'my-sid-cookie', + }), + loggingServiceMock.create().get(), + { isTLSEnabled: false } + ), clusterClient: elasticsearchServiceMock.createClusterClient(), license: licenseMock.create(), loggers: loggingServiceMock.create(), diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index c5c72853e68e1..30ac84632cb7e 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -21,7 +21,7 @@ export { canRedirectRequest } from './can_redirect_request'; export { Authenticator, ProviderLoginAttempt } from './authenticator'; export { AuthenticationResult } from './authentication_result'; export { DeauthenticationResult } from './deauthentication_result'; -export { OIDCAuthenticationFlow, SAMLLoginStep } from './providers'; +export { OIDCLogin, SAMLLogin } from './providers'; export { CreateAPIKeyResult, InvalidateAPIKeyResult, @@ -169,7 +169,7 @@ export async function setupAuthentication({ login: authenticator.login.bind(authenticator), logout: authenticator.logout.bind(authenticator), getSessionInfo: authenticator.getSessionInfo.bind(authenticator), - isProviderEnabled: authenticator.isProviderEnabled.bind(authenticator), + isProviderTypeEnabled: authenticator.isProviderTypeEnabled.bind(authenticator), getCurrentUser, createAPIKey: (request: KibanaRequest, params: CreateAPIKeyParams) => apiKeys.create(request, params), diff --git a/x-pack/plugins/security/server/authentication/providers/base.mock.ts b/x-pack/plugins/security/server/authentication/providers/base.mock.ts index 0781608f8bc4c..1dcd2885f66dc 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.mock.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.mock.ts @@ -14,7 +14,7 @@ export type MockAuthenticationProviderOptions = ReturnType< typeof mockAuthenticationProviderOptions >; -export function mockAuthenticationProviderOptions() { +export function mockAuthenticationProviderOptions(options?: { name: string }) { const basePath = httpServiceMock.createSetupContract().basePath; basePath.get.mockReturnValue('/base-path'); @@ -23,5 +23,6 @@ export function mockAuthenticationProviderOptions() { logger: loggingServiceMock.create().get(), basePath, tokens: { refresh: jest.fn(), invalidate: jest.fn() }, + name: options?.name ?? 'basic1', }; } diff --git a/x-pack/plugins/security/server/authentication/providers/base.ts b/x-pack/plugins/security/server/authentication/providers/base.ts index 300e59d9ea3da..48a73586a6fed 100644 --- a/x-pack/plugins/security/server/authentication/providers/base.ts +++ b/x-pack/plugins/security/server/authentication/providers/base.ts @@ -21,6 +21,7 @@ import { Tokens } from '../tokens'; * Represents available provider options. */ export interface AuthenticationProviderOptions { + name: string; basePath: HttpServiceSetup['basePath']; client: IClusterClient; logger: Logger; @@ -41,6 +42,12 @@ export abstract class BaseAuthenticationProvider { */ static readonly type: string; + /** + * Type of the provider. We use `this.constructor` trick to get access to the static `type` field + * of the specific `BaseAuthenticationProvider` subclass. + */ + public readonly type = (this.constructor as any).type as string; + /** * Logger instance bound to a specific provider context. */ @@ -102,9 +109,7 @@ export abstract class BaseAuthenticationProvider { ...(await this.options.client .asScoped({ headers: { ...request.headers, ...authHeaders } }) .callAsCurrentUser('shield.authenticate')), - // We use `this.constructor` trick to get access to the static `type` field of the specific - // `BaseAuthenticationProvider` subclass. - authentication_provider: (this.constructor as any).type, + authentication_provider: this.options.name, } as AuthenticatedUser); } } diff --git a/x-pack/plugins/security/server/authentication/providers/basic.test.ts b/x-pack/plugins/security/server/authentication/providers/basic.test.ts index b7bdff0531fc2..97ca4e46d3eb5 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.test.ts @@ -91,6 +91,12 @@ describe('BasicAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.notHandled()); }); + it('does not redirect requests that do not require authentication to the login page.', async () => { + await expect( + provider.authenticate(httpServerMock.createKibanaRequest({ routeAuthRequired: false })) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { await expect( provider.authenticate( @@ -172,8 +178,14 @@ describe('BasicAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('always redirects to the login page.', async () => { + it('does not handle logout if state is not present', async () => { await expect(provider.logout(httpServerMock.createKibanaRequest())).resolves.toEqual( + DeauthenticationResult.notHandled() + ); + }); + + it('always redirects to the login page.', async () => { + await expect(provider.logout(httpServerMock.createKibanaRequest(), {})).resolves.toEqual( DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') ); }); @@ -181,7 +193,10 @@ describe('BasicAuthenticationProvider', () => { it('passes query string parameters to the login page.', async () => { await expect( provider.logout( - httpServerMock.createKibanaRequest({ query: { next: '/app/ml', msg: 'SESSION_EXPIRED' } }) + httpServerMock.createKibanaRequest({ + query: { next: '/app/ml', msg: 'SESSION_EXPIRED' }, + }), + {} ) ).resolves.toEqual( DeauthenticationResult.redirectTo('/base-path/login?next=%2Fapp%2Fml&msg=SESSION_EXPIRED') diff --git a/x-pack/plugins/security/server/authentication/providers/basic.ts b/x-pack/plugins/security/server/authentication/providers/basic.ts index 76a9f936eca48..83d4ea689f46a 100644 --- a/x-pack/plugins/security/server/authentication/providers/basic.ts +++ b/x-pack/plugins/security/server/authentication/providers/basic.ts @@ -34,6 +34,16 @@ interface ProviderState { authorization?: string; } +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the login page where they can enter username and password. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + /** * Provider that supports request authentication via Basic HTTP Authentication. */ @@ -92,7 +102,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { } // If state isn't present let's redirect user to the login page. - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug('Redirecting request to Login page.'); const basePath = this.options.basePath.get(request); return AuthenticationResult.redirectTo( @@ -106,8 +116,15 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { /** * Redirects user to the login page preserving query string parameters. * @param request Request instance. + * @param [state] Optional state object associated with the provider. */ - public async logout(request: KibanaRequest) { + public async logout(request: KibanaRequest, state?: ProviderState | null) { + this.logger.debug(`Trying to log user out via ${request.url.path}.`); + + if (!state) { + return DeauthenticationResult.notHandled(); + } + // Query string may contain the path where logout has been called or // logout reason that login page may need to know. const queryString = request.url.search || `?msg=LOGGED_OUT`; @@ -134,7 +151,7 @@ export class BasicAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Trying to authenticate via state.'); if (!authorization) { - this.logger.debug('Access token is not found in state.'); + this.logger.debug('Authorization header is not found in state.'); return AuthenticationResult.notHandled(); } diff --git a/x-pack/plugins/security/server/authentication/providers/http.test.ts b/x-pack/plugins/security/server/authentication/providers/http.test.ts index 65fbd7cd9f4ad..47715670e4697 100644 --- a/x-pack/plugins/security/server/authentication/providers/http.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/http.test.ts @@ -32,7 +32,7 @@ function expectAuthenticateCall( describe('HTTPAuthenticationProvider', () => { let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'http' }); }); it('throws if `schemes` are not specified', () => { diff --git a/x-pack/plugins/security/server/authentication/providers/index.ts b/x-pack/plugins/security/server/authentication/providers/index.ts index cd8f5a70c64e3..048afb6190d18 100644 --- a/x-pack/plugins/security/server/authentication/providers/index.ts +++ b/x-pack/plugins/security/server/authentication/providers/index.ts @@ -11,8 +11,8 @@ export { } from './base'; export { BasicAuthenticationProvider } from './basic'; export { KerberosAuthenticationProvider } from './kerberos'; -export { SAMLAuthenticationProvider, isSAMLRequestQuery, SAMLLoginStep } from './saml'; +export { SAMLAuthenticationProvider, SAMLLogin } from './saml'; export { TokenAuthenticationProvider } from './token'; -export { OIDCAuthenticationProvider, OIDCAuthenticationFlow } from './oidc'; +export { OIDCAuthenticationProvider, OIDCLogin } from './oidc'; export { PKIAuthenticationProvider } from './pki'; export { HTTPAuthenticationProvider } from './http'; diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts index 955805296e2bd..6eb47cfa83e32 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.test.ts @@ -14,6 +14,7 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } import { ElasticsearchErrorHelpers, IClusterClient, + KibanaRequest, ScopeableRequest, } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; @@ -36,43 +37,13 @@ describe('KerberosAuthenticationProvider', () => { let provider: KerberosAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'kerberos' }); provider = new KerberosAuthenticationProvider(mockOptions); }); - describe('`authenticate` method', () => { - it('does not handle authentication via `authorization` header with non-negotiate scheme.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - - it('does not handle authentication via `authorization` header with non-negotiate scheme even if state contains a valid token.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - const tokenPair = { - accessToken: 'some-valid-token', - refreshToken: 'some-valid-refresh-token', - }; - - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - + function defineCommonLoginAndAuthenticateTests( + operation: (request: KibanaRequest) => Promise + ) { it('does not handle requests that can be authenticated without `Negotiate` header.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); @@ -80,9 +51,7 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue({}); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, @@ -98,33 +67,13 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, }); }); - it('fails if state is present, but backend does not support Kerberos.', async () => { - const request = httpServerMock.createKibanaRequest(); - const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; - - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); - mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.tokens.refresh.mockResolvedValue(null); - - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); - - expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); - }); - it('fails with `Negotiate` challenge if backend supports Kerberos.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); @@ -137,7 +86,7 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(failureReason, { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) @@ -156,9 +105,7 @@ describe('KerberosAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); expectAuthenticateCall(mockOptions.client, { headers: { authorization: `Negotiate ${Buffer.from('__fake__').toString('base64')}` }, @@ -179,7 +126,7 @@ describe('KerberosAuthenticationProvider', () => { refresh_token: 'some-refresh-token', }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'kerberos' }, { @@ -215,7 +162,7 @@ describe('KerberosAuthenticationProvider', () => { kerberos_authentication_response_token: 'response-token', }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'kerberos' }, { @@ -249,7 +196,7 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate response-token' }, }) @@ -274,7 +221,7 @@ describe('KerberosAuthenticationProvider', () => { ); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.failed(Boom.unauthorized(), { authResponseHeaders: { 'WWW-Authenticate': 'Negotiate' }, }) @@ -295,9 +242,7 @@ describe('KerberosAuthenticationProvider', () => { const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.getAccessToken', { body: { grant_type: '_kerberos', kerberos_ticket: 'spnego' }, @@ -320,9 +265,7 @@ describe('KerberosAuthenticationProvider', () => { refresh_token: 'some-refresh-token', }); - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); expectAuthenticateCall(mockOptions.client, { headers: { authorization: 'Bearer some-token' }, @@ -334,6 +277,74 @@ describe('KerberosAuthenticationProvider', () => { expect(request.headers.authorization).toBe('negotiate spnego'); }); + } + + describe('`login` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.login(request)); + }); + + describe('`authenticate` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.authenticate(request, null)); + + it('does not handle authentication via `authorization` header with non-negotiate scheme.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('does not handle authentication via `authorization` header with non-negotiate scheme even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + const tokenPair = { + accessToken: 'some-valid-token', + refreshToken: 'some-valid-refresh-token', + }; + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('fails if state is present, but backend does not support Kerberos.', async () => { + const request = httpServerMock.createKibanaRequest(); + const tokenPair = { accessToken: 'token', refreshToken: 'refresh-token' }; + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(failureReason) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + }); + + it('does not start SPNEGO if request does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); it('succeeds if state contains a valid token.', async () => { const user = mockAuthenticatedUser(); @@ -454,6 +465,29 @@ describe('KerberosAuthenticationProvider', () => { expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); }); + + it('does not re-start SPNEGO if both access and refresh tokens from the state are expired.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + const tokenPair = { accessToken: 'expired-token', refreshToken: 'some-valid-refresh-token' }; + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError( + new (errors.AuthenticationException as any)('Unauthorized', { + body: { error: { header: { 'WWW-Authenticate': 'Negotiate' } } }, + }) + ); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + }); }); describe('`logout` method', () => { diff --git a/x-pack/plugins/security/server/authentication/providers/kerberos.ts b/x-pack/plugins/security/server/authentication/providers/kerberos.ts index dbd0a438d71c9..c4bbe554a3da1 100644 --- a/x-pack/plugins/security/server/authentication/providers/kerberos.ts +++ b/x-pack/plugins/security/server/authentication/providers/kerberos.ts @@ -27,6 +27,15 @@ type ProviderState = TokenPair; */ const WWWAuthenticateHeaderName = 'WWW-Authenticate'; +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication. + return request.route.options.authRequired === true; +} + /** * Provider that supports Kerberos request authentication. */ @@ -36,6 +45,20 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { */ static readonly type = 'kerberos'; + /** + * Performs initial login request. + * @param request Request instance. + */ + public async login(request: KibanaRequest) { + this.logger.debug('Trying to perform a login.'); + + if (HTTPAuthorizationHeader.parseFromRequest(request)?.scheme.toLowerCase() === 'negotiate') { + return await this.authenticateWithNegotiateScheme(request); + } + + return await this.authenticateViaSPNEGO(request); + } + /** * Performs Kerberos request authentication. * @param request Request instance. @@ -66,7 +89,7 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // If we couldn't authenticate by means of all methods above, let's try to check if Elasticsearch can // start authentication mechanism negotiation, otherwise just return authentication result we have. - return authenticationResult.notHandled() + return authenticationResult.notHandled() && canStartNewSession(request) ? await this.authenticateViaSPNEGO(request, state) : authenticationResult; } @@ -239,10 +262,10 @@ export class KerberosAuthenticationProvider extends BaseAuthenticationProvider { // If refresh token is no longer valid, then we should clear session and renegotiate using SPNEGO. if (refreshedTokenPair === null) { - this.logger.debug( - 'Both access and refresh tokens are expired. Re-initiating SPNEGO handshake.' - ); - return this.authenticateViaSPNEGO(request, state); + this.logger.debug('Both access and refresh tokens are expired.'); + return canStartNewSession(request) + ? this.authenticateViaSPNEGO(request, state) + : AuthenticationResult.notHandled(); } try { diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts index 6a4ba1ccb41e2..14fe42aac7599 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.test.ts @@ -18,7 +18,7 @@ import { } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { OIDCAuthenticationProvider, OIDCAuthenticationFlow, ProviderLoginAttempt } from './oidc'; +import { OIDCAuthenticationProvider, OIDCLogin, ProviderLoginAttempt } from './oidc'; function expectAuthenticateCall( mockClusterClient: jest.Mocked, @@ -36,7 +36,7 @@ describe('OIDCAuthenticationProvider', () => { let provider: OIDCAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'oidc' }); provider = new OIDCAuthenticationProvider(mockOptions, { realm: 'oidc1' }); }); @@ -72,7 +72,7 @@ describe('OIDCAuthenticationProvider', () => { await expect( provider.login(request, { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: 'theissuer', loginHint: 'loginhint', }) @@ -84,7 +84,14 @@ describe('OIDCAuthenticationProvider', () => { '&state=statevalue' + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + '&login_hint=loginhint', - { state: { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/' } } + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/mock-server-basepath/', + realm: 'oidc1', + }, + } ) ); @@ -93,6 +100,50 @@ describe('OIDCAuthenticationProvider', () => { }); }); + it('redirects user initiated login attempts to the OpenId Connect Provider.', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + state: 'statevalue', + nonce: 'noncevalue', + redirect: + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + }); + + await expect( + provider.login(request, { + type: OIDCLogin.LoginInitiatedByUser, + redirectURLPath: '/mock-server-basepath/app/super-kibana', + }) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://op-host/path/login?response_type=code' + + '&scope=openid%20profile%20email' + + '&client_id=s6BhdRkqt3' + + '&state=statevalue' + + '&redirect_uri=https%3A%2F%2Ftest-hostname:1234%2Ftest-base-path%2Fapi%2Fsecurity%2Fv1%2F/oidc' + + '&login_hint=loginhint', + { + state: { + state: 'statevalue', + nonce: 'noncevalue', + nextURL: '/mock-server-basepath/app/super-kibana', + realm: 'oidc1', + }, + } + ) + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcPrepare', { + body: { realm: 'oidc1' }, + }); + }); + function defineAuthenticationFlowTests( getMocks: () => { request: KibanaRequest; @@ -113,10 +164,15 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path', + realm: 'oidc1', }) ).resolves.toEqual( AuthenticationResult.redirectTo('/base-path/some-path', { - state: { accessToken: 'some-token', refreshToken: 'some-refresh-token' }, + state: { + accessToken: 'some-token', + refreshToken: 'some-refresh-token', + realm: 'oidc1', + }, }) ); @@ -137,7 +193,7 @@ describe('OIDCAuthenticationProvider', () => { const { request, attempt } = getMocks(); await expect( - provider.login(request, attempt, { nextURL: '/base-path/some-path' }) + provider.login(request, attempt, { nextURL: '/base-path/some-path', realm: 'oidc1' }) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -153,7 +209,11 @@ describe('OIDCAuthenticationProvider', () => { const { request, attempt } = getMocks(); await expect( - provider.login(request, attempt, { state: 'statevalue', nonce: 'noncevalue' }) + provider.login(request, attempt, { + state: 'statevalue', + nonce: 'noncevalue', + realm: 'oidc1', + }) ).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( @@ -168,7 +228,7 @@ describe('OIDCAuthenticationProvider', () => { it('fails if session state is not presented.', async () => { const { request, attempt } = getMocks(); - await expect(provider.login(request, attempt, {})).resolves.toEqual( + await expect(provider.login(request, attempt, {} as any)).resolves.toEqual( AuthenticationResult.failed( Boom.badRequest( 'Response session state does not have corresponding state or nonce parameters or redirect URL.' @@ -192,6 +252,7 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/some-path', + realm: 'oidc1', }) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -207,6 +268,20 @@ describe('OIDCAuthenticationProvider', () => { } ); }); + + it('fails if realm from state is different from the realm provider is configured with.', async () => { + const { request, attempt } = getMocks(); + + await expect(provider.login(request, attempt, { realm: 'other-realm' })).resolves.toEqual( + AuthenticationResult.failed( + Boom.unauthorized( + 'State based on realm "other-realm", but provider with the name "oidc" is configured to use realm "oidc1".' + ) + ) + ); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); } describe('authorization code flow', () => { @@ -215,7 +290,7 @@ describe('OIDCAuthenticationProvider', () => { path: '/api/security/oidc/callback?code=somecodehere&state=somestatehere', }), attempt: { - flow: OIDCAuthenticationFlow.AuthorizationCode, + type: OIDCLogin.LoginWithAuthorizationCodeFlow, authenticationResponseURI: '/api/security/oidc/callback?code=somecodehere&state=somestatehere', }, @@ -230,7 +305,7 @@ describe('OIDCAuthenticationProvider', () => { '/api/security/oidc/callback?authenticationResponseURI=http://kibana/api/security/oidc/implicit#id_token=sometoken', }), attempt: { - flow: OIDCAuthenticationFlow.Implicit, + type: OIDCLogin.LoginWithImplicitFlow, authenticationResponseURI: 'http://kibana/api/security/oidc/implicit#id_token=sometoken', }, expectedRedirectURI: 'http://kibana/api/security/oidc/implicit#id_token=sometoken', @@ -246,6 +321,13 @@ describe('OIDCAuthenticationProvider', () => { ); }); + it('does not handle non-AJAX request that does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + }); + it('redirects non-AJAX request that can not be authenticated to the OpenId Connect Provider.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path' }); @@ -272,6 +354,7 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/s/foo/some-path', + realm: 'oidc1', }, } ) @@ -310,7 +393,9 @@ describe('OIDCAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockResolvedValue(user); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'oidc' }, { authHeaders: { authorization } } @@ -344,6 +429,7 @@ describe('OIDCAuthenticationProvider', () => { provider.authenticate(request, { accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'oidc1', }) ).resolves.toEqual(AuthenticationResult.notHandled()); @@ -364,9 +450,9 @@ describe('OIDCAuthenticationProvider', () => { mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual(AuthenticationResult.failed(failureReason)); expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); @@ -401,12 +487,18 @@ describe('OIDCAuthenticationProvider', () => { refreshToken: 'new-refresh-token', }); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'oidc' }, { authHeaders: { authorization: 'Bearer new-access-token' }, - state: { accessToken: 'new-access-token', refreshToken: 'new-refresh-token' }, + state: { + accessToken: 'new-access-token', + refreshToken: 'new-refresh-token', + realm: 'oidc1', + }, } ) ); @@ -434,9 +526,9 @@ describe('OIDCAuthenticationProvider', () => { }; mockOptions.tokens.refresh.mockRejectedValue(refreshFailureReason); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( - AuthenticationResult.failed(refreshFailureReason as any) - ); + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual(AuthenticationResult.failed(refreshFailureReason as any)); expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); @@ -470,7 +562,9 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.redirectTo( 'https://op-host/path/login?response_type=code' + '&scope=openid%20profile%20email' + @@ -482,6 +576,7 @@ describe('OIDCAuthenticationProvider', () => { state: 'statevalue', nonce: 'noncevalue', nextURL: '/base-path/s/foo/some-path', + realm: 'oidc1', }, } ) @@ -515,7 +610,9 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.tokens.refresh.mockResolvedValue(null); - await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) ); @@ -528,6 +625,44 @@ describe('OIDCAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + + it('fails for non-AJAX requests that do not require authentication with user friendly message if refresh token is expired.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false, headers: {} }); + const tokenPair = { accessToken: 'expired-token', refreshToken: 'expired-refresh-token' }; + const authorization = `Bearer ${tokenPair.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect( + provider.authenticate(request, { ...tokenPair, realm: 'oidc1' }) + ).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + + it('fails if realm from state is different from the realm provider is configured with.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual( + AuthenticationResult.failed( + Boom.unauthorized( + 'State based on realm "other-realm", but provider with the name "oidc" is configured to use realm "oidc1".' + ) + ) + ); + }); }); describe('`logout` method', () => { @@ -538,11 +673,11 @@ describe('OIDCAuthenticationProvider', () => { DeauthenticationResult.notHandled() ); - await expect(provider.logout(request, {})).resolves.toEqual( + await expect(provider.logout(request, {} as any)).resolves.toEqual( DeauthenticationResult.notHandled() ); - await expect(provider.logout(request, { nonce: 'x' })).resolves.toEqual( + await expect(provider.logout(request, { nonce: 'x', realm: 'oidc1' })).resolves.toEqual( DeauthenticationResult.notHandled() ); @@ -557,9 +692,9 @@ describe('OIDCAuthenticationProvider', () => { const failureReason = new Error('Realm is misconfigured!'); mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( - DeauthenticationResult.failed(failureReason) - ); + await expect( + provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.oidcLogout', { @@ -574,7 +709,9 @@ describe('OIDCAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); - await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + await expect( + provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -593,7 +730,9 @@ describe('OIDCAuthenticationProvider', () => { redirect: 'http://fake-idp/logout&id_token_hint=thehint', }); - await expect(provider.logout(request, { accessToken, refreshToken })).resolves.toEqual( + await expect( + provider.logout(request, { accessToken, refreshToken, realm: 'oidc1' }) + ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/logout&id_token_hint=thehint') ); diff --git a/x-pack/plugins/security/server/authentication/providers/oidc.ts b/x-pack/plugins/security/server/authentication/providers/oidc.ts index 21bce028b0d98..f8e6ac0f9b5d0 100644 --- a/x-pack/plugins/security/server/authentication/providers/oidc.ts +++ b/x-pack/plugins/security/server/authentication/providers/oidc.ts @@ -19,23 +19,25 @@ import { } from './base'; /** - * Describes possible OpenID Connect authentication flows. + * Describes possible OpenID Connect login flows. */ -export enum OIDCAuthenticationFlow { - Implicit = 'implicit', - AuthorizationCode = 'authorization-code', - InitiatedBy3rdParty = 'initiated-by-3rd-party', +export enum OIDCLogin { + LoginInitiatedByUser = 'login-by-user', + LoginWithImplicitFlow = 'login-implicit', + LoginWithAuthorizationCodeFlow = 'login-authorization-code', + LoginInitiatedBy3rdParty = 'login-initiated-by-3rd-party', } /** * Describes the parameters that are required by the provider to process the initial login request. */ export type ProviderLoginAttempt = + | { type: OIDCLogin.LoginInitiatedByUser; redirectURLPath: string } | { - flow: OIDCAuthenticationFlow.Implicit | OIDCAuthenticationFlow.AuthorizationCode; + type: OIDCLogin.LoginWithImplicitFlow | OIDCLogin.LoginWithAuthorizationCodeFlow; authenticationResponseURI: string; } - | { flow: OIDCAuthenticationFlow.InitiatedBy3rdParty; iss: string; loginHint?: string }; + | { type: OIDCLogin.LoginInitiatedBy3rdParty; iss: string; loginHint?: string }; /** * The state supported by the provider (for the OpenID Connect handshake or established session). @@ -57,6 +59,21 @@ interface ProviderState extends Partial { * URL to redirect user to after successful OpenID Connect handshake. */ nextURL?: string; + + /** + * The name of the OpenID Connect realm that was used to establish session. + */ + realm: string; +} + +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the Identity Provider where they can authenticate. + return canRedirectRequest(request) && request.route.options.authRequired === true; } /** @@ -102,15 +119,38 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { ) { this.logger.debug('Trying to perform a login.'); - if (attempt.flow === OIDCAuthenticationFlow.InitiatedBy3rdParty) { - this.logger.debug('Authentication has been initiated by a Third Party.'); + // It may happen that Kibana is re-configured to use different realm for the same provider name, + // we should clear such session an log user out. + if (state?.realm && state.realm !== this.realm) { + const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + + if (attempt.type === OIDCLogin.LoginInitiatedBy3rdParty) { + this.logger.debug('Login has been initiated by a Third Party.'); // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) const oidcPrepareParams = attempt.loginHint ? { iss: attempt.iss, login_hint: attempt.loginHint } : { iss: attempt.iss }; - return this.initiateOIDCAuthentication(request, oidcPrepareParams); - } else if (attempt.flow === OIDCAuthenticationFlow.Implicit) { + return this.initiateOIDCAuthentication( + request, + oidcPrepareParams, + `${this.options.basePath.serverBasePath}/` + ); + } + + if (attempt.type === OIDCLogin.LoginInitiatedByUser) { + this.logger.debug(`Login has been initiated by a user.`); + return this.initiateOIDCAuthentication( + request, + { realm: this.realm }, + attempt.redirectURLPath + ); + } + + if (attempt.type === OIDCLogin.LoginWithImplicitFlow) { this.logger.debug('OpenID Connect Implicit Authentication flow is used.'); } else { this.logger.debug('OpenID Connect Authorization Code Authentication flow is used.'); @@ -136,6 +176,14 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } + // It may happen that Kibana is re-configured to use different realm for the same provider name, + // we should clear such session an log user out. + if (state?.realm && state.realm !== this.realm) { + const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + let authenticationResult = AuthenticationResult.notHandled(); if (state) { authenticationResult = await this.authenticateViaState(request, state); @@ -151,7 +199,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // initiate an OpenID Connect based authentication, otherwise just return the authentication result we have. // We might already have a state and nonce generated by Elasticsearch (from an unfinished authentication in // another tab) - return authenticationResult.notHandled() + return authenticationResult.notHandled() && canStartNewSession(request) ? await this.initiateOIDCAuthentication(request, { realm: this.realm }) : authenticationResult; } @@ -211,7 +259,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via OpenID Connect.'); return AuthenticationResult.redirectTo(stateRedirectURL, { - state: { accessToken, refreshToken }, + state: { accessToken, refreshToken, realm: this.realm }, }); } catch (err) { this.logger.debug(`Failed to authenticate request via OpenID Connect: ${err.message}`); @@ -224,49 +272,30 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { * * @param request Request instance. * @param params OIDC authentication parameters. - * @param [sessionState] Optional state object associated with the provider. + * @param [redirectURLPath] Optional URL user is supposed to be redirected to after successful + * login. If not provided the URL of the specified request is used. */ private async initiateOIDCAuthentication( request: KibanaRequest, params: { realm: string } | { iss: string; login_hint?: string }, - sessionState?: ProviderState | null + redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}` ) { this.logger.debug('Trying to initiate OpenID Connect authentication.'); - // If client can't handle redirect response, we shouldn't initiate OpenID Connect authentication. - if (!canRedirectRequest(request)) { - this.logger.debug('OpenID Connect authentication can not be initiated by AJAX requests.'); - return AuthenticationResult.notHandled(); - } - try { - /* - * Possibly adds the state and nonce parameter that was saved in the user's session state to - * the params. There is no use case where we would have only a state parameter or only a nonce - * parameter in the session state so we only enrich the params object if we have both - */ - const oidcPrepareParams = - sessionState && sessionState.nonce && sessionState.state - ? { ...params, nonce: sessionState.nonce, state: sessionState.state } - : params; // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/oidc/prepare`. - const { state, nonce, redirect } = await this.options.client.callAsInternalUser( - 'shield.oidcPrepare', - { - body: oidcPrepareParams, - } - ); + const { + state, + nonce, + redirect, + } = await this.options.client.callAsInternalUser('shield.oidcPrepare', { body: params }); this.logger.debug('Redirecting to OpenID Connect Provider with authentication request.'); - // If this is a third party initiated login, redirect to the base path - const redirectAfterLogin = `${this.options.basePath.get(request)}${ - 'iss' in params ? '/' : request.url.path - }`; return AuthenticationResult.redirectTo( redirect, // Store the state and nonce parameters in the session state of the user - { state: { state, nonce, nextURL: redirectAfterLogin } } + { state: { state, nonce, nextURL: redirectURLPath, realm: this.realm } } ); } catch (err) { this.logger.debug(`Failed to initiate OpenID Connect authentication: ${err.message}`); @@ -334,7 +363,7 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { // seems logical to do the same on Kibana side and `401` would force user to logout and do full SLO if it's // supported. if (refreshedTokenPair === null) { - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug( 'Both elasticsearch access and refresh tokens are expired. Re-initiating OpenID Connect authentication.' ); @@ -356,7 +385,10 @@ export class OIDCAuthenticationProvider extends BaseAuthenticationProvider { const user = await this.getUser(request, authHeaders); this.logger.debug('Request has been authenticated via refreshed token.'); - return AuthenticationResult.succeeded(user, { authHeaders, state: refreshedTokenPair }); + return AuthenticationResult.succeeded(user, { + authHeaders, + state: { ...refreshedTokenPair, realm: this.realm }, + }); } catch (err) { this.logger.debug(`Failed to refresh elasticsearch access token: ${err.message}`); return AuthenticationResult.failed(err); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.test.ts b/x-pack/plugins/security/server/authentication/providers/pki.test.ts index 044416032a4c3..638bb5732f3c0 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.test.ts @@ -19,6 +19,7 @@ import { MockAuthenticationProviderOptions, mockAuthenticationProviderOptions } import { ElasticsearchErrorHelpers, IClusterClient, + KibanaRequest, ScopeableRequest, } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; @@ -78,53 +79,21 @@ describe('PKIAuthenticationProvider', () => { let provider: PKIAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'pki' }); provider = new PKIAuthenticationProvider(mockOptions); }); afterEach(() => jest.clearAllMocks()); - describe('`authenticate` method', () => { - it('does not handle authentication via `authorization` header.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - - it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: { authorization: 'Bearer some-token' }, - }); - const state = { - accessToken: 'some-valid-token', - peerCertificateFingerprint256: '2A:7A:C2:DD', - }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.notHandled() - ); - - expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - expect(request.headers.authorization).toBe('Bearer some-token'); - }); - + function defineCommonLoginAndAuthenticateTests( + operation: (request: KibanaRequest) => Promise + ) { it('does not handle requests without certificate.', async () => { const request = httpServerMock.createKibanaRequest({ socket: getMockSocket({ authorized: true }), }); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); @@ -135,58 +104,12 @@ describe('PKIAuthenticationProvider', () => { socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), }); - await expect(provider.authenticate(request, null)).resolves.toEqual( - AuthenticationResult.notHandled() - ); + await expect(operation(request)).resolves.toEqual(AuthenticationResult.notHandled()); expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); - it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => { - const request = httpServerMock.createKibanaRequest({ - socket: getMockSocket({ authorized: true }), - }); - - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(new Error('Peer certificate is not available')) - ); - - expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); - }); - - it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => { - const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) - ); - - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - }); - }); - - it('invalidates token and fails with 401 if new certificate is present, but not authorized.', async () => { - const request = httpServerMock.createKibanaRequest({ - socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), - }); - const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - - await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) - ); - - expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); - expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ - accessToken: state.accessToken, - }); - }); - it('gets an access token in exchange to peer certificate chain and stores it in the state.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest({ @@ -202,7 +125,7 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'pki' }, { @@ -244,7 +167,7 @@ describe('PKIAuthenticationProvider', () => { mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - await expect(provider.authenticate(request)).resolves.toEqual( + await expect(operation(request)).resolves.toEqual( AuthenticationResult.succeeded( { ...user, authentication_provider: 'pki' }, { @@ -266,6 +189,156 @@ describe('PKIAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), + }), + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); + + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + + it('fails if could not retrieve user using the new access token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: {}, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), + }), + }); + + const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); + + await expect(operation(request)).resolves.toEqual(AuthenticationResult.failed(failureReason)); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { + body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, + }); + + expectAuthenticateCall(mockOptions.client, { + headers: { authorization: 'Bearer access-token' }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + } + + describe('`login` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.login(request)); + }); + + describe('`authenticate` method', () => { + defineCommonLoginAndAuthenticateTests(request => provider.authenticate(request, null)); + + it('does not handle authentication via `authorization` header.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('does not handle authentication via `authorization` header even if state contains a valid token.', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: { authorization: 'Bearer some-token' }, + }); + const state = { + accessToken: 'some-valid-token', + peerCertificateFingerprint256: '2A:7A:C2:DD', + }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(request.headers.authorization).toBe('Bearer some-token'); + }); + + it('does not exchange peer certificate to access token if request does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ + routeAuthRequired: false, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), + }), + }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + + expect(mockOptions.client.asScoped).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + + it('fails with non-401 error if state is available, peer is authorized, but certificate is not available.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ authorized: true }), + }); + + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(new Error('Peer certificate is not available')) + ); + + expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); + }); + + it('invalidates token and fails with 401 if state is present, but peer certificate is not.', async () => { + const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + }); + }); + + it('invalidates token and fails with 401 if new certificate is present, but not authorized.', async () => { + const request = httpServerMock.createKibanaRequest({ + socket: getMockSocket({ peerCertificate: getMockPeerCertificate('2A:7A:C2:DD') }), + }); + const state = { accessToken: 'token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) + ); + + expect(mockOptions.tokens.invalidate).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.invalidate).toHaveBeenCalledWith({ + accessToken: state.accessToken, + }); + }); + it('invalidates existing token and gets a new one if fingerprints do not match.', async () => { const user = mockAuthenticatedUser(); const request = httpServerMock.createKibanaRequest({ @@ -351,75 +424,45 @@ describe('PKIAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); - it('fails with 401 if existing token is expired, but certificate is not present.', async () => { - const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + it('does not exchange peer certificate to a new access token even if existing token is expired and request does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ + routeAuthRequired: false, + socket: getMockSocket({ + authorized: true, + peerCertificate: getMockPeerCertificate(['2A:7A:C2:DD', '3B:8B:D3:EE']), + }), + }); const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + mockScopedClusterClient.callAsCurrentUser.mockRejectedValueOnce( ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); await expect(provider.authenticate(request, state)).resolves.toEqual( - AuthenticationResult.failed(Boom.unauthorized()) + AuthenticationResult.notHandled() ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); - - expect(request.headers).not.toHaveProperty('authorization'); - }); - - it('fails if could not retrieve an access token in exchange to peer certificate chain.', async () => { - const request = httpServerMock.createKibanaRequest({ - socket: getMockSocket({ - authorized: true, - peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), - }), - }); - - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); - mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); - - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) - ); - - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { - body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, - }); - expect(request.headers).not.toHaveProperty('authorization'); }); - it('fails if could not retrieve user using the new access token.', async () => { - const request = httpServerMock.createKibanaRequest({ - headers: {}, - socket: getMockSocket({ - authorized: true, - peerCertificate: getMockPeerCertificate('2A:7A:C2:DD'), - }), - }); + it('fails with 401 if existing token is expired, but certificate is not present.', async () => { + const request = httpServerMock.createKibanaRequest({ socket: getMockSocket() }); + const state = { accessToken: 'existing-token', peerCertificateFingerprint256: '2A:7A:C2:DD' }; - const failureReason = ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()); const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); - mockScopedClusterClient.callAsCurrentUser.mockRejectedValue(failureReason); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); - mockOptions.client.callAsInternalUser.mockResolvedValue({ access_token: 'access-token' }); - await expect(provider.authenticate(request)).resolves.toEqual( - AuthenticationResult.failed(failureReason) + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.unauthorized()) ); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); - expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.delegatePKI', { - body: { x509_certificate_chain: ['fingerprint:2A:7A:C2:DD:base64'] }, - }); - - expectAuthenticateCall(mockOptions.client, { - headers: { authorization: 'Bearer access-token' }, - }); + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); expect(request.headers).not.toHaveProperty('authorization'); }); diff --git a/x-pack/plugins/security/server/authentication/providers/pki.ts b/x-pack/plugins/security/server/authentication/providers/pki.ts index db022ff355702..243e5415ad2c2 100644 --- a/x-pack/plugins/security/server/authentication/providers/pki.ts +++ b/x-pack/plugins/security/server/authentication/providers/pki.ts @@ -28,6 +28,15 @@ interface ProviderState { peerCertificateFingerprint256: string; } +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication. + return request.route.options.authRequired === true; +} + /** * Provider that supports PKI request authentication. */ @@ -37,6 +46,15 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { */ static readonly type = 'pki'; + /** + * Performs initial login request. + * @param request Request instance. + */ + public async login(request: KibanaRequest) { + this.logger.debug('Trying to perform a login.'); + return await this.authenticateViaPeerCertificate(request); + } + /** * Performs PKI request authentication. * @param request Request instance. @@ -55,12 +73,12 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { authenticationResult = await this.authenticateViaState(request, state); // If access token expired or doesn't match to the certificate fingerprint we should try to get - // a new one in exchange to peer certificate chain. - if ( + // a new one in exchange to peer certificate chain assuming request can initiate new session. + const invalidAccessToken = authenticationResult.notHandled() || (authenticationResult.failed() && - Tokens.isAccessTokenExpiredError(authenticationResult.error)) - ) { + Tokens.isAccessTokenExpiredError(authenticationResult.error)); + if (invalidAccessToken && canStartNewSession(request)) { authenticationResult = await this.authenticateViaPeerCertificate(request); // If we have an active session that we couldn't use to authenticate user and at the same time // we couldn't use peer's certificate to establish a new one, then we should respond with 401 @@ -68,12 +86,15 @@ export class PKIAuthenticationProvider extends BaseAuthenticationProvider { if (authenticationResult.notHandled()) { return AuthenticationResult.failed(Boom.unauthorized()); } + } else if (invalidAccessToken) { + return AuthenticationResult.notHandled(); } } // If we couldn't authenticate by means of all methods above, let's try to check if we can authenticate // request using its peer certificate chain, otherwise just return authentication result we have. - return authenticationResult.notHandled() + // We shouldn't establish new session if authentication isn't required for this particular request. + return authenticationResult.notHandled() && canStartNewSession(request) ? await this.authenticateViaPeerCertificate(request) : authenticationResult; } diff --git a/x-pack/plugins/security/server/authentication/providers/saml.test.ts b/x-pack/plugins/security/server/authentication/providers/saml.test.ts index e00d3b89fb0bf..a7a43a3031571 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.test.ts @@ -18,7 +18,7 @@ import { } from '../../../../../../src/core/server'; import { AuthenticationResult } from '../authentication_result'; import { DeauthenticationResult } from '../deauthentication_result'; -import { SAMLAuthenticationProvider, SAMLLoginStep } from './saml'; +import { SAMLAuthenticationProvider, SAMLLogin } from './saml'; function expectAuthenticateCall( mockClusterClient: jest.Mocked, @@ -36,7 +36,7 @@ describe('SAMLAuthenticationProvider', () => { let provider: SAMLAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'saml' }); provider = new SAMLAuthenticationProvider(mockOptions, { realm: 'test-realm', maxRedirectURLSize: new ByteSizeValue(100), @@ -86,8 +86,12 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#some-app' } + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path#some-app', + realm: 'test-realm', + } ) ).resolves.toEqual( AuthenticationResult.redirectTo('/test-base-path/some-path#some-app', { @@ -95,6 +99,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-token', refreshToken: 'some-refresh-token', + realm: 'test-realm', }, }) ); @@ -111,8 +116,8 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - {} + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + {} as any ) ).resolves.toEqual( AuthenticationResult.failed( @@ -123,6 +128,26 @@ describe('SAMLAuthenticationProvider', () => { expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); + it('fails if realm from state is different from the realm provider is configured with.', async () => { + const request = httpServerMock.createKibanaRequest(); + + await expect( + provider.login( + request, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { realm: 'other-realm' } + ) + ).resolves.toEqual( + AuthenticationResult.failed( + Boom.unauthorized( + 'State based on realm "other-realm", but provider with the name "saml" is configured to use realm "test-realm".' + ) + ) + ); + + expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + }); + it('redirects to the default location if state contains empty redirect URL.', async () => { const request = httpServerMock.createKibanaRequest(); @@ -134,14 +159,15 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '' } + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } ) ).resolves.toEqual( AuthenticationResult.redirectTo('/base-path/', { state: { accessToken: 'user-initiated-login-token', refreshToken: 'user-initiated-login-refresh-token', + realm: 'test-realm', }, }) ); @@ -162,7 +188,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login(request, { - step: SAMLLoginStep.SAMLResponseReceived, + type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml', }) ).resolves.toEqual( @@ -170,6 +196,7 @@ describe('SAMLAuthenticationProvider', () => { state: { accessToken: 'idp-initiated-login-token', refreshToken: 'idp-initiated-login-refresh-token', + realm: 'test-realm', }, }) ); @@ -189,8 +216,12 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, - { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path' } + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, + { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path', + realm: 'test-realm', + } ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -201,7 +232,7 @@ describe('SAMLAuthenticationProvider', () => { }); describe('IdP initiated login with existing session', () => { - it('fails if new SAML Response is rejected.', async () => { + it('returns `notHandled` if new SAML Response is rejected.', async () => { const request = httpServerMock.createKibanaRequest({ headers: {} }); const authorization = 'Bearer some-valid-token'; @@ -216,14 +247,15 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', } ) - ).resolves.toEqual(AuthenticationResult.failed(failureReason)); + ).resolves.toEqual(AuthenticationResult.notHandled()); expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); @@ -241,6 +273,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -261,7 +294,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, state ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -288,6 +321,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -307,7 +341,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, state ) ).resolves.toEqual( @@ -316,6 +350,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', + realm: 'test-realm', }, }) ); @@ -342,6 +377,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'existing-valid-token', refreshToken: 'existing-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -361,7 +397,7 @@ describe('SAMLAuthenticationProvider', () => { await expect( provider.login( request, - { step: SAMLLoginStep.SAMLResponseReceived, samlResponse: 'saml-response-xml' }, + { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response-xml' }, state ) ).resolves.toEqual( @@ -370,6 +406,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'new-user', accessToken: 'new-valid-token', refreshToken: 'new-valid-refresh-token', + realm: 'test-realm', }, }) ); @@ -392,41 +429,61 @@ describe('SAMLAuthenticationProvider', () => { }); describe('User initiated login with captured redirect URL', () => { - it('fails if state is not available', async () => { + it('fails if redirectURLPath is not available', async () => { const request = httpServerMock.createKibanaRequest(); await expect( provider.login(request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment', }) ).resolves.toEqual( AuthenticationResult.failed( - Boom.badRequest('State does not include URL path to redirect to.') + Boom.badRequest('State or login attempt does not include URL path to redirect to.') ) ); expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); }); - it('does not handle AJAX requests.', async () => { - const request = httpServerMock.createKibanaRequest({ headers: { 'kbn-xsrf': 'xsrf' } }); + it('redirects requests to the IdP remembering combined redirect URL.', async () => { + const request = httpServerMock.createKibanaRequest(); + + mockOptions.client.callAsInternalUser.mockResolvedValue({ + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }); await expect( provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment', }, - { redirectURL: '/test-base-path/some-path' } + { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } ) - ).resolves.toEqual(AuthenticationResult.notHandled()); + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { + state: { + requestId: 'some-request-id', + redirectURL: '/test-base-path/some-path#some-fragment', + realm: 'test-realm', + }, + } + ) + ); - expect(mockOptions.client.callAsInternalUser).not.toHaveBeenCalled(); + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); + + expect(mockOptions.logger.warn).not.toHaveBeenCalled(); }); - it('redirects non-AJAX requests to the IdP remembering combined redirect URL.', async () => { + it('redirects requests to the IdP remembering combined redirect URL if path is provided in attempt.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ @@ -438,10 +495,11 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, + redirectURLPath: '/test-base-path/some-path', redirectURLFragment: '#some-fragment', }, - { redirectURL: '/test-base-path/some-path' } + null ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -450,6 +508,7 @@ describe('SAMLAuthenticationProvider', () => { state: { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#some-fragment', + realm: 'test-realm', }, } ) @@ -474,10 +533,10 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '../some-fragment', }, - { redirectURL: '/test-base-path/some-path' } + { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -486,6 +545,7 @@ describe('SAMLAuthenticationProvider', () => { state: { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path#../some-fragment', + realm: 'test-realm', }, } ) @@ -501,7 +561,7 @@ describe('SAMLAuthenticationProvider', () => { ); }); - it('redirects non-AJAX requests to the IdP remembering only redirect URL path if fragment is too large.', async () => { + it('redirects requests to the IdP remembering only redirect URL path if fragment is too large.', async () => { const request = httpServerMock.createKibanaRequest(); mockOptions.client.callAsInternalUser.mockResolvedValue({ @@ -513,10 +573,10 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment'.repeat(10), }, - { redirectURL: '/test-base-path/some-path' } + { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } ) ).resolves.toEqual( AuthenticationResult.redirectTo( @@ -525,6 +585,7 @@ describe('SAMLAuthenticationProvider', () => { state: { requestId: 'some-request-id', redirectURL: '/test-base-path/some-path', + realm: 'test-realm', }, } ) @@ -540,6 +601,40 @@ describe('SAMLAuthenticationProvider', () => { ); }); + it('redirects requests to the IdP remembering base path if redirect URL path in attempt is too large.', async () => { + const request = httpServerMock.createKibanaRequest(); + mockOptions.client.callAsInternalUser.mockResolvedValue({ + id: 'some-request-id', + redirect: 'https://idp-host/path/login?SAMLRequest=some%20request%20', + }); + + await expect( + provider.login( + request, + { + type: SAMLLogin.LoginInitiatedByUser, + redirectURLPath: `/s/foo/${'some-path'.repeat(11)}`, + redirectURLFragment: '#some-fragment', + }, + null + ) + ).resolves.toEqual( + AuthenticationResult.redirectTo( + 'https://idp-host/path/login?SAMLRequest=some%20request%20', + { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } + ) + ); + + expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledWith('shield.samlPrepare', { + body: { realm: 'test-realm' }, + }); + + expect(mockOptions.logger.warn).toHaveBeenCalledTimes(1); + expect(mockOptions.logger.warn).toHaveBeenCalledWith( + 'Max URL path size should not exceed 100b but it was 106b. URL is not captured.' + ); + }); + it('fails if SAML request preparation fails.', async () => { const request = httpServerMock.createKibanaRequest(); @@ -550,10 +645,10 @@ describe('SAMLAuthenticationProvider', () => { provider.login( request, { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: '#some-fragment', }, - { redirectURL: '/test-base-path/some-path' } + { redirectURL: '/test-base-path/some-path', realm: 'test-realm' } ) ).resolves.toEqual(AuthenticationResult.failed(failureReason)); @@ -573,6 +668,13 @@ describe('SAMLAuthenticationProvider', () => { ); }); + it('does not handle non-AJAX request that does not require authentication.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false }); + await expect(provider.authenticate(request)).resolves.toEqual( + AuthenticationResult.notHandled() + ); + }); + it('does not handle authentication via `authorization` header.', async () => { const request = httpServerMock.createKibanaRequest({ headers: { authorization: 'Bearer some-token' }, @@ -596,6 +698,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', }) ).resolves.toEqual(AuthenticationResult.notHandled()); @@ -613,8 +716,8 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/api/security/saml/capture-url-fragment', - { state: { redirectURL: '/base-path/s/foo/some-path' } } + '/mock-server-basepath/internal/security/saml/capture-url-fragment', + { state: { redirectURL: '/base-path/s/foo/some-path', realm: 'test-realm' } } ) ); @@ -634,7 +737,7 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request)).resolves.toEqual( AuthenticationResult.redirectTo( 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '' } } + { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } ) ); @@ -672,6 +775,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -697,6 +801,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'some-valid-token', refreshToken: 'some-valid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -721,6 +826,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'valid-refresh-token', + realm: 'test-realm', }; mockOptions.client.asScoped.mockImplementation(scopeableRequest => { @@ -755,6 +861,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'new-access-token', refreshToken: 'new-refresh-token', + realm: 'test-realm', }, } ) @@ -772,6 +879,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'invalid-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -805,6 +913,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -830,12 +939,45 @@ describe('SAMLAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + it('fails for non-AJAX requests that do not require authentication with user friendly message if refresh token is expired.', async () => { + const request = httpServerMock.createKibanaRequest({ routeAuthRequired: false, headers: {} }); + const state = { + username: 'user', + accessToken: 'expired-token', + refreshToken: 'expired-refresh-token', + realm: 'test-realm', + }; + const authorization = `Bearer ${state.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, state)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(state.refreshToken); + + expectAuthenticateCall(mockOptions.client, { + headers: { authorization }, + }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + it('re-capture URL for non-AJAX requests if refresh token is expired.', async () => { const request = httpServerMock.createKibanaRequest({ path: '/s/foo/some-path', headers: {} }); const state = { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -849,8 +991,8 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( - '/mock-server-basepath/api/security/saml/capture-url-fragment', - { state: { redirectURL: '/base-path/s/foo/some-path' } } + '/mock-server-basepath/internal/security/saml/capture-url-fragment', + { state: { redirectURL: '/base-path/s/foo/some-path', realm: 'test-realm' } } ) ); @@ -871,6 +1013,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'expired-token', refreshToken: 'expired-refresh-token', + realm: 'test-realm', }; const authorization = `Bearer ${state.accessToken}`; @@ -890,7 +1033,7 @@ describe('SAMLAuthenticationProvider', () => { await expect(provider.authenticate(request, state)).resolves.toEqual( AuthenticationResult.redirectTo( 'https://idp-host/path/login?SAMLRequest=some%20request%20', - { state: { requestId: 'some-request-id', redirectURL: '' } } + { state: { requestId: 'some-request-id', redirectURL: '', realm: 'test-realm' } } ) ); @@ -908,6 +1051,17 @@ describe('SAMLAuthenticationProvider', () => { 'Max URL path size should not exceed 100b but it was 107b. URL is not captured.' ); }); + + it('fails if realm from state is different from the realm provider is configured with.', async () => { + const request = httpServerMock.createKibanaRequest(); + await expect(provider.authenticate(request, { realm: 'other-realm' })).resolves.toEqual( + AuthenticationResult.failed( + Boom.unauthorized( + 'State based on realm "other-realm", but provider with the name "saml" is configured to use realm "test-realm".' + ) + ) + ); + }); }); describe('`logout` method', () => { @@ -934,7 +1088,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockRejectedValue(failureReason); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual(DeauthenticationResult.failed(failureReason)); expect(mockOptions.client.callAsInternalUser).toHaveBeenCalledTimes(1); @@ -967,7 +1126,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -986,7 +1150,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: undefined }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -1007,7 +1176,12 @@ describe('SAMLAuthenticationProvider', () => { mockOptions.client.callAsInternalUser.mockResolvedValue({ redirect: null }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') ); @@ -1028,6 +1202,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', + realm: 'test-realm', }) ).resolves.toEqual( DeauthenticationResult.redirectTo('/mock-server-basepath/security/logged_out') @@ -1079,7 +1254,12 @@ describe('SAMLAuthenticationProvider', () => { }); await expect( - provider.logout(request, { username: 'user', accessToken, refreshToken }) + provider.logout(request, { + username: 'user', + accessToken, + refreshToken, + realm: 'test-realm', + }) ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') ); @@ -1099,6 +1279,7 @@ describe('SAMLAuthenticationProvider', () => { username: 'user', accessToken: 'x-saml-token', refreshToken: 'x-saml-refresh-token', + realm: 'test-realm', }) ).resolves.toEqual( DeauthenticationResult.redirectTo('http://fake-idp/SLO?SAMLRequest=7zlH37H') diff --git a/x-pack/plugins/security/server/authentication/providers/saml.ts b/x-pack/plugins/security/server/authentication/providers/saml.ts index ddf6814989a49..e14d34d1901eb 100644 --- a/x-pack/plugins/security/server/authentication/providers/saml.ts +++ b/x-pack/plugins/security/server/authentication/providers/saml.ts @@ -32,37 +32,53 @@ interface ProviderState extends Partial { * initiate SAML handshake and where we should redirect user after successful authentication. */ redirectURL?: string; + + /** + * The name of the SAML realm that was used to establish session. + */ + realm: string; } /** - * Describes possible SAML Login steps. + * Describes possible SAML Login flows. */ -export enum SAMLLoginStep { +export enum SAMLLogin { /** - * The final login step when IdP responds with SAML Response payload. + * The login flow when user initiates SAML handshake (SP Initiated Login). */ - SAMLResponseReceived = 'saml-response-received', + LoginInitiatedByUser = 'login-by-user', /** - * The login step when we've captured user URL fragment and ready to start SAML handshake. + * The login flow when IdP responds with SAML Response payload (last step of the SP Initiated + * Login or IdP initiated Login). */ - RedirectURLFragmentCaptured = 'redirect-url-fragment-captured', + LoginWithSAMLResponse = 'login-saml-response', } /** * Describes the parameters that are required by the provider to process the initial login request. */ type ProviderLoginAttempt = - | { step: SAMLLoginStep.RedirectURLFragmentCaptured; redirectURLFragment: string } - | { step: SAMLLoginStep.SAMLResponseReceived; samlResponse: string }; + | { type: SAMLLogin.LoginInitiatedByUser; redirectURLPath?: string; redirectURLFragment?: string } + | { type: SAMLLogin.LoginWithSAMLResponse; samlResponse: string }; /** * Checks whether request query includes SAML request from IdP. * @param query Parsed HTTP request query. */ -export function isSAMLRequestQuery(query: any): query is { SAMLRequest: string } { +function isSAMLRequestQuery(query: any): query is { SAMLRequest: string } { return query && query.SAMLRequest; } +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the Identity Provider where they can authenticate. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + /** * Provider that supports SAML request authentication. */ @@ -113,31 +129,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { ) { this.logger.debug('Trying to perform a login.'); - if (attempt.step === SAMLLoginStep.RedirectURLFragmentCaptured) { - if (!state || !state.redirectURL) { - const message = 'State does not include URL path to redirect to.'; + // It may happen that Kibana is re-configured to use different realm for the same provider name, + // we should clear such session an log user out. + if (state?.realm && state.realm !== this.realm) { + const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + + if (attempt.type === SAMLLogin.LoginInitiatedByUser) { + const redirectURLPath = attempt.redirectURLPath || state?.redirectURL; + if (!redirectURLPath) { + const message = 'State or login attempt does not include URL path to redirect to.'; this.logger.debug(message); return AuthenticationResult.failed(Boom.badRequest(message)); } - let redirectURLFragment = attempt.redirectURLFragment; - if (redirectURLFragment.length > 0 && !redirectURLFragment.startsWith('#')) { - this.logger.warn('Redirect URL fragment does not start with `#`.'); - redirectURLFragment = `#${redirectURLFragment}`; - } - - let redirectURL = `${state.redirectURL}${redirectURLFragment}`; - const redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); - if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { - this.logger.warn( - `Max URL size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. Only URL path is captured.` - ); - redirectURL = state.redirectURL; - } else { - this.logger.debug('Captured redirect URL.'); - } - - return this.authenticateViaHandshake(request, redirectURL); + return this.captureRedirectURL(request, redirectURLPath, attempt.redirectURLFragment); } const { samlResponse } = attempt; @@ -186,6 +194,14 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return AuthenticationResult.notHandled(); } + // It may happen that Kibana is re-configured to use different realm for the same provider name, + // we should clear such session an log user out. + if (state?.realm && state.realm !== this.realm) { + const message = `State based on realm "${state.realm}", but provider with the name "${this.options.name}" is configured to use realm "${this.realm}".`; + this.logger.debug(message); + return AuthenticationResult.failed(Boom.unauthorized(message)); + } + let authenticationResult = AuthenticationResult.notHandled(); if (state) { authenticationResult = await this.authenticateViaState(request, state); @@ -199,7 +215,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // If we couldn't authenticate by means of all methods above, let's try to capture user URL and // initiate SAML handshake, otherwise just return authentication result we have. - return authenticationResult.notHandled() && canRedirectRequest(request) + return authenticationResult.notHandled() && canStartNewSession(request) ? this.captureRedirectURL(request) : authenticationResult; } @@ -212,15 +228,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if ((!state || !state.accessToken) && !isSAMLRequestQuery(request.query)) { - this.logger.debug('There is neither access token nor SAML session to invalidate.'); + // Normally when there is no active session in Kibana, `logout` method shouldn't do anything + // and user will eventually be redirected to the home page to log in. But when SAML is enabled + // there is a special case when logout is initiated by the IdP or another SP, then IdP will + // request _every_ SP associated with the current user session to do the logout. So if Kibana, + // without an active session, receives such request it shouldn't redirect user to the home page, + // but rather redirect back to IdP with correct logout response and only Elasticsearch knows how + // to do that. + const isIdPInitiatedSLO = isSAMLRequestQuery(request.query); + if (!state?.accessToken && !isIdPInitiatedSLO) { + this.logger.debug('There is no SAML session to invalidate.'); return DeauthenticationResult.notHandled(); } try { - const redirect = isSAMLRequestQuery(request.query) + const redirect = isIdPInitiatedSLO ? await this.performIdPInitiatedSingleLogout(request) - : await this.performUserInitiatedSingleLogout(state!.accessToken!, state!.refreshToken!); + : await this.performUserInitiatedSingleLogout(state?.accessToken!, state?.refreshToken!); // Having non-null `redirect` field within logout response means that IdP // supports SAML Single Logout and we should redirect user to the specified @@ -283,8 +307,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } // When we don't have state and hence request id we assume that SAMLResponse came from the IdP initiated login. + const isIdPInitiatedLogin = !stateRequestId; this.logger.debug( - stateRequestId + !isIdPInitiatedLogin ? 'Login has been previously initiated by Kibana.' : 'Login has been initiated by Identity Provider.' ); @@ -298,7 +323,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { refresh_token: refreshToken, } = await this.options.client.callAsInternalUser('shield.samlAuthenticate', { body: { - ids: stateRequestId ? [stateRequestId] : [], + ids: !isIdPInitiatedLogin ? [stateRequestId] : [], content: samlResponse, realm: this.realm, }, @@ -307,11 +332,17 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Login has been performed with SAML response.'); return AuthenticationResult.redirectTo( stateRedirectURL || `${this.options.basePath.get(request)}/`, - { state: { username, accessToken, refreshToken } } + { state: { username, accessToken, refreshToken, realm: this.realm } } ); } catch (err) { this.logger.debug(`Failed to log in with SAML response: ${err.message}`); - return AuthenticationResult.failed(err); + + // Since we don't know upfront what realm is targeted by the Identity Provider initiated login + // there is a chance that it failed because of realm mismatch and hence we should return + // `notHandled` and give other SAML providers a chance to properly handle it instead. + return isIdPInitiatedLogin + ? AuthenticationResult.notHandled() + : AuthenticationResult.failed(err); } } @@ -336,7 +367,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // First let's try to authenticate via SAML Response payload. const payloadAuthenticationResult = await this.loginWithSAMLResponse(request, samlResponse); - if (payloadAuthenticationResult.failed()) { + if (payloadAuthenticationResult.failed() || payloadAuthenticationResult.notHandled()) { return payloadAuthenticationResult; } @@ -434,7 +465,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { // There are two reasons for `400` and not `401`: Elasticsearch search responds with `400` so it seems logical // to do the same on Kibana side and `401` would force user to logout and do full SLO if it's supported. if (refreshedTokenPair === null) { - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug( 'Both access and refresh tokens are expired. Capturing redirect URL and re-initiating SAML handshake.' ); @@ -458,7 +489,7 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Request has been authenticated via refreshed token.'); return AuthenticationResult.succeeded(user, { authHeaders, - state: { username, ...refreshedTokenPair }, + state: { username, realm: this.realm, ...refreshedTokenPair }, }); } catch (err) { this.logger.debug( @@ -476,12 +507,6 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { private async authenticateViaHandshake(request: KibanaRequest, redirectURL: string) { this.logger.debug('Trying to initiate SAML handshake.'); - // If client can't handle redirect response, we shouldn't initiate SAML handshake. - if (!canRedirectRequest(request)) { - this.logger.debug('SAML handshake can not be initiated by AJAX requests.'); - return AuthenticationResult.notHandled(); - } - try { // This operation should be performed on behalf of the user with a privilege that normal // user usually doesn't have `cluster:admin/xpack/security/saml/prepare`. @@ -495,7 +520,9 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { this.logger.debug('Redirecting to Identity Provider with SAML request.'); // Store request id in the state so that we can reuse it once we receive `SAMLResponse`. - return AuthenticationResult.redirectTo(redirect, { state: { requestId, redirectURL } }); + return AuthenticationResult.redirectTo(redirect, { + state: { requestId, redirectURL, realm: this.realm }, + }); } catch (err) { this.logger.debug(`Failed to initiate SAML handshake: ${err.message}`); return AuthenticationResult.failed(err); @@ -545,18 +572,23 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { } /** - * Redirects user to the client-side page that will grab URL fragment and redirect user back to Kibana - * to initiate SAML handshake. + * Tries to capture full redirect URL (both path and fragment) and initiate SAML handshake. * @param request Request instance. + * @param [redirectURLPath] Optional URL path user is supposed to be redirected to after successful + * login. If not provided the URL path of the specified request is used. + * @param [redirectURLFragment] Optional URL fragment of the URL user is supposed to be redirected + * to after successful login. If not provided user will be redirected to the client-side page that + * will grab it and redirect user back to Kibana to initiate SAML handshake. */ - private captureRedirectURL(request: KibanaRequest) { - const basePath = this.options.basePath.get(request); - const redirectURL = `${basePath}${request.url.path}`; - + private captureRedirectURL( + request: KibanaRequest, + redirectURLPath = `${this.options.basePath.get(request)}${request.url.path}`, + redirectURLFragment?: string + ) { // If the size of the path already exceeds the maximum allowed size of the URL to store in the // session there is no reason to try to capture URL fragment and we start handshake immediately. // In this case user will be redirected to the Kibana home/root after successful login. - const redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); + let redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURLPath)); if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { this.logger.warn( `Max URL path size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. URL is not captured.` @@ -564,9 +596,30 @@ export class SAMLAuthenticationProvider extends BaseAuthenticationProvider { return this.authenticateViaHandshake(request, ''); } - return AuthenticationResult.redirectTo( - `${this.options.basePath.serverBasePath}/api/security/saml/capture-url-fragment`, - { state: { redirectURL } } - ); + // If URL fragment wasn't specified at all, let's try to capture it. + if (redirectURLFragment === undefined) { + return AuthenticationResult.redirectTo( + `${this.options.basePath.serverBasePath}/internal/security/saml/capture-url-fragment`, + { state: { redirectURL: redirectURLPath, realm: this.realm } } + ); + } + + if (redirectURLFragment.length > 0 && !redirectURLFragment.startsWith('#')) { + this.logger.warn('Redirect URL fragment does not start with `#`.'); + redirectURLFragment = `#${redirectURLFragment}`; + } + + let redirectURL = `${redirectURLPath}${redirectURLFragment}`; + redirectURLSize = new ByteSizeValue(Buffer.byteLength(redirectURL)); + if (this.maxRedirectURLSize.isLessThan(redirectURLSize)) { + this.logger.warn( + `Max URL size should not exceed ${this.maxRedirectURLSize.toString()} but it was ${redirectURLSize.toString()}. Only URL path is captured.` + ); + redirectURL = redirectURLPath; + } else { + this.logger.debug('Captured redirect URL.'); + } + + return this.authenticateViaHandshake(request, redirectURL); } } diff --git a/x-pack/plugins/security/server/authentication/providers/token.test.ts b/x-pack/plugins/security/server/authentication/providers/token.test.ts index e81d14e8bf9f3..7472adb30307c 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.test.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.test.ts @@ -36,7 +36,7 @@ describe('TokenAuthenticationProvider', () => { let provider: TokenAuthenticationProvider; let mockOptions: MockAuthenticationProviderOptions; beforeEach(() => { - mockOptions = mockAuthenticationProviderOptions(); + mockOptions = mockAuthenticationProviderOptions({ name: 'token' }); provider = new TokenAuthenticationProvider(mockOptions); }); @@ -163,6 +163,12 @@ describe('TokenAuthenticationProvider', () => { ).resolves.toEqual(AuthenticationResult.notHandled()); }); + it('does not redirect requests that do not require authentication to the login page.', async () => { + await expect( + provider.authenticate(httpServerMock.createKibanaRequest({ routeAuthRequired: false })) + ).resolves.toEqual(AuthenticationResult.notHandled()); + }); + it('redirects non-AJAX requests that can not be authenticated to the login page.', async () => { await expect( provider.authenticate( @@ -346,6 +352,35 @@ describe('TokenAuthenticationProvider', () => { expect(request.headers).not.toHaveProperty('authorization'); }); + it('does not redirect non-AJAX requests that do not require authentication if token token cannot be refreshed', async () => { + const request = httpServerMock.createKibanaRequest({ + headers: {}, + routeAuthRequired: false, + path: '/some-path', + }); + const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; + const authorization = `Bearer ${tokenPair.accessToken}`; + + const mockScopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); + mockScopedClusterClient.callAsCurrentUser.mockRejectedValue( + ElasticsearchErrorHelpers.decorateNotAuthorizedError(new Error()) + ); + mockOptions.client.asScoped.mockReturnValue(mockScopedClusterClient); + + mockOptions.tokens.refresh.mockResolvedValue(null); + + await expect(provider.authenticate(request, tokenPair)).resolves.toEqual( + AuthenticationResult.failed(Boom.badRequest('Both access and refresh tokens are expired.')) + ); + + expect(mockOptions.tokens.refresh).toHaveBeenCalledTimes(1); + expect(mockOptions.tokens.refresh).toHaveBeenCalledWith(tokenPair.refreshToken); + + expectAuthenticateCall(mockOptions.client, { headers: { authorization } }); + + expect(request.headers).not.toHaveProperty('authorization'); + }); + it('fails if new access token is rejected after successful refresh', async () => { const request = httpServerMock.createKibanaRequest(); const tokenPair = { accessToken: 'foo', refreshToken: 'bar' }; @@ -386,15 +421,13 @@ describe('TokenAuthenticationProvider', () => { }); describe('`logout` method', () => { - it('returns `redirected` if state is not presented.', async () => { + it('returns `notHandled` if state is not presented.', async () => { const request = httpServerMock.createKibanaRequest(); - await expect(provider.logout(request)).resolves.toEqual( - DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') - ); + await expect(provider.logout(request)).resolves.toEqual(DeauthenticationResult.notHandled()); await expect(provider.logout(request, null)).resolves.toEqual( - DeauthenticationResult.redirectTo('/base-path/login?msg=LOGGED_OUT') + DeauthenticationResult.notHandled() ); expect(mockOptions.tokens.invalidate).not.toHaveBeenCalled(); diff --git a/x-pack/plugins/security/server/authentication/providers/token.ts b/x-pack/plugins/security/server/authentication/providers/token.ts index 91808c22c4300..abf4c293c4c53 100644 --- a/x-pack/plugins/security/server/authentication/providers/token.ts +++ b/x-pack/plugins/security/server/authentication/providers/token.ts @@ -26,6 +26,16 @@ interface ProviderLoginAttempt { */ type ProviderState = TokenPair; +/** + * Checks whether current request can initiate new session. + * @param request Request instance. + */ +function canStartNewSession(request: KibanaRequest) { + // We should try to establish new session only if request requires authentication and client + // can be redirected to the login page where they can enter username and password. + return canRedirectRequest(request) && request.route.options.authRequired === true; +} + /** * Provider that supports token-based request authentication. */ @@ -102,7 +112,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { // finally, if authentication still can not be handled for this // request/state combination, redirect to the login page if appropriate - if (authenticationResult.notHandled() && canRedirectRequest(request)) { + if (authenticationResult.notHandled() && canStartNewSession(request)) { this.logger.debug('Redirecting request to Login page.'); authenticationResult = AuthenticationResult.redirectTo(this.getLoginPageURL(request)); } @@ -118,16 +128,17 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { public async logout(request: KibanaRequest, state?: ProviderState | null) { this.logger.debug(`Trying to log user out via ${request.url.path}.`); - if (state) { - this.logger.debug('Token-based logout has been initiated by the user.'); - try { - await this.options.tokens.invalidate(state); - } catch (err) { - this.logger.debug(`Failed invalidating user's access token: ${err.message}`); - return DeauthenticationResult.failed(err); - } - } else { + if (!state) { this.logger.debug('There are no access and refresh tokens to invalidate.'); + return DeauthenticationResult.notHandled(); + } + + this.logger.debug('Token-based logout has been initiated by the user.'); + try { + await this.options.tokens.invalidate(state); + } catch (err) { + this.logger.debug(`Failed invalidating user's access token: ${err.message}`); + return DeauthenticationResult.failed(err); } const queryString = request.url.search || `?msg=LOGGED_OUT`; @@ -190,7 +201,7 @@ export class TokenAuthenticationProvider extends BaseAuthenticationProvider { // If refresh token is no longer valid, then we should clear session and redirect user to the // login page to re-authenticate, or fail if redirect isn't possible. if (refreshedTokenPair === null) { - if (canRedirectRequest(request)) { + if (canStartNewSession(request)) { this.logger.debug('Clearing session since both access and refresh tokens are expired.'); // Set state to `null` to let `Authenticator` know that we want to clear current session. diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 03285184d6572..46a7ee79ee60c 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -6,9 +6,8 @@ jest.mock('crypto', () => ({ randomBytes: jest.fn() })); -import { first } from 'rxjs/operators'; -import { loggingServiceMock, coreMock } from '../../../../src/core/server/mocks'; -import { createConfig$, ConfigSchema } from './config'; +import { loggingServiceMock } from '../../../../src/core/server/mocks'; +import { createConfig, ConfigSchema } from './config'; describe('config schema', () => { it('generates proper defaults', () => { @@ -25,9 +24,22 @@ describe('config schema', () => { "apikey", ], }, - "providers": Array [ - "basic", - ], + "providers": Object { + "basic": Object { + "basic": Object { + "description": undefined, + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + "kerberos": undefined, + "oidc": undefined, + "pki": undefined, + "saml": undefined, + "token": undefined, + }, + "selector": Object {}, }, "cookieName": "sid", "enabled": true, @@ -54,9 +66,22 @@ describe('config schema', () => { "apikey", ], }, - "providers": Array [ - "basic", - ], + "providers": Object { + "basic": Object { + "basic": Object { + "description": undefined, + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + "kerberos": undefined, + "oidc": undefined, + "pki": undefined, + "saml": undefined, + "token": undefined, + }, + "selector": Object {}, }, "cookieName": "sid", "enabled": true, @@ -83,9 +108,22 @@ describe('config schema', () => { "apikey", ], }, - "providers": Array [ - "basic", - ], + "providers": Object { + "basic": Object { + "basic": Object { + "description": undefined, + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + "kerberos": undefined, + "oidc": undefined, + "pki": undefined, + "saml": undefined, + "token": undefined, + }, + "selector": Object {}, }, "cookieName": "sid", "enabled": true, @@ -148,6 +186,7 @@ describe('config schema', () => { "providers": Array [ "oidc", ], + "selector": Object {}, } `); }); @@ -181,6 +220,7 @@ describe('config schema', () => { "oidc", "basic", ], + "selector": Object {}, } `); }); @@ -228,6 +268,7 @@ describe('config schema', () => { }, "realm": "realm-1", }, + "selector": Object {}, } `); }); @@ -305,27 +346,476 @@ describe('config schema', () => { `); }); }); + + describe('authc.providers (extended format)', () => { + describe('`basic` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { basic: { basic1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('does not allow custom description', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { basic: { basic1: { order: 0, description: 'Some description' } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.description]: \`basic\` provider does not support custom description." +`); + }); + + it('cannot be hidden from selector', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { basic: { basic1: { order: 0, showInSelector: false } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic.basic1.showInSelector]: \`basic\` provider only supports \`true\` in \`showInSelector\`." +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { basic: { basic1: { order: 0 }, basic2: { order: 1 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.basic]: Only one \\"basic\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { basic: { basic1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "basic": Object { + "basic1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`token` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { token: { token1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('does not allow custom description', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { token: { token1: { order: 0, description: 'Some description' } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.description]: \`token\` provider does not support custom description." +`); + }); + + it('cannot be hidden from selector', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { token: { token1: { order: 0, showInSelector: false } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token.token1.showInSelector]: \`token\` provider only supports \`true\` in \`showInSelector\`." +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { token: { token1: { order: 0 }, token2: { order: 1 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.token]: Only one \\"token\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { token: { token1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "token": Object { + "token1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`pki` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { pki: { pki1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.pki.pki1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { pki: { pki1: { order: 0 }, pki2: { order: 1 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.pki]: Only one \\"pki\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { pki: { pki1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "pki": Object { + "pki1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`kerberos` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { kerberos: { kerberos1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.kerberos.kerberos1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('can have only provider of this type', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { kerberos: { kerberos1: { order: 0 }, kerberos2: { order: 1 } } }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.kerberos]: Only one \\"kerberos\\" provider can be configured." +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { providers: { kerberos: { kerberos1: { order: 0 } } } }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "kerberos": Object { + "kerberos1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`oidc` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { oidc: { oidc1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.oidc.oidc1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('requires `realm`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { oidc: { oidc1: { order: 0 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.oidc.oidc1.realm]: expected value of type [string] but got [undefined]" +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + oidc: { oidc1: { order: 0, realm: 'oidc1' }, oidc2: { order: 1, realm: 'oidc2' } }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "oidc": Object { + "oidc1": Object { + "enabled": true, + "order": 0, + "realm": "oidc1", + "showInSelector": true, + }, + "oidc2": Object { + "enabled": true, + "order": 1, + "realm": "oidc2", + "showInSelector": true, + }, + }, + } + `); + }); + }); + + describe('`saml` provider', () => { + it('requires `order`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { saml: { saml1: { enabled: true } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.saml.saml1.order]: expected value of type [number] but got [undefined]" +`); + }); + + it('requires `realm`', () => { + expect(() => + ConfigSchema.validate({ + authc: { providers: { saml: { saml1: { order: 0 } } } }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1.saml.saml1.realm]: expected value of type [string] but got [undefined]" +`); + }); + + it('can be successfully validated', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + saml: { + saml1: { order: 0, realm: 'saml1' }, + saml2: { order: 1, realm: 'saml2', maxRedirectURLSize: '1kb' }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "saml": Object { + "saml1": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 0, + "realm": "saml1", + "showInSelector": true, + }, + "saml2": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 1024, + }, + "order": 1, + "realm": "saml2", + "showInSelector": true, + }, + }, + } + `); + }); + }); + + it('`name` should be unique across all provider types', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + basic: { provider1: { order: 0 } }, + saml: { + provider2: { order: 1, realm: 'saml1' }, + provider1: { order: 2, realm: 'saml2' }, + }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1]: Found multiple providers configured with the same name \\"provider1\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider1]" +`); + }); + + it('`order` should be unique across all provider types', () => { + expect(() => + ConfigSchema.validate({ + authc: { + providers: { + basic: { provider1: { order: 0 } }, + saml: { + provider2: { order: 0, realm: 'saml1' }, + provider3: { order: 2, realm: 'saml2' }, + }, + }, + }, + }) + ).toThrowErrorMatchingInlineSnapshot(` +"[authc.providers]: types that failed validation: +- [authc.providers.0]: expected value of type [array] but got [Object] +- [authc.providers.1]: Found multiple providers configured with the same order \\"0\\": [xpack.security.authc.providers.basic.provider1, xpack.security.authc.providers.saml.provider2]" +`); + }); + + it('can be successfully validated with multiple providers ignoring uniqueness violations in disabled ones', () => { + expect( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 0 }, basic2: { enabled: false, order: 1 } }, + saml: { + saml1: { order: 1, realm: 'saml1' }, + saml2: { order: 2, realm: 'saml2' }, + basic1: { order: 3, realm: 'saml3', enabled: false }, + }, + }, + }, + }).authc.providers + ).toMatchInlineSnapshot(` + Object { + "basic": Object { + "basic1": Object { + "enabled": true, + "order": 0, + "showInSelector": true, + }, + "basic2": Object { + "enabled": false, + "order": 1, + "showInSelector": true, + }, + }, + "saml": Object { + "basic1": Object { + "enabled": false, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 3, + "realm": "saml3", + "showInSelector": true, + }, + "saml1": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 1, + "realm": "saml1", + "showInSelector": true, + }, + "saml2": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 2, + "realm": "saml2", + "showInSelector": true, + }, + }, + } + `); + }); + }); }); -describe('createConfig$()', () => { - const mockAndCreateConfig = async (isTLSEnabled: boolean, value = {}, context?: any) => { - const contextMock = coreMock.createPluginInitializerContext( - // we must use validate to avoid errors in `createConfig$` - ConfigSchema.validate(value, context) - ); - return await createConfig$(contextMock, isTLSEnabled) - .pipe(first()) - .toPromise() - .then(config => ({ contextMock, config })); - }; +describe('createConfig()', () => { it('should log a warning and set xpack.security.encryptionKey if not set', async () => { const mockRandomBytes = jest.requireMock('crypto').randomBytes; mockRandomBytes.mockReturnValue('ab'.repeat(16)); - const { contextMock, config } = await mockAndCreateConfig(true, {}, { dist: true }); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({}, { dist: true }), logger, { + isTLSEnabled: true, + }); expect(config.encryptionKey).toEqual('ab'.repeat(16)); - expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.security.encryptionKey in kibana.yml", @@ -335,10 +825,11 @@ describe('createConfig$()', () => { }); it('should log a warning if SSL is not configured', async () => { - const { contextMock, config } = await mockAndCreateConfig(false, {}); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({}), logger, { isTLSEnabled: false }); expect(config.secureCookies).toEqual(false); - expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Session cookies will be transmitted over insecure connections. This is not recommended.", @@ -348,10 +839,13 @@ describe('createConfig$()', () => { }); it('should log a warning if SSL is not configured yet secure cookies are being used', async () => { - const { contextMock, config } = await mockAndCreateConfig(false, { secureCookies: true }); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({ secureCookies: true }), logger, { + isTLSEnabled: false, + }); expect(config.secureCookies).toEqual(true); - expect(loggingServiceMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingServiceMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to function properly.", @@ -361,9 +855,210 @@ describe('createConfig$()', () => { }); it('should set xpack.security.secureCookies if SSL is configured', async () => { - const { contextMock, config } = await mockAndCreateConfig(true, {}); + const logger = loggingServiceMock.create().get(); + const config = createConfig(ConfigSchema.validate({}), logger, { isTLSEnabled: true }); expect(config.secureCookies).toEqual(true); - expect(loggingServiceMock.collect(contextMock.logger).warn).toEqual([]); + expect(loggingServiceMock.collect(logger).warn).toEqual([]); + }); + + it('transforms legacy `authc.providers` into new format', () => { + const logger = loggingServiceMock.create().get(); + + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: ['saml', 'basic'], + saml: { realm: 'saml-realm' }, + }, + }), + logger, + { isTLSEnabled: true } + ).authc + ).toMatchInlineSnapshot(` + Object { + "http": Object { + "autoSchemesEnabled": true, + "enabled": true, + "schemes": Array [ + "apikey", + ], + }, + "providers": Object { + "basic": Object { + "basic": Object { + "enabled": true, + "order": 1, + "showInSelector": true, + }, + }, + "saml": Object { + "saml": Object { + "enabled": true, + "maxRedirectURLSize": ByteSizeValue { + "valueInBytes": 2048, + }, + "order": 0, + "realm": "saml-realm", + "showInSelector": true, + }, + }, + }, + "selector": Object { + "enabled": false, + }, + "sortedProviders": Array [ + Object { + "name": "saml", + "options": Object { + "description": undefined, + "order": 0, + "showInSelector": true, + }, + "type": "saml", + }, + Object { + "name": "basic", + "options": Object { + "description": undefined, + "order": 1, + "showInSelector": true, + }, + "type": "basic", + }, + ], + } + `); + }); + + it('does not automatically set `authc.selector.enabled` to `true` if legacy `authc.providers` format is used', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { providers: ['saml', 'basic'], saml: { realm: 'saml-realm' } }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(false); + + // But keep it as `true` if it's explicitly set. + expect( + createConfig( + ConfigSchema.validate({ + authc: { + selector: { enabled: true }, + providers: ['saml', 'basic'], + saml: { realm: 'saml-realm' }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(true); + }); + + it('does not automatically set `authc.selector.enabled` to `true` if less than 2 providers must be shown there', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 0 } }, + saml: { + saml1: { order: 1, realm: 'saml1', showInSelector: false }, + saml2: { enabled: false, order: 2, realm: 'saml2' }, + }, + }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(false); + }); + + it('automatically set `authc.selector.enabled` to `true` if more than 1 provider must be shown there', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 0 } }, + saml: { saml1: { order: 1, realm: 'saml1' }, saml2: { order: 2, realm: 'saml2' } }, + }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.selector.enabled + ).toBe(true); + }); + + it('correctly sorts providers based on the `order`', () => { + expect( + createConfig( + ConfigSchema.validate({ + authc: { + providers: { + basic: { basic1: { order: 3 } }, + saml: { saml1: { order: 2, realm: 'saml1' }, saml2: { order: 1, realm: 'saml2' } }, + oidc: { oidc1: { order: 0, realm: 'oidc1' }, oidc2: { order: 4, realm: 'oidc2' } }, + }, + }, + }), + loggingServiceMock.create().get(), + { isTLSEnabled: true } + ).authc.sortedProviders + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "oidc1", + "options": Object { + "description": undefined, + "order": 0, + "showInSelector": true, + }, + "type": "oidc", + }, + Object { + "name": "saml2", + "options": Object { + "description": undefined, + "order": 1, + "showInSelector": true, + }, + "type": "saml", + }, + Object { + "name": "saml1", + "options": Object { + "description": undefined, + "order": 2, + "showInSelector": true, + }, + "type": "saml", + }, + Object { + "name": "basic1", + "options": Object { + "description": undefined, + "order": 3, + "showInSelector": true, + }, + "type": "basic", + }, + Object { + "name": "oidc2", + "options": Object { + "description": undefined, + "order": 4, + "showInSelector": true, + }, + "type": "oidc", + }, + ] + `); }); }); diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 2345249e94bc8..97ff7d00a4336 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -5,14 +5,10 @@ */ import crypto from 'crypto'; -import { Observable } from 'rxjs'; -import { map } from 'rxjs/operators'; import { schema, Type, TypeOf } from '@kbn/config-schema'; -import { PluginInitializerContext } from '../../../../src/core/server'; +import { Logger } from '../../../../src/core/server'; -export type ConfigType = ReturnType extends Observable - ? P - : ReturnType; +export type ConfigType = ReturnType; const providerOptionsSchema = (providerType: string, optionsSchema: Type) => schema.conditional( @@ -24,6 +20,114 @@ const providerOptionsSchema = (providerType: string, optionsSchema: Type) = schema.never() ); +type ProvidersCommonConfigType = Record< + 'enabled' | 'showInSelector' | 'order' | 'description', + Type +>; +function getCommonProviderSchemaProperties(overrides: Partial = {}) { + return { + enabled: schema.boolean({ defaultValue: true }), + showInSelector: schema.boolean({ defaultValue: true }), + order: schema.number({ min: 0 }), + description: schema.maybe(schema.string()), + ...overrides, + }; +} + +function getUniqueProviderSchema( + providerType: string, + overrides?: Partial +) { + return schema.maybe( + schema.recordOf(schema.string(), schema.object(getCommonProviderSchemaProperties(overrides)), { + validate(config) { + if (Object.values(config).filter(provider => provider.enabled).length > 1) { + return `Only one "${providerType}" provider can be configured.`; + } + }, + }) + ); +} + +type ProvidersConfigType = TypeOf; +const providersConfigSchema = schema.object( + { + basic: getUniqueProviderSchema('basic', { + description: schema.maybe( + schema.any({ + validate: () => '`basic` provider does not support custom description.', + }) + ), + showInSelector: schema.boolean({ + defaultValue: true, + validate: value => { + if (!value) { + return '`basic` provider only supports `true` in `showInSelector`.'; + } + }, + }), + }), + token: getUniqueProviderSchema('token', { + description: schema.maybe( + schema.any({ + validate: () => '`token` provider does not support custom description.', + }) + ), + showInSelector: schema.boolean({ + defaultValue: true, + validate: value => { + if (!value) { + return '`token` provider only supports `true` in `showInSelector`.'; + } + }, + }), + }), + kerberos: getUniqueProviderSchema('kerberos'), + pki: getUniqueProviderSchema('pki'), + saml: schema.maybe( + schema.recordOf( + schema.string(), + schema.object({ + ...getCommonProviderSchemaProperties(), + realm: schema.string(), + maxRedirectURLSize: schema.byteSize({ defaultValue: '2kb' }), + }) + ) + ), + oidc: schema.maybe( + schema.recordOf( + schema.string(), + schema.object({ ...getCommonProviderSchemaProperties(), realm: schema.string() }) + ) + ), + }, + { + validate(config) { + const checks = { sameOrder: new Map(), sameName: new Map() }; + for (const [providerType, providerGroup] of Object.entries(config)) { + for (const [providerName, { enabled, order }] of Object.entries(providerGroup ?? {})) { + if (!enabled) { + continue; + } + + const providerPath = `xpack.security.authc.providers.${providerType}.${providerName}`; + const providerWithSameOrderPath = checks.sameOrder.get(order); + if (providerWithSameOrderPath) { + return `Found multiple providers configured with the same order "${order}": [${providerWithSameOrderPath}, ${providerPath}]`; + } + checks.sameOrder.set(order, providerPath); + + const providerWithSameName = checks.sameName.get(providerName); + if (providerWithSameName) { + return `Found multiple providers configured with the same name "${providerName}": [${providerWithSameName}, ${providerPath}]`; + } + checks.sameName.set(providerName, providerPath); + } + } + }, + } +); + export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), loginAssistanceMessage: schema.string({ defaultValue: '' }), @@ -40,7 +144,17 @@ export const ConfigSchema = schema.object({ }), secureCookies: schema.boolean({ defaultValue: false }), authc: schema.object({ - providers: schema.arrayOf(schema.string(), { defaultValue: ['basic'], minSize: 1 }), + selector: schema.object({ enabled: schema.maybe(schema.boolean()) }), + providers: schema.oneOf([schema.arrayOf(schema.string()), providersConfigSchema], { + defaultValue: { + basic: { basic: { enabled: true, showInSelector: true, order: 0, description: undefined } }, + token: undefined, + saml: undefined, + oidc: undefined, + pki: undefined, + kerberos: undefined, + }, + }), oidc: providerOptionsSchema('oidc', schema.object({ realm: schema.string() })), saml: providerOptionsSchema( 'saml', @@ -60,42 +174,96 @@ export const ConfigSchema = schema.object({ }), }); -export function createConfig$(context: PluginInitializerContext, isTLSEnabled: boolean) { - return context.config.create>().pipe( - map(config => { - const logger = context.logger.get('config'); +export function createConfig( + config: TypeOf, + logger: Logger, + { isTLSEnabled }: { isTLSEnabled: boolean } +) { + let encryptionKey = config.encryptionKey; + if (encryptionKey === undefined) { + logger.warn( + 'Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + + 'restart, please set xpack.security.encryptionKey in kibana.yml' + ); - let encryptionKey = config.encryptionKey; - if (encryptionKey === undefined) { - logger.warn( - 'Generating a random key for xpack.security.encryptionKey. To prevent sessions from being invalidated on ' + - 'restart, please set xpack.security.encryptionKey in kibana.yml' - ); + encryptionKey = crypto.randomBytes(16).toString('hex'); + } - encryptionKey = crypto.randomBytes(16).toString('hex'); - } + let secureCookies = config.secureCookies; + if (!isTLSEnabled) { + if (secureCookies) { + logger.warn( + 'Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' + + 'function properly.' + ); + } else { + logger.warn( + 'Session cookies will be transmitted over insecure connections. This is not recommended.' + ); + } + } else if (!secureCookies) { + secureCookies = true; + } - let secureCookies = config.secureCookies; - if (!isTLSEnabled) { - if (secureCookies) { - logger.warn( - 'Using secure cookies, but SSL is not enabled inside Kibana. SSL must be configured outside of Kibana to ' + - 'function properly.' - ); - } else { - logger.warn( - 'Session cookies will be transmitted over insecure connections. This is not recommended.' - ); - } - } else if (!secureCookies) { - secureCookies = true; + const isUsingLegacyProvidersFormat = Array.isArray(config.authc.providers); + const providers = (isUsingLegacyProvidersFormat + ? [...new Set(config.authc.providers as Array)].reduce( + (legacyProviders, providerType, order) => { + legacyProviders[providerType] = { + [providerType]: + providerType === 'saml' || providerType === 'oidc' + ? { enabled: true, showInSelector: true, order, ...config.authc[providerType] } + : { enabled: true, showInSelector: true, order }, + }; + return legacyProviders; + }, + {} as Record + ) + : config.authc.providers) as ProvidersConfigType; + + // Remove disabled providers and sort the rest. + const sortedProviders: Array<{ + type: keyof ProvidersConfigType; + name: string; + options: { order: number; showInSelector: boolean; description?: string }; + }> = []; + for (const [type, providerGroup] of Object.entries(providers)) { + for (const [name, { enabled, showInSelector, order, description }] of Object.entries( + providerGroup ?? {} + )) { + if (!enabled) { + delete providerGroup![name]; + } else { + sortedProviders.push({ + type: type as any, + name, + options: { order, showInSelector, description }, + }); } + } + } - return { - ...config, - encryptionKey, - secureCookies, - }; - }) + sortedProviders.sort(({ options: { order: orderA } }, { options: { order: orderB } }) => + orderA < orderB ? -1 : orderA > orderB ? 1 : 0 ); + + // We enable Login Selector by default if a) it's not explicitly disabled, b) new config + // format of providers is used and c) we have more than one provider enabled. + const isLoginSelectorEnabled = + typeof config.authc.selector.enabled === 'boolean' + ? config.authc.selector.enabled + : !isUsingLegacyProvidersFormat && + sortedProviders.filter(provider => provider.options.showInSelector).length > 1; + + return { + ...config, + authc: { + selector: { ...config.authc.selector, enabled: isLoginSelectorEnabled }, + providers, + sortedProviders: Object.freeze(sortedProviders), + http: config.authc.http, + }, + encryptionKey, + secureCookies, + }; } diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 0b17f0554fac8..caeb06e6f3153 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -23,6 +23,8 @@ export { CreateAPIKeyResult, InvalidateAPIKeyParams, InvalidateAPIKeyResult, + SAMLLogin, + OIDCLogin, } from './authentication'; export { SecurityPluginSetup }; export { AuthenticatedUser } from '../common/model'; @@ -32,11 +34,29 @@ export const config: PluginConfigDescriptor> = { deprecations: ({ rename, unused }) => [ rename('sessionTimeout', 'session.idleTimeout'), unused('authorization.legacyFallback.enabled'), + // Deprecation warning for the old array-based format of `xpack.security.authc.providers`. (settings, fromPath, log) => { - const hasProvider = (provider: string) => - settings?.xpack?.security?.authc?.providers?.includes(provider) ?? false; + if (Array.isArray(settings?.xpack?.security?.authc?.providers)) { + log( + 'Defining `xpack.security.authc.providers` as an array of provider types is deprecated. Use extended `object` format instead.' + ); + } + + return settings; + }, + (settings, fromPath, log) => { + const hasProviderType = (providerType: string) => { + const providers = settings?.xpack?.security?.authc?.providers; + if (Array.isArray(providers)) { + return providers.includes(providerType); + } + + return Object.values(providers?.[providerType] || {}).some( + provider => (provider as { enabled: boolean | undefined })?.enabled !== false + ); + }; - if (hasProvider('basic') && hasProvider('token')) { + if (hasProviderType('basic') && hasProviderType('token')) { log( 'Enabling both `basic` and `token` authentication providers in `xpack.security.authc.providers` is deprecated. Login page will only use `token` provider.' ); diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index a011f7e7be11e..a23c826b32fbd 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -26,6 +26,7 @@ describe('Security Plugin', () => { lifespan: null, }, authc: { + selector: { enabled: false }, providers: ['saml', 'token'], saml: { realm: 'saml1', maxRedirectURLSize: new ByteSizeValue(2048) }, http: { enabled: true, autoSchemesEnabled: true, schemes: ['apikey'] }, @@ -49,9 +50,6 @@ describe('Security Plugin', () => { await expect(plugin.setup(mockCoreSetup, mockDependencies)).resolves.toMatchInlineSnapshot(` Object { "__legacyCompat": Object { - "config": Object { - "secureCookies": true, - }, "license": Object { "features$": Observable { "_isScalar": false, @@ -78,7 +76,7 @@ describe('Security Plugin', () => { "invalidateAPIKey": [Function], "invalidateAPIKeyAsInternalUser": [Function], "isAuthenticated": [Function], - "isProviderEnabled": [Function], + "isProviderTypeEnabled": [Function], "login": [Function], "logout": [Function], }, diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 13300ee55eba0..032d231fe798f 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -5,13 +5,13 @@ */ import { combineLatest } from 'rxjs'; -import { first } from 'rxjs/operators'; +import { first, map } from 'rxjs/operators'; +import { TypeOf } from '@kbn/config-schema'; import { ICustomClusterClient, CoreSetup, Logger, PluginInitializerContext, - RecursiveReadonly, } from '../../../../src/core/server'; import { deepFreeze } from '../../../../src/core/utils'; import { SpacesPluginSetup } from '../../spaces/server'; @@ -20,7 +20,7 @@ import { LicensingPluginSetup } from '../../licensing/server'; import { Authentication, setupAuthentication } from './authentication'; import { Authorization, setupAuthorization } from './authorization'; -import { createConfig$ } from './config'; +import { ConfigSchema, createConfig } from './config'; import { defineRoutes } from './routes'; import { SecurityLicenseService, SecurityLicense } from '../common/licensing'; import { setupSavedObjects } from './saved_objects'; @@ -65,7 +65,6 @@ export interface SecurityPluginSetup { registerLegacyAPI: (legacyAPI: LegacyAPI) => void; registerPrivilegesWithCluster: () => void; license: SecurityLicense; - config: RecursiveReadonly<{ secureCookies: boolean }>; }; } @@ -106,7 +105,13 @@ export class Plugin { public async setup(core: CoreSetup, { features, licensing }: PluginSetupDependencies) { const [config, legacyConfig] = await combineLatest([ - createConfig$(this.initializerContext, core.http.isTlsEnabled), + this.initializerContext.config.create>().pipe( + map(rawConfig => + createConfig(rawConfig, this.initializerContext.logger.get('config'), { + isTLSEnabled: core.http.isTlsEnabled, + }) + ) + ), this.initializerContext.config.legacy.globalConfig$, ]) .pipe(first()) @@ -183,11 +188,6 @@ export class Plugin { registerPrivilegesWithCluster: async () => await authz.registerPrivilegesWithCluster(), license, - - // We should stop exposing this config as soon as only new platform plugin consumes it. - // This is only currently required because we use legacy code to inject this as metadata - // for consumption by public code in the new platform. - config: { secureCookies: config.secureCookies }, }, }); } diff --git a/x-pack/plugins/security/server/routes/authentication/basic.test.ts b/x-pack/plugins/security/server/routes/authentication/basic.test.ts index cd3b871671551..3c114978f26d2 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.test.ts @@ -29,7 +29,7 @@ describe('Basic authentication routes', () => { router = routeParamsMock.router; authc = routeParamsMock.authc; - authc.isProviderEnabled.mockImplementation(provider => provider === 'basic'); + authc.isProviderTypeEnabled.mockImplementation(provider => provider === 'basic'); mockContext = ({ licensing: { @@ -108,7 +108,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(500); expect(response.payload).toEqual(unhandledException); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); @@ -122,7 +122,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(401); expect(response.payload).toEqual(failureReason); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); @@ -135,7 +135,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(401); expect(response.payload).toEqual('Unauthorized'); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); @@ -149,14 +149,14 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(204); expect(response.payload).toBeUndefined(); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { type: 'basic' }, value: { username: 'user', password: 'password' }, }); }); it('prefers `token` authentication provider if it is enabled', async () => { authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); - authc.isProviderEnabled.mockImplementation( + authc.isProviderTypeEnabled.mockImplementation( provider => provider === 'token' || provider === 'basic' ); @@ -165,7 +165,7 @@ describe('Basic authentication routes', () => { expect(response.status).toBe(204); expect(response.payload).toBeUndefined(); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'token', + provider: { type: 'token' }, value: { username: 'user', password: 'password' }, }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/basic.ts b/x-pack/plugins/security/server/routes/authentication/basic.ts index db36e45fc07e8..ccc6a8df24d6e 100644 --- a/x-pack/plugins/security/server/routes/authentication/basic.ts +++ b/x-pack/plugins/security/server/routes/authentication/basic.ts @@ -26,9 +26,10 @@ export function defineBasicRoutes({ router, authc, config }: RouteDefinitionPara }, createLicensedRouteHandler(async (context, request, response) => { // We should prefer `token` over `basic` if possible. - const loginAttempt = authc.isProviderEnabled('token') - ? { provider: 'token', value: request.body } - : { provider: 'basic', value: request.body }; + const loginAttempt = { + provider: { type: authc.isProviderTypeEnabled('token') ? 'token' : 'basic' }, + value: request.body, + }; try { const authenticationResult = await authc.login(request, loginAttempt); diff --git a/x-pack/plugins/security/server/routes/authentication/common.test.ts b/x-pack/plugins/security/server/routes/authentication/common.test.ts index b611ffffee935..e2f9593bc09ee 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.test.ts @@ -13,7 +13,13 @@ import { RouteConfig, } from '../../../../../../src/core/server'; import { LICENSE_CHECK_STATE } from '../../../../licensing/server'; -import { Authentication, DeauthenticationResult } from '../../authentication'; +import { + Authentication, + AuthenticationResult, + DeauthenticationResult, + OIDCLogin, + SAMLLogin, +} from '../../authentication'; import { defineCommonRoutes } from './common'; import { httpServerMock } from '../../../../../../src/core/server/mocks'; @@ -172,4 +178,260 @@ describe('Common authentication routes', () => { expect(authc.getCurrentUser).toHaveBeenCalledWith(mockRequest); }); }); + + describe('login_with', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [acsRouteConfig, acsRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/internal/security/login_with' + )!; + + routeConfig = acsRouteConfig; + routeHandler = acsRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.validate).toEqual({ + body: expect.any(Type), + query: undefined, + params: undefined, + }); + + const bodyValidator = (routeConfig.validate as any).body as Type; + expect( + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + }) + ).toEqual({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + }); + + expect( + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '', + }) + ).toEqual({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '', + }); + + expect(() => bodyValidator.validate({})).toThrowErrorMatchingInlineSnapshot( + `"[providerType]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ providerType: 'saml' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[providerName]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ providerType: 'saml', providerName: 'saml1' }) + ).toThrowErrorMatchingInlineSnapshot( + `"[currentURL]: expected value of type [string] but got [undefined]"` + ); + + expect(() => + bodyValidator.validate({ + providerType: 'saml', + providerName: 'saml1', + currentURL: '/some-url', + UnknownArg: 'arg', + }) + ).toThrowErrorMatchingInlineSnapshot(`"[UnknownArg]: definition for this key is missing"`); + }); + + it('returns 500 if login throws unhandled exception.', async () => { + const unhandledException = new Error('Something went wrong.'); + authc.login.mockRejectedValue(unhandledException); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 500, + payload: 'Internal Error', + options: {}, + }); + }); + + it('returns 401 if login fails.', async () => { + const failureReason = new Error('Something went wrong.'); + authc.login.mockResolvedValue( + AuthenticationResult.failed(failureReason, { + authResponseHeaders: { 'WWW-Something': 'something' }, + }) + ); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 401, + payload: failureReason, + options: { body: failureReason, headers: { 'WWW-Something': 'something' } }, + }); + }); + + it('returns 401 if login is not handled.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.notHandled()); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 401, + payload: 'Unauthorized', + options: {}, + }); + }); + + it('returns redirect location from authentication result if any.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL: '/some-url' }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + }); + + it('returns location extracted from `next` parameter if authentication result does not specify any.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: '/mock-server-basepath/some-url#/app/nav' }, + options: { body: { location: '/mock-server-basepath/some-url#/app/nav' } }, + }); + }); + + it('returns base path if location cannot be extracted from `currentURL` parameter and authentication result does not specify any.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockAuthenticatedUser())); + + const invalidCurrentURLs = [ + 'https://kibana.com/?next=https://evil.com/mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=https://kibana.com:9000/mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=kibana.com/mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=//mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=../mock-server-basepath/some-url#/app/nav', + 'https://kibana.com/?next=/some-url#/app/nav', + '', + ]; + + for (const currentURL of invalidCurrentURLs) { + const request = httpServerMock.createKibanaRequest({ + body: { providerType: 'saml', providerName: 'saml1', currentURL }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: '/mock-server-basepath/' }, + options: { body: { location: '/mock-server-basepath/' } }, + }); + } + }); + + it('correctly performs SAML login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'saml1' }, + value: { + type: SAMLLogin.LoginInitiatedByUser, + redirectURLPath: '/mock-server-basepath/some-url', + redirectURLFragment: '#/app/nav', + }, + }); + }); + + it('correctly performs OIDC login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'oidc', + providerName: 'oidc1', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'oidc1' }, + value: { + type: OIDCLogin.LoginInitiatedByUser, + redirectURLPath: '/mock-server-basepath/some-url', + }, + }); + }); + + it('correctly performs generic login.', async () => { + authc.login.mockResolvedValue(AuthenticationResult.redirectTo('http://redirect-to/path')); + + const request = httpServerMock.createKibanaRequest({ + body: { + providerType: 'some-type', + providerName: 'some-name', + currentURL: 'https://kibana.com/?next=/mock-server-basepath/some-url#/app/nav', + }, + }); + + await expect(routeHandler(mockContext, request, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { location: 'http://redirect-to/path' }, + options: { body: { location: 'http://redirect-to/path' } }, + }); + + expect(authc.login).toHaveBeenCalledTimes(1); + expect(authc.login).toHaveBeenCalledWith(request, { + provider: { name: 'some-name' }, + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/routes/authentication/common.ts b/x-pack/plugins/security/server/routes/authentication/common.ts index 19d197b63f540..abab67c9cd1d2 100644 --- a/x-pack/plugins/security/server/routes/authentication/common.ts +++ b/x-pack/plugins/security/server/routes/authentication/common.ts @@ -5,9 +5,14 @@ */ import { schema } from '@kbn/config-schema'; -import { canRedirectRequest } from '../../authentication'; +import { parseNext } from '../../../common/parse_next'; +import { canRedirectRequest, OIDCLogin, SAMLLogin } from '../../authentication'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; +import { + OIDCAuthenticationProvider, + SAMLAuthenticationProvider, +} from '../../authentication/providers'; import { RouteDefinitionParams } from '..'; /** @@ -71,4 +76,63 @@ export function defineCommonRoutes({ router, authc, basePath, logger }: RouteDef }) ); } + + function getLoginAttemptForProviderType(providerType: string, redirectURL: string) { + const [redirectURLPath] = redirectURL.split('#'); + const redirectURLFragment = + redirectURL.length > redirectURLPath.length + ? redirectURL.substring(redirectURLPath.length) + : ''; + + if (providerType === SAMLAuthenticationProvider.type) { + return { type: SAMLLogin.LoginInitiatedByUser, redirectURLPath, redirectURLFragment }; + } + + if (providerType === OIDCAuthenticationProvider.type) { + return { type: OIDCLogin.LoginInitiatedByUser, redirectURLPath }; + } + + return undefined; + } + + router.post( + { + path: '/internal/security/login_with', + validate: { + body: schema.object({ + providerType: schema.string(), + providerName: schema.string(), + currentURL: schema.string(), + }), + }, + options: { authRequired: false }, + }, + createLicensedRouteHandler(async (context, request, response) => { + const { providerType, providerName, currentURL } = request.body; + logger.info(`Logging in with provider "${providerName}" (${providerType})`); + + const redirectURL = parseNext(currentURL, basePath.serverBasePath); + try { + const authenticationResult = await authc.login(request, { + provider: { name: providerName }, + value: getLoginAttemptForProviderType(providerType, redirectURL), + }); + + if (authenticationResult.redirected() || authenticationResult.succeeded()) { + return response.ok({ + body: { location: authenticationResult.redirectURL || redirectURL }, + headers: authenticationResult.authResponseHeaders, + }); + } + + return response.unauthorized({ + body: authenticationResult.error, + headers: authenticationResult.authResponseHeaders, + }); + } catch (err) { + logger.error(err); + return response.internalError(); + } + }) + ); } diff --git a/x-pack/plugins/security/server/routes/authentication/index.ts b/x-pack/plugins/security/server/routes/authentication/index.ts index a774edfb4ab2c..f3082b089faf5 100644 --- a/x-pack/plugins/security/server/routes/authentication/index.ts +++ b/x-pack/plugins/security/server/routes/authentication/index.ts @@ -27,15 +27,15 @@ export function defineAuthenticationRoutes(params: RouteDefinitionParams) { defineSessionRoutes(params); defineCommonRoutes(params); - if (params.authc.isProviderEnabled('basic') || params.authc.isProviderEnabled('token')) { + if (params.authc.isProviderTypeEnabled('basic') || params.authc.isProviderTypeEnabled('token')) { defineBasicRoutes(params); } - if (params.authc.isProviderEnabled('saml')) { + if (params.authc.isProviderTypeEnabled('saml')) { defineSAMLRoutes(params); } - if (params.authc.isProviderEnabled('oidc')) { + if (params.authc.isProviderTypeEnabled('oidc')) { defineOIDCRoutes(params); } } diff --git a/x-pack/plugins/security/server/routes/authentication/oidc.ts b/x-pack/plugins/security/server/routes/authentication/oidc.ts index 96c36af20e982..d325a453af9d1 100644 --- a/x-pack/plugins/security/server/routes/authentication/oidc.ts +++ b/x-pack/plugins/security/server/routes/authentication/oidc.ts @@ -7,11 +7,14 @@ import { schema } from '@kbn/config-schema'; import { i18n } from '@kbn/i18n'; import { KibanaRequest, KibanaResponseFactory } from '../../../../../../src/core/server'; -import { OIDCAuthenticationFlow } from '../../authentication'; +import { OIDCLogin } from '../../authentication'; import { createCustomResourceResponse } from '.'; import { createLicensedRouteHandler } from '../licensed_route_handler'; import { wrapIntoCustomErrorResponse } from '../../errors'; -import { ProviderLoginAttempt } from '../../authentication/providers/oidc'; +import { + OIDCAuthenticationProvider, + ProviderLoginAttempt, +} from '../../authentication/providers/oidc'; import { RouteDefinitionParams } from '..'; /** @@ -118,7 +121,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route let loginAttempt: ProviderLoginAttempt | undefined; if (request.query.authenticationResponseURI) { loginAttempt = { - flow: OIDCAuthenticationFlow.Implicit, + type: OIDCLogin.LoginWithImplicitFlow, authenticationResponseURI: request.query.authenticationResponseURI, }; } else if (request.query.code || request.query.error) { @@ -133,7 +136,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route // failed) authentication from an OpenID Connect Provider during authorization code authentication flow. // See more details at https://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth. loginAttempt = { - flow: OIDCAuthenticationFlow.AuthorizationCode, + type: OIDCLogin.LoginWithAuthorizationCodeFlow, // We pass the path only as we can't be sure of the full URL and Elasticsearch doesn't need it anyway. authenticationResponseURI: request.url.path!, }; @@ -145,7 +148,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route // An HTTP GET request with a query parameter named `iss` as part of a 3rd party initiated authentication. // See more details at https://openid.net/specs/openid-connect-core-1_0.html#ThirdPartyInitiatedLogin loginAttempt = { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: request.query.iss, loginHint: request.query.login_hint, }; @@ -181,7 +184,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route { unknowns: 'allow' } ), }, - options: { authRequired: false }, + options: { authRequired: false, xsrfRequired: false }, }, createLicensedRouteHandler(async (context, request, response) => { const serverBasePath = basePath.serverBasePath; @@ -193,7 +196,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route } return performOIDCLogin(request, response, { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: request.body.iss, loginHint: request.body.login_hint, }); @@ -224,7 +227,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route }, createLicensedRouteHandler(async (context, request, response) => { return performOIDCLogin(request, response, { - flow: OIDCAuthenticationFlow.InitiatedBy3rdParty, + type: OIDCLogin.LoginInitiatedBy3rdParty, iss: request.query.iss, loginHint: request.query.login_hint, }); @@ -240,7 +243,7 @@ export function defineOIDCRoutes({ router, logger, authc, csp, basePath }: Route // We handle the fact that the user might get redirected to Kibana while already having a session // Return an error notifying the user they are already logged in. const authenticationResult = await authc.login(request, { - provider: 'oidc', + provider: { type: OIDCAuthenticationProvider.type }, value: loginAttempt, }); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.test.ts b/x-pack/plugins/security/server/routes/authentication/saml.test.ts index b4434715a72ba..af63dfa2f4471 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.test.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.test.ts @@ -5,7 +5,7 @@ */ import { Type } from '@kbn/config-schema'; -import { Authentication, AuthenticationResult, SAMLLoginStep } from '../../authentication'; +import { Authentication, AuthenticationResult, SAMLLogin } from '../../authentication'; import { defineSAMLRoutes } from './saml'; import { IRouter, RequestHandler, RouteConfig } from '../../../../../../src/core/server'; @@ -37,7 +37,7 @@ describe('SAML authentication routes', () => { }); it('correctly defines route.', () => { - expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.options).toEqual({ authRequired: false, xsrfRequired: false }); expect(routeConfig.validate).toEqual({ body: expect.any(Type), query: undefined, @@ -84,9 +84,9 @@ describe('SAML authentication routes', () => { ); expect(authc.login).toHaveBeenCalledWith(request, { - provider: 'saml', + provider: { type: 'saml' }, value: { - step: SAMLLoginStep.SAMLResponseReceived, + type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response', }, }); @@ -163,9 +163,9 @@ describe('SAML authentication routes', () => { ); expect(authc.login).toHaveBeenCalledWith(request, { - provider: 'saml', + provider: { type: 'saml' }, value: { - step: SAMLLoginStep.SAMLResponseReceived, + type: SAMLLogin.LoginWithSAMLResponse, samlResponse: 'saml-response', }, }); diff --git a/x-pack/plugins/security/server/routes/authentication/saml.ts b/x-pack/plugins/security/server/routes/authentication/saml.ts index 465ea61e12a4e..8f08f250a1c75 100644 --- a/x-pack/plugins/security/server/routes/authentication/saml.ts +++ b/x-pack/plugins/security/server/routes/authentication/saml.ts @@ -5,7 +5,8 @@ */ import { schema } from '@kbn/config-schema'; -import { SAMLLoginStep } from '../../authentication'; +import { SAMLLogin } from '../../authentication'; +import { SAMLAuthenticationProvider } from '../../authentication/providers'; import { createCustomResourceResponse } from '.'; import { RouteDefinitionParams } from '..'; @@ -15,7 +16,7 @@ import { RouteDefinitionParams } from '..'; export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: RouteDefinitionParams) { router.get( { - path: '/api/security/saml/capture-url-fragment', + path: '/internal/security/saml/capture-url-fragment', validate: false, options: { authRequired: false }, }, @@ -27,7 +28,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route Kibana SAML Login - + `, 'text/html', csp.header @@ -38,7 +39,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route router.get( { - path: '/api/security/saml/capture-url-fragment.js', + path: '/internal/security/saml/capture-url-fragment.js', validate: false, options: { authRequired: false }, }, @@ -47,7 +48,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route createCustomResourceResponse( ` window.location.replace( - '${basePath.serverBasePath}/api/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) + '${basePath.serverBasePath}/internal/security/saml/start?redirectURLFragment=' + encodeURIComponent(window.location.hash) ); `, 'text/javascript', @@ -59,7 +60,7 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route router.get( { - path: '/api/security/saml/start', + path: '/internal/security/saml/start', validate: { query: schema.object({ redirectURLFragment: schema.string() }), }, @@ -68,9 +69,9 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route async (context, request, response) => { try { const authenticationResult = await authc.login(request, { - provider: 'saml', + provider: { type: SAMLAuthenticationProvider.type }, value: { - step: SAMLLoginStep.RedirectURLFragmentCaptured, + type: SAMLLogin.LoginInitiatedByUser, redirectURLFragment: request.query.redirectURLFragment, }, }); @@ -97,17 +98,14 @@ export function defineSAMLRoutes({ router, logger, authc, csp, basePath }: Route RelayState: schema.maybe(schema.string()), }), }, - options: { authRequired: false }, + options: { authRequired: false, xsrfRequired: false }, }, async (context, request, response) => { try { - // When authenticating using SAML we _expect_ to redirect to the SAML Identity provider. + // When authenticating using SAML we _expect_ to redirect to the Kibana target location. const authenticationResult = await authc.login(request, { - provider: 'saml', - value: { - step: SAMLLoginStep.SAMLResponseReceived, - samlResponse: request.body.SAMLResponse, - }, + provider: { type: SAMLAuthenticationProvider.type }, + value: { type: SAMLLogin.LoginWithSAMLResponse, samlResponse: request.body.SAMLResponse }, }); if (authenticationResult.redirected()) { diff --git a/x-pack/plugins/security/server/routes/index.mock.ts b/x-pack/plugins/security/server/routes/index.mock.ts index 0821ed8b96af9..aaefdad6c221a 100644 --- a/x-pack/plugins/security/server/routes/index.mock.ts +++ b/x-pack/plugins/security/server/routes/index.mock.ts @@ -11,17 +11,19 @@ import { } from '../../../../../src/core/server/mocks'; import { authenticationMock } from '../authentication/index.mock'; import { authorizationMock } from '../authorization/index.mock'; -import { ConfigSchema } from '../config'; +import { ConfigSchema, createConfig } from '../config'; import { licenseMock } from '../../common/licensing/index.mock'; export const routeDefinitionParamsMock = { - create: () => ({ + create: (config: Record = {}) => ({ router: httpServiceMock.createRouter(), basePath: httpServiceMock.createBasePath(), csp: httpServiceMock.createSetupContract().csp, logger: loggingServiceMock.create().get(), clusterClient: elasticsearchServiceMock.createClusterClient(), - config: { ...ConfigSchema.validate({}), encryptionKey: 'some-enc-key' }, + config: createConfig(ConfigSchema.validate(config), loggingServiceMock.create().get(), { + isTLSEnabled: false, + }), authc: authenticationMock.create(), authz: authorizationMock.create(), license: licenseMock.create(), diff --git a/x-pack/plugins/security/server/routes/users/change_password.test.ts b/x-pack/plugins/security/server/routes/users/change_password.test.ts index c2db34dc3c33c..bac40202ee6ef 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.test.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.test.ts @@ -188,7 +188,7 @@ describe('Change password', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'basic', + provider: { name: 'basic1' }, value: { username, password: 'new-password' }, }); }); @@ -196,7 +196,7 @@ describe('Change password', () => { it('successfully changes own password if provided old password is correct for non-basic provider.', async () => { const mockUser = mockAuthenticatedUser({ username: 'user', - authentication_provider: 'token', + authentication_provider: 'token1', }); authc.getCurrentUser.mockReturnValue(mockUser); authc.login.mockResolvedValue(AuthenticationResult.succeeded(mockUser)); @@ -215,7 +215,7 @@ describe('Change password', () => { expect(authc.login).toHaveBeenCalledTimes(1); expect(authc.login).toHaveBeenCalledWith(mockRequest, { - provider: 'token', + provider: { name: 'token1' }, value: { username, password: 'new-password' }, }); }); diff --git a/x-pack/plugins/security/server/routes/users/change_password.ts b/x-pack/plugins/security/server/routes/users/change_password.ts index aa7e8bc26cc1f..e915cd8759ff1 100644 --- a/x-pack/plugins/security/server/routes/users/change_password.ts +++ b/x-pack/plugins/security/server/routes/users/change_password.ts @@ -81,7 +81,7 @@ export function defineChangeUserPasswordRoutes({ if (isUserChangingOwnPassword && currentSession) { try { const authenticationResult = await authc.login(request, { - provider: currentUser!.authentication_provider, + provider: { name: currentUser!.authentication_provider }, value: { username, password: newPassword }, }); diff --git a/x-pack/plugins/security/server/routes/views/index.test.ts b/x-pack/plugins/security/server/routes/views/index.test.ts index 63e8a518c6198..80f7f62a5ff43 100644 --- a/x-pack/plugins/security/server/routes/views/index.test.ts +++ b/x-pack/plugins/security/server/routes/views/index.test.ts @@ -11,7 +11,7 @@ import { routeDefinitionParamsMock } from '../index.mock'; describe('View routes', () => { it('does not register Login routes if both `basic` and `token` providers are disabled', () => { const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderEnabled.mockImplementation( + routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( provider => provider !== 'basic' && provider !== 'token' ); @@ -29,7 +29,9 @@ describe('View routes', () => { it('registers Login routes if `basic` provider is enabled', () => { const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderEnabled.mockImplementation(provider => provider !== 'token'); + routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( + provider => provider !== 'token' + ); defineViewRoutes(routeParamsMock); @@ -47,7 +49,29 @@ describe('View routes', () => { it('registers Login routes if `token` provider is enabled', () => { const routeParamsMock = routeDefinitionParamsMock.create(); - routeParamsMock.authc.isProviderEnabled.mockImplementation(provider => provider !== 'basic'); + routeParamsMock.authc.isProviderTypeEnabled.mockImplementation( + provider => provider !== 'basic' + ); + + defineViewRoutes(routeParamsMock); + + expect(routeParamsMock.router.get.mock.calls.map(([{ path }]) => path)).toMatchInlineSnapshot(` + Array [ + "/login", + "/internal/security/login_state", + "/security/account", + "/security/logged_out", + "/logout", + "/security/overwritten_session", + ] + `); + }); + + it('registers Login routes if Login Selector is enabled even if both `token` and `basic` providers are not enabled', () => { + const routeParamsMock = routeDefinitionParamsMock.create({ + authc: { selector: { enabled: true } }, + }); + routeParamsMock.authc.isProviderTypeEnabled.mockReturnValue(false); defineViewRoutes(routeParamsMock); diff --git a/x-pack/plugins/security/server/routes/views/index.ts b/x-pack/plugins/security/server/routes/views/index.ts index 91e57aed44ab6..255989dfeb90c 100644 --- a/x-pack/plugins/security/server/routes/views/index.ts +++ b/x-pack/plugins/security/server/routes/views/index.ts @@ -12,7 +12,11 @@ import { defineOverwrittenSessionRoutes } from './overwritten_session'; import { RouteDefinitionParams } from '..'; export function defineViewRoutes(params: RouteDefinitionParams) { - if (params.authc.isProviderEnabled('basic') || params.authc.isProviderEnabled('token')) { + if ( + params.config.authc.selector.enabled || + params.authc.isProviderTypeEnabled('basic') || + params.authc.isProviderTypeEnabled('token') + ) { defineLoginRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/views/login.test.ts b/x-pack/plugins/security/server/routes/views/login.test.ts index d14aa226e17ba..9217d5a437f9c 100644 --- a/x-pack/plugins/security/server/routes/views/login.test.ts +++ b/x-pack/plugins/security/server/routes/views/login.test.ts @@ -13,21 +13,22 @@ import { IRouter, } from '../../../../../../src/core/server'; import { SecurityLicense } from '../../../common/licensing'; -import { Authentication } from '../../authentication'; +import { LoginState } from '../../../common/login_state'; +import { ConfigType } from '../../config'; import { defineLoginRoutes } from './login'; import { coreMock, httpServerMock } from '../../../../../../src/core/server/mocks'; import { routeDefinitionParamsMock } from '../index.mock'; describe('Login view routes', () => { - let authc: jest.Mocked; let router: jest.Mocked; let license: jest.Mocked; + let config: ConfigType; beforeEach(() => { const routeParamsMock = routeDefinitionParamsMock.create(); - authc = routeParamsMock.authc; router = routeParamsMock.router; license = routeParamsMock.license; + config = routeParamsMock.config; defineLoginRoutes(routeParamsMock); }); @@ -45,7 +46,7 @@ describe('Login view routes', () => { }); it('correctly defines route.', () => { - expect(routeConfig.options).toEqual({ authRequired: false }); + expect(routeConfig.options).toEqual({ authRequired: 'optional' }); expect(routeConfig.validate).toEqual({ body: undefined, @@ -73,7 +74,7 @@ describe('Login view routes', () => { ); }); - it('redirects user to the root page if they have a session already or login is disabled.', async () => { + it('redirects user to the root page if they are authenticated or login is disabled.', async () => { for (const { query, expectedLocation } of [ { query: {}, expectedLocation: '/mock-server-basepath/' }, { @@ -85,27 +86,27 @@ describe('Login view routes', () => { expectedLocation: '/mock-server-basepath/', }, ]) { - const request = httpServerMock.createKibanaRequest({ query }); + // Redirect if user is authenticated even if `showLogin` is `true`. + let request = httpServerMock.createKibanaRequest({ + query, + auth: { isAuthenticated: true }, + }); (request as any).url = new URL( `${request.url.path}${request.url.search}`, 'https://kibana.co' ); - - // Redirect if user has an active session even if `showLogin` is `true`. - authc.getSessionInfo.mockResolvedValue({ - provider: 'basic', - now: 0, - idleTimeoutExpiration: null, - lifespanExpiration: null, - }); license.getFeatures.mockReturnValue({ showLogin: true } as any); await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ options: { headers: { location: `${expectedLocation}` } }, status: 302, }); - // Redirect if `showLogin` is `false` even if user doesn't have an active session even. - authc.getSessionInfo.mockResolvedValue(null); + // Redirect if `showLogin` is `false` even if user is not authenticated. + request = httpServerMock.createKibanaRequest({ query, auth: { isAuthenticated: false } }); + (request as any).url = new URL( + `${request.url.path}${request.url.search}`, + 'https://kibana.co' + ); license.getFeatures.mockReturnValue({ showLogin: false } as any); await expect(routeHandler({} as any, request, kibanaResponseFactory)).resolves.toEqual({ options: { headers: { location: `${expectedLocation}` } }, @@ -114,11 +115,10 @@ describe('Login view routes', () => { } }); - it('renders view if user does not have an active session and login page can be shown.', async () => { - authc.getSessionInfo.mockResolvedValue(null); + it('renders view if user is not authenticated and login page can be shown.', async () => { license.getFeatures.mockReturnValue({ showLogin: true } as any); - const request = httpServerMock.createKibanaRequest(); + const request = httpServerMock.createKibanaRequest({ auth: { isAuthenticated: false } }); const contextMock = coreMock.createRequestHandlerContext(); await expect( @@ -133,7 +133,6 @@ describe('Login view routes', () => { status: 200, }); - expect(authc.getSessionInfo).toHaveBeenCalledWith(request); expect(contextMock.rendering.render).toHaveBeenCalledWith({ includeUserSettings: false }); }); }); @@ -170,11 +169,18 @@ describe('Login view routes', () => { const request = httpServerMock.createKibanaRequest(); const contextMock = coreMock.createRequestHandlerContext(); + const expectedPayload = { + allowLogin: true, + layout: 'error-es-unavailable', + showLoginForm: true, + requiresSecureConnection: false, + selector: { enabled: false, providers: [] }, + }; await expect( routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) ).resolves.toEqual({ - options: { body: { allowLogin: true, layout: 'error-es-unavailable', showLogin: true } }, - payload: { allowLogin: true, layout: 'error-es-unavailable', showLogin: true }, + options: { body: expectedPayload }, + payload: expectedPayload, status: 200, }); }); @@ -185,13 +191,156 @@ describe('Login view routes', () => { const request = httpServerMock.createKibanaRequest(); const contextMock = coreMock.createRequestHandlerContext(); + const expectedPayload = { + allowLogin: true, + layout: 'form', + showLoginForm: true, + requiresSecureConnection: false, + selector: { enabled: false, providers: [] }, + }; + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: expectedPayload }, + payload: expectedPayload, + status: 200, + }); + }); + + it('returns `requiresSecureConnection: true` if `secureCookies` is enabled in config.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + config.secureCookies = true; + + const expectedPayload = expect.objectContaining({ requiresSecureConnection: true }); await expect( routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) ).resolves.toEqual({ - options: { body: { allowLogin: true, layout: 'form', showLogin: true } }, - payload: { allowLogin: true, layout: 'form', showLogin: true }, + options: { body: expectedPayload }, + payload: expectedPayload, status: 200, }); }); + + it('returns `showLoginForm: true` only if either `basic` or `token` provider is enabled.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + const cases: Array<[boolean, ConfigType['authc']['sortedProviders']]> = [ + [false, []], + [true, [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }]], + [true, [{ type: 'token', name: 'token1', options: { order: 0, showInSelector: true } }]], + ]; + + for (const [showLoginForm, sortedProviders] of cases) { + config.authc.sortedProviders = sortedProviders; + + const expectedPayload = expect.objectContaining({ showLoginForm }); + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: expectedPayload }, + payload: expectedPayload, + status: 200, + }); + } + }); + + it('correctly returns `selector` information.', async () => { + license.getFeatures.mockReturnValue({ allowLogin: true, showLogin: true } as any); + + const request = httpServerMock.createKibanaRequest(); + const contextMock = coreMock.createRequestHandlerContext(); + + const cases: Array<[ + boolean, + ConfigType['authc']['sortedProviders'], + LoginState['selector']['providers'] + ]> = [ + // selector is disabled, providers shouldn't be returned. + [ + false, + [ + { type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }, + { type: 'saml', name: 'saml1', options: { order: 1, showInSelector: true } }, + ], + [], + ], + // selector is enabled, but only basic/token is available, providers shouldn't be returned. + [ + true, + [{ type: 'basic', name: 'basic1', options: { order: 0, showInSelector: true } }], + [], + ], + // selector is enabled, non-basic/token providers should be returned + [ + true, + [ + { + type: 'basic', + name: 'basic1', + options: { order: 0, showInSelector: true, description: 'some-desc1' }, + }, + { + type: 'saml', + name: 'saml1', + options: { order: 1, showInSelector: true, description: 'some-desc2' }, + }, + { + type: 'saml', + name: 'saml2', + options: { order: 2, showInSelector: true, description: 'some-desc3' }, + }, + ], + [ + { type: 'saml', name: 'saml1', description: 'some-desc2' }, + { type: 'saml', name: 'saml2', description: 'some-desc3' }, + ], + ], + // selector is enabled, only non-basic/token providers that are enabled in selector should be returned. + [ + true, + [ + { + type: 'basic', + name: 'basic1', + options: { order: 0, showInSelector: true, description: 'some-desc1' }, + }, + { + type: 'saml', + name: 'saml1', + options: { order: 1, showInSelector: false, description: 'some-desc2' }, + }, + { + type: 'saml', + name: 'saml2', + options: { order: 2, showInSelector: true, description: 'some-desc3' }, + }, + ], + [{ type: 'saml', name: 'saml2', description: 'some-desc3' }], + ], + ]; + + for (const [selectorEnabled, sortedProviders, expectedProviders] of cases) { + config.authc.selector.enabled = selectorEnabled; + config.authc.sortedProviders = sortedProviders; + + const expectedPayload = expect.objectContaining({ + selector: { enabled: selectorEnabled, providers: expectedProviders }, + }); + await expect( + routeHandler({ core: contextMock } as any, request, kibanaResponseFactory) + ).resolves.toEqual({ + options: { body: expectedPayload }, + payload: expectedPayload, + status: 200, + }); + } + }); }); }); diff --git a/x-pack/plugins/security/server/routes/views/login.ts b/x-pack/plugins/security/server/routes/views/login.ts index ee1fe01ab1b22..4cabd4337971c 100644 --- a/x-pack/plugins/security/server/routes/views/login.ts +++ b/x-pack/plugins/security/server/routes/views/login.ts @@ -6,15 +6,16 @@ import { schema } from '@kbn/config-schema'; import { parseNext } from '../../../common/parse_next'; +import { LoginState } from '../../../common/login_state'; import { RouteDefinitionParams } from '..'; /** * Defines routes required for the Login view. */ export function defineLoginRoutes({ + config, router, logger, - authc, csp, basePath, license, @@ -31,15 +32,12 @@ export function defineLoginRoutes({ { unknowns: 'allow' } ), }, - options: { authRequired: false }, + options: { authRequired: 'optional' }, }, async (context, request, response) => { // Default to true if license isn't available or it can't be resolved for some reason. const shouldShowLogin = license.isEnabled() ? license.getFeatures().showLogin : true; - - // Authentication flow isn't triggered automatically for this route, so we should explicitly - // check whether user has an active session already. - const isUserAlreadyLoggedIn = (await authc.getSessionInfo(request)) !== null; + const isUserAlreadyLoggedIn = request.auth.isAuthenticated; if (isUserAlreadyLoggedIn || !shouldShowLogin) { logger.debug('User is already authenticated, redirecting...'); return response.redirected({ @@ -57,8 +55,30 @@ export function defineLoginRoutes({ router.get( { path: '/internal/security/login_state', validate: false, options: { authRequired: false } }, async (context, request, response) => { - const { showLogin, allowLogin, layout = 'form' } = license.getFeatures(); - return response.ok({ body: { showLogin, allowLogin, layout } }); + const { allowLogin, layout = 'form' } = license.getFeatures(); + const { sortedProviders, selector } = config.authc; + + let showLoginForm = false; + const providers = []; + for (const { type, name, options } of sortedProviders) { + if (options.showInSelector) { + if (type === 'basic' || type === 'token') { + showLoginForm = true; + } else if (selector.enabled) { + providers.push({ type, name, description: options.description }); + } + } + } + + const loginState: LoginState = { + allowLogin, + layout, + requiresSecureConnection: config.secureCookies, + showLoginForm, + selector: { enabled: selector.enabled, providers }, + }; + + return response.ok({ body: loginState }); } ); } diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 06ee0b91c8a5d..242ee890d4847 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -26,6 +26,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/oidc_api_integration/config.ts'), require.resolve('../test/oidc_api_integration/implicit_flow.config.ts'), require.resolve('../test/pki_api_integration/config.ts'), + require.resolve('../test/login_selector_api_integration/config.ts'), require.resolve('../test/spaces_api_integration/spaces_only/config.ts'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial.ts'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic.ts'), diff --git a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts index b561c9ea47513..81999826adbb1 100644 --- a/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts +++ b/x-pack/test/kerberos_api_integration/apis/security/kerberos_login.ts @@ -8,10 +8,14 @@ import expect from '@kbn/expect'; import request, { Cookie } from 'request'; import { delay } from 'bluebird'; import { FtrProviderContext } from '../../ftr_provider_context'; +import { + getMutualAuthenticationResponseToken, + getSPNEGOToken, +} from '../../fixtures/kerberos_tools'; export default function({ getService }: FtrProviderContext) { - const spnegoToken = - 'YIIChwYGKwYBBQUCoIICezCCAnegDTALBgkqhkiG9xIBAgKiggJkBIICYGCCAlwGCSqGSIb3EgECAgEAboICSzCCAkegAwIBBaEDAgEOogcDBQAAAAAAo4IBW2GCAVcwggFToAMCAQWhERsPVEVTVC5FTEFTVElDLkNPohwwGqADAgEDoRMwERsESFRUUBsJbG9jYWxob3N0o4IBGTCCARWgAwIBEqEDAgECooIBBwSCAQNBN2a1Rso+KEJsDwICYLCt7ACLzdlbhEZF5YNsehO109b/WiZR1VTK6kCQyDdBdQFefyvV8EiC35mz7XnTb239nWz6xBGbdmtjSfF0XzpXKbL/zGzLEKkEXQuqFLPUN6qEJXsh0OoNdj9OWwmTr93FVyugs1hO/E5wjlAe2SDYpBN6uZICXu6dFg9nLQKkb/XgbgKM7ZZvgA/UElWDgHav4nPO1VWppCCLKHqXTRnvpr/AsxeON4qeJLaukxBigfIaJlLFMNQal5H7MyXa0j3Y1sckbURnWoBt6r4XE7c8F8cz0rYoGwoCO+Cs5tNutKY6XcsAFbLh59hjgIkhVBhhyTeypIHSMIHPoAMCARKigccEgcSsXqIRAcHfZivrbHfsnvbFgmzmnrKVPFNtJ9Hl23KunCsNW49nP4VF2dEf9n12prDaIguJDV5LPHpTew9rmCj1GCahKJ9bJbRKIgImLFd+nelm3E2zxRqAhrgM1469oDg0ksE3+5lJBuJlVEECMp0F/gxvEiL7DhasICqw+FOJ/jD9QUYvg+E6BIxWgZyPszaxerzBBszAhIF1rxCHRRL1KLjskNeJlBhH77DkAO6AEmsYGdsgEq7b7uCov9PKPiiPAuFF'; + const spnegoToken = getSPNEGOToken(); + const supertest = getService('supertestWithoutAuth'); const config = getService('config'); @@ -105,7 +109,7 @@ export default function({ getService }: FtrProviderContext) { // Verify that mutual authentication works. expect(response.headers['www-authenticate']).to.be( - 'Negotiate oRQwEqADCgEAoQsGCSqGSIb3EgECAg==' + `Negotiate ${getMutualAuthenticationResponseToken()}` ); const cookies = response.headers['set-cookie']; diff --git a/x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts b/x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts new file mode 100644 index 0000000000000..2fed5d475cd5c --- /dev/null +++ b/x-pack/test/kerberos_api_integration/fixtures/kerberos_tools.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function getSPNEGOToken() { + return 'YIIChwYGKwYBBQUCoIICezCCAnegDTALBgkqhkiG9xIBAgKiggJkBIICYGCCAlwGCSqGSIb3EgECAgEAboICSzCCAkegAwIBBaEDAgEOogcDBQAAAAAAo4IBW2GCAVcwggFToAMCAQWhERsPVEVTVC5FTEFTVElDLkNPohwwGqADAgEDoRMwERsESFRUUBsJbG9jYWxob3N0o4IBGTCCARWgAwIBEqEDAgECooIBBwSCAQNBN2a1Rso+KEJsDwICYLCt7ACLzdlbhEZF5YNsehO109b/WiZR1VTK6kCQyDdBdQFefyvV8EiC35mz7XnTb239nWz6xBGbdmtjSfF0XzpXKbL/zGzLEKkEXQuqFLPUN6qEJXsh0OoNdj9OWwmTr93FVyugs1hO/E5wjlAe2SDYpBN6uZICXu6dFg9nLQKkb/XgbgKM7ZZvgA/UElWDgHav4nPO1VWppCCLKHqXTRnvpr/AsxeON4qeJLaukxBigfIaJlLFMNQal5H7MyXa0j3Y1sckbURnWoBt6r4XE7c8F8cz0rYoGwoCO+Cs5tNutKY6XcsAFbLh59hjgIkhVBhhyTeypIHSMIHPoAMCARKigccEgcSsXqIRAcHfZivrbHfsnvbFgmzmnrKVPFNtJ9Hl23KunCsNW49nP4VF2dEf9n12prDaIguJDV5LPHpTew9rmCj1GCahKJ9bJbRKIgImLFd+nelm3E2zxRqAhrgM1469oDg0ksE3+5lJBuJlVEECMp0F/gxvEiL7DhasICqw+FOJ/jD9QUYvg+E6BIxWgZyPszaxerzBBszAhIF1rxCHRRL1KLjskNeJlBhH77DkAO6AEmsYGdsgEq7b7uCov9PKPiiPAuFF'; +} + +export function getMutualAuthenticationResponseToken() { + return 'oRQwEqADCgEAoQsGCSqGSIb3EgECAg=='; +} diff --git a/x-pack/test/login_selector_api_integration/apis/index.ts b/x-pack/test/login_selector_api_integration/apis/index.ts new file mode 100644 index 0000000000000..35f83733a7105 --- /dev/null +++ b/x-pack/test/login_selector_api_integration/apis/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function({ loadTestFile }: FtrProviderContext) { + describe('apis', function() { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./login_selector')); + }); +} diff --git a/x-pack/test/login_selector_api_integration/apis/login_selector.ts b/x-pack/test/login_selector_api_integration/apis/login_selector.ts new file mode 100644 index 0000000000000..3be96d27186d9 --- /dev/null +++ b/x-pack/test/login_selector_api_integration/apis/login_selector.ts @@ -0,0 +1,545 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import request, { Cookie } from 'request'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import url from 'url'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; +import expect from '@kbn/expect'; +import { getStateAndNonce } from '../../oidc_api_integration/fixtures/oidc_tools'; +import { + getMutualAuthenticationResponseToken, + getSPNEGOToken, +} from '../../kerberos_api_integration/fixtures/kerberos_tools'; +import { getSAMLRequestId, getSAMLResponse } from '../../saml_api_integration/fixtures/saml_tools'; +import { FtrProviderContext } from '../ftr_provider_context'; + +export default function({ getService }: FtrProviderContext) { + const randomness = getService('randomness'); + const supertest = getService('supertestWithoutAuth'); + const config = getService('config'); + + const kibanaServerConfig = config.get('servers.kibana'); + const validUsername = kibanaServerConfig.username; + const validPassword = kibanaServerConfig.password; + + const CA_CERT = readFileSync(CA_CERT_PATH); + const CLIENT_CERT = readFileSync( + resolve(__dirname, '../../pki_api_integration/fixtures/first_client.p12') + ); + + async function checkSessionCookie(sessionCookie: Cookie, username: string, providerName: string) { + expect(sessionCookie.key).to.be('sid'); + expect(sessionCookie.value).to.not.be.empty(); + expect(sessionCookie.path).to.be('/'); + expect(sessionCookie.httpOnly).to.be(true); + + const apiResponse = await supertest + .get('/internal/security/me') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', sessionCookie.cookieString()) + .expect(200); + + expect(apiResponse.body).to.only.have.keys([ + 'username', + 'full_name', + 'email', + 'roles', + 'metadata', + 'enabled', + 'authentication_realm', + 'lookup_realm', + 'authentication_provider', + ]); + + expect(apiResponse.body.username).to.be(username); + expect(apiResponse.body.authentication_provider).to.be(providerName); + } + + describe('Login Selector', () => { + it('should redirect user to a login selector', async () => { + const response = await supertest + .get('/abc/xyz/handshake?one=two three') + .ca(CA_CERT) + .expect(302); + expect(response.headers['set-cookie']).to.be(undefined); + expect(response.headers.location).to.be( + '/login?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three' + ); + }); + + it('should allow access to login selector with intermediate authentication cookie', async () => { + const handshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ providerType: 'saml', providerName: 'saml1', currentURL: 'https://kibana.com/' }) + .expect(200); + + // The cookie that includes some state of the in-progress authentication, that doesn't allow + // to fully authenticate user yet. + const intermediateAuthCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + + await supertest + .get('/login') + .ca(CA_CERT) + .set('Cookie', intermediateAuthCookie.cookieString()) + .expect(200); + }); + + describe('SAML', () => { + function createSAMLResponse(options = {}) { + return getSAMLResponse({ + destination: `http://localhost:${kibanaServerConfig.port}/api/security/saml/callback`, + sessionIndex: String(randomness.naturalNumber()), + ...options, + }); + } + + it('should be able to log in via IdP initiated login for any configured realm', async () => { + for (const providerName of ['saml1', 'saml2']) { + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .send({ + SAMLResponse: await createSAMLResponse({ + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .expect(302); + + // User should be redirected to the base URL. + expect(authenticationResponse.headers.location).to.be('/'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + + it('should be able to log in via IdP initiated login even if session with other provider type exists', async () => { + const basicAuthenticationResponse = await supertest + .post('/internal/security/login') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ username: validUsername, password: validPassword }) + .expect(204); + + const basicSessionCookie = request.cookie( + basicAuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(basicSessionCookie, 'elastic', 'basic1'); + + for (const providerName of ['saml1', 'saml2']) { + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', basicSessionCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .expect(302); + + // It should be `/overwritten_session` instead of `/` once it's generalized. + expect(authenticationResponse.headers.location).to.be('/'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + + it('should be able to log in via IdP initiated login even if session with other SAML provider exists', async () => { + // First login with `saml1`. + const saml1AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml1` }), + }) + .expect(302); + + const saml1SessionCookie = request.cookie( + saml1AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml1SessionCookie, 'a@b.c', 'saml1'); + + // And now try to login with `saml2`. + const saml2AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', saml1SessionCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }), + }) + .expect(302); + + // It should be `/overwritten_session` instead of `/` once it's generalized. + expect(saml2AuthenticationResponse.headers.location).to.be('/'); + + const saml2SessionCookie = request.cookie( + saml2AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml2SessionCookie, 'a@b.c', 'saml2'); + }); + + // Ideally we should be able to abandon intermediate session and let user log in, but for the + // time being we cannot distinguish errors coming from Elasticsearch for the case when SAML + // response just doesn't correspond to request ID we have in intermediate cookie and the case + // when something else has happened. + it('should fail for IdP initiated login if intermediate session with other SAML provider exists', async () => { + // First start authentication flow with `saml1`. + const saml1HandshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + expect( + saml1HandshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`) + ).to.be(true); + + const saml1HandshakeCookie = request.cookie( + saml1HandshakeResponse.headers['set-cookie'][0] + )!; + + // And now try to login with `saml2`. + await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', saml1HandshakeCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }), + }) + .expect(401); + }); + + it('should be able to log in via SP initiated login with any configured realm', async () => { + for (const providerName of ['saml1', 'saml2']) { + const handshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName, + currentURL: + 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + expect(handshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`)).to.be( + true + ); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const samlRequestId = await getSAMLRequestId(handshakeResponse.body.location); + + const authenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', handshakeCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ + inResponseTo: samlRequestId, + issuer: `http://www.elastic.co/${providerName}`, + }), + }) + .expect(302); + + // User should be redirected to the URL that initiated handshake. + expect(authenticationResponse.headers.location).to.be( + '/abc/xyz/handshake?one=two three#/workpad' + ); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'a@b.c', providerName); + } + }); + + it('should be able to log in via SP initiated login even if intermediate session with other SAML provider exists', async () => { + // First start authentication flow with `saml1`. + const saml1HandshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'saml', + providerName: 'saml1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/saml1', + }) + .expect(200); + + expect( + saml1HandshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`) + ).to.be(true); + + const saml1HandshakeCookie = request.cookie( + saml1HandshakeResponse.headers['set-cookie'][0] + )!; + + // And now try to login with `saml2`. + const saml2HandshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .set('Cookie', saml1HandshakeCookie.cookieString()) + .send({ + providerType: 'saml', + providerName: 'saml2', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/saml2', + }) + .expect(200); + + expect( + saml2HandshakeResponse.body.location.startsWith(`https://elastic.co/sso/saml`) + ).to.be(true); + + const saml2HandshakeCookie = request.cookie( + saml2HandshakeResponse.headers['set-cookie'][0] + )!; + + const saml2AuthenticationResponse = await supertest + .post('/api/security/saml/callback') + .ca(CA_CERT) + .set('Cookie', saml2HandshakeCookie.cookieString()) + .send({ + SAMLResponse: await createSAMLResponse({ issuer: `http://www.elastic.co/saml2` }), + }) + .expect(302); + + expect(saml2AuthenticationResponse.headers.location).to.be( + '/abc/xyz/handshake?one=two three#/saml2' + ); + + const saml2SessionCookie = request.cookie( + saml2AuthenticationResponse.headers['set-cookie'][0] + )!; + await checkSessionCookie(saml2SessionCookie, 'a@b.c', 'saml2'); + }); + }); + + describe('Kerberos', () => { + it('should be able to log in from Login Selector', async () => { + const spnegoResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(401); + + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); + + const authenticationResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .set('Authorization', `Negotiate ${getSPNEGOToken()}`) + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + // Verify that mutual authentication works. + expect(authenticationResponse.headers['www-authenticate']).to.be( + `Negotiate ${getMutualAuthenticationResponseToken()}` + ); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'tester@TEST.ELASTIC.CO', + 'kerberos1' + ); + }); + + it('should be able to log in from Login Selector even if client provides certificate and PKI is enabled', async () => { + const spnegoResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(401); + + expect(spnegoResponse.headers['set-cookie']).to.be(undefined); + expect(spnegoResponse.headers['www-authenticate']).to.be('Negotiate'); + + const authenticationResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .set('Authorization', `Negotiate ${getSPNEGOToken()}`) + .send({ + providerType: 'kerberos', + providerName: 'kerberos1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + // Verify that mutual authentication works. + expect(authenticationResponse.headers['www-authenticate']).to.be( + `Negotiate ${getMutualAuthenticationResponseToken()}` + ); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie( + request.cookie(cookies[0])!, + 'tester@TEST.ELASTIC.CO', + 'kerberos1' + ); + }); + }); + + describe('OpenID Connect', () => { + it('should be able to log in via IdP initiated login', async () => { + const handshakeResponse = await supertest + .get('/api/security/oidc/initiate_login?iss=https://test-op.elastic.co') + .ca(CA_CERT) + .expect(302); + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + const { state, nonce } = getStateAndNonce(handshakeResponse.headers.location); + await supertest + .post('/api/oidc_provider/setup') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ nonce }) + .expect(200); + + const authenticationResponse = await supertest + .get(`/api/security/oidc/callback?code=code2&state=${state}`) + .ca(CA_CERT) + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + // User should be redirected to the base URL. + expect(authenticationResponse.headers.location).to.be('/'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'user2', 'oidc1'); + }); + + it('should be able to log in via SP initiated login', async () => { + const handshakeResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'oidc', + providerName: 'oidc1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three', + }) + .expect(200); + + const handshakeCookie = request.cookie(handshakeResponse.headers['set-cookie'][0])!; + const redirectURL = url.parse(handshakeResponse.body.location, true /* parseQueryString */); + expect( + handshakeResponse.body.location.startsWith( + `https://test-op.elastic.co/oauth2/v1/authorize` + ) + ).to.be(true); + + expect(redirectURL.query.scope).to.not.be.empty(); + expect(redirectURL.query.response_type).to.not.be.empty(); + expect(redirectURL.query.client_id).to.not.be.empty(); + expect(redirectURL.query.redirect_uri).to.not.be.empty(); + expect(redirectURL.query.state).to.not.be.empty(); + expect(redirectURL.query.nonce).to.not.be.empty(); + + // Set the nonce in our mock OIDC Provider so that it can generate the ID Tokens + const { state, nonce } = redirectURL.query; + await supertest + .post('/api/oidc_provider/setup') + .ca(CA_CERT) + .set('kbn-xsrf', 'xxx') + .send({ nonce }) + .expect(200); + + const authenticationResponse = await supertest + .get(`/api/security/oidc/callback?code=code1&state=${state}`) + .ca(CA_CERT) + .set('Cookie', handshakeCookie.cookieString()) + .expect(302); + + // User should be redirected to the URL that initiated handshake. + expect(authenticationResponse.headers.location).to.be('/abc/xyz/handshake?one=two three'); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'user1', 'oidc1'); + }); + }); + + describe('PKI', () => { + it('should redirect user to a login selector even if client provides certificate', async () => { + const response = await supertest + .get('/abc/xyz/handshake?one=two three') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .expect(302); + expect(response.headers['set-cookie']).to.be(undefined); + expect(response.headers.location).to.be( + '/login?next=%2Fabc%2Fxyz%2Fhandshake%3Fone%3Dtwo%2520three' + ); + }); + + it('should be able to log in from Login Selector', async () => { + const authenticationResponse = await supertest + .post('/internal/security/login_with') + .ca(CA_CERT) + .pfx(CLIENT_CERT) + .set('kbn-xsrf', 'xxx') + .send({ + providerType: 'pki', + providerName: 'pki1', + currentURL: 'https://kibana.com/login?next=/abc/xyz/handshake?one=two%20three#/workpad', + }) + .expect(200); + + const cookies = authenticationResponse.headers['set-cookie']; + expect(cookies).to.have.length(1); + + await checkSessionCookie(request.cookie(cookies[0])!, 'first_client', 'pki1'); + }); + }); + }); +} diff --git a/x-pack/test/login_selector_api_integration/config.ts b/x-pack/test/login_selector_api_integration/config.ts new file mode 100644 index 0000000000000..6ca9d19b74c17 --- /dev/null +++ b/x-pack/test/login_selector_api_integration/config.ts @@ -0,0 +1,141 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve } from 'path'; +import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; + +export default async function({ readConfigFile }: FtrConfigProviderContext) { + const kibanaAPITestsConfig = await readConfigFile( + require.resolve('../../../test/api_integration/config.js') + ); + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.js')); + const kibanaPort = xPackAPITestsConfig.get('servers.kibana.port'); + + const kerberosKeytabPath = resolve(__dirname, '../kerberos_api_integration/fixtures/krb5.keytab'); + const kerberosConfigPath = resolve(__dirname, '../kerberos_api_integration/fixtures/krb5.conf'); + + const oidcJWKSPath = resolve(__dirname, '../oidc_api_integration/fixtures/jwks.json'); + const oidcIdPPlugin = resolve(__dirname, '../oidc_api_integration/fixtures/oidc_provider'); + + const pkiKibanaCAPath = resolve(__dirname, '../pki_api_integration/fixtures/kibana_ca.crt'); + + const saml1IdPMetadataPath = resolve( + __dirname, + '../saml_api_integration/fixtures/idp_metadata.xml' + ); + const saml2IdPMetadataPath = resolve( + __dirname, + '../saml_api_integration/fixtures/idp_metadata_2.xml' + ); + + const servers = { + ...xPackAPITestsConfig.get('servers'), + elasticsearch: { + ...xPackAPITestsConfig.get('servers.elasticsearch'), + protocol: 'https', + }, + kibana: { + ...xPackAPITestsConfig.get('servers.kibana'), + protocol: 'https', + }, + }; + + return { + testFiles: [require.resolve('./apis')], + servers, + security: { disableTestUser: true }, + services: { + randomness: kibanaAPITestsConfig.get('services.randomness'), + legacyEs: kibanaAPITestsConfig.get('services.legacyEs'), + supertestWithoutAuth: xPackAPITestsConfig.get('services.supertestWithoutAuth'), + }, + junit: { + reportName: 'X-Pack Login Selector API Integration Tests', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + ssl: true, + serverArgs: [ + ...xPackAPITestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.token.timeout=15s', + 'xpack.security.http.ssl.client_authentication=optional', + 'xpack.security.http.ssl.verification_mode=certificate', + 'xpack.security.authc.realms.native.native1.order=0', + 'xpack.security.authc.realms.kerberos.kerb1.order=1', + `xpack.security.authc.realms.kerberos.kerb1.keytab.path=${kerberosKeytabPath}`, + 'xpack.security.authc.realms.pki.pki1.order=2', + 'xpack.security.authc.realms.pki.pki1.delegation.enabled=true', + `xpack.security.authc.realms.pki.pki1.certificate_authorities=${CA_CERT_PATH}`, + 'xpack.security.authc.realms.saml.saml1.order=3', + `xpack.security.authc.realms.saml.saml1.idp.metadata.path=${saml1IdPMetadataPath}`, + 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co/saml1', + `xpack.security.authc.realms.saml.saml1.sp.entity_id=http://localhost:${kibanaPort}`, + `xpack.security.authc.realms.saml.saml1.sp.logout=http://localhost:${kibanaPort}/logout`, + `xpack.security.authc.realms.saml.saml1.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, + 'xpack.security.authc.realms.saml.saml1.attributes.principal=urn:oid:0.0.7', + 'xpack.security.authc.realms.oidc.oidc1.order=4', + `xpack.security.authc.realms.oidc.oidc1.rp.client_id=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.client_secret=0oa8sqpov3TxMWJOt356`, + `xpack.security.authc.realms.oidc.oidc1.rp.response_type=code`, + `xpack.security.authc.realms.oidc.oidc1.rp.redirect_uri=https://localhost:${kibanaPort}/api/security/oidc/callback`, + `xpack.security.authc.realms.oidc.oidc1.op.authorization_endpoint=https://test-op.elastic.co/oauth2/v1/authorize`, + `xpack.security.authc.realms.oidc.oidc1.op.endsession_endpoint=https://test-op.elastic.co/oauth2/v1/endsession`, + `xpack.security.authc.realms.oidc.oidc1.op.token_endpoint=https://localhost:${kibanaPort}/api/oidc_provider/token_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.userinfo_endpoint=https://localhost:${kibanaPort}/api/oidc_provider/userinfo_endpoint`, + `xpack.security.authc.realms.oidc.oidc1.op.issuer=https://test-op.elastic.co`, + `xpack.security.authc.realms.oidc.oidc1.op.jwkset_path=${oidcJWKSPath}`, + `xpack.security.authc.realms.oidc.oidc1.claims.principal=sub`, + `xpack.security.authc.realms.oidc.oidc1.ssl.certificate_authorities=${CA_CERT_PATH}`, + 'xpack.security.authc.realms.saml.saml2.order=5', + `xpack.security.authc.realms.saml.saml2.idp.metadata.path=${saml2IdPMetadataPath}`, + 'xpack.security.authc.realms.saml.saml2.idp.entity_id=http://www.elastic.co/saml2', + `xpack.security.authc.realms.saml.saml2.sp.entity_id=http://localhost:${kibanaPort}`, + `xpack.security.authc.realms.saml.saml2.sp.logout=http://localhost:${kibanaPort}/logout`, + `xpack.security.authc.realms.saml.saml2.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, + 'xpack.security.authc.realms.saml.saml2.attributes.principal=urn:oid:0.0.7', + ], + serverEnvVars: { + // We're going to use the same TGT multiple times and during a short period of time, so we + // have to disable replay cache so that ES doesn't complain about that. + ES_JAVA_OPTS: `-Djava.security.krb5.conf=${kerberosConfigPath} -Dsun.security.krb5.rcache=none`, + }, + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + serverArgs: [ + ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), + `--plugin-path=${oidcIdPPlugin}`, + '--optimize.enabled=false', + '--server.ssl.enabled=true', + `--server.ssl.key=${KBN_KEY_PATH}`, + `--server.ssl.certificate=${KBN_CERT_PATH}`, + `--server.ssl.certificateAuthorities=${JSON.stringify([CA_CERT_PATH, pkiKibanaCAPath])}`, + `--server.ssl.clientAuthentication=optional`, + `--elasticsearch.hosts=${servers.elasticsearch.protocol}://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + `--xpack.security.authc.providers=${JSON.stringify({ + basic: { basic1: { order: 0 } }, + kerberos: { kerberos1: { order: 4 } }, + pki: { pki1: { order: 2 } }, + oidc: { oidc1: { order: 3, realm: 'oidc1' } }, + saml: { + saml1: { order: 1, realm: 'saml1' }, + saml2: { order: 5, realm: 'saml2', maxRedirectURLSize: '100b' }, + }, + })}`, + '--server.xsrf.whitelist', + JSON.stringify([ + '/api/oidc_provider/token_endpoint', + '/api/oidc_provider/userinfo_endpoint', + ]), + ], + }, + }; +} diff --git a/x-pack/plugins/security/public/authentication/login/login_state.ts b/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts similarity index 56% rename from x-pack/plugins/security/public/authentication/login/login_state.ts rename to x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts index 6ca38296706fe..e3add3748f56d 100644 --- a/x-pack/plugins/security/public/authentication/login/login_state.ts +++ b/x-pack/test/login_selector_api_integration/ftr_provider_context.d.ts @@ -4,9 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LoginLayout } from '../../../common/licensing'; +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; -export interface LoginState { - layout: LoginLayout; - allowLogin: boolean; -} +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/login_selector_api_integration/services.ts b/x-pack/test/login_selector_api_integration/services.ts new file mode 100644 index 0000000000000..8bb2dae90bf59 --- /dev/null +++ b/x-pack/test/login_selector_api_integration/services.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { services as commonServices } from '../common/services'; +import { services as apiIntegrationServices } from '../api_integration/services'; + +export const services = { + ...commonServices, + randomness: apiIntegrationServices.randomness, + supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, +}; diff --git a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts index fe772a3b1d460..ac16335b7f466 100644 --- a/x-pack/test/pki_api_integration/apis/security/pki_auth.ts +++ b/x-pack/test/pki_api_integration/apis/security/pki_auth.ts @@ -9,7 +9,6 @@ import request, { Cookie } from 'request'; import { delay } from 'bluebird'; import { readFileSync } from 'fs'; import { resolve } from 'path'; -// @ts-ignore import { CA_CERT_PATH } from '@kbn/dev-utils'; import { FtrProviderContext } from '../../ftr_provider_context'; diff --git a/x-pack/test/pki_api_integration/config.ts b/x-pack/test/pki_api_integration/config.ts index 21ae1b40efa16..8177e4aa1afba 100644 --- a/x-pack/test/pki_api_integration/config.ts +++ b/x-pack/test/pki_api_integration/config.ts @@ -6,7 +6,6 @@ import { resolve } from 'path'; import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; -// @ts-ignore import { CA_CERT_PATH, KBN_CERT_PATH, KBN_KEY_PATH } from '@kbn/dev-utils'; import { services } from './services'; diff --git a/x-pack/test/saml_api_integration/apis/security/saml_login.ts b/x-pack/test/saml_api_integration/apis/security/saml_login.ts index e49d95f2ec6c2..a4cb34c13c0e1 100644 --- a/x-pack/test/saml_api_integration/apis/security/saml_login.ts +++ b/x-pack/test/saml_api_integration/apis/security/saml_login.ts @@ -108,11 +108,15 @@ export default function({ getService }: FtrProviderContext) { expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - expect(handshakeResponse.headers.location).to.be('/api/security/saml/capture-url-fragment'); + expect(handshakeResponse.headers.location).to.be( + '/internal/security/saml/capture-url-fragment' + ); }); it('should return an HTML page that will extract URL fragment', async () => { - const response = await supertest.get('/api/security/saml/capture-url-fragment').expect(200); + const response = await supertest + .get('/internal/security/saml/capture-url-fragment') + .expect(200); const kibanaBaseURL = url.format({ ...config.get('servers.kibana'), auth: false }); const dom = new JSDOM(response.text, { @@ -127,7 +131,7 @@ export default function({ getService }: FtrProviderContext) { Object.defineProperty(window, 'location', { value: { hash: '#/workpad', - href: `${kibanaBaseURL}/api/security/saml/capture-url-fragment#/workpad`, + href: `${kibanaBaseURL}/internal/security/saml/capture-url-fragment#/workpad`, replace(newLocation: string) { this.href = newLocation; resolve(); @@ -149,13 +153,13 @@ export default function({ getService }: FtrProviderContext) { // Check that script that forwards URL fragment worked correctly. expect(dom.window.location.href).to.be( - '/api/security/saml/start?redirectURLFragment=%23%2Fworkpad' + '/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad' ); }); }); describe('initiating handshake', () => { - const initiateHandshakeURL = `/api/security/saml/start?redirectURLFragment=%23%2Fworkpad`; + const initiateHandshakeURL = `/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad`; let captureURLCookie: Cookie; beforeEach(async () => { @@ -202,9 +206,8 @@ export default function({ getService }: FtrProviderContext) { it('AJAX requests should not initiate handshake', async () => { const ajaxResponse = await supertest - .get(initiateHandshakeURL) + .get('/abc/xyz/handshake?one=two three') .set('kbn-xsrf', 'xxx') - .set('Cookie', captureURLCookie.cookieString()) .expect(401); expect(ajaxResponse.headers['set-cookie']).to.be(undefined); @@ -222,7 +225,7 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=%23%2Fworkpad`) + .get(`/internal/security/saml/start?redirectURLFragment=%23%2Fworkpad`) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -360,7 +363,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -515,7 +520,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -603,7 +610,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -647,7 +656,9 @@ export default function({ getService }: FtrProviderContext) { expect(handshakeCookie.path).to.be('/'); expect(handshakeCookie.httpOnly).to.be(true); - expect(handshakeResponse.headers.location).to.be('/api/security/saml/capture-url-fragment'); + expect(handshakeResponse.headers.location).to.be( + '/internal/security/saml/capture-url-fragment' + ); }); }); @@ -662,7 +673,9 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}`) + .get( + `/internal/security/saml/start?redirectURLFragment=${encodeURIComponent('#workpad')}` + ) .set('Cookie', captureURLCookie.cookieString()) .expect(302); @@ -798,12 +811,12 @@ export default function({ getService }: FtrProviderContext) { const captureURLCookie = request.cookie(captureURLResponse.headers['set-cookie'][0])!; expect(captureURLResponse.headers.location).to.be( - '/api/security/saml/capture-url-fragment' + '/internal/security/saml/capture-url-fragment' ); // 2. Initiate SAML handshake. const handshakeResponse = await supertest - .get(`/api/security/saml/start?redirectURLFragment=%23%2F${'workpad'.repeat(10)}`) + .get(`/internal/security/saml/start?redirectURLFragment=%23%2F${'workpad'.repeat(10)}`) .set('Cookie', captureURLCookie.cookieString()) .expect(302); diff --git a/x-pack/test/saml_api_integration/config.ts b/x-pack/test/saml_api_integration/config.ts index 502d34d4c9e5d..0580c28555d16 100644 --- a/x-pack/test/saml_api_integration/config.ts +++ b/x-pack/test/saml_api_integration/config.ts @@ -37,7 +37,7 @@ export default async function({ readConfigFile }: FtrConfigProviderContext) { 'xpack.security.authc.token.timeout=15s', 'xpack.security.authc.realms.saml.saml1.order=0', `xpack.security.authc.realms.saml.saml1.idp.metadata.path=${idpPath}`, - 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co', + 'xpack.security.authc.realms.saml.saml1.idp.entity_id=http://www.elastic.co/saml1', `xpack.security.authc.realms.saml.saml1.sp.entity_id=http://localhost:${kibanaPort}`, `xpack.security.authc.realms.saml.saml1.sp.logout=http://localhost:${kibanaPort}/logout`, `xpack.security.authc.realms.saml.saml1.sp.acs=http://localhost:${kibanaPort}/api/security/saml/callback`, diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml b/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml index a890fe812987b..57b9e824c9d53 100644 --- a/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml +++ b/x-pack/test/saml_api_integration/fixtures/idp_metadata.xml @@ -1,6 +1,6 @@ + entityID="http://www.elastic.co/saml1"> diff --git a/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml b/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml new file mode 100644 index 0000000000000..ff67779d7732c --- /dev/null +++ b/x-pack/test/saml_api_integration/fixtures/idp_metadata_2.xml @@ -0,0 +1,41 @@ + + + + + + + + MIIDOTCCAiGgAwIBAgIVANNWkg9lzNiLqNkMFhFKHcXyaZmqMA0GCSqGSIb3DQEB +CwUAMDQxMjAwBgNVBAMTKUVsYXN0aWMgQ2VydGlmaWNhdGUgVG9vbCBBdXRvZ2Vu +ZXJhdGVkIENBMCAXDTE5MTIyNzE3MDM0MloYDzIwNjkxMjE0MTcwMzQyWjARMQ8w +DQYDVQQDEwZraWJhbmEwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCQ +wYYbQtbRBKJ4uNZc2+IgRU+7NNL21ZebQlEIMgK7jAqOMrsW2b5DATz41Fd+GQFU +FUYYjwo+PQj6sJHshOJo/gNb32HrydvMI7YPvevkszkuEGCfXxQ3Dw2RTACLgD0Q +OCkwHvn3TMf0loloV/ePGWaZDYZaXi3a5DdWi/HFFoJysgF0JV2f6XyKhJkGaEfJ +s9pWX269zH/XQvGNx4BEimJpYB8h4JnDYPFIiQdqj+sl2b+kS1hH9kL5gBAMXjFU +vcNnX+PmyTjyJrGo75k0ku+spBf1bMwuQt3uSmM+TQIXkvFDmS0DOVESrpA5EC1T +BUGRz6o/I88Xx4Mud771AgMBAAGjYzBhMB0GA1UdDgQWBBQLB1Eo23M3Ss8MsFaz +V+Twcb3PmDAfBgNVHSMEGDAWgBQa7SYOe8NGcF00EbwPHA91YCsHSTAUBgNVHREE +DTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADANBgkqhkiG9w0BAQsFAAOCAQEAnEl/ +z5IElIjvkK4AgMPrNcRlvIGDt2orEik7b6Jsq6/RiJQ7cSsYTZf7xbqyxNsUOTxv ++frj47MEN448H2nRvUxH29YR3XygV5aEwADSAhwaQWn0QfWTCZbJTmSoNEDtDOzX +TGDlAoCD9s9Xz9S1JpxY4H+WWRZrBSDM6SC1c6CzuEeZRuScNAjYD5mh2v6fOlSy +b8xJWSg0AFlJPCa3ZsA2SKbNqI0uNfJTnkXRm88Z2NHcgtlADbOLKauWfCrpgsCk +cZgo6yAYkOM148h/8wGla1eX+iE1R72NUABGydu8MSQKvc0emWJkGsC1/KqPlf/O +eOUsdwn1yDKHRxDHyA== + + + + + + + + + + diff --git a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts b/x-pack/test/saml_api_integration/fixtures/saml_tools.ts index bbe0df7ff3a2c..a924d0964c245 100644 --- a/x-pack/test/saml_api_integration/fixtures/saml_tools.ts +++ b/x-pack/test/saml_api_integration/fixtures/saml_tools.ts @@ -45,14 +45,21 @@ export async function getSAMLResponse({ inResponseTo, sessionIndex, username = 'a@b.c', -}: { destination?: string; inResponseTo?: string; sessionIndex?: string; username?: string } = {}) { + issuer = 'http://www.elastic.co/saml1', +}: { + destination?: string; + inResponseTo?: string; + sessionIndex?: string; + username?: string; + issuer?: string; +} = {}) { const issueInstant = new Date().toISOString(); const notOnOrAfter = new Date(Date.now() + 3600 * 1000).toISOString(); const samlAssertionTemplateXML = ` - http://www.elastic.co + ${issuer} a@b.c @@ -99,7 +106,7 @@ export async function getSAMLResponse({ ${inResponseTo ? `InResponseTo="${inResponseTo}"` : ''} Version="2.0" IssueInstant="${issueInstant}" Destination="${destination}"> - http://www.elastic.co + ${issuer} ${signature.getSignedXml()} @@ -111,9 +118,11 @@ export async function getSAMLResponse({ export async function getLogoutRequest({ destination, sessionIndex, + issuer = 'http://www.elastic.co/saml1', }: { destination: string; sessionIndex: string; + issuer?: string; }) { const issueInstant = new Date().toISOString(); const logoutRequestTemplateXML = ` @@ -121,7 +130,7 @@ export async function getLogoutRequest({ Destination="${destination}" Consent="urn:oasis:names:tc:SAML:2.0:consent:unspecified" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"> - http://www.elastic.co + ${issuer} a@b.c ${sessionIndex} From 13baa5156151af258249afc6468f15b53fffeee0 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Mon, 23 Mar 2020 23:08:44 +0100 Subject: [PATCH 16/34] [APM] Collect telemetry about data/API performance (#51612) * [APM] Collect telemetry about data/API performance Closes #50757. * Ignore apm scripts package.json * Config flag for enabling/disabling telemetry collection --- src/dev/run_check_lockfile_symlinks.js | 2 + x-pack/legacy/plugins/apm/index.ts | 19 +- x-pack/legacy/plugins/apm/mappings.json | 773 +++++++++++++++++- x-pack/legacy/plugins/apm/scripts/.gitignore | 1 + .../legacy/plugins/apm/scripts/package.json | 10 + .../apm/scripts/setup-kibana-security.js | 1 + .../apm/scripts/upload-telemetry-data.js | 21 + .../download-telemetry-template.ts | 26 + .../generate-sample-documents.ts | 124 +++ .../scripts/upload-telemetry-data/index.ts | 208 +++++ .../elasticsearch_fieldnames.test.ts.snap | 48 +- x-pack/plugins/apm/common/agent_name.ts | 44 +- .../apm/common/apm_saved_object_constants.ts | 10 +- .../common/elasticsearch_fieldnames.test.ts | 15 +- .../apm/common/elasticsearch_fieldnames.ts | 11 +- x-pack/plugins/apm/kibana.json | 7 +- x-pack/plugins/apm/server/index.ts | 6 +- .../lib/apm_telemetry/__test__/index.test.ts | 83 -- .../collect_data_telemetry/index.ts | 77 ++ .../collect_data_telemetry/tasks.ts | 725 ++++++++++++++++ .../apm/server/lib/apm_telemetry/index.ts | 155 +++- .../apm/server/lib/apm_telemetry/types.ts | 118 +++ .../server/lib/helpers/setup_request.test.ts | 13 + x-pack/plugins/apm/server/plugin.ts | 37 +- .../server/routes/create_api/index.test.ts | 1 + x-pack/plugins/apm/server/routes/services.ts | 16 - .../apm/typings/elasticsearch/aggregations.ts | 26 +- .../apm/typings/es_schemas/raw/error_raw.ts | 2 + .../typings/es_schemas/raw/fields/observer.ts | 10 + .../apm/typings/es_schemas/raw/span_raw.ts | 2 + .../typings/es_schemas/raw/transaction_raw.ts | 2 + 31 files changed, 2387 insertions(+), 206 deletions(-) create mode 100644 x-pack/legacy/plugins/apm/scripts/.gitignore create mode 100644 x-pack/legacy/plugins/apm/scripts/package.json create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts create mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/types.ts create mode 100644 x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts diff --git a/src/dev/run_check_lockfile_symlinks.js b/src/dev/run_check_lockfile_symlinks.js index 54a8cdf638a78..6c6fc54638ee8 100644 --- a/src/dev/run_check_lockfile_symlinks.js +++ b/src/dev/run_check_lockfile_symlinks.js @@ -36,6 +36,8 @@ const IGNORE_FILE_GLOBS = [ '**/*fixtures*/**/*', // cypress isn't used in production, ignore it 'x-pack/legacy/plugins/apm/e2e/*', + // apm scripts aren't used in production, ignore them + 'x-pack/legacy/plugins/apm/scripts/*', ]; run(async ({ log }) => { diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 0107997f233fe..594e8a4a7af72 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -14,7 +14,13 @@ import mappings from './mappings.json'; export const apm: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ - require: ['kibana', 'elasticsearch', 'xpack_main', 'apm_oss'], + require: [ + 'kibana', + 'elasticsearch', + 'xpack_main', + 'apm_oss', + 'task_manager' + ], id: 'apm', configPrefix: 'xpack.apm', publicDir: resolve(__dirname, 'public'), @@ -71,7 +77,10 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(true) + serviceMapEnabled: Joi.boolean().default(true), + + // telemetry + telemetryCollectionEnabled: Joi.boolean().default(true) }).default(); }, @@ -107,10 +116,12 @@ export const apm: LegacyPluginInitializer = kibana => { } } }); - const apmPlugin = server.newPlatform.setup.plugins .apm as APMPluginContract; - apmPlugin.registerLegacyAPI({ server }); + + apmPlugin.registerLegacyAPI({ + server + }); } }); }; diff --git a/x-pack/legacy/plugins/apm/mappings.json b/x-pack/legacy/plugins/apm/mappings.json index 61bc90da28756..ba4c7a89ceaa8 100644 --- a/x-pack/legacy/plugins/apm/mappings.json +++ b/x-pack/legacy/plugins/apm/mappings.json @@ -1,20 +1,659 @@ { - "apm-services-telemetry": { + "apm-telemetry": { "properties": { + "agents": { + "properties": { + "dotnet": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "go": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "java": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "js-base": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "type": "object" + } + } + } + } + }, + "nodejs": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "python": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "ruby": { + "properties": { + "agent": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "service": { + "properties": { + "framework": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "language": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "runtime": { + "properties": { + "composite": { + "type": "keyword", + "ignore_above": 256 + }, + "name": { + "type": "keyword", + "ignore_above": 256 + }, + "version": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + } + } + }, + "rum-js": { + "properties": { + "agent": { + "type": "object" + }, + "service": { + "properties": { + "framework": { + "type": "object" + }, + "language": { + "type": "object" + }, + "runtime": { + "type": "object" + } + } + } + } + } + } + }, + "counts": { + "properties": { + "agent_configuration": { + "properties": { + "all": { + "type": "long" + } + } + }, + "error": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "max_error_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "max_transaction_groups_per_service": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "services": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "sourcemap": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "span": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + }, + "traces": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "1d": { + "type": "long" + }, + "all": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "user_agent": { + "properties": { + "original": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + }, + "transaction": { + "properties": { + "name": { + "properties": { + "all_agents": { + "properties": { + "1d": { + "type": "long" + } + } + }, + "rum": { + "properties": { + "1d": { + "type": "long" + } + } + } + } + } + } + } + } + }, "has_any_services": { "type": "boolean" }, + "indices": { + "properties": { + "all": { + "properties": { + "total": { + "properties": { + "docs": { + "properties": { + "count": { + "type": "long" + } + } + }, + "store": { + "properties": { + "size_in_bytes": { + "type": "long" + } + } + } + } + } + } + }, + "shards": { + "properties": { + "total": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "ml": { + "properties": { + "all_jobs_count": { + "type": "long" + } + } + } + } + }, + "retainment": { + "properties": { + "error": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "metric": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "onboarding": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "span": { + "properties": { + "ms": { + "type": "long" + } + } + }, + "transaction": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, "services_per_agent": { "properties": { - "python": { + "dotnet": { "type": "long", "null_value": 0 }, - "java": { + "go": { "type": "long", "null_value": 0 }, - "nodejs": { + "java": { "type": "long", "null_value": 0 }, @@ -22,11 +661,11 @@ "type": "long", "null_value": 0 }, - "rum-js": { + "nodejs": { "type": "long", "null_value": 0 }, - "dotnet": { + "python": { "type": "long", "null_value": 0 }, @@ -34,11 +673,131 @@ "type": "long", "null_value": 0 }, - "go": { + "rum-js": { "type": "long", "null_value": 0 } } + }, + "tasks": { + "properties": { + "agent_configuration": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "agents": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "cardinality": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "groupings": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "indices_stats": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "integrations": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "processor_events": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "services": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, + "versions": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + } + } + }, + "version": { + "properties": { + "apm_server": { + "properties": { + "major": { + "type": "long" + }, + "minor": { + "type": "long" + }, + "patch": { + "type": "long" + } + } + } + } } } }, diff --git a/x-pack/legacy/plugins/apm/scripts/.gitignore b/x-pack/legacy/plugins/apm/scripts/.gitignore new file mode 100644 index 0000000000000..8ee01d321b721 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/.gitignore @@ -0,0 +1 @@ +yarn.lock diff --git a/x-pack/legacy/plugins/apm/scripts/package.json b/x-pack/legacy/plugins/apm/scripts/package.json new file mode 100644 index 0000000000000..9121449c53619 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/package.json @@ -0,0 +1,10 @@ +{ + "name": "apm-scripts", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "@octokit/rest": "^16.35.0", + "console-stamp": "^0.2.9" + } +} diff --git a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js index 825c1a526fcc5..61ba2fdc7f7e3 100644 --- a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js +++ b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js @@ -16,6 +16,7 @@ ******************************/ // compile typescript on the fly +// eslint-disable-next-line import/no-extraneous-dependencies require('@babel/register')({ extensions: ['.ts'], plugins: ['@babel/plugin-proposal-optional-chaining'], diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js new file mode 100644 index 0000000000000..a99651c62dd7a --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// compile typescript on the fly +// eslint-disable-next-line import/no-extraneous-dependencies +require('@babel/register')({ + extensions: ['.ts'], + plugins: [ + '@babel/plugin-proposal-optional-chaining', + '@babel/plugin-proposal-nullish-coalescing-operator' + ], + presets: [ + '@babel/typescript', + ['@babel/preset-env', { targets: { node: 'current' } }] + ] +}); + +require('./upload-telemetry-data/index.ts'); diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts new file mode 100644 index 0000000000000..dfed9223ef708 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// @ts-ignore +import { Octokit } from '@octokit/rest'; + +export async function downloadTelemetryTemplate(octokit: Octokit) { + const file = await octokit.repos.getContents({ + owner: 'elastic', + repo: 'telemetry', + path: 'config/templates/xpack-phone-home.json', + // @ts-ignore + mediaType: { + format: 'application/vnd.github.VERSION.raw' + } + }); + + if (Array.isArray(file.data)) { + throw new Error('Expected single response, got array'); + } + + return JSON.parse(Buffer.from(file.data.content!, 'base64').toString()); +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts new file mode 100644 index 0000000000000..8d76063a7fdf6 --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { DeepPartial } from 'utility-types'; +import { + merge, + omit, + defaultsDeep, + range, + mapValues, + isPlainObject, + flatten +} from 'lodash'; +import uuid from 'uuid'; +import { + CollectTelemetryParams, + collectDataTelemetry + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; + +interface GenerateOptions { + days: number; + instances: number; + variation: { + min: number; + max: number; + }; +} + +const randomize = ( + value: unknown, + instanceVariation: number, + dailyGrowth: number +) => { + if (typeof value === 'boolean') { + return Math.random() > 0.5; + } + if (typeof value === 'number') { + return Math.round(instanceVariation * dailyGrowth * value); + } + return value; +}; + +const mapValuesDeep = ( + obj: Record, + iterator: (value: unknown, key: string, obj: Record) => unknown +): Record => + mapValues(obj, (val, key) => + isPlainObject(val) ? mapValuesDeep(val, iterator) : iterator(val, key!, obj) + ); + +export async function generateSampleDocuments( + options: DeepPartial & { + collectTelemetryParams: CollectTelemetryParams; + } +) { + const { collectTelemetryParams, ...preferredOptions } = options; + + const opts: GenerateOptions = defaultsDeep( + { + days: 100, + instances: 50, + variation: { + min: 0.1, + max: 4 + } + }, + preferredOptions + ); + + const sample = await collectDataTelemetry(collectTelemetryParams); + + console.log('Collected telemetry'); // eslint-disable-line no-console + console.log('\n' + JSON.stringify(sample, null, 2)); // eslint-disable-line no-console + + const dateOfScriptExecution = new Date(); + + return flatten( + range(0, opts.instances).map(instanceNo => { + const instanceId = uuid.v4(); + const defaults = { + cluster_uuid: instanceId, + stack_stats: { + kibana: { + versions: { + version: '8.0.0' + } + } + } + }; + + const instanceVariation = + Math.random() * (opts.variation.max - opts.variation.min) + + opts.variation.min; + + return range(0, opts.days).map(dayNo => { + const dailyGrowth = Math.pow(1.005, opts.days - 1 - dayNo); + + const timestamp = Date.UTC( + dateOfScriptExecution.getFullYear(), + dateOfScriptExecution.getMonth(), + -dayNo + ); + + const generated = mapValuesDeep(omit(sample, 'versions'), value => + randomize(value, instanceVariation, dailyGrowth) + ); + + return merge({}, defaults, { + timestamp, + stack_stats: { + kibana: { + plugins: { + apm: merge({}, sample, generated) + } + } + } + }); + }); + }) + ); +} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts new file mode 100644 index 0000000000000..bdc57eac412fc --- /dev/null +++ b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts @@ -0,0 +1,208 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// This script downloads the telemetry mapping, runs the APM telemetry tasks, +// generates a bunch of randomized data based on the downloaded sample, +// and uploads it to a cluster of your choosing in the same format as it is +// stored in the telemetry cluster. Its purpose is twofold: +// - Easier testing of the telemetry tasks +// - Validate whether we can run the queries we want to on the telemetry data + +import fs from 'fs'; +import path from 'path'; +// @ts-ignore +import { Octokit } from '@octokit/rest'; +import { merge, chunk, flatten, pick, identity } from 'lodash'; +import axios from 'axios'; +import yaml from 'js-yaml'; +import { Client } from 'elasticsearch'; +import { argv } from 'yargs'; +import { promisify } from 'util'; +import { Logger } from 'kibana/server'; +// @ts-ignore +import consoleStamp from 'console-stamp'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { CollectTelemetryParams } from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; +import { downloadTelemetryTemplate } from './download-telemetry-template'; +import mapping from '../../mappings.json'; +import { generateSampleDocuments } from './generate-sample-documents'; + +consoleStamp(console, '[HH:MM:ss.l]'); + +const githubToken = process.env.GITHUB_TOKEN; + +if (!githubToken) { + throw new Error('GITHUB_TOKEN was not provided.'); +} + +const kibanaConfigDir = path.join(__filename, '../../../../../../../config'); +const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); +const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); + +const xpackTelemetryIndexName = 'xpack-phone-home'; + +const loadedKibanaConfig = (yaml.safeLoad( + fs.readFileSync( + fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, + 'utf8' + ) +) || {}) as {}; + +const cliEsCredentials = pick( + { + 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, + 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, + 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST + }, + identity +) as { + 'elasticsearch.username': string; + 'elasticsearch.password': string; + 'elasticsearch.hosts': string; +}; + +const config = { + 'apm_oss.transactionIndices': 'apm-*', + 'apm_oss.metricsIndices': 'apm-*', + 'apm_oss.errorIndices': 'apm-*', + 'apm_oss.spanIndices': 'apm-*', + 'apm_oss.onboardingIndices': 'apm-*', + 'apm_oss.sourcemapIndices': 'apm-*', + 'elasticsearch.hosts': 'http://localhost:9200', + ...loadedKibanaConfig, + ...cliEsCredentials +}; + +async function uploadData() { + const octokit = new Octokit({ + auth: githubToken + }); + + const telemetryTemplate = await downloadTelemetryTemplate(octokit); + + const kibanaMapping = mapping['apm-telemetry']; + + const httpAuth = + config['elasticsearch.username'] && config['elasticsearch.password'] + ? { + username: config['elasticsearch.username'], + password: config['elasticsearch.password'] + } + : null; + + const client = new Client({ + host: config['elasticsearch.hosts'], + ...(httpAuth + ? { + httpAuth: `${httpAuth.username}:${httpAuth.password}` + } + : {}) + }); + + if (argv.clear) { + try { + await promisify(client.indices.delete.bind(client))({ + index: xpackTelemetryIndexName + }); + } catch (err) { + // 404 = index not found, totally okay + if (err.status !== 404) { + throw err; + } + } + } + + const axiosInstance = axios.create({ + baseURL: config['elasticsearch.hosts'], + ...(httpAuth ? { auth: httpAuth } : {}) + }); + + const newTemplate = merge(telemetryTemplate, { + settings: { + index: { mapping: { total_fields: { limit: 10000 } } } + } + }); + + // override apm mapping instead of merging + newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; + + await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); + + const sampleDocuments = await generateSampleDocuments({ + collectTelemetryParams: { + logger: (console as unknown) as Logger, + indices: { + ...config, + apmCustomLinkIndex: '.apm-custom-links', + apmAgentConfigurationIndex: '.apm-agent-configuration' + }, + search: body => { + return promisify(client.search.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + indicesStats: body => { + return promisify(client.indices.stats.bind(client))({ + ...body, + requestTimeout: 120000 + }) as any; + }, + transportRequest: (params => { + return axiosInstance[params.method](params.path); + }) as CollectTelemetryParams['transportRequest'] + } + }); + + const chunks = chunk(sampleDocuments, 250); + + await chunks.reduce>((prev, documents) => { + return prev.then(async () => { + const body = flatten( + documents.map(doc => [{ index: { _index: 'xpack-phone-home' } }, doc]) + ); + + return promisify(client.bulk.bind(client))({ + body, + refresh: true + }).then((response: any) => { + if (response.errors) { + const firstError = response.items.filter( + (item: any) => item.index.status >= 400 + )[0].index.error; + throw new Error(`Failed to upload documents: ${firstError.reason} `); + } + }); + }); + }, Promise.resolve()); +} + +uploadData() + .catch(e => { + if ('response' in e) { + if (typeof e.response === 'string') { + // eslint-disable-next-line no-console + console.log(e.response); + } else { + // eslint-disable-next-line no-console + console.log( + JSON.stringify( + e.response, + ['status', 'statusText', 'headers', 'data'], + 2 + ) + ); + } + } else { + // eslint-disable-next-line no-console + console.log(e); + } + process.exit(1); + }) + .then(() => { + // eslint-disable-next-line no-console + console.log('Finished uploading generated telemetry data'); + }); diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 897d4e979fce3..5de82a9ee8788 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -2,6 +2,8 @@ exports[`Error AGENT_NAME 1`] = `"java"`; +exports[`Error AGENT_VERSION 1`] = `"agent version"`; + exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; @@ -56,7 +58,7 @@ exports[`Error METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Error OBSERVER_LISTENING 1`] = `undefined`; -exports[`Error OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Error OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Error PARENT_ID 1`] = `"parentId"`; @@ -68,10 +70,20 @@ exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Error SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Error SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; + +exports[`Error SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; + exports[`Error SERVICE_NAME 1`] = `"service name"`; exports[`Error SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Error SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Error SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Error SERVICE_VERSION 1`] = `undefined`; exports[`Error SPAN_ACTION 1`] = `undefined`; @@ -112,10 +124,14 @@ exports[`Error URL_FULL 1`] = `undefined`; exports[`Error USER_AGENT_NAME 1`] = `undefined`; +exports[`Error USER_AGENT_ORIGINAL 1`] = `undefined`; + exports[`Error USER_ID 1`] = `undefined`; exports[`Span AGENT_NAME 1`] = `"java"`; +exports[`Span AGENT_VERSION 1`] = `"agent version"`; + exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; @@ -170,7 +186,7 @@ exports[`Span METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Span OBSERVER_LISTENING 1`] = `undefined`; -exports[`Span OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Span OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Span PARENT_ID 1`] = `"parentId"`; @@ -182,10 +198,20 @@ exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Span SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Span SERVICE_LANGUAGE_NAME 1`] = `undefined`; + +exports[`Span SERVICE_LANGUAGE_VERSION 1`] = `undefined`; + exports[`Span SERVICE_NAME 1`] = `"service name"`; exports[`Span SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Span SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Span SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Span SERVICE_VERSION 1`] = `undefined`; exports[`Span SPAN_ACTION 1`] = `"my action"`; @@ -226,10 +252,14 @@ exports[`Span URL_FULL 1`] = `undefined`; exports[`Span USER_AGENT_NAME 1`] = `undefined`; +exports[`Span USER_AGENT_ORIGINAL 1`] = `undefined`; + exports[`Span USER_ID 1`] = `undefined`; exports[`Transaction AGENT_NAME 1`] = `"java"`; +exports[`Transaction AGENT_VERSION 1`] = `"agent version"`; + exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; @@ -284,7 +314,7 @@ exports[`Transaction METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Transaction OBSERVER_LISTENING 1`] = `undefined`; -exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `undefined`; +exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `8`; exports[`Transaction PARENT_ID 1`] = `"parentId"`; @@ -296,10 +326,20 @@ exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`; +exports[`Transaction SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; + +exports[`Transaction SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; + +exports[`Transaction SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; + exports[`Transaction SERVICE_NAME 1`] = `"service name"`; exports[`Transaction SERVICE_NODE_NAME 1`] = `undefined`; +exports[`Transaction SERVICE_RUNTIME_NAME 1`] = `undefined`; + +exports[`Transaction SERVICE_RUNTIME_VERSION 1`] = `undefined`; + exports[`Transaction SERVICE_VERSION 1`] = `undefined`; exports[`Transaction SPAN_ACTION 1`] = `undefined`; @@ -340,4 +380,6 @@ exports[`Transaction URL_FULL 1`] = `"http://www.elastic.co"`; exports[`Transaction USER_AGENT_NAME 1`] = `"Other"`; +exports[`Transaction USER_AGENT_ORIGINAL 1`] = `"test original"`; + exports[`Transaction USER_ID 1`] = `"1337"`; diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index bb68eb88b8e18..085828b729ea5 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -4,36 +4,40 @@ * you may not use this file except in compliance with the Elastic License. */ +import { AgentName } from '../typings/es_schemas/ui/fields/agent'; + /* * Agent names can be any string. This list only defines the official agents * that we might want to target specifically eg. linking to their documentation * & telemetry reporting. Support additional agent types by appending * definitions in mappings.json (for telemetry), the AgentName type, and the - * agentNames object. + * AGENT_NAMES array. */ -import { AgentName } from '../typings/es_schemas/ui/fields/agent'; -const agentNames: { [agentName in AgentName]: agentName } = { - python: 'python', - java: 'java', - nodejs: 'nodejs', - 'js-base': 'js-base', - 'rum-js': 'rum-js', - dotnet: 'dotnet', - ruby: 'ruby', - go: 'go' -}; +export const AGENT_NAMES: AgentName[] = [ + 'java', + 'js-base', + 'rum-js', + 'dotnet', + 'go', + 'java', + 'nodejs', + 'python', + 'ruby' +]; -export function isAgentName(agentName: string): boolean { - return Object.values(agentNames).includes(agentName as AgentName); +export function isAgentName(agentName: string): agentName is AgentName { + return AGENT_NAMES.includes(agentName as AgentName); } -export function isRumAgentName(agentName: string | undefined) { - return ( - agentName === agentNames['js-base'] || agentName === agentNames['rum-js'] - ); +export function isRumAgentName( + agentName: string | undefined +): agentName is 'js-base' | 'rum-js' { + return agentName === 'js-base' || agentName === 'rum-js'; } -export function isJavaAgentName(agentName: string | undefined) { - return agentName === agentNames.java; +export function isJavaAgentName( + agentName: string | undefined +): agentName is 'java' { + return agentName === 'java'; } diff --git a/x-pack/plugins/apm/common/apm_saved_object_constants.ts b/x-pack/plugins/apm/common/apm_saved_object_constants.ts index ac43b700117c6..0529d90fe940a 100644 --- a/x-pack/plugins/apm/common/apm_saved_object_constants.ts +++ b/x-pack/plugins/apm/common/apm_saved_object_constants.ts @@ -4,11 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -// APM Services telemetry -export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE = - 'apm-services-telemetry'; -export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID = 'apm-services-telemetry'; +// the types have to match the names of the saved object mappings +// in /x-pack/legacy/plugins/apm/mappings.json // APM indices export const APM_INDICES_SAVED_OBJECT_TYPE = 'apm-indices'; export const APM_INDICES_SAVED_OBJECT_ID = 'apm-indices'; + +// APM telemetry +export const APM_TELEMETRY_SAVED_OBJECT_TYPE = 'apm-telemetry'; +export const APM_TELEMETRY_SAVED_OBJECT_ID = 'apm-telemetry'; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts index 1add2427d16a0..63fa749cd9f2c 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts @@ -15,7 +15,10 @@ describe('Transaction', () => { const transaction: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' @@ -63,7 +66,10 @@ describe('Span', () => { const span: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' @@ -107,7 +113,10 @@ describe('Span', () => { describe('Error', () => { const errorDoc: AllowUnknownProperties = { '@metadata': 'whatever', - observer: 'whatever', + observer: { + version: 'whatever', + version_major: 8 + }, agent: { name: 'java', version: 'agent version' diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 822201baddd88..bc1b346f50da7 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -4,15 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -export const AGENT_NAME = 'agent.name'; export const SERVICE_NAME = 'service.name'; export const SERVICE_ENVIRONMENT = 'service.environment'; export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; +export const SERVICE_FRAMEWORK_VERSION = 'service.framework.version'; +export const SERVICE_LANGUAGE_NAME = 'service.language.name'; +export const SERVICE_LANGUAGE_VERSION = 'service.language.version'; +export const SERVICE_RUNTIME_NAME = 'service.runtime.name'; +export const SERVICE_RUNTIME_VERSION = 'service.runtime.version'; export const SERVICE_NODE_NAME = 'service.node.name'; export const SERVICE_VERSION = 'service.version'; + +export const AGENT_NAME = 'agent.name'; +export const AGENT_VERSION = 'agent.version'; + export const URL_FULL = 'url.full'; export const HTTP_REQUEST_METHOD = 'http.request.method'; export const USER_ID = 'user.id'; +export const USER_AGENT_ORIGINAL = 'user_agent.original'; export const USER_AGENT_NAME = 'user_agent.name'; export const DESTINATION_ADDRESS = 'destination.address'; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index 96579377c95e8..dadb1dff6d7a9 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -3,8 +3,11 @@ "server": true, "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": ["xpack", "apm"], + "configPath": [ + "xpack", + "apm" + ], "ui": false, "requiredPlugins": ["apm_oss", "data", "home", "licensing"], - "optionalPlugins": ["cloud", "usageCollection"] + "optionalPlugins": ["cloud", "usageCollection", "taskManager"] } diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 8afdb9e99c1a3..77655568a7e9c 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -29,7 +29,8 @@ export const config = { enabled: schema.boolean({ defaultValue: true }), transactionGroupBucketSize: schema.number({ defaultValue: 100 }), maxTraceItems: schema.number({ defaultValue: 1000 }) - }) + }), + telemetryCollectionEnabled: schema.boolean({ defaultValue: true }) }) }; @@ -62,7 +63,8 @@ export function mergeConfigs( 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, - 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern + 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern, + 'xpack.apm.telemetryCollectionEnabled': apmConfig.telemetryCollectionEnabled }; } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts deleted file mode 100644 index c45c74a791aee..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { SavedObjectAttributes } from '../../../../../../../src/core/server'; -import { createApmTelementry, storeApmServicesTelemetry } from '../index'; -import { - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID -} from '../../../../common/apm_saved_object_constants'; - -describe('apm_telemetry', () => { - describe('createApmTelementry', () => { - it('should create a ApmTelemetry object with boolean flag and frequency map of the given list of AgentNames', () => { - const apmTelemetry = createApmTelementry([ - 'go', - 'nodejs', - 'go', - 'js-base' - ]); - expect(apmTelemetry.has_any_services).toBe(true); - expect(apmTelemetry.services_per_agent).toMatchObject({ - go: 2, - nodejs: 1, - 'js-base': 1 - }); - }); - it('should ignore undefined or unknown AgentName values', () => { - const apmTelemetry = createApmTelementry([ - 'go', - 'nodejs', - 'go', - 'js-base', - 'example-platform' as any, - undefined as any - ]); - expect(apmTelemetry.services_per_agent).toMatchObject({ - go: 2, - nodejs: 1, - 'js-base': 1 - }); - }); - }); - - describe('storeApmServicesTelemetry', () => { - let apmTelemetry: SavedObjectAttributes; - let savedObjectsClient: any; - - beforeEach(() => { - apmTelemetry = { - has_any_services: true, - services_per_agent: { - go: 2, - nodejs: 1, - 'js-base': 1 - } - }; - savedObjectsClient = { create: jest.fn() }; - }); - - it('should call savedObjectsClient create with the given ApmTelemetry object', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][1]).toBe(apmTelemetry); - }); - - it('should call savedObjectsClient create with the apm-telemetry document type and ID', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][0]).toBe( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE - ); - expect(savedObjectsClient.create.mock.calls[0][2].id).toBe( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID - ); - }); - - it('should call savedObjectsClient create with overwrite: true', () => { - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); - expect(savedObjectsClient.create.mock.calls[0][2].overwrite).toBe(true); - }); - }); -}); diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts new file mode 100644 index 0000000000000..729ccb73d73f3 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.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; + * you may not use this file except in compliance with the Elastic License. + */ +import { merge } from 'lodash'; +import { Logger, CallAPIOptions } from 'kibana/server'; +import { IndicesStatsParams, Client } from 'elasticsearch'; +import { + ESSearchRequest, + ESSearchResponse +} from '../../../../typings/elasticsearch'; +import { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; +import { tasks } from './tasks'; +import { APMDataTelemetry } from '../types'; + +type TelemetryTaskExecutor = (params: { + indices: ApmIndicesConfig; + search( + params: TSearchRequest + ): Promise>; + indicesStats( + params: IndicesStatsParams, + options?: CallAPIOptions + ): ReturnType; + transportRequest: (params: { + path: string; + method: 'get'; + }) => Promise; +}) => Promise; + +export interface TelemetryTask { + name: string; + executor: TelemetryTaskExecutor; +} + +export type CollectTelemetryParams = Parameters[0] & { + logger: Logger; +}; + +export function collectDataTelemetry({ + search, + indices, + logger, + indicesStats, + transportRequest +}: CollectTelemetryParams) { + return tasks.reduce((prev, task) => { + return prev.then(async data => { + logger.debug(`Executing APM telemetry task ${task.name}`); + try { + const time = process.hrtime(); + const next = await task.executor({ + search, + indices, + indicesStats, + transportRequest + }); + const took = process.hrtime(time); + + return merge({}, data, next, { + tasks: { + [task.name]: { + took: { + ms: Math.round(took[0] * 1000 + took[1] / 1e6) + } + } + } + }); + } catch (err) { + logger.warn(`Failed executing APM telemetry task ${task.name}`); + logger.warn(err); + return data; + } + }); + }, Promise.resolve({} as APMDataTelemetry)); +} diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts new file mode 100644 index 0000000000000..415076b6ae116 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -0,0 +1,725 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { flatten, merge, sortBy, sum } from 'lodash'; +import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; +import { AGENT_NAMES } from '../../../../common/agent_name'; +import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + AGENT_NAME, + AGENT_VERSION, + ERROR_GROUP_ID, + TRANSACTION_NAME, + PARENT_ID, + SERVICE_FRAMEWORK_NAME, + SERVICE_FRAMEWORK_VERSION, + SERVICE_LANGUAGE_NAME, + SERVICE_LANGUAGE_VERSION, + SERVICE_RUNTIME_NAME, + SERVICE_RUNTIME_VERSION, + USER_AGENT_ORIGINAL +} from '../../../../common/elasticsearch_fieldnames'; +import { Span } from '../../../../typings/es_schemas/ui/span'; +import { APMError } from '../../../../typings/es_schemas/ui/apm_error'; +import { TelemetryTask } from '.'; +import { APMTelemetry } from '../types'; + +const TIME_RANGES = ['1d', 'all'] as const; +type TimeRange = typeof TIME_RANGES[number]; + +export const tasks: TelemetryTask[] = [ + { + name: 'processor_events', + executor: async ({ indices, search }) => { + const indicesByProcessorEvent = { + error: indices['apm_oss.errorIndices'], + metric: indices['apm_oss.metricsIndices'], + span: indices['apm_oss.spanIndices'], + transaction: indices['apm_oss.transactionIndices'], + onboarding: indices['apm_oss.onboardingIndices'], + sourcemap: indices['apm_oss.sourcemapIndices'] + }; + + type ProcessorEvent = keyof typeof indicesByProcessorEvent; + + const jobs: Array<{ + processorEvent: ProcessorEvent; + timeRange: TimeRange; + }> = flatten( + (Object.keys( + indicesByProcessorEvent + ) as ProcessorEvent[]).map(processorEvent => + TIME_RANGES.map(timeRange => ({ processorEvent, timeRange })) + ) + ); + + const allData = await jobs.reduce((prevJob, current) => { + return prevJob.then(async data => { + const { processorEvent, timeRange } = current; + + const response = await search({ + index: indicesByProcessorEvent[processorEvent], + body: { + size: 1, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: processorEvent } }, + ...(timeRange !== 'all' + ? [ + { + range: { + '@timestamp': { + gte: `now-${timeRange}` + } + } + } + ] + : []) + ] + } + }, + sort: { + '@timestamp': 'asc' + }, + _source: ['@timestamp'], + track_total_hits: true + } + }); + + const event = response.hits.hits[0]?._source as { + '@timestamp': number; + }; + + return merge({}, data, { + counts: { + [processorEvent]: { + [timeRange]: response.hits.total.value + } + }, + ...(timeRange === 'all' && event + ? { + retainment: { + [processorEvent]: { + ms: + new Date().getTime() - + new Date(event['@timestamp']).getTime() + } + } + } + : {}) + }); + }); + }, Promise.resolve({} as Record> }>)); + + return allData; + } + }, + { + name: 'agent_configuration', + executor: async ({ indices, search }) => { + const agentConfigurationCount = ( + await search({ + index: indices.apmAgentConfigurationIndex, + body: { + size: 0, + track_total_hits: true + } + }) + ).hits.total.value; + + return { + counts: { + agent_configuration: { + all: agentConfigurationCount + } + } + }; + } + }, + { + name: 'services', + executor: async ({ indices, search }) => { + const servicesPerAgent = await AGENT_NAMES.reduce( + (prevJob, agentName) => { + return prevJob.then(async data => { + const response = await search({ + index: [ + indices['apm_oss.errorIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { + term: { + [AGENT_NAME]: agentName + } + }, + { + range: { + '@timestamp': { + gte: 'now-1d' + } + } + } + ] + } + }, + aggs: { + services: { + cardinality: { + field: SERVICE_NAME + } + } + } + } + }); + + return { + ...data, + [agentName]: response.aggregations?.services.value || 0 + }; + }); + }, + Promise.resolve({} as Record) + ); + + return { + has_any_services: sum(Object.values(servicesPerAgent)) > 0, + services_per_agent: servicesPerAgent + }; + } + }, + { + name: 'versions', + executor: async ({ search, indices }) => { + const response = await search({ + index: [ + indices['apm_oss.transactionIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.errorIndices'] + ], + terminateAfter: 1, + body: { + query: { + exists: { + field: 'observer.version' + } + }, + size: 1, + sort: { + '@timestamp': 'desc' + } + } + }); + + const hit = response.hits.hits[0]?._source as Pick< + Transaction | Span | APMError, + 'observer' + >; + + if (!hit || !hit.observer?.version) { + return {}; + } + + const [major, minor, patch] = hit.observer.version + .split('.') + .map(part => Number(part)); + + return { + versions: { + apm_server: { + major, + minor, + patch + } + } + }; + } + }, + { + name: 'groupings', + executor: async ({ search, indices }) => { + const range1d = { range: { '@timestamp': { gte: 'now-1d' } } }; + const errorGroupsCount = ( + await search({ + index: indices['apm_oss.errorIndices'], + body: { + size: 0, + query: { + bool: { + filter: [{ term: { [PROCESSOR_EVENT]: 'error' } }, range1d] + } + }, + aggs: { + top_service: { + terms: { + field: SERVICE_NAME, + order: { + error_groups: 'desc' + }, + size: 1 + }, + aggs: { + error_groups: { + cardinality: { + field: ERROR_GROUP_ID + } + } + } + } + } + } + }) + ).aggregations?.top_service.buckets[0]?.error_groups.value; + + const transactionGroupsCount = ( + await search({ + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + range1d + ] + } + }, + aggs: { + top_service: { + terms: { + field: SERVICE_NAME, + order: { + transaction_groups: 'desc' + }, + size: 1 + }, + aggs: { + transaction_groups: { + cardinality: { + field: TRANSACTION_NAME + } + } + } + } + } + } + }) + ).aggregations?.top_service.buckets[0]?.transaction_groups.value; + + const tracesPerDayCount = ( + await search({ + index: indices['apm_oss.transactionIndices'], + body: { + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + range1d + ], + must_not: { + exists: { field: PARENT_ID } + } + } + }, + track_total_hits: true, + size: 0 + } + }) + ).hits.total.value; + + const servicesCount = ( + await search({ + index: [ + indices['apm_oss.transactionIndices'], + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [range1d] + } + }, + aggs: { + service_name: { + cardinality: { + field: SERVICE_NAME + } + } + } + } + }) + ).aggregations?.service_name.value; + + return { + counts: { + max_error_groups_per_service: { + '1d': errorGroupsCount || 0 + }, + max_transaction_groups_per_service: { + '1d': transactionGroupsCount || 0 + }, + traces: { + '1d': tracesPerDayCount || 0 + }, + services: { + '1d': servicesCount || 0 + } + } + }; + } + }, + { + name: 'integrations', + executor: async ({ transportRequest }) => { + const apmJobs = ['*-high_mean_response_time']; + + const response = (await transportRequest({ + method: 'get', + path: `/_ml/anomaly_detectors/${apmJobs.join(',')}` + })) as { data?: { count: number } }; + + return { + integrations: { + ml: { + all_jobs_count: response.data?.count ?? 0 + } + } + }; + } + }, + { + name: 'agents', + executor: async ({ search, indices }) => { + const size = 3; + + const agentData = await AGENT_NAMES.reduce(async (prevJob, agentName) => { + const data = await prevJob; + + const response = await search({ + index: [ + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { term: { [AGENT_NAME]: agentName } }, + { range: { '@timestamp': { gte: 'now-1d' } } } + ] + } + }, + sort: { + '@timestamp': 'desc' + }, + aggs: { + [AGENT_VERSION]: { + terms: { + field: AGENT_VERSION, + size + } + }, + [SERVICE_FRAMEWORK_NAME]: { + terms: { + field: SERVICE_FRAMEWORK_NAME, + size + }, + aggs: { + [SERVICE_FRAMEWORK_VERSION]: { + terms: { + field: SERVICE_FRAMEWORK_VERSION, + size + } + } + } + }, + [SERVICE_FRAMEWORK_VERSION]: { + terms: { + field: SERVICE_FRAMEWORK_VERSION, + size + } + }, + [SERVICE_LANGUAGE_NAME]: { + terms: { + field: SERVICE_LANGUAGE_NAME, + size + }, + aggs: { + [SERVICE_LANGUAGE_VERSION]: { + terms: { + field: SERVICE_LANGUAGE_VERSION, + size + } + } + } + }, + [SERVICE_LANGUAGE_VERSION]: { + terms: { + field: SERVICE_LANGUAGE_VERSION, + size + } + }, + [SERVICE_RUNTIME_NAME]: { + terms: { + field: SERVICE_RUNTIME_NAME, + size + }, + aggs: { + [SERVICE_RUNTIME_VERSION]: { + terms: { + field: SERVICE_RUNTIME_VERSION, + size + } + } + } + }, + [SERVICE_RUNTIME_VERSION]: { + terms: { + field: SERVICE_RUNTIME_VERSION, + size + } + } + } + } + }); + + const { aggregations } = response; + + if (!aggregations) { + return data; + } + + const toComposite = ( + outerKey: string | number, + innerKey: string | number + ) => `${outerKey}/${innerKey}`; + + return { + ...data, + [agentName]: { + agent: { + version: aggregations[AGENT_VERSION].buckets.map( + bucket => bucket.key as string + ) + }, + service: { + framework: { + name: aggregations[SERVICE_FRAMEWORK_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_FRAMEWORK_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_FRAMEWORK_NAME].buckets.map(bucket => + bucket[SERVICE_FRAMEWORK_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + }, + language: { + name: aggregations[SERVICE_LANGUAGE_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_LANGUAGE_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_LANGUAGE_NAME].buckets.map(bucket => + bucket[SERVICE_LANGUAGE_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + }, + runtime: { + name: aggregations[SERVICE_RUNTIME_NAME].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + version: aggregations[SERVICE_RUNTIME_VERSION].buckets + .map(bucket => bucket.key as string) + .slice(0, size), + composite: sortBy( + flatten( + aggregations[SERVICE_RUNTIME_NAME].buckets.map(bucket => + bucket[SERVICE_RUNTIME_VERSION].buckets.map( + versionBucket => ({ + doc_count: versionBucket.doc_count, + name: toComposite(bucket.key, versionBucket.key) + }) + ) + ) + ), + 'doc_count' + ) + .reverse() + .slice(0, size) + .map(composite => composite.name) + } + } + } + }; + }, Promise.resolve({} as APMTelemetry['agents'])); + + return { + agents: agentData + }; + } + }, + { + name: 'indices_stats', + executor: async ({ indicesStats, indices }) => { + const response = await indicesStats({ + index: [ + indices.apmAgentConfigurationIndex, + indices['apm_oss.errorIndices'], + indices['apm_oss.metricsIndices'], + indices['apm_oss.onboardingIndices'], + indices['apm_oss.sourcemapIndices'], + indices['apm_oss.spanIndices'], + indices['apm_oss.transactionIndices'] + ] + }); + + return { + indices: { + shards: { + total: response._shards.total + }, + all: { + total: { + docs: { + count: response._all.total.docs.count + }, + store: { + size_in_bytes: response._all.total.store.size_in_bytes + } + } + } + } + }; + } + }, + { + name: 'cardinality', + executor: async ({ search }) => { + const allAgentsCardinalityResponse = await search({ + body: { + size: 0, + query: { + bool: { + filter: [{ range: { '@timestamp': { gte: 'now-1d' } } }] + } + }, + aggs: { + [TRANSACTION_NAME]: { + cardinality: { + field: TRANSACTION_NAME + } + }, + [USER_AGENT_ORIGINAL]: { + cardinality: { + field: USER_AGENT_ORIGINAL + } + } + } + } + }); + + const rumAgentCardinalityResponse = await search({ + body: { + size: 0, + query: { + bool: { + filter: [ + { range: { '@timestamp': { gte: 'now-1d' } } }, + { terms: { [AGENT_NAME]: ['rum-js', 'js-base'] } } + ] + } + }, + aggs: { + [TRANSACTION_NAME]: { + cardinality: { + field: TRANSACTION_NAME + } + }, + [USER_AGENT_ORIGINAL]: { + cardinality: { + field: USER_AGENT_ORIGINAL + } + } + } + } + }); + + return { + cardinality: { + transaction: { + name: { + all_agents: { + '1d': + allAgentsCardinalityResponse.aggregations?.[TRANSACTION_NAME] + .value + }, + rum: { + '1d': + rumAgentCardinalityResponse.aggregations?.[TRANSACTION_NAME] + .value + } + } + }, + user_agent: { + original: { + all_agents: { + '1d': + allAgentsCardinalityResponse.aggregations?.[ + USER_AGENT_ORIGINAL + ].value + }, + rum: { + '1d': + rumAgentCardinalityResponse.aggregations?.[ + USER_AGENT_ORIGINAL + ].value + } + } + } + } + }; + } + } +]; diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index a2b0494730826..c80057a2894dc 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -3,60 +3,127 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import { countBy } from 'lodash'; -import { SavedObjectAttributes } from '../../../../../../src/core/server'; -import { isAgentName } from '../../../common/agent_name'; +import { CoreSetup, Logger } from 'src/core/server'; +import { Observable } from 'rxjs'; +import { take } from 'rxjs/operators'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { + TaskManagerStartContract, + TaskManagerSetupContract +} from '../../../../task_manager/server'; +import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; import { - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID + APM_TELEMETRY_SAVED_OBJECT_ID, + APM_TELEMETRY_SAVED_OBJECT_TYPE } from '../../../common/apm_saved_object_constants'; -import { UsageCollectionSetup } from '../../../../../../src/plugins/usage_collection/server'; -import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; - -export function createApmTelementry( - agentNames: string[] = [] -): SavedObjectAttributes { - const validAgentNames = agentNames.filter(isAgentName); - return { - has_any_services: validAgentNames.length > 0, - services_per_agent: countBy(validAgentNames) +import { + collectDataTelemetry, + CollectTelemetryParams +} from './collect_data_telemetry'; +import { APMConfig } from '../..'; +import { getInternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; + +const APM_TELEMETRY_TASK_NAME = 'apm-telemetry-task'; + +export async function createApmTelemetry({ + core, + config$, + usageCollector, + taskManager, + logger +}: { + core: CoreSetup; + config$: Observable; + usageCollector: UsageCollectionSetup; + taskManager: TaskManagerSetupContract; + logger: Logger; +}) { + const savedObjectsClient = await getInternalSavedObjectsClient(core); + + const collectAndStore = async () => { + const config = await config$.pipe(take(1)).toPromise(); + const esClient = core.elasticsearch.dataClient; + + const indices = await getApmIndices({ + config, + savedObjectsClient + }); + + const search = esClient.callAsInternalUser.bind( + esClient, + 'search' + ) as CollectTelemetryParams['search']; + + const indicesStats = esClient.callAsInternalUser.bind( + esClient, + 'indices.stats' + ) as CollectTelemetryParams['indicesStats']; + + const transportRequest = esClient.callAsInternalUser.bind( + esClient, + 'transport.request' + ) as CollectTelemetryParams['transportRequest']; + + const dataTelemetry = await collectDataTelemetry({ + search, + indices, + logger, + indicesStats, + transportRequest + }); + + await savedObjectsClient.create( + APM_TELEMETRY_SAVED_OBJECT_TYPE, + dataTelemetry, + { id: APM_TELEMETRY_SAVED_OBJECT_TYPE, overwrite: true } + ); }; -} -export async function storeApmServicesTelemetry( - savedObjectsClient: InternalSavedObjectsClient, - apmTelemetry: SavedObjectAttributes -) { - return savedObjectsClient.create( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - apmTelemetry, - { - id: APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID, - overwrite: true + taskManager.registerTaskDefinitions({ + [APM_TELEMETRY_TASK_NAME]: { + title: 'Collect APM telemetry', + type: APM_TELEMETRY_TASK_NAME, + createTaskRunner: () => { + return { + run: async () => { + await collectAndStore(); + } + }; + } } - ); -} + }); -export function makeApmUsageCollector( - usageCollector: UsageCollectionSetup, - savedObjectsRepository: InternalSavedObjectsClient -) { - const apmUsageCollector = usageCollector.makeUsageCollector({ + const collector = usageCollector.makeUsageCollector({ type: 'apm', fetch: async () => { - try { - const apmTelemetrySavedObject = await savedObjectsRepository.get( - APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, - APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID - ); - return apmTelemetrySavedObject.attributes; - } catch (err) { - return createApmTelementry(); - } + const data = ( + await savedObjectsClient.get( + APM_TELEMETRY_SAVED_OBJECT_TYPE, + APM_TELEMETRY_SAVED_OBJECT_ID + ) + ).attributes; + + return data; }, isReady: () => true }); - usageCollector.registerCollector(apmUsageCollector); + usageCollector.registerCollector(collector); + + core.getStartServices().then(([coreStart, pluginsStart]) => { + const { taskManager: taskManagerStart } = pluginsStart as { + taskManager: TaskManagerStartContract; + }; + + taskManagerStart.ensureScheduled({ + id: APM_TELEMETRY_TASK_NAME, + taskType: APM_TELEMETRY_TASK_NAME, + schedule: { + interval: '720m' + }, + scope: ['apm'], + params: {}, + state: {} + }); + }); } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts new file mode 100644 index 0000000000000..f68dc517a2227 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts @@ -0,0 +1,118 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DeepPartial } from 'utility-types'; +import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; + +export interface TimeframeMap { + '1d': number; + all: number; +} + +export type TimeframeMap1d = Pick; +export type TimeframeMapAll = Pick; + +export type APMDataTelemetry = DeepPartial<{ + has_any_services: boolean; + services_per_agent: Record; + versions: { + apm_server: { + minor: number; + major: number; + patch: number; + }; + }; + counts: { + transaction: TimeframeMap; + span: TimeframeMap; + error: TimeframeMap; + metric: TimeframeMap; + sourcemap: TimeframeMap; + onboarding: TimeframeMap; + agent_configuration: TimeframeMapAll; + max_transaction_groups_per_service: TimeframeMap; + max_error_groups_per_service: TimeframeMap; + traces: TimeframeMap; + services: TimeframeMap; + }; + cardinality: { + user_agent: { + original: { + all_agents: TimeframeMap1d; + rum: TimeframeMap1d; + }; + }; + transaction: { + name: { + all_agents: TimeframeMap1d; + rum: TimeframeMap1d; + }; + }; + }; + retainment: Record< + 'span' | 'transaction' | 'error' | 'metric' | 'sourcemap' | 'onboarding', + { ms: number } + >; + integrations: { + ml: { + all_jobs_count: number; + }; + }; + agents: Record< + AgentName, + { + agent: { + version: string[]; + }; + service: { + framework: { + name: string[]; + version: string[]; + composite: string[]; + }; + language: { + name: string[]; + version: string[]; + composite: string[]; + }; + runtime: { + name: string[]; + version: string[]; + composite: string[]; + }; + }; + } + >; + indices: { + shards: { + total: number; + }; + all: { + total: { + docs: { + count: number; + }; + store: { + size_in_bytes: number; + }; + }; + }; + }; + tasks: Record< + | 'processor_events' + | 'agent_configuration' + | 'services' + | 'versions' + | 'groupings' + | 'integrations' + | 'agents' + | 'indices_stats' + | 'cardinality', + { took: { ms: number } } + >; +}>; + +export type APMTelemetry = APMDataTelemetry; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 40a2a0e7216a0..8e8cf698a84cf 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -39,6 +39,19 @@ function getMockRequest() { _debug: false } }, + __LEGACY: { + server: { + plugins: { + elasticsearch: { + getCluster: jest.fn().mockReturnValue({ callWithInternalUser: {} }) + } + }, + savedObjects: { + SavedObjectsClient: jest.fn(), + getSavedObjectsRepository: jest.fn() + } + } + }, core: { elasticsearch: { dataClient: { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index db14730f802a9..a29b9399d8435 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -8,9 +8,9 @@ import { Observable, combineLatest, AsyncSubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { Server } from 'hapi'; import { once } from 'lodash'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { TaskManagerSetupContract } from '../../task_manager/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; -import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; import { createApmApi } from './routes/create_apm_api'; @@ -21,6 +21,7 @@ import { tutorialProvider } from './tutorial'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { LicensingPluginSetup } from '../../licensing/public'; +import { createApmTelemetry } from './lib/apm_telemetry'; export interface LegacySetup { server: Server; @@ -47,9 +48,10 @@ export class APMPlugin implements Plugin { licensing: LicensingPluginSetup; cloud?: CloudSetup; usageCollection?: UsageCollectionSetup; + taskManager?: TaskManagerSetupContract; } ) { - const logger = this.initContext.logger.get('apm'); + const logger = this.initContext.logger.get(); const config$ = this.initContext.config.create(); const mergedConfig$ = combineLatest(plugins.apm_oss.config$, config$).pipe( map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) @@ -61,6 +63,20 @@ export class APMPlugin implements Plugin { const currentConfig = await mergedConfig$.pipe(take(1)).toPromise(); + if ( + plugins.taskManager && + plugins.usageCollection && + currentConfig['xpack.apm.telemetryCollectionEnabled'] + ) { + createApmTelemetry({ + core, + config$: mergedConfig$, + usageCollector: plugins.usageCollection, + taskManager: plugins.taskManager, + logger + }); + } + // create agent configuration index without blocking setup lifecycle createApmAgentConfigurationIndex({ esClient: core.elasticsearch.dataClient, @@ -89,18 +105,6 @@ export class APMPlugin implements Plugin { }) ); - const usageCollection = plugins.usageCollection; - if (usageCollection) { - getInternalSavedObjectsClient(core) - .then(savedObjectsClient => { - makeApmUsageCollector(usageCollection, savedObjectsClient); - }) - .catch(error => { - logger.error('Unable to initialize use collection'); - logger.error(error.message); - }); - } - return { config$: mergedConfig$, registerLegacyAPI: once((__LEGACY: LegacySetup) => { @@ -115,6 +119,7 @@ export class APMPlugin implements Plugin { }; } - public start() {} + public async start() {} + public stop() {} } diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index e639bb5101e2f..312dae1d1f9d2 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -36,6 +36,7 @@ const getCoreMock = () => { put, createRouter, context: { + measure: () => undefined, config$: new BehaviorSubject({} as APMConfig), logger: ({ error: jest.fn() diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 2d4fae9d2707a..1c6561ee24c93 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,11 +5,6 @@ */ import * as t from 'io-ts'; -import { AgentName } from '../../typings/es_schemas/ui/fields/agent'; -import { - createApmTelementry, - storeApmServicesTelemetry -} from '../lib/apm_telemetry'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServices } from '../lib/services/get_services'; @@ -18,7 +13,6 @@ import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadat import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; -import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; export const servicesRoute = createRoute(core => ({ path: '/api/apm/services', @@ -29,16 +23,6 @@ export const servicesRoute = createRoute(core => ({ const setup = await setupRequest(context, request); const services = await getServices(setup); - // Store telemetry data derived from services - const agentNames = services.items.map( - ({ agentName }) => agentName as AgentName - ); - const apmTelemetry = createApmTelementry(agentNames); - const savedObjectsClient = await getInternalSavedObjectsClient(core); - storeApmServicesTelemetry(savedObjectsClient, apmTelemetry).catch(error => { - context.logger.error(error.message); - }); - return services; } })); diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 6d3620f11a87b..8a8d256cf4273 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -126,6 +126,16 @@ export interface AggregationOptionsByType { combine_script: Script; reduce_script: Script; }; + date_range: { + field: string; + format?: string; + ranges: Array< + | { from: string | number } + | { to: string | number } + | { from: string | number; to: string | number } + >; + keyed?: boolean; + }; } type AggregationType = keyof AggregationOptionsByType; @@ -136,6 +146,15 @@ type AggregationOptionsMap = Unionize< } > & { aggs?: AggregationInputMap }; +interface DateRangeBucket { + key: string; + to?: number; + from?: number; + to_as_string?: string; + from_as_string?: string; + doc_count: number; +} + export interface AggregationInputMap { [key: string]: AggregationOptionsMap; } @@ -276,6 +295,11 @@ interface AggregationResponsePart< scripted_metric: { value: unknown; }; + date_range: { + buckets: TAggregationOptionsMap extends { date_range: { keyed: true } } + ? Record + : { buckets: DateRangeBucket[] }; + }; } // Type for debugging purposes. If you see an error in AggregationResponseMap @@ -285,7 +309,7 @@ interface AggregationResponsePart< // type MissingAggregationResponseTypes = Exclude< // AggregationType, -// keyof AggregationResponsePart<{}> +// keyof AggregationResponsePart<{}, unknown> // >; export type AggregationResponseMap< diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts index daf65e44980b6..8e49d02beb908 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts @@ -15,6 +15,7 @@ import { Service } from './fields/service'; import { IStackframe } from './fields/stackframe'; import { Url } from './fields/url'; import { User } from './fields/user'; +import { Observer } from './fields/observer'; interface Processor { name: 'error'; @@ -61,4 +62,5 @@ export interface ErrorRaw extends APMBaseDoc { service: Service; url?: Url; user?: User; + observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts new file mode 100644 index 0000000000000..42843130ec47f --- /dev/null +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Observer { + version: string; + version_major: number; +} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts index dbd9e7ede4256..4d5d2c5c4a12e 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts @@ -6,6 +6,7 @@ import { APMBaseDoc } from './apm_base_doc'; import { IStackframe } from './fields/stackframe'; +import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -50,4 +51,5 @@ export interface SpanRaw extends APMBaseDoc { transaction?: { id: string; }; + observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index 3673f1f13c403..b8ebb4cf8da51 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -15,6 +15,7 @@ import { Service } from './fields/service'; import { Url } from './fields/url'; import { User } from './fields/user'; import { UserAgent } from './fields/user_agent'; +import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -61,4 +62,5 @@ export interface TransactionRaw extends APMBaseDoc { url?: Url; user?: User; user_agent?: UserAgent; + observer?: Observer; } From dc31736dd28187750fd3cd51ed3aae7dc230179c Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Mon, 23 Mar 2020 16:40:43 -0600 Subject: [PATCH 17/34] [Maps] Default ES document layer scaling type to clusters and show scaling UI in the create wizard (#60668) * [Maps] show scaling panel in ES documents create wizard * minor fix * remove unused async state * update create editor to use ScalingForm * default geo field * ts lint errors * remove old dynamic filter behavior * update jest tests * eslint * remove indexCount route Co-authored-by: Elastic Machine --- .../maps/public/actions/map_actions.d.ts | 8 + .../layer_panel/view.d.ts | 14 + .../__snapshots__/scaling_form.test.tsx.snap | 205 ++++++++++++ .../update_source_editor.test.js.snap | 315 +----------------- .../es_search_source/create_source_editor.js | 189 +++++------ .../es_search_source/scaling_form.test.tsx | 47 +++ .../sources/es_search_source/scaling_form.tsx | 230 +++++++++++++ .../es_search_source/update_source_editor.js | 196 +---------- .../update_source_editor.test.js | 8 - x-pack/legacy/plugins/maps/server/routes.js | 20 -- x-pack/plugins/maps/common/constants.ts | 14 +- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 13 files changed, 636 insertions(+), 616 deletions(-) create mode 100644 x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts create mode 100644 x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap create mode 100644 x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx create mode 100644 x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx diff --git a/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts b/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts index 418f2880c1077..3a61d5affd861 100644 --- a/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts +++ b/x-pack/legacy/plugins/maps/public/actions/map_actions.d.ts @@ -5,6 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ +import { LAYER_TYPE } from '../../common/constants'; import { DataMeta, MapFilters } from '../../common/data_request_descriptor_types'; export type SyncContext = { @@ -16,3 +17,10 @@ export type SyncContext = { registerCancelCallback(requestToken: symbol, callback: () => void): void; dataFilters: MapFilters; }; + +export function updateSourceProp( + layerId: string, + propName: string, + value: unknown, + newLayerType?: LAYER_TYPE +): void; diff --git a/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts new file mode 100644 index 0000000000000..6d1d076c723ad --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/connected_components/layer_panel/view.d.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ + +import { LAYER_TYPE } from '../../../common/constants'; + +export type OnSourceChangeArgs = { + propName: string; + value: unknown; + newLayerType?: LAYER_TYPE; +}; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap new file mode 100644 index 0000000000000..967225d6f0fdc --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/scaling_form.test.tsx.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should not render clusters option when clustering is not supported 1`] = ` + + +
+ +
+
+ + + + + + + +
+`; + +exports[`should render 1`] = ` + + +
+ +
+
+ + + + + + + +
+`; + +exports[`should render top hits form when scaling type is TOP_HITS 1`] = ` + + +
+ +
+
+ + + + + + + + + + + +
+`; diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap index c94f305773f35..0cb7f67fb9c92 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/__snapshots__/update_source_editor.test.js.snap @@ -91,253 +91,16 @@ exports[`should enable sort order select when sort field provided 1`] = ` size="s" /> - -
- -
-
- - - - - - - -
- - -`; - -exports[`should render top hits form when scaling type is TOP_HITS 1`] = ` - - - -
- -
-
- - -
- - - -
- -
-
- - - - - - - -
- - - -
- -
-
- - - - - - - - - - - - - -
- -
- -
-
- - - - - - -
{ @@ -34,11 +27,26 @@ function getGeoFields(fields) { ); }); } + +function isGeoFieldAggregatable(indexPattern, geoFieldName) { + if (!indexPattern) { + return false; + } + + const geoField = indexPattern.fields.getByName(geoFieldName); + return geoField && geoField.aggregatable; +} + const RESET_INDEX_PATTERN_STATE = { indexPattern: undefined, - geoField: undefined, + geoFields: undefined, + + // ES search source descriptor state + geoFieldName: undefined, filterByMapBounds: DEFAULT_FILTER_BY_MAP_BOUNDS, - showFilterByBoundsSwitch: false, + scalingType: SCALING_TYPES.CLUSTERS, // turn on clusting by default + topHitsSplitField: undefined, + topHitsSize: 1, }; export class CreateSourceEditor extends Component { @@ -58,41 +66,28 @@ export class CreateSourceEditor extends Component { componentDidMount() { this._isMounted = true; - this.loadIndexPattern(this.state.indexPatternId); } - onIndexPatternSelect = indexPatternId => { + _onIndexPatternSelect = indexPatternId => { this.setState( { indexPatternId, }, - this.loadIndexPattern(indexPatternId) + this._loadIndexPattern(indexPatternId) ); }; - loadIndexPattern = indexPatternId => { + _loadIndexPattern = indexPatternId => { this.setState( { isLoadingIndexPattern: true, ...RESET_INDEX_PATTERN_STATE, }, - this.debouncedLoad.bind(null, indexPatternId) + this._debouncedLoad.bind(null, indexPatternId) ); }; - loadIndexDocCount = async indexPatternTitle => { - const http = getHttp(); - const { count } = await http.fetch(`../${GIS_API_PATH}/indexCount`, { - method: 'GET', - credentials: 'same-origin', - query: { - index: indexPatternTitle, - }, - }); - return count; - }; - - debouncedLoad = _.debounce(async indexPatternId => { + _debouncedLoad = _.debounce(async indexPatternId => { if (!indexPatternId || indexPatternId.length === 0) { return; } @@ -105,15 +100,6 @@ export class CreateSourceEditor extends Component { return; } - let indexHasSmallDocCount = false; - try { - const indexDocCount = await this.loadIndexDocCount(indexPattern.title); - indexHasSmallDocCount = indexDocCount <= DEFAULT_MAX_RESULT_WINDOW; - } catch (error) { - // retrieving index count is a nice to have and is not essential - // do not interrupt user flow if unable to retrieve count - } - if (!this._isMounted) { return; } @@ -124,43 +110,71 @@ export class CreateSourceEditor extends Component { return; } + const geoFields = getGeoFields(indexPattern.fields); this.setState({ isLoadingIndexPattern: false, indexPattern: indexPattern, - filterByMapBounds: !indexHasSmallDocCount, // Turn off filterByMapBounds when index contains a limited number of documents - showFilterByBoundsSwitch: indexHasSmallDocCount, + geoFields, }); - //make default selection - const geoFields = getGeoFields(indexPattern.fields); - if (geoFields[0]) { - this.onGeoFieldSelect(geoFields[0].name); + if (geoFields.length) { + // make default selection, prefer aggregatable field over the first available + const firstAggregatableGeoField = geoFields.find(geoField => { + return geoField.aggregatable; + }); + const defaultGeoFieldName = firstAggregatableGeoField + ? firstAggregatableGeoField + : geoFields[0]; + this._onGeoFieldSelect(defaultGeoFieldName.name); } }, 300); - onGeoFieldSelect = geoField => { + _onGeoFieldSelect = geoFieldName => { + // Respect previous scaling type selection unless newly selected geo field does not support clustering. + const scalingType = + this.state.scalingType === SCALING_TYPES.CLUSTERS && + !isGeoFieldAggregatable(this.state.indexPattern, geoFieldName) + ? SCALING_TYPES.LIMIT + : this.state.scalingType; this.setState( { - geoField, + geoFieldName, + scalingType, }, - this.previewLayer + this._previewLayer ); }; - onFilterByMapBoundsChange = event => { + _onScalingPropChange = ({ propName, value }) => { this.setState( { - filterByMapBounds: event.target.checked, + [propName]: value, }, - this.previewLayer + this._previewLayer ); }; - previewLayer = () => { - const { indexPatternId, geoField, filterByMapBounds } = this.state; + _previewLayer = () => { + const { + indexPatternId, + geoFieldName, + filterByMapBounds, + scalingType, + topHitsSplitField, + topHitsSize, + } = this.state; const sourceConfig = - indexPatternId && geoField ? { indexPatternId, geoField, filterByMapBounds } : null; + indexPatternId && geoFieldName + ? { + indexPatternId, + geoField: geoFieldName, + filterByMapBounds, + scalingType, + topHitsSplitField, + topHitsSize, + } + : null; this.props.onSourceConfigChange(sourceConfig); }; @@ -183,56 +197,35 @@ export class CreateSourceEditor extends Component { placeholder={i18n.translate('xpack.maps.source.esSearch.selectLabel', { defaultMessage: 'Select geo field', })} - value={this.state.geoField} - onChange={this.onGeoFieldSelect} - fields={ - this.state.indexPattern ? getGeoFields(this.state.indexPattern.fields) : undefined - } + value={this.state.geoFieldName} + onChange={this._onGeoFieldSelect} + fields={this.state.geoFields} /> ); } - _renderFilterByMapBounds() { - if (!this.state.showFilterByBoundsSwitch) { + _renderScalingPanel() { + if (!this.state.indexPattern || !this.state.geoFieldName) { return null; } return ( - -

- -

-

- -

-
- - - - + +
); } @@ -265,7 +258,7 @@ export class CreateSourceEditor extends Component { ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx new file mode 100644 index 0000000000000..03f29685891ec --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('../../../kibana_services', () => ({})); + +jest.mock('./load_index_settings', () => ({ + loadIndexSettings: async () => { + return { maxInnerResultWindow: 100, maxResultWindow: 10000 }; + }, +})); + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { ScalingForm } from './scaling_form'; +import { SCALING_TYPES } from '../../../../common/constants'; + +const defaultProps = { + filterByMapBounds: true, + indexPatternId: 'myIndexPattern', + onChange: () => {}, + scalingType: SCALING_TYPES.LIMIT, + supportsClustering: true, + termFields: [], + topHitsSize: 1, +}; + +test('should render', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should not render clusters option when clustering is not supported', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); + +test('should render top hits form when scaling type is TOP_HITS', async () => { + const component = shallow(); + + expect(component).toMatchSnapshot(); +}); diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx new file mode 100644 index 0000000000000..c5950f1132974 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/scaling_form.tsx @@ -0,0 +1,230 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, Component } from 'react'; +import { + EuiFormRow, + EuiSwitch, + EuiSwitchEvent, + EuiTitle, + EuiSpacer, + EuiHorizontalRule, + EuiRadioGroup, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-ignore +import { SingleFieldSelect } from '../../../components/single_field_select'; + +// @ts-ignore +import { indexPatternService } from '../../../kibana_services'; +// @ts-ignore +import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; +// @ts-ignore +import { ValidatedRange } from '../../../components/validated_range'; +import { + DEFAULT_MAX_INNER_RESULT_WINDOW, + DEFAULT_MAX_RESULT_WINDOW, + SCALING_TYPES, + LAYER_TYPE, +} from '../../../../common/constants'; +// @ts-ignore +import { loadIndexSettings } from './load_index_settings'; +import { IFieldType } from '../../../../../../../../src/plugins/data/public'; +import { OnSourceChangeArgs } from '../../../connected_components/layer_panel/view'; + +interface Props { + filterByMapBounds: boolean; + indexPatternId: string; + onChange: (args: OnSourceChangeArgs) => void; + scalingType: SCALING_TYPES; + supportsClustering: boolean; + termFields: IFieldType[]; + topHitsSplitField?: string; + topHitsSize: number; +} + +interface State { + maxInnerResultWindow: number; + maxResultWindow: number; +} + +export class ScalingForm extends Component { + state = { + maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, + maxResultWindow: DEFAULT_MAX_RESULT_WINDOW, + }; + _isMounted = false; + + componentDidMount() { + this._isMounted = true; + this.loadIndexSettings(); + } + + componentWillUnmount() { + this._isMounted = false; + } + + async loadIndexSettings() { + try { + const indexPattern = await indexPatternService.get(this.props.indexPatternId); + const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(indexPattern.title); + if (this._isMounted) { + this.setState({ maxInnerResultWindow, maxResultWindow }); + } + } catch (err) { + return; + } + } + + _onScalingTypeChange = (optionId: string): void => { + const layerType = + optionId === SCALING_TYPES.CLUSTERS ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR; + this.props.onChange({ propName: 'scalingType', value: optionId, newLayerType: layerType }); + }; + + _onFilterByMapBoundsChange = (event: EuiSwitchEvent) => { + this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked }); + }; + + _onTopHitsSplitFieldChange = (topHitsSplitField: string) => { + this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); + }; + + _onTopHitsSizeChange = (size: number) => { + this.props.onChange({ propName: 'topHitsSize', value: size }); + }; + + _renderTopHitsForm() { + let sizeSlider; + if (this.props.topHitsSplitField) { + sizeSlider = ( + + + + ); + } + + return ( + + + + + + {sizeSlider} + + ); + } + + render() { + const scalingOptions = [ + { + id: SCALING_TYPES.LIMIT, + label: i18n.translate('xpack.maps.source.esSearch.limitScalingLabel', { + defaultMessage: 'Limit results to {maxResultWindow}.', + values: { maxResultWindow: this.state.maxResultWindow }, + }), + }, + { + id: SCALING_TYPES.TOP_HITS, + label: i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', { + defaultMessage: 'Show top hits per entity.', + }), + }, + ]; + if (this.props.supportsClustering) { + scalingOptions.push({ + id: SCALING_TYPES.CLUSTERS, + label: i18n.translate('xpack.maps.source.esSearch.clusterScalingLabel', { + defaultMessage: 'Show clusters when results exceed {maxResultWindow}.', + values: { maxResultWindow: this.state.maxResultWindow }, + }), + }); + } + + let filterByBoundsSwitch; + if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { + filterByBoundsSwitch = ( + + + + ); + } + + let scalingForm = null; + if (this.props.scalingType === SCALING_TYPES.TOP_HITS) { + scalingForm = ( + + + {this._renderTopHitsForm()} + + ); + } + + return ( + + +
+ +
+
+ + + + + + + + {filterByBoundsSwitch} + + {scalingForm} +
+ ); + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js index 4d1e32087ab8c..9c92ec5801e49 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.js @@ -6,34 +6,18 @@ import React, { Fragment, Component } from 'react'; import PropTypes from 'prop-types'; -import { - EuiFormRow, - EuiSwitch, - EuiSelect, - EuiTitle, - EuiPanel, - EuiSpacer, - EuiHorizontalRule, - EuiRadioGroup, -} from '@elastic/eui'; +import { EuiFormRow, EuiSelect, EuiTitle, EuiPanel, EuiSpacer } from '@elastic/eui'; import { SingleFieldSelect } from '../../../components/single_field_select'; import { TooltipSelector } from '../../../components/tooltip_selector'; import { getIndexPatternService } from '../../../kibana_services'; import { i18n } from '@kbn/i18n'; import { getTermsFields, getSourceFields } from '../../../index_pattern_util'; -import { ValidatedRange } from '../../../components/validated_range'; -import { - DEFAULT_MAX_INNER_RESULT_WINDOW, - DEFAULT_MAX_RESULT_WINDOW, - SORT_ORDER, - SCALING_TYPES, - LAYER_TYPE, -} from '../../../../common/constants'; +import { SORT_ORDER } from '../../../../common/constants'; import { ESDocField } from '../../fields/es_doc_field'; import { FormattedMessage } from '@kbn/i18n/react'; -import { loadIndexSettings } from './load_index_settings'; import { indexPatterns } from '../../../../../../../../src/plugins/data/public'; +import { ScalingForm } from './scaling_form'; export class UpdateSourceEditor extends Component { static propTypes = { @@ -52,33 +36,18 @@ export class UpdateSourceEditor extends Component { sourceFields: null, termFields: null, sortFields: null, - maxInnerResultWindow: DEFAULT_MAX_INNER_RESULT_WINDOW, - maxResultWindow: DEFAULT_MAX_RESULT_WINDOW, supportsClustering: false, }; componentDidMount() { this._isMounted = true; this.loadFields(); - this.loadIndexSettings(); } componentWillUnmount() { this._isMounted = false; } - async loadIndexSettings() { - try { - const indexPattern = await getIndexPatternService().get(this.props.indexPatternId); - const { maxInnerResultWindow, maxResultWindow } = await loadIndexSettings(indexPattern.title); - if (this._isMounted) { - this.setState({ maxInnerResultWindow, maxResultWindow }); - } - } catch (err) { - return; - } - } - async loadFields() { let indexPattern; try { @@ -133,85 +102,14 @@ export class UpdateSourceEditor extends Component { this.props.onChange({ propName: 'tooltipProperties', value: propertyNames }); }; - _onScalingTypeChange = optionId => { - const layerType = - optionId === SCALING_TYPES.CLUSTERS ? LAYER_TYPE.BLENDED_VECTOR : LAYER_TYPE.VECTOR; - this.props.onChange({ propName: 'scalingType', value: optionId, newLayerType: layerType }); - }; - - _onFilterByMapBoundsChange = event => { - this.props.onChange({ propName: 'filterByMapBounds', value: event.target.checked }); - }; - - onTopHitsSplitFieldChange = topHitsSplitField => { - this.props.onChange({ propName: 'topHitsSplitField', value: topHitsSplitField }); - }; - - onSortFieldChange = sortField => { + _onSortFieldChange = sortField => { this.props.onChange({ propName: 'sortField', value: sortField }); }; - onSortOrderChange = e => { + _onSortOrderChange = e => { this.props.onChange({ propName: 'sortOrder', value: e.target.value }); }; - onTopHitsSizeChange = size => { - this.props.onChange({ propName: 'topHitsSize', value: size }); - }; - - _renderTopHitsForm() { - let sizeSlider; - if (this.props.topHitsSplitField) { - sizeSlider = ( - - - - ); - } - - return ( - - - - - - {sizeSlider} - - ); - } - _renderTooltipsPanel() { return ( @@ -257,7 +155,7 @@ export class UpdateSourceEditor extends Component { defaultMessage: 'Select sort field', })} value={this.props.sortField} - onChange={this.onSortFieldChange} + onChange={this._onSortFieldChange} fields={this.state.sortFields} compressed /> @@ -286,7 +184,7 @@ export class UpdateSourceEditor extends Component { }, ]} value={this.props.sortOrder} - onChange={this.onSortOrderChange} + onChange={this._onSortOrderChange} compressed /> @@ -295,78 +193,18 @@ export class UpdateSourceEditor extends Component { } _renderScalingPanel() { - const scalingOptions = [ - { - id: SCALING_TYPES.LIMIT, - label: i18n.translate('xpack.maps.source.esSearch.limitScalingLabel', { - defaultMessage: 'Limit results to {maxResultWindow}.', - values: { maxResultWindow: this.state.maxResultWindow }, - }), - }, - { - id: SCALING_TYPES.TOP_HITS, - label: i18n.translate('xpack.maps.source.esSearch.useTopHitsLabel', { - defaultMessage: 'Show top hits per entity.', - }), - }, - ]; - if (this.state.supportsClustering) { - scalingOptions.push({ - id: SCALING_TYPES.CLUSTERS, - label: i18n.translate('xpack.maps.source.esSearch.clusterScalingLabel', { - defaultMessage: 'Show clusters when results exceed {maxResultWindow}.', - values: { maxResultWindow: this.state.maxResultWindow }, - }), - }); - } - - let filterByBoundsSwitch; - if (this.props.scalingType !== SCALING_TYPES.CLUSTERS) { - filterByBoundsSwitch = ( - - - - ); - } - - let scalingForm = null; - if (this.props.scalingType === SCALING_TYPES.TOP_HITS) { - scalingForm = ( - - - {this._renderTopHitsForm()} - - ); - } - return ( - -
- -
-
- - - - - - - - {filterByBoundsSwitch} - - {scalingForm} +
); } diff --git a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js index e8a845c4b1669..65a91ce03994a 100644 --- a/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/sources/es_search_source/update_source_editor.test.js @@ -40,11 +40,3 @@ test('should enable sort order select when sort field provided', async () => { expect(component).toMatchSnapshot(); }); - -test('should render top hits form when scaling type is TOP_HITS', async () => { - const component = shallow( - - ); - - expect(component).toMatchSnapshot(); -}); diff --git a/x-pack/legacy/plugins/maps/server/routes.js b/x-pack/legacy/plugins/maps/server/routes.js index 7ca659148449f..6aacfdc41aeea 100644 --- a/x-pack/legacy/plugins/maps/server/routes.js +++ b/x-pack/legacy/plugins/maps/server/routes.js @@ -409,26 +409,6 @@ export function initRoutes(server, licenseUid) { }, }); - server.route({ - method: 'GET', - path: `${ROOT}/indexCount`, - handler: async (request, h) => { - const { server, query } = request; - - if (!query.index) { - return h.response().code(400); - } - - const { callWithRequest } = server.plugins.elasticsearch.getCluster('data'); - try { - const { count } = await callWithRequest(request, 'count', { index: query.index }); - return { count }; - } catch (error) { - return h.response().code(400); - } - }, - }); - server.route({ method: 'GET', path: `/${INDEX_SETTINGS_API_PATH}`, diff --git a/x-pack/plugins/maps/common/constants.ts b/x-pack/plugins/maps/common/constants.ts index fecf8db0e85de..12b03f0386304 100644 --- a/x-pack/plugins/maps/common/constants.ts +++ b/x-pack/plugins/maps/common/constants.ts @@ -43,13 +43,13 @@ export function createMapPath(id: string) { return `${MAP_BASE_URL}/${id}`; } -export const LAYER_TYPE = { - TILE: 'TILE', - VECTOR: 'VECTOR', - VECTOR_TILE: 'VECTOR_TILE', - HEATMAP: 'HEATMAP', - BLENDED_VECTOR: 'BLENDED_VECTOR', -}; +export enum LAYER_TYPE { + TILE = 'TILE', + VECTOR = 'VECTOR', + VECTOR_TILE = 'VECTOR_TILE', + HEATMAP = 'HEATMAP', + BLENDED_VECTOR = 'BLENDED_VECTOR', +} export enum SORT_ORDER { ASC = 'asc', diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ce78847a8e8b3..e8d93ba6d3200 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -7170,9 +7170,6 @@ "xpack.maps.source.esGridDescription": "それぞれのグリッド付きセルのメトリックでグリッドにグループ分けされた地理空間データです。", "xpack.maps.source.esGridTitle": "グリッド集約", "xpack.maps.source.esSearch.convertToGeoJsonErrorMsg": "検索への応答を geoJson 機能コレクションに変換できません。エラー: {errorMsg}", - "xpack.maps.source.esSearch.disableFilterByMapBoundsExplainMsg": "インデックス「{indexPatternTitle}」はドキュメント数が少なく、ダイナミックフィルターが必要ありません。", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "ダイナミックデータフィルターは無効です", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTurnOnMsg": "ドキュメント数が増えると思われる場合はダイナミックフィルターをオンにしてください。", "xpack.maps.source.esSearch.extentFilterLabel": "マップの表示範囲でデータを動的にフィルタリング", "xpack.maps.source.esSearch.geofieldLabel": "地理空間フィールド", "xpack.maps.source.esSearch.geoFieldLabel": "地理空間フィールド", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index d5d0a2f9e7aff..cfab424935c6d 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -7170,9 +7170,6 @@ "xpack.maps.source.esGridDescription": "地理空间数据在网格中进行分组,每个网格单元格都具有指标", "xpack.maps.source.esGridTitle": "网格聚合", "xpack.maps.source.esSearch.convertToGeoJsonErrorMsg": "无法将搜索响应转换成 geoJson 功能集合,错误:{errorMsg}", - "xpack.maps.source.esSearch.disableFilterByMapBoundsExplainMsg": "索引“{indexPatternTitle}”具有很少数量的文档,不需要动态筛选。", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTitle": "动态数据筛选已禁用", - "xpack.maps.source.esSearch.disableFilterByMapBoundsTurnOnMsg": "如果预期文档数量会增加,请打开动态筛选。", "xpack.maps.source.esSearch.extentFilterLabel": "在可见地图区域中动态筛留数据", "xpack.maps.source.esSearch.geofieldLabel": "地理空间字段", "xpack.maps.source.esSearch.geoFieldLabel": "地理空间字段", From 72bc0eae3268b1dfb7c314e80438c7a88f61352e Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Mon, 23 Mar 2020 19:02:28 -0400 Subject: [PATCH 18/34] [Alerting] allow email action to not require auth (#60839) resolves https://github.com/elastic/kibana/issues/57143 Currently, the built-in email action requires user/password properties to be set in it's secrets parameters. This PR changes that requirement, so they are no longer required. --- .../server/builtin_action_types/email.test.ts | 14 ++-- .../server/builtin_action_types/email.ts | 25 +++--- .../builtin_action_types/email.test.tsx | 81 +++++++++++++++++++ .../components/builtin_action_types/email.tsx | 44 +++++----- .../components/builtin_action_types/types.ts | 4 +- .../actions/builtin_action_types/email.ts | 56 +++++++++++++ 6 files changed, 181 insertions(+), 43 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts index 0bd3992de30e6..469df4fd86e2c 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.test.ts @@ -184,12 +184,14 @@ describe('secrets validation', () => { expect(validateSecrets(actionType, secrets)).toEqual(secrets); }); - test('secrets validation fails when secrets is not valid', () => { - expect(() => { - validateSecrets(actionType, {}); - }).toThrowErrorMatchingInlineSnapshot( - `"error validating action type secrets: [user]: expected value of type [string] but got [undefined]"` - ); + test('secrets validation succeeds when secrets props are null/undefined', () => { + const secrets: Record = { + user: null, + password: null, + }; + expect(validateSecrets(actionType, {})).toEqual(secrets); + expect(validateSecrets(actionType, { user: null })).toEqual(secrets); + expect(validateSecrets(actionType, { password: null })).toEqual(secrets); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/email.ts b/x-pack/plugins/actions/server/builtin_action_types/email.ts index 16e0168a7deb9..7992920fdfcb4 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/email.ts @@ -10,7 +10,6 @@ import { schema, TypeOf } from '@kbn/config-schema'; import nodemailerGetService from 'nodemailer/lib/well-known'; import { sendEmail, JSON_TRANSPORT_SERVICE } from './lib/send_email'; -import { nullableType } from './lib/nullable'; import { portSchema } from './lib/schemas'; import { Logger } from '../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; @@ -20,10 +19,10 @@ import { ActionsConfigurationUtilities } from '../actions_config'; export type ActionTypeConfigType = TypeOf; const ConfigSchemaProps = { - service: nullableType(schema.string()), - host: nullableType(schema.string()), - port: nullableType(portSchema()), - secure: nullableType(schema.boolean()), + service: schema.nullable(schema.string()), + host: schema.nullable(schema.string()), + port: schema.nullable(portSchema()), + secure: schema.nullable(schema.boolean()), from: schema.string(), }; @@ -75,8 +74,8 @@ function validateConfig( export type ActionTypeSecretsType = TypeOf; const SecretsSchema = schema.object({ - user: schema.string(), - password: schema.string(), + user: schema.nullable(schema.string()), + password: schema.nullable(schema.string()), }); // params definition @@ -144,10 +143,14 @@ async function executor( const secrets = execOptions.secrets as ActionTypeSecretsType; const params = execOptions.params as ActionParamsType; - const transport: any = { - user: secrets.user, - password: secrets.password, - }; + const transport: any = {}; + + if (secrets.user != null) { + transport.user = secrets.user; + } + if (secrets.password != null) { + transport.password = secrets.password; + } if (config.service !== null) { transport.service = config.service; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx index 49a611167cf16..a7d479f922ed1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.test.tsx @@ -58,6 +58,33 @@ describe('connector validation', () => { }); }); + test('connector validation succeeds when connector config is valid with empty user/password', () => { + const actionConnector = { + secrets: { + user: null, + password: null, + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: 2323, + host: 'localhost', + test: 'test', + }, + } as EmailActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + port: [], + host: [], + user: [], + password: [], + }, + }); + }); test('connector validation fails when connector config is not valid', () => { const actionConnector = { secrets: { @@ -82,6 +109,60 @@ describe('connector validation', () => { }, }); }); + test('connector validation fails when user specified but not password', () => { + const actionConnector = { + secrets: { + user: 'user', + password: null, + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: 2323, + host: 'localhost', + test: 'test', + }, + } as EmailActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + port: [], + host: [], + user: [], + password: ['Password is required when username is used.'], + }, + }); + }); + test('connector validation fails when password specified but not user', () => { + const actionConnector = { + secrets: { + user: null, + password: 'password', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: 2323, + host: 'localhost', + test: 'test', + }, + } as EmailActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + port: [], + host: [], + user: ['Username is required when password is used.'], + password: [], + }, + }); + }); }); describe('action params validation', () => { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx index 6c994051ec980..f17180ee74e56 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/email.tsx @@ -97,22 +97,22 @@ export function getActionType(): ActionTypeModel { ) ); } - if (!action.secrets.user) { - errors.user.push( + if (action.secrets.user && !action.secrets.password) { + errors.password.push( i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', { - defaultMessage: 'Username is required.', + defaultMessage: 'Password is required when username is used.', } ) ); } - if (!action.secrets.password) { - errors.password.push( + if (!action.secrets.user && action.secrets.password) { + errors.user.push( i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', { - defaultMessage: 'Password is required.', + defaultMessage: 'Username is required when password is used.', } ) ); @@ -303,7 +303,7 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && user !== undefined} + isInvalid={errors.user.length > 0} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', { @@ -313,17 +313,12 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && user !== undefined} + isInvalid={errors.user.length > 0} name="user" value={user || ''} data-test-subj="emailUserInput" onChange={e => { - editActionSecrets('user', e.target.value); - }} - onBlur={() => { - if (!user) { - editActionSecrets('user', ''); - } + editActionSecrets('user', nullableString(e.target.value)); }} /> @@ -333,7 +328,7 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && password !== undefined} + isInvalid={errors.password.length > 0} label={i18n.translate( 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', { @@ -343,17 +338,12 @@ const EmailActionConnectorFields: React.FunctionComponent 0 && password !== undefined} + isInvalid={errors.password.length > 0} name="password" value={password || ''} data-test-subj="emailPasswordInput" onChange={e => { - editActionSecrets('password', e.target.value); - }} - onBlur={() => { - if (!password) { - editActionSecrets('password', ''); - } + editActionSecrets('password', nullableString(e.target.value)); }} /> @@ -624,3 +614,9 @@ const EmailParamsFields: React.FunctionComponent ); }; + +// if the string == null or is empty, return null, else return string +function nullableString(str: string | null | undefined) { + if (str == null || str.trim() === '') return null; + return str; +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts index c0ddd6791e90e..2e0576d933f90 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/types.ts @@ -72,8 +72,8 @@ interface EmailConfig { } interface EmailSecrets { - user: string; - password: string; + user: string | null; + password: string | null; } export interface EmailActionConnector extends ActionConnector { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts index de856492e12fc..e228f6c1f81c6 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/actions/builtin_action_types/email.ts @@ -227,5 +227,61 @@ export default function emailTest({ getService }: FtrProviderContext) { .expect(200); expect(typeof createdAction.id).to.be('string'); }); + + it('should handle an email action with no auth', async () => { + const { body: createdAction } = await supertest + .post('/api/action') + .set('kbn-xsrf', 'foo') + .send({ + name: 'An email action with no auth', + actionTypeId: '.email', + config: { + service: '__json', + from: 'jim@example.com', + }, + }) + .expect(200); + + await supertest + .post(`/api/action/${createdAction.id}/_execute`) + .set('kbn-xsrf', 'foo') + .send({ + params: { + to: ['kibana-action-test@elastic.co'], + subject: 'email-subject', + message: 'email-message', + }, + }) + .expect(200) + .then((resp: any) => { + expect(resp.body.data.message.messageId).to.be.a('string'); + expect(resp.body.data.messageId).to.be.a('string'); + + delete resp.body.data.message.messageId; + delete resp.body.data.messageId; + + expect(resp.body.data).to.eql({ + envelope: { + from: 'jim@example.com', + to: ['kibana-action-test@elastic.co'], + }, + message: { + from: { address: 'jim@example.com', name: '' }, + to: [ + { + address: 'kibana-action-test@elastic.co', + name: '', + }, + ], + cc: null, + bcc: null, + subject: 'email-subject', + html: '

email-message

\n', + text: 'email-message', + headers: {}, + }, + }); + }); + }); }); } From 5755b2ac522483bd71ad0e1b31459338ff69cf93 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 23 Mar 2020 16:02:44 -0700 Subject: [PATCH 19/34] [Reporting/New Platform Migration] Use a new config service on server-side (#55882) * [Reporting/New Platform Migration] Use a new config service on server-side * unit test for createConfig * use promise.all and remove outdated comment * design feedback to avoid handling the entire config getter Co-authored-by: Elastic Machine --- .../__snapshots__/index.test.js.snap | 383 ------------------ x-pack/legacy/plugins/reporting/config.ts | 182 --------- .../execute_job/decrypt_job_headers.test.ts | 22 +- .../common/execute_job/decrypt_job_headers.ts | 8 +- .../get_conditional_headers.test.ts | 173 ++------ .../execute_job/get_conditional_headers.ts | 20 +- .../execute_job/get_custom_logo.test.ts | 14 +- .../common/execute_job/get_custom_logo.ts | 11 +- .../common/execute_job/get_full_urls.test.ts | 80 ++-- .../common/execute_job/get_full_urls.ts | 22 +- .../common/layouts/create_layout.ts | 7 +- .../common/layouts/print_layout.ts | 9 +- .../lib/screenshots/get_number_of_items.ts | 7 +- .../common/lib/screenshots/observable.test.ts | 19 +- .../common/lib/screenshots/observable.ts | 18 +- .../common/lib/screenshots/open_url.ts | 11 +- .../common/lib/screenshots/types.ts | 2 +- .../common/lib/screenshots/wait_for_render.ts | 4 +- .../screenshots/wait_for_visualizations.ts | 7 +- .../export_types/csv/server/create_job.ts | 6 +- .../csv/server/execute_job.test.js | 344 +++------------- .../export_types/csv/server/execute_job.ts | 30 +- .../csv/server/lib/hit_iterator.test.ts | 3 +- .../csv/server/lib/hit_iterator.ts | 5 +- .../reporting/export_types/csv/types.d.ts | 5 +- .../server/create_job/create_job.ts | 19 +- .../server/execute_job.ts | 23 +- .../server/lib/generate_csv.ts | 16 +- .../server/lib/generate_csv_search.ts | 20 +- .../csv_from_savedobject/types.d.ts | 5 +- .../png/server/create_job/index.ts | 6 +- .../png/server/execute_job/index.test.js | 94 ++--- .../png/server/execute_job/index.ts | 23 +- .../png/server/lib/generate_png.ts | 7 +- .../printable_pdf/server/create_job/index.ts | 6 +- .../server/execute_job/index.test.js | 80 ++-- .../printable_pdf/server/execute_job/index.ts | 25 +- .../printable_pdf/server/lib/generate_pdf.ts | 9 +- .../export_types/printable_pdf/types.d.ts | 2 +- x-pack/legacy/plugins/reporting/index.test.js | 34 -- x-pack/legacy/plugins/reporting/index.ts | 16 +- .../plugins/reporting/log_configuration.ts | 23 +- .../browsers/chromium/driver_factory/args.ts | 7 +- .../browsers/chromium/driver_factory/index.ts | 19 +- .../server/browsers/chromium/index.ts | 5 +- .../browsers/create_browser_driver_factory.ts | 22 +- .../browsers/download/ensure_downloaded.ts | 13 +- .../server/browsers/network_policy.ts | 9 +- .../reporting/server/browsers/types.d.ts | 2 - .../plugins/reporting/server/config/config.js | 21 - .../legacy/plugins/reporting/server/core.ts | 72 +++- .../legacy/plugins/reporting/server/index.ts | 2 +- .../legacy/plugins/reporting/server/legacy.ts | 73 +++- .../reporting/server/lib/create_queue.ts | 20 +- .../server/lib/create_worker.test.ts | 39 +- .../reporting/server/lib/create_worker.ts | 24 +- .../plugins/reporting/server/lib/crypto.ts | 7 +- .../reporting/server/lib/enqueue_job.ts | 31 +- .../lib/esqueue/helpers/index_timestamp.js | 1 + .../plugins/reporting/server/lib/get_user.ts | 4 +- .../plugins/reporting/server/lib/index.ts | 9 +- .../reporting/server/lib/jobs_query.ts | 10 +- .../reporting/server/lib/once_per_server.ts | 43 -- .../__tests__/validate_encryption_key.js | 34 -- .../__tests__/validate_server_host.ts | 30 -- .../reporting/server/lib/validate/index.ts | 13 +- .../server/lib/validate/validate_browser.ts | 4 +- .../lib/validate/validate_encryption_key.ts | 31 -- .../validate_max_content_length.test.js | 16 +- .../validate/validate_max_content_length.ts | 14 +- .../lib/validate/validate_server_host.ts | 27 -- .../legacy/plugins/reporting/server/plugin.ts | 24 +- .../server/routes/generate_from_jobparams.ts | 5 +- .../routes/generate_from_savedobject.ts | 5 +- .../generate_from_savedobject_immediate.ts | 18 +- .../server/routes/generation.test.ts | 11 +- .../reporting/server/routes/generation.ts | 15 +- .../plugins/reporting/server/routes/index.ts | 7 +- .../reporting/server/routes/jobs.test.js | 46 ++- .../plugins/reporting/server/routes/jobs.ts | 15 +- .../lib/authorized_user_pre_routing.test.js | 131 +++--- .../routes/lib/authorized_user_pre_routing.ts | 16 +- .../server/routes/lib/get_document_payload.ts | 31 +- .../server/routes/lib/job_response_handler.ts | 15 +- .../lib/reporting_feature_pre_routing.ts | 8 +- .../routes/lib/route_config_factories.ts | 28 +- .../plugins/reporting/server/types.d.ts | 11 +- .../server/usage/get_reporting_usage.ts | 28 +- .../usage/reporting_usage_collector.test.js | 152 +++---- .../server/usage/reporting_usage_collector.ts | 23 +- .../create_mock_browserdriverfactory.ts | 45 +- .../create_mock_layoutinstance.ts | 8 +- .../create_mock_reportingplugin.ts | 22 +- .../test_helpers/create_mock_server.ts | 34 +- x-pack/legacy/plugins/reporting/types.d.ts | 62 +-- x-pack/plugins/reporting/config.ts | 10 - x-pack/plugins/reporting/kibana.json | 6 +- .../reporting/server/config/index.test.ts | 122 ++++++ .../plugins/reporting/server/config/index.ts | 85 ++++ .../reporting/server/config/schema.test.ts | 103 +++++ .../plugins/reporting/server/config/schema.ts | 174 ++++++++ x-pack/plugins/reporting/server/index.ts | 14 + x-pack/plugins/reporting/server/plugin.ts | 38 ++ 103 files changed, 1522 insertions(+), 2192 deletions(-) delete mode 100644 x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap delete mode 100644 x-pack/legacy/plugins/reporting/config.ts delete mode 100644 x-pack/legacy/plugins/reporting/index.test.js delete mode 100644 x-pack/legacy/plugins/reporting/server/config/config.js delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts delete mode 100644 x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts delete mode 100644 x-pack/plugins/reporting/config.ts create mode 100644 x-pack/plugins/reporting/server/config/index.test.ts create mode 100644 x-pack/plugins/reporting/server/config/index.ts create mode 100644 x-pack/plugins/reporting/server/config/schema.test.ts create mode 100644 x-pack/plugins/reporting/server/config/schema.ts create mode 100644 x-pack/plugins/reporting/server/index.ts create mode 100644 x-pack/plugins/reporting/server/plugin.ts diff --git a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap b/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap deleted file mode 100644 index 757677f1d4f82..0000000000000 --- a/x-pack/legacy/plugins/reporting/__snapshots__/index.test.js.snap +++ /dev/null @@ -1,383 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`config schema with context {"dev":false,"dist":false} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": true, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 1, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":false,"dist":true} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": false, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 3, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":true,"dist":false} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": true, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 1, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; - -exports[`config schema with context {"dev":true,"dist":true} produces correct config 1`] = ` -Object { - "capture": Object { - "browser": Object { - "autoDownload": false, - "chromium": Object { - "disableSandbox": "", - "maxScreenshotDimension": 1950, - "proxy": Object { - "enabled": false, - }, - }, - "type": "chromium", - }, - "concurrency": 4, - "loadDelay": 3000, - "maxAttempts": 3, - "networkPolicy": Object { - "enabled": true, - "rules": Array [ - Object { - "allow": true, - "protocol": "http:", - }, - Object { - "allow": true, - "protocol": "https:", - }, - Object { - "allow": true, - "protocol": "ws:", - }, - Object { - "allow": true, - "protocol": "wss:", - }, - Object { - "allow": true, - "protocol": "data:", - }, - Object { - "allow": false, - }, - ], - }, - "settleTime": 1000, - "timeout": 20000, - "timeouts": Object { - "openUrl": 30000, - "renderComplete": 30000, - "waitForElements": 30000, - }, - "viewport": Object { - "height": 1200, - "width": 1950, - }, - "zoom": 2, - }, - "csv": Object { - "checkForFormulas": true, - "enablePanelActionDownload": true, - "maxSizeBytes": 10485760, - "scroll": Object { - "duration": "30s", - "size": 500, - }, - }, - "enabled": true, - "index": ".reporting", - "kibanaServer": Object {}, - "poll": Object { - "jobCompletionNotifier": Object { - "interval": 10000, - "intervalErrorMultiplier": 5, - }, - "jobsRefresh": Object { - "interval": 5000, - "intervalErrorMultiplier": 5, - }, - }, - "queue": Object { - "indexInterval": "week", - "pollEnabled": true, - "pollInterval": 3000, - "pollIntervalErrorMultiplier": 10, - "timeout": 120000, - }, - "roles": Object { - "allow": Array [ - "reporting_user", - ], - }, -} -`; diff --git a/x-pack/legacy/plugins/reporting/config.ts b/x-pack/legacy/plugins/reporting/config.ts deleted file mode 100644 index 211fa70301bbf..0000000000000 --- a/x-pack/legacy/plugins/reporting/config.ts +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { BROWSER_TYPE } from './common/constants'; -// @ts-ignore untyped module -import { config as appConfig } from './server/config/config'; -import { getDefaultChromiumSandboxDisabled } from './server/browsers'; - -export async function config(Joi: any) { - return Joi.object({ - enabled: Joi.boolean().default(true), - kibanaServer: Joi.object({ - protocol: Joi.string().valid(['http', 'https']), - hostname: Joi.string().invalid('0'), - port: Joi.number().integer(), - }).default(), - queue: Joi.object({ - indexInterval: Joi.string().default('week'), - pollEnabled: Joi.boolean().default(true), - pollInterval: Joi.number() - .integer() - .default(3000), - pollIntervalErrorMultiplier: Joi.number() - .integer() - .default(10), - timeout: Joi.number() - .integer() - .default(120000), - }).default(), - capture: Joi.object({ - timeouts: Joi.object({ - openUrl: Joi.number() - .integer() - .default(30000), - waitForElements: Joi.number() - .integer() - .default(30000), - renderComplete: Joi.number() - .integer() - .default(30000), - }).default(), - networkPolicy: Joi.object({ - enabled: Joi.boolean().default(true), - rules: Joi.array() - .items( - Joi.object({ - allow: Joi.boolean().required(), - protocol: Joi.string(), - host: Joi.string(), - }) - ) - .default([ - { allow: true, protocol: 'http:' }, - { allow: true, protocol: 'https:' }, - { allow: true, protocol: 'ws:' }, - { allow: true, protocol: 'wss:' }, - { allow: true, protocol: 'data:' }, - { allow: false }, // Default action is to deny! - ]), - }).default(), - zoom: Joi.number() - .integer() - .default(2), - viewport: Joi.object({ - width: Joi.number() - .integer() - .default(1950), - height: Joi.number() - .integer() - .default(1200), - }).default(), - timeout: Joi.number() - .integer() - .default(20000), // deprecated - loadDelay: Joi.number() - .integer() - .default(3000), - settleTime: Joi.number() - .integer() - .default(1000), // deprecated - concurrency: Joi.number() - .integer() - .default(appConfig.concurrency), // deprecated - browser: Joi.object({ - type: Joi.any() - .valid(BROWSER_TYPE) - .default(BROWSER_TYPE), - autoDownload: Joi.boolean().when('$dist', { - is: true, - then: Joi.default(false), - otherwise: Joi.default(true), - }), - chromium: Joi.object({ - inspect: Joi.boolean() - .when('$dev', { - is: false, - then: Joi.valid(false), - else: Joi.default(false), - }) - .default(), - disableSandbox: Joi.boolean().default(await getDefaultChromiumSandboxDisabled()), - proxy: Joi.object({ - enabled: Joi.boolean().default(false), - server: Joi.string() - .uri({ scheme: ['http', 'https'] }) - .when('enabled', { - is: Joi.valid(false), - then: Joi.valid(null), - else: Joi.required(), - }), - bypass: Joi.array() - .items(Joi.string().regex(/^[^\s]+$/)) - .when('enabled', { - is: Joi.valid(false), - then: Joi.valid(null), - else: Joi.default([]), - }), - }).default(), - maxScreenshotDimension: Joi.number() - .integer() - .default(1950), - }).default(), - }).default(), - maxAttempts: Joi.number() - .integer() - .greater(0) - .when('$dist', { - is: true, - then: Joi.default(3), - otherwise: Joi.default(1), - }) - .default(), - }).default(), - csv: Joi.object({ - checkForFormulas: Joi.boolean().default(true), - enablePanelActionDownload: Joi.boolean().default(true), - maxSizeBytes: Joi.number() - .integer() - .default(1024 * 1024 * 10), // bytes in a kB * kB in a mB * 10 - scroll: Joi.object({ - duration: Joi.string() - .regex(/^[0-9]+(d|h|m|s|ms|micros|nanos)$/, { name: 'DurationString' }) - .default('30s'), - size: Joi.number() - .integer() - .default(500), - }).default(), - }).default(), - encryptionKey: Joi.when(Joi.ref('$dist'), { - is: true, - then: Joi.string(), - otherwise: Joi.string().default('a'.repeat(32)), - }), - roles: Joi.object({ - allow: Joi.array() - .items(Joi.string()) - .default(['reporting_user']), - }).default(), - index: Joi.string().default('.reporting'), - poll: Joi.object({ - jobCompletionNotifier: Joi.object({ - interval: Joi.number() - .integer() - .default(10000), - intervalErrorMultiplier: Joi.number() - .integer() - .default(5), - }).default(), - jobsRefresh: Joi.object({ - interval: Joi.number() - .integer() - .default(5000), - intervalErrorMultiplier: Joi.number() - .integer() - .default(5), - }).default(), - }).default(), - }).default(); -} diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts index 468caf93ec5dd..9085fb3cbc876 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.test.ts @@ -5,33 +5,27 @@ */ import { cryptoFactory } from '../../../server/lib/crypto'; -import { createMockServer } from '../../../test_helpers'; import { Logger } from '../../../types'; import { decryptJobHeaders } from './decrypt_job_headers'; -let mockServer: any; -beforeEach(() => { - mockServer = createMockServer(''); -}); - -const encryptHeaders = async (headers: Record) => { - const crypto = cryptoFactory(mockServer); +const encryptHeaders = async (encryptionKey: string, headers: Record) => { + const crypto = cryptoFactory(encryptionKey); return await crypto.encrypt(headers); }; describe('headers', () => { test(`fails if it can't decrypt headers`, async () => { - await expect( + const getDecryptedHeaders = () => decryptJobHeaders({ + encryptionKey: 'abcsecretsauce', job: { headers: 'Q53+9A+zf+Xe+ceR/uB/aR/Sw/8e+M+qR+WiG+8z+EY+mo+HiU/zQL+Xn', }, logger: ({ error: jest.fn(), } as unknown) as Logger, - server: mockServer, - }) - ).rejects.toMatchInlineSnapshot( + }); + await expect(getDecryptedHeaders()).rejects.toMatchInlineSnapshot( `[Error: Failed to decrypt report job data. Please ensure that xpack.reporting.encryptionKey is set and re-generate this report. Error: Invalid IV length]` ); }); @@ -42,15 +36,15 @@ describe('headers', () => { baz: 'quix', }; - const encryptedHeaders = await encryptHeaders(headers); + const encryptedHeaders = await encryptHeaders('abcsecretsauce', headers); const decryptedHeaders = await decryptJobHeaders({ + encryptionKey: 'abcsecretsauce', job: { title: 'cool-job-bro', type: 'csv', headers: encryptedHeaders, }, logger: {} as Logger, - server: mockServer, }); expect(decryptedHeaders).toEqual(headers); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts index 436b2c2dab1ad..6f415d7ee5ea9 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/decrypt_job_headers.ts @@ -6,7 +6,7 @@ import { i18n } from '@kbn/i18n'; import { cryptoFactory } from '../../../server/lib/crypto'; -import { CryptoFactory, ServerFacade, Logger } from '../../../types'; +import { CryptoFactory, Logger } from '../../../types'; interface HasEncryptedHeaders { headers?: string; @@ -17,15 +17,15 @@ export const decryptJobHeaders = async < JobParamsType, JobDocPayloadType extends HasEncryptedHeaders >({ - server, + encryptionKey, job, logger, }: { - server: ServerFacade; + encryptionKey?: string; job: JobDocPayloadType; logger: Logger; }): Promise> => { - const crypto: CryptoFactory = cryptoFactory(server); + const crypto: CryptoFactory = cryptoFactory(encryptionKey); try { const decryptedHeaders: Record = await crypto.decrypt(job.headers); return decryptedHeaders; diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts index eedb742ad7597..09527621fa49f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.test.ts @@ -4,27 +4,33 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createMockReportingCore, createMockServer } from '../../../test_helpers'; -import { ReportingCore } from '../../../server'; +import sinon from 'sinon'; +import { createMockReportingCore } from '../../../test_helpers'; +import { ReportingConfig, ReportingCore } from '../../../server/types'; import { JobDocPayload } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './index'; +let mockConfig: ReportingConfig; let mockReportingPlugin: ReportingCore; -let mockServer: any; + +const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ + get: mockConfigGet, + kbnConfig: { get: mockConfigGet }, +}); + beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(); - mockServer = createMockServer(''); + + const mockConfigGet = sinon + .stub() + .withArgs('kibanaServer', 'hostname') + .returns('custom-hostname'); + mockConfig = getMockConfig(mockConfigGet); }); describe('conditions', () => { test(`uses hostname from reporting config if set`, async () => { - const settings: any = { - 'xpack.reporting.kibanaServer.hostname': 'custom-hostname', - }; - - mockServer = createMockServer({ settings }); - const permittedHeaders = { foo: 'bar', baz: 'quix', @@ -33,121 +39,20 @@ describe('conditions', () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); expect(conditionalHeaders.conditions.hostname).toEqual( - mockServer.config().get('xpack.reporting.kibanaServer.hostname') + mockConfig.get('kibanaServer', 'hostname') ); - }); - - test(`uses hostname from server.config if reporting config not set`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.hostname).toEqual(mockServer.config().get('server.host')); - }); - - test(`uses port from reporting config if set`, async () => { - const settings = { - 'xpack.reporting.kibanaServer.port': 443, - }; - - mockServer = createMockServer({ settings }); - - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.port).toEqual( - mockServer.config().get('xpack.reporting.kibanaServer.port') + expect(conditionalHeaders.conditions.port).toEqual(mockConfig.get('kibanaServer', 'port')); + expect(conditionalHeaders.conditions.protocol).toEqual( + mockConfig.get('kibanaServer', 'protocol') ); - }); - - test(`uses port from server if reporting config not set`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.port).toEqual(mockServer.config().get('server.port')); - }); - - test(`uses basePath from server config`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - expect(conditionalHeaders.conditions.basePath).toEqual( - mockServer.config().get('server.basePath') + mockConfig.kbnConfig.get('server', 'basePath') ); }); - - test(`uses protocol from reporting config if set`, async () => { - const settings = { - 'xpack.reporting.kibanaServer.protocol': 'https', - }; - - mockServer = createMockServer({ settings }); - - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.protocol).toEqual( - mockServer.config().get('xpack.reporting.kibanaServer.protocol') - ); - }); - - test(`uses protocol from server.info`, async () => { - const permittedHeaders = { - foo: 'bar', - baz: 'quix', - }; - - const conditionalHeaders = await getConditionalHeaders({ - job: {} as JobDocPayload, - filteredHeaders: permittedHeaders, - server: mockServer, - }); - - expect(conditionalHeaders.conditions.protocol).toEqual(mockServer.info.protocol); - }); }); test('uses basePath from job when creating saved object service', async () => { @@ -161,14 +66,14 @@ test('uses basePath from job when creating saved object service', async () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); const jobBasePath = '/sbp/s/marketing'; await getCustomLogo({ reporting: mockReportingPlugin, job: { basePath: jobBasePath } as JobDocPayloadPDF, conditionalHeaders, - server: mockServer, + config: mockConfig, }); const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; @@ -179,6 +84,11 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const mockGetSavedObjectsClient = jest.fn(); mockReportingPlugin.getSavedObjectsClient = mockGetSavedObjectsClient; + const mockConfigGet = sinon.stub(); + mockConfigGet.withArgs('kibanaServer', 'hostname').returns('localhost'); + mockConfigGet.withArgs('server', 'basePath').returns('/sbp'); + mockConfig = getMockConfig(mockConfigGet); + const permittedHeaders = { foo: 'bar', baz: 'quix', @@ -186,14 +96,14 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); await getCustomLogo({ reporting: mockReportingPlugin, job: {} as JobDocPayloadPDF, conditionalHeaders, - server: mockServer, + config: mockConfig, }); const getBasePath = mockGetSavedObjectsClient.mock.calls[0][0].getBasePath; @@ -225,19 +135,26 @@ test(`uses basePath from server if job doesn't have a basePath when creating sav describe('config formatting', () => { test(`lowercases server.host`, async () => { - mockServer = createMockServer({ settings: { 'server.host': 'COOL-HOSTNAME' } }); + const mockConfigGet = sinon + .stub() + .withArgs('server', 'host') + .returns('COOL-HOSTNAME'); + mockConfig = getMockConfig(mockConfigGet); + const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayload, filteredHeaders: {}, - server: mockServer, + config: mockConfig, }); expect(conditionalHeaders.conditions.hostname).toEqual('cool-hostname'); }); - test(`lowercases xpack.reporting.kibanaServer.hostname`, async () => { - mockServer = createMockServer({ - settings: { 'xpack.reporting.kibanaServer.hostname': 'GREAT-HOSTNAME' }, - }); + test(`lowercases kibanaServer.hostname`, async () => { + const mockConfigGet = sinon + .stub() + .withArgs('kibanaServer', 'hostname') + .returns('GREAT-HOSTNAME'); + mockConfig = getMockConfig(mockConfigGet); const conditionalHeaders = await getConditionalHeaders({ job: { title: 'cool-job-bro', @@ -249,7 +166,7 @@ describe('config formatting', () => { }, }, filteredHeaders: {}, - server: mockServer, + config: mockConfig, }); expect(conditionalHeaders.conditions.hostname).toEqual('great-hostname'); }); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts index 975060a8052f0..bd7999d697ca9 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_conditional_headers.ts @@ -3,29 +3,31 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ConditionalHeaders, ServerFacade } from '../../../types'; + +import { ReportingConfig } from '../../../server/types'; +import { ConditionalHeaders } from '../../../types'; export const getConditionalHeaders = ({ - server, + config, job, filteredHeaders, }: { - server: ServerFacade; + config: ReportingConfig; job: JobDocPayloadType; filteredHeaders: Record; }) => { - const config = server.config(); + const { kbnConfig } = config; const [hostname, port, basePath, protocol] = [ - config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), - config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), - config.get('server.basePath'), - config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, + config.get('kibanaServer', 'hostname'), + config.get('kibanaServer', 'port'), + kbnConfig.get('server', 'basePath'), + config.get('kibanaServer', 'protocol'), ] as [string, number, string, string]; const conditionalHeaders: ConditionalHeaders = { headers: filteredHeaders, conditions: { - hostname: hostname.toLowerCase(), + hostname: hostname ? hostname.toLowerCase() : hostname, port, basePath, protocol, diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts index fa53f474dfba7..7c4c889e3e14f 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.test.ts @@ -5,16 +5,18 @@ */ import { ReportingCore } from '../../../server'; -import { createMockReportingCore, createMockServer } from '../../../test_helpers'; -import { ServerFacade } from '../../../types'; +import { createMockReportingCore } from '../../../test_helpers'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getConditionalHeaders, getCustomLogo } from './index'; +const mockConfigGet = jest.fn().mockImplementation((key: string) => { + return 'localhost'; +}); +const mockConfig = { get: mockConfigGet, kbnConfig: { get: mockConfigGet } }; + let mockReportingPlugin: ReportingCore; -let mockServer: ServerFacade; beforeEach(async () => { mockReportingPlugin = await createMockReportingCore(); - mockServer = createMockServer(''); }); test(`gets logo from uiSettings`, async () => { @@ -37,14 +39,14 @@ test(`gets logo from uiSettings`, async () => { const conditionalHeaders = await getConditionalHeaders({ job: {} as JobDocPayloadPDF, filteredHeaders: permittedHeaders, - server: mockServer, + config: mockConfig, }); const { logo } = await getCustomLogo({ reporting: mockReportingPlugin, + config: mockConfig, job: {} as JobDocPayloadPDF, conditionalHeaders, - server: mockServer, }); expect(mockGet).toBeCalledWith('xpackReporting:customPdfLogo'); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts index 7af5edab41ab7..a13f992e7867c 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_custom_logo.ts @@ -5,23 +5,22 @@ */ import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../common/constants'; -import { ReportingCore } from '../../../server'; -import { ConditionalHeaders, ServerFacade } from '../../../types'; +import { ReportingConfig, ReportingCore } from '../../../server/types'; +import { ConditionalHeaders } from '../../../types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; // Logo is PDF only export const getCustomLogo = async ({ reporting, - server, + config, job, conditionalHeaders, }: { reporting: ReportingCore; - server: ServerFacade; + config: ReportingConfig; job: JobDocPayloadPDF; conditionalHeaders: ConditionalHeaders; }) => { - const serverBasePath: string = server.config().get('server.basePath'); - + const serverBasePath: string = config.kbnConfig.get('server', 'basePath'); const fakeRequest: any = { headers: conditionalHeaders.headers, // This is used by the spaces SavedObjectClientWrapper to determine the existing space. diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts index 27e772195f726..5f55617724ff6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.test.ts @@ -4,29 +4,41 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createMockServer } from '../../../test_helpers'; -import { ServerFacade } from '../../../types'; +import { ReportingConfig } from '../../../server'; import { JobDocPayloadPNG } from '../../png/types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; import { getFullUrls } from './get_full_urls'; interface FullUrlsOpts { job: JobDocPayloadPNG & JobDocPayloadPDF; - server: ServerFacade; - conditionalHeaders: any; + config: ReportingConfig; } -let mockServer: any; +let mockConfig: ReportingConfig; +const getMockConfig = (mockConfigGet: jest.Mock) => { + return { + get: mockConfigGet, + kbnConfig: { get: mockConfigGet }, + }; +}; + beforeEach(() => { - mockServer = createMockServer(''); + const reportingConfig: Record = { + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + 'server.basePath': '/sbp', + }; + const mockConfigGet = jest.fn().mockImplementation((...keys: string[]) => { + return reportingConfig[keys.join('.') as string]; + }); + mockConfig = getMockConfig(mockConfigGet); }); +const getMockJob = (base: object) => base as JobDocPayloadPNG & JobDocPayloadPDF; + test(`fails if no URL is passed`, async () => { - const fn = () => - getFullUrls({ - job: {}, - server: mockServer, - } as FullUrlsOpts); + const fn = () => getFullUrls({ job: getMockJob({}), config: mockConfig } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid URL fields found in Job Params! Expected \`job.relativeUrl: string\` or \`job.relativeUrls: string[]\`"` ); @@ -37,8 +49,8 @@ test(`fails if URLs are file-protocols for PNGs`, async () => { const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => getFullUrls({ - job: { relativeUrl, forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl, forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` @@ -51,8 +63,8 @@ test(`fails if URLs are absolute for PNGs`, async () => { 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => getFullUrls({ - job: { relativeUrl, forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl, forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` @@ -64,11 +76,11 @@ test(`fails if URLs are file-protocols for PDF`, async () => { const relativeUrl = 'file://etc/passwd/#/something'; const fn = () => getFullUrls({ - job: { + job: getMockJob({ relativeUrls: [relativeUrl], forceNow, - }, - server: mockServer, + }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: file://etc/passwd/#/something"` @@ -81,11 +93,11 @@ test(`fails if URLs are absolute for PDF`, async () => { 'http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something'; const fn = () => getFullUrls({ - job: { + job: getMockJob({ relativeUrls: [relativeUrl], forceNow, - }, - server: mockServer, + }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something"` @@ -102,8 +114,8 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { const fn = () => getFullUrls({ - job: { relativeUrls, forceNow }, - server: mockServer, + job: getMockJob({ relativeUrls, forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"Found invalid URL(s), all URLs must be relative: http://169.254.169.254/latest/meta-data/iam/security-credentials/profileName/#/something file://etc/passwd/#/something"` @@ -113,8 +125,8 @@ test(`fails if any URLs are absolute or file's for PDF`, async () => { test(`fails if URL does not route to a visualization`, async () => { const fn = () => getFullUrls({ - job: { relativeUrl: '/app/phoney' }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/phoney' }), + config: mockConfig, } as FullUrlsOpts); expect(fn).toThrowErrorMatchingInlineSnapshot( `"No valid hash in the URL! A hash is expected for the application to route to the intended visualization."` @@ -124,8 +136,8 @@ test(`fails if URL does not route to a visualization`, async () => { test(`adds forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: { relativeUrl: '/app/kibana#/something', forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/kibana#/something', forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(urls[0]).toEqual( @@ -137,8 +149,8 @@ test(`appends forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: { relativeUrl: '/app/kibana#/something?_g=something', forceNow }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/kibana#/something?_g=something', forceNow }), + config: mockConfig, } as FullUrlsOpts); expect(urls[0]).toEqual( @@ -148,8 +160,8 @@ test(`appends forceNow to hash's query, if it exists`, async () => { test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { const urls = await getFullUrls({ - job: { relativeUrl: '/app/kibana#/something' }, - server: mockServer, + job: getMockJob({ relativeUrl: '/app/kibana#/something' }), + config: mockConfig, } as FullUrlsOpts); expect(urls[0]).toEqual('http://localhost:5601/sbp/app/kibana#/something'); @@ -158,7 +170,7 @@ test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { test(`adds forceNow to each of multiple urls`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; const urls = await getFullUrls({ - job: { + job: getMockJob({ relativeUrls: [ '/app/kibana#/something_aaa', '/app/kibana#/something_bbb', @@ -166,8 +178,8 @@ test(`adds forceNow to each of multiple urls`, async () => { '/app/kibana#/something_ddd', ], forceNow, - }, - server: mockServer, + }), + config: mockConfig, } as FullUrlsOpts); expect(urls).toEqual([ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts index ca64d8632dbfe..c4b6f31019fdf 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/execute_job/get_full_urls.ts @@ -12,7 +12,7 @@ import { } from 'url'; import { getAbsoluteUrlFactory } from '../../../common/get_absolute_url'; import { validateUrls } from '../../../common/validate_urls'; -import { ServerFacade } from '../../../types'; +import { ReportingConfig } from '../../../server/types'; import { JobDocPayloadPNG } from '../../png/types'; import { JobDocPayloadPDF } from '../../printable_pdf/types'; @@ -24,19 +24,23 @@ function isPdfJob(job: JobDocPayloadPNG | JobDocPayloadPDF): job is JobDocPayloa } export function getFullUrls({ - server, + config, job, }: { - server: ServerFacade; + config: ReportingConfig; job: JobDocPayloadPDF | JobDocPayloadPNG; }) { - const config = server.config(); - + const [basePath, protocol, hostname, port] = [ + config.kbnConfig.get('server', 'basePath'), + config.get('kibanaServer', 'protocol'), + config.get('kibanaServer', 'hostname'), + config.get('kibanaServer', 'port'), + ] as string[]; const getAbsoluteUrl = getAbsoluteUrlFactory({ - defaultBasePath: config.get('server.basePath'), - protocol: config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, - hostname: config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), - port: config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), + defaultBasePath: basePath, + protocol, + hostname, + port, }); // PDF and PNG job params put in the url differently diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts index 0cb83352d4606..07fceb603e451 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/create_layout.ts @@ -3,17 +3,18 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ServerFacade } from '../../../types'; + +import { CaptureConfig } from '../../../server/types'; import { LayoutTypes } from '../constants'; import { Layout, LayoutParams } from './layout'; import { PreserveLayout } from './preserve_layout'; import { PrintLayout } from './print_layout'; -export function createLayout(server: ServerFacade, layoutParams?: LayoutParams): Layout { +export function createLayout(captureConfig: CaptureConfig, layoutParams?: LayoutParams): Layout { if (layoutParams && layoutParams.id === LayoutTypes.PRESERVE_LAYOUT) { return new PreserveLayout(layoutParams.dimensions); } // this is the default because some jobs won't have anything specified - return new PrintLayout(server); + return new PrintLayout(captureConfig); } diff --git a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts index 6007c2960057a..98d8dc2983653 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/layouts/print_layout.ts @@ -3,11 +3,12 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import path from 'path'; import { EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; -import { LevelLogger } from '../../../server/lib'; import { HeadlessChromiumDriver } from '../../../server/browsers'; -import { ServerFacade } from '../../../types'; +import { LevelLogger } from '../../../server/lib'; +import { ReportingConfigType } from '../../../server/core'; import { LayoutTypes } from '../constants'; import { getDefaultLayoutSelectors, Layout, LayoutSelectorDictionary, Size } from './layout'; import { CaptureConfig } from './types'; @@ -20,9 +21,9 @@ export class PrintLayout extends Layout { public readonly groupCount = 2; private captureConfig: CaptureConfig; - constructor(server: ServerFacade) { + constructor(captureConfig: ReportingConfigType['capture']) { super(LayoutTypes.PRINT); - this.captureConfig = server.config().get('xpack.reporting.capture'); + this.captureConfig = captureConfig; } public getCssOverridesPath() { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts index 16eb433e8a75e..57d025890d3e2 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/get_number_of_items.ts @@ -7,17 +7,16 @@ import { i18n } from '@kbn/i18n'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; -import { ServerFacade } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_GETNUMBEROFITEMS, CONTEXT_READMETADATA } from './constants'; export const getNumberOfItems = async ( - server: ServerFacade, + captureConfig: CaptureConfig, browser: HeadlessBrowser, layout: LayoutInstance, logger: LevelLogger ): Promise => { - const config = server.config(); const { renderComplete: renderCompleteSelector, itemsCountAttribute } = layout.selectors; let itemsCount: number; @@ -33,7 +32,7 @@ export const getNumberOfItems = async ( // we have to use this hint to wait for all of them await browser.waitForSelector( `${renderCompleteSelector},[${itemsCountAttribute}]`, - { timeout: config.get('xpack.reporting.capture.timeouts.waitForElements') }, + { timeout: captureConfig.timeouts.waitForElements }, { context: CONTEXT_READMETADATA }, logger ); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts index 13d07bcdd6baf..75ac3dca4ffa0 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.test.ts @@ -19,12 +19,9 @@ import * as Rx from 'rxjs'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { loggingServiceMock } from '../../../../../../../../src/core/server/mocks'; import { LevelLogger } from '../../../../server/lib'; -import { - createMockBrowserDriverFactory, - createMockLayoutInstance, - createMockServer, -} from '../../../../test_helpers'; +import { createMockBrowserDriverFactory, createMockLayoutInstance } from '../../../../test_helpers'; import { ConditionalHeaders, HeadlessChromiumDriver } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; import { screenshotsObservableFactory } from './observable'; import { ElementsPositionAndAttribute } from './types'; @@ -34,8 +31,8 @@ import { ElementsPositionAndAttribute } from './types'; const mockLogger = jest.fn(loggingServiceMock.create); const logger = new LevelLogger(mockLogger()); -const __LEGACY = createMockServer({ settings: { 'xpack.reporting.capture': { loadDelay: 13 } } }); -const mockLayout = createMockLayoutInstance(__LEGACY); +const mockConfig = { timeouts: { openUrl: 13 } } as CaptureConfig; +const mockLayout = createMockLayoutInstance(mockConfig); /* * Tests @@ -48,7 +45,7 @@ describe('Screenshot Observable Pipeline', () => { }); it('pipelines a single url into screenshot and timeRange', async () => { - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index.htm'], @@ -86,7 +83,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const result = await getScreenshots$({ logger, urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php'], @@ -136,7 +133,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, @@ -197,7 +194,7 @@ describe('Screenshot Observable Pipeline', () => { }); // test - const getScreenshots$ = screenshotsObservableFactory(__LEGACY, mockBrowserDriverFactory); + const getScreenshots$ = screenshotsObservableFactory(mockConfig, mockBrowserDriverFactory); const getScreenshot = async () => { return await getScreenshots$({ logger, diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts index 44c04c763f840..53a11c18abd79 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/observable.ts @@ -6,24 +6,22 @@ import * as Rx from 'rxjs'; import { catchError, concatMap, first, mergeMap, take, takeUntil, toArray } from 'rxjs/operators'; -import { CaptureConfig, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; +import { HeadlessChromiumDriverFactory } from '../../../../types'; import { getElementPositionAndAttributes } from './get_element_position_data'; import { getNumberOfItems } from './get_number_of_items'; import { getScreenshots } from './get_screenshots'; import { getTimeRange } from './get_time_range'; +import { injectCustomCss } from './inject_css'; import { openUrl } from './open_url'; import { ScreenSetupData, ScreenshotObservableOpts, ScreenshotResults } from './types'; import { waitForRenderComplete } from './wait_for_render'; import { waitForVisualizations } from './wait_for_visualizations'; -import { injectCustomCss } from './inject_css'; export function screenshotsObservableFactory( - server: ServerFacade, + captureConfig: CaptureConfig, browserDriverFactory: HeadlessChromiumDriverFactory ) { - const config = server.config(); - const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); - return function screenshotsObservable({ logger, urls, @@ -41,13 +39,13 @@ export function screenshotsObservableFactory( mergeMap(({ driver, exit$ }) => { const setup$: Rx.Observable = Rx.of(1).pipe( takeUntil(exit$), - mergeMap(() => openUrl(server, driver, url, conditionalHeaders, logger)), - mergeMap(() => getNumberOfItems(server, driver, layout, logger)), + mergeMap(() => openUrl(captureConfig, driver, url, conditionalHeaders, logger)), + mergeMap(() => getNumberOfItems(captureConfig, driver, layout, logger)), mergeMap(async itemsCount => { const viewport = layout.getViewport(itemsCount); await Promise.all([ driver.setViewport(viewport, logger), - waitForVisualizations(server, driver, itemsCount, layout, logger), + waitForVisualizations(captureConfig, driver, itemsCount, layout, logger), ]); }), mergeMap(async () => { @@ -60,7 +58,7 @@ export function screenshotsObservableFactory( await layout.positionElements(driver, logger); } - await waitForRenderComplete(driver, layout, captureConfig, logger); + await waitForRenderComplete(captureConfig, driver, layout, logger); }), mergeMap(async () => { return await Promise.all([ diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts index fbae1f91a7a6a..a484dfb243563 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/open_url.ts @@ -5,27 +5,26 @@ */ import { i18n } from '@kbn/i18n'; -import { ConditionalHeaders, ServerFacade } from '../../../../types'; -import { LevelLogger } from '../../../../server/lib'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; +import { LevelLogger } from '../../../../server/lib'; +import { CaptureConfig } from '../../../../server/types'; +import { ConditionalHeaders } from '../../../../types'; import { PAGELOAD_SELECTOR } from '../../constants'; export const openUrl = async ( - server: ServerFacade, + captureConfig: CaptureConfig, browser: HeadlessBrowser, url: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise => { - const config = server.config(); - try { await browser.open( url, { conditionalHeaders, waitForSelector: PAGELOAD_SELECTOR, - timeout: config.get('xpack.reporting.capture.timeouts.openUrl'), + timeout: captureConfig.timeouts.openUrl, }, logger ); diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts index ab81a952f345c..76613c2d631d6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/types.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElementPosition, ConditionalHeaders } from '../../../../types'; import { LevelLogger } from '../../../../server/lib'; +import { ConditionalHeaders, ElementPosition } from '../../../../types'; import { LayoutInstance } from '../../layouts/layout'; export interface ScreenshotObservableOpts { diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts index 2f6dc2829dfd8..069896c8d9e90 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_render.ts @@ -5,16 +5,16 @@ */ import { i18n } from '@kbn/i18n'; -import { CaptureConfig } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; +import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORRENDER } from './constants'; export const waitForRenderComplete = async ( + captureConfig: CaptureConfig, browser: HeadlessBrowser, layout: LayoutInstance, - captureConfig: CaptureConfig, logger: LevelLogger ) => { logger.debug( diff --git a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts index 93ad40026dff8..7960e1552e559 100644 --- a/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts +++ b/x-pack/legacy/plugins/reporting/export_types/common/lib/screenshots/wait_for_visualizations.ts @@ -5,9 +5,9 @@ */ import { i18n } from '@kbn/i18n'; -import { ServerFacade } from '../../../../types'; import { HeadlessChromiumDriver as HeadlessBrowser } from '../../../../server/browsers'; import { LevelLogger } from '../../../../server/lib'; +import { CaptureConfig } from '../../../../server/types'; import { LayoutInstance } from '../../layouts/layout'; import { CONTEXT_WAITFORELEMENTSTOBEINDOM } from './constants'; @@ -23,13 +23,12 @@ const getCompletedItemsCount = ({ renderCompleteSelector }: SelectorArgs) => { * 3. Wait for the render complete event to be fired once for each item */ export const waitForVisualizations = async ( - server: ServerFacade, + captureConfig: CaptureConfig, browser: HeadlessBrowser, itemsCount: number, layout: LayoutInstance, logger: LevelLogger ): Promise => { - const config = server.config(); const { renderComplete: renderCompleteSelector } = layout.selectors; logger.debug( @@ -45,7 +44,7 @@ export const waitForVisualizations = async ( fn: getCompletedItemsCount, args: [{ renderCompleteSelector }], toEqual: itemsCount, - timeout: config.get('xpack.reporting.capture.timeouts.renderComplete'), + timeout: captureConfig.timeouts.renderComplete, }, { context: CONTEXT_WAITFORELEMENTSTOBEINDOM }, logger diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts index 7ea67277015ab..b87403ac74f89 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/create_job.ts @@ -11,14 +11,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, - ServerFacade, } from '../../../types'; import { JobParamsDiscoverCsv } from '../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJob( jobParams: JobParamsDiscoverCsv, diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js index f12916b734dbf..7dfa705901fbe 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.test.js @@ -36,11 +36,12 @@ describe('CSV Execute Job', function() { let defaultElasticsearchResponse; let encryptedHeaders; - let cancellationToken; - let mockReportingPlugin; - let mockServer; let clusterStub; + let configGetStub; + let mockReportingConfig; + let mockReportingPlugin; let callAsCurrentUserStub; + let cancellationToken; const mockElasticsearch = { dataClient: { @@ -58,7 +59,17 @@ describe('CSV Execute Job', function() { beforeEach(async function() { mockReportingPlugin = await createMockReportingCore(); - mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; + + configGetStub = sinon.stub(); + configGetStub.withArgs('encryptionKey').returns(encryptionKey); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(1024 * 1000); // 1mB + configGetStub.withArgs('csv', 'scroll').returns({}); + mockReportingConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; + + mockReportingPlugin.getConfig = () => Promise.resolve(mockReportingConfig); + mockReportingPlugin.getUiSettingsServiceFactory = () => Promise.resolve(mockUiSettingsClient); + mockReportingPlugin.getElasticsearchService = () => Promise.resolve(mockElasticsearch); + cancellationToken = new CancellationToken(); defaultElasticsearchResponse = { @@ -75,7 +86,6 @@ describe('CSV Execute Job', function() { .stub(clusterStub, 'callAsCurrentUser') .resolves(defaultElasticsearchResponse); - const configGetStub = sinon.stub(); mockUiSettingsClient.get.withArgs('csv:separator').returns(','); mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); @@ -93,36 +103,11 @@ describe('CSV Execute Job', function() { return fieldFormatsRegistry; }, }); - - mockServer = { - config: function() { - return { - get: configGetStub, - }; - }, - }; - mockServer - .config() - .get.withArgs('xpack.reporting.encryptionKey') - .returns(encryptionKey); - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(1024 * 1000); // 1mB - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({}); }); describe('basic Elasticsearch call behavior', function() { it('should decrypt encrypted headers and pass to callAsCurrentUser', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -138,12 +123,7 @@ describe('CSV Execute Job', function() { testBody: true, }; - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const job = { headers: encryptedHeaders, fields: [], @@ -170,12 +150,7 @@ describe('CSV Execute Job', function() { _scroll_id: scrollId, }); callAsCurrentUserStub.onSecondCall().resolves(defaultElasticsearchResponse); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -189,12 +164,7 @@ describe('CSV Execute Job', function() { }); it('should not execute scroll if there are no hits from the search', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -224,12 +194,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -264,12 +229,7 @@ describe('CSV Execute Job', function() { _scroll_id: lastScrollId, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); await executeJob( 'job456', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -297,12 +257,7 @@ describe('CSV Execute Job', function() { _scroll_id: lastScrollId, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -321,10 +276,7 @@ describe('CSV Execute Job', function() { describe('Cells with formula values', () => { it('returns `csv_contains_formulas` when cells contain formulas', async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(true); + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], @@ -332,12 +284,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -354,10 +301,7 @@ describe('CSV Execute Job', function() { }); it('returns warnings when headings contain formulas', async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(true); + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { '=SUM(A1:A2)': 'foo', two: 'bar' } }], @@ -365,12 +309,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['=SUM(A1:A2)', 'two'], @@ -387,10 +326,7 @@ describe('CSV Execute Job', function() { }); it('returns no warnings when cells have no formulas', async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(true); + configGetStub.withArgs('csv', 'checkForFormulas').returns(true); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -398,12 +334,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -420,10 +351,7 @@ describe('CSV Execute Job', function() { }); it('returns no warnings when configured not to', async () => { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.checkForFormulas') - .returns(false); + configGetStub.withArgs('csv', 'checkForFormulas').returns(false); callAsCurrentUserStub.onFirstCall().returns({ hits: { hits: [{ _source: { one: '=SUM(A1:A2)', two: 'bar' } }], @@ -431,12 +359,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -456,12 +379,7 @@ describe('CSV Execute Job', function() { describe('Elasticsearch call errors', function() { it('should reject Promise if search call errors out', async function() { callAsCurrentUserStub.rejects(new Error()); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -480,12 +398,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); callAsCurrentUserStub.onSecondCall().rejects(new Error()); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -506,12 +419,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -532,12 +440,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -565,12 +468,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -598,12 +496,7 @@ describe('CSV Execute Job', function() { _scroll_id: undefined, }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: [], @@ -639,12 +532,7 @@ describe('CSV Execute Job', function() { }); it('should stop calling Elasticsearch when cancellationToken.cancel is called', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -659,12 +547,7 @@ describe('CSV Execute Job', function() { }); it(`shouldn't call clearScroll if it never got a scrollId`, async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -678,12 +561,7 @@ describe('CSV Execute Job', function() { }); it('should call clearScroll if it got a scrollId', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); executeJob( 'job345', { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, @@ -701,12 +579,7 @@ describe('CSV Execute Job', function() { describe('csv content', function() { it('should write column headers to output, even if there are no results', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -718,12 +591,7 @@ describe('CSV Execute Job', function() { it('should use custom uiSettings csv:separator for header', async function() { mockUiSettingsClient.get.withArgs('csv:separator').returns(';'); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -735,12 +603,7 @@ describe('CSV Execute Job', function() { it('should escape column headers if uiSettings csv:quoteValues is true', async function() { mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(true); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -752,12 +615,7 @@ describe('CSV Execute Job', function() { it(`shouldn't escape column headers if uiSettings csv:quoteValues is false`, async function() { mockUiSettingsClient.get.withArgs('csv:quoteValues').returns(false); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -768,12 +626,7 @@ describe('CSV Execute Job', function() { }); it('should write column headers to output, when there are results', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ one: '1', two: '2' }], @@ -793,12 +646,7 @@ describe('CSV Execute Job', function() { }); it('should use comma separated values of non-nested fields from _source', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -819,12 +667,7 @@ describe('CSV Execute Job', function() { }); it('should concatenate the hits from multiple responses', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -852,12 +695,7 @@ describe('CSV Execute Job', function() { }); it('should use field formatters to format fields', async function() { - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -897,17 +735,9 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(1); - - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(1); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -935,17 +765,9 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(9); - - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); + + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -973,10 +795,7 @@ describe('CSV Execute Job', function() { let maxSizeReached; beforeEach(async function() { - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(9); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -985,12 +804,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1020,10 +834,7 @@ describe('CSV Execute Job', function() { beforeEach(async function() { mockReportingPlugin.getUiSettingsServiceFactory = () => mockUiSettingsClient; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.maxSizeBytes') - .returns(18); + configGetStub.withArgs('csv', 'maxSizeBytes').returns(18); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -1032,12 +843,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1065,10 +871,7 @@ describe('CSV Execute Job', function() { describe('scroll settings', function() { it('passes scroll duration to initial search call', async function() { const scrollDuration = 'test'; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({ duration: scrollDuration }); + configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); callAsCurrentUserStub.onFirstCall().returns({ hits: { @@ -1077,12 +880,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1099,10 +897,7 @@ describe('CSV Execute Job', function() { it('passes scroll size to initial search call', async function() { const scrollSize = 100; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({ size: scrollSize }); + configGetStub.withArgs('csv', 'scroll').returns({ size: scrollSize }); callAsCurrentUserStub.onFirstCall().resolves({ hits: { @@ -1111,12 +906,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], @@ -1133,10 +923,7 @@ describe('CSV Execute Job', function() { it('passes scroll duration to subsequent scroll call', async function() { const scrollDuration = 'test'; - mockServer - .config() - .get.withArgs('xpack.reporting.csv.scroll') - .returns({ duration: scrollDuration }); + configGetStub.withArgs('csv', 'scroll').returns({ duration: scrollDuration }); callAsCurrentUserStub.onFirstCall().resolves({ hits: { @@ -1145,12 +932,7 @@ describe('CSV Execute Job', function() { _scroll_id: 'scrollId', }); - const executeJob = await executeJobFactory( - mockReportingPlugin, - mockServer, - mockElasticsearch, - mockLogger - ); + const executeJob = await executeJobFactory(mockReportingPlugin, mockLogger); const jobParams = { headers: encryptedHeaders, fields: ['one', 'two'], diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts index 1579985891053..a8249e5810d3c 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/server/execute_job.ts @@ -6,32 +6,26 @@ import { i18n } from '@kbn/i18n'; import Hapi from 'hapi'; -import { - ElasticsearchServiceSetup, - IUiSettingsClient, - KibanaRequest, -} from '../../../../../../../src/core/server'; +import { IUiSettingsClient, KibanaRequest } from '../../../../../../../src/core/server'; import { CSV_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; import { getFieldFormats } from '../../../server/services'; -import { ESQueueWorkerExecuteFn, ExecuteJobFactory, Logger, ServerFacade } from '../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, Logger } from '../../../types'; import { JobDocPayloadDiscoverCsv } from '../types'; import { fieldFormatMapFactory } from './lib/field_format_map'; import { createGenerateCsv } from './lib/generate_csv'; export const executeJobFactory: ExecuteJobFactory> = async function executeJobFactoryFn( - reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, - parentLogger: Logger -) { - const crypto = cryptoFactory(server); - const config = server.config(); +>> = async function executeJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { + const [config, elasticsearch] = await Promise.all([ + reporting.getConfig(), + reporting.getElasticsearchService(), + ]); + const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_JOB_TYPE, 'execute-job']); - const serverBasePath = config.get('server.basePath'); + const serverBasePath = config.kbnConfig.get('server', 'basePath'); return async function executeJob( jobId: string, @@ -131,9 +125,9 @@ export const executeJobFactory: ExecuteJobFactory) { const response = await request; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts index 842330fa7c93f..529c195486bc6 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv/types.d.ts @@ -5,7 +5,8 @@ */ import { CancellationToken } from '../../common/cancellation_token'; -import { JobDocPayload, JobParamPostPayload, ConditionalHeaders, RequestFacade } from '../../types'; +import { ScrollConfig } from '../../server/types'; +import { JobDocPayload, JobParamPostPayload } from '../../types'; interface DocValueField { field: string; @@ -106,7 +107,7 @@ export interface GenerateCsvParams { quoteValues: boolean; timezone: string | null; maxSizeBytes: number; - scroll: { duration: string; size: number }; + scroll: ScrollConfig; checkForFormulas?: boolean; }; } diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts index 17072d311b35f..15a1c3e0a9fad 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/create_job/create_job.ts @@ -5,18 +5,11 @@ */ import { notFound, notImplemented } from 'boom'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; import { cryptoFactory } from '../../../../server/lib'; -import { - CreateJobFactory, - ImmediateCreateJobFn, - Logger, - RequestFacade, - ServerFacade, -} from '../../../../types'; +import { CreateJobFactory, ImmediateCreateJobFn, Logger, RequestFacade } from '../../../../types'; import { JobDocPayloadPanelCsv, JobParamsPanelCsv, @@ -37,13 +30,9 @@ interface VisData { export const createJobFactory: CreateJobFactory> = function createJobFactoryFn( - reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, - parentLogger: Logger -) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'create-job']); return async function createJob( diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts index 6bb3e73fcfe84..debcdb47919f1 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/server/execute_job.ts @@ -5,7 +5,6 @@ */ import { i18n } from '@kbn/i18n'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { CONTENT_TYPE_CSV, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; import { ReportingCore } from '../../../server'; import { cryptoFactory } from '../../../server/lib'; @@ -15,7 +14,6 @@ import { JobDocOutput, Logger, RequestFacade, - ServerFacade, } from '../../../types'; import { CsvResultFromSearch } from '../../csv/types'; import { FakeRequest, JobDocPayloadPanelCsv, JobParamsPanelCsv, SearchPanel } from '../types'; @@ -23,15 +21,11 @@ import { createGenerateCsv } from './lib'; export const executeJobFactory: ExecuteJobFactory> = async function executeJobFactoryFn( - reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, - parentLogger: Logger -) { - const crypto = cryptoFactory(server); +>> = async function executeJobFactoryFn(reporting: ReportingCore, parentLogger: Logger) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); const logger = parentLogger.clone([CSV_FROM_SAVEDOBJECT_JOB_TYPE, 'execute-job']); - const generateCsv = createGenerateCsv(reporting, server, elasticsearch, parentLogger); + const generateCsv = await createGenerateCsv(reporting, parentLogger); return async function executeJob( jobId: string | null, @@ -57,11 +51,11 @@ export const executeJobFactory: ExecuteJobFactory; const serializedEncryptedHeaders = job.headers; try { decryptedHeaders = await crypto.decrypt(serializedEncryptedHeaders); @@ -79,10 +73,7 @@ export const executeJobFactory: ExecuteJobFactory { export async function generateCsvSearch( req: RequestFacade, reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: Logger, searchPanel: SearchPanel, jobParams: JobParamsDiscoverCsv @@ -159,11 +153,15 @@ export async function generateCsvSearch( }, }; + const [elasticsearch, config] = await Promise.all([ + reporting.getElasticsearchService(), + reporting.getConfig(), + ]); + const { callAsCurrentUser } = elasticsearch.dataClient.asScoped( KibanaRequest.from(req.getRawRequest()) ); const callCluster = (...params: [string, object]) => callAsCurrentUser(...params); - const config = server.config(); const uiSettings = await getUiSettings(uiConfig); const generateCsvParams: GenerateCsvParams = { @@ -176,8 +174,8 @@ export async function generateCsvSearch( cancellationToken: new CancellationToken(), settings: { ...uiSettings, - maxSizeBytes: config.get('xpack.reporting.csv.maxSizeBytes'), - scroll: config.get('xpack.reporting.csv.scroll'), + maxSizeBytes: config.get('csv', 'maxSizeBytes'), + scroll: config.get('csv', 'scroll'), timezone, }, }; diff --git a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts index 6a7d5f336e238..ab14d2dd8a660 100644 --- a/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/csv_from_savedobject/types.d.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { JobParamPostPayload, JobDocPayload, ServerFacade } from '../../types'; +import { JobDocPayload, JobParamPostPayload } from '../../types'; export interface FakeRequest { - headers: any; - server: ServerFacade; + headers: Record; } export interface JobParamsPostPayloadPanelCsv extends JobParamPostPayload { diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts index a6911e1f14704..9aac612677094 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/create_job/index.ts @@ -12,14 +12,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, - ServerFacade, } from '../../../../types'; import { JobParamsPNG } from '../../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJob( { objectType, title, relativeUrl, browserTimezone, layout }: JobParamsPNG, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js index e2e6ba1b89096..267321d33809d 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.test.js @@ -5,7 +5,6 @@ */ import * as Rx from 'rxjs'; -import { memoize } from 'lodash'; import { createMockReportingCore } from '../../../../test_helpers'; import { cryptoFactory } from '../../../../server/lib/crypto'; import { executeJobFactory } from './index'; @@ -14,63 +13,70 @@ import { LevelLogger } from '../../../../server/lib'; jest.mock('../lib/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); +let mockReporting; +let mockReportingConfig; + const cancellationToken = { on: jest.fn(), }; -let config; -let mockServer; -let mockReporting; +const mockLoggerFactory = { + get: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + })), +}; +const getMockLogger = () => new LevelLogger(mockLoggerFactory); + +const mockEncryptionKey = 'abcabcsecuresecret'; +const encryptHeaders = async headers => { + const crypto = cryptoFactory(mockEncryptionKey); + return await crypto.encrypt(headers); +}; beforeEach(async () => { mockReporting = await createMockReportingCore(); - config = { - 'xpack.reporting.encryptionKey': 'testencryptionkey', + const kbnConfig = { 'server.basePath': '/sbp', - 'server.host': 'localhost', - 'server.port': 5601, }; - mockServer = { - config: memoize(() => ({ get: jest.fn() })), - info: { - protocol: 'http', + const reportingConfig = { + encryptionKey: mockEncryptionKey, + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + }; + + const mockGetConfig = jest.fn(); + mockReportingConfig = { + get: (...keys) => reportingConfig[keys.join('.')], + kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, + }; + mockGetConfig.mockImplementation(() => Promise.resolve(mockReportingConfig)); + mockReporting.getConfig = mockGetConfig; + + const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), }, }; - mockServer.config().get.mockImplementation(key => { - return config[key]; - }); + const mockGetElasticsearch = jest.fn(); + mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); + mockReporting.getElasticsearchService = mockGetElasticsearch; generatePngObservableFactory.mockReturnValue(jest.fn()); }); afterEach(() => generatePngObservableFactory.mockReset()); -const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), - }, -}; - -const getMockLogger = () => new LevelLogger(); - -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockServer); - return await crypto.encrypt(headers); -}; - test(`passes browserTimezone to generatePng`, async () => { const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = generatePngObservableFactory(); generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger() - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const browserTimezone = 'UTC'; await executeJob( 'pngJobId', @@ -88,15 +94,7 @@ test(`passes browserTimezone to generatePng`, async () => { }); test(`returns content_type of application/png`, async () => { - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger(), - { - browserDriverFactory: {}, - } - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const generatePngObservable = generatePngObservableFactory(); @@ -116,15 +114,7 @@ test(`returns content of generatePng getBuffer base64 encoded`, async () => { const generatePngObservable = generatePngObservableFactory(); generatePngObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger(), - { - browserDriverFactory: {}, - } - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const { content } = await executeJob( 'pngJobId', diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts index 8670f0027af89..c53c20efec247 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/execute_job/index.ts @@ -4,18 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PNG_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; -import { - ESQueueWorkerExecuteFn, - ExecuteJobFactory, - JobDocOutput, - Logger, - ServerFacade, -} from '../../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, JobDocOutput, Logger } from '../../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -29,22 +22,24 @@ type QueuedPngExecutorFactory = ExecuteJobFactory = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ server, job, logger })), + mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), - map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), + map(filteredHeaders => getConditionalHeaders({ config, job, filteredHeaders })), mergeMap(conditionalHeaders => { - const urls = getFullUrls({ server, job }); + const urls = getFullUrls({ config, job }); const hashUrl = urls[0]; return generatePngObservable( jobLogger, diff --git a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts index 88e91982adc63..a15541d99f6fb 100644 --- a/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts +++ b/x-pack/legacy/plugins/reporting/export_types/png/server/lib/generate_png.ts @@ -7,17 +7,18 @@ import * as Rx from 'rxjs'; import { map } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory } from '../../../../types'; import { LayoutParams } from '../../../common/layouts/layout'; import { PreserveLayout } from '../../../common/layouts/preserve_layout'; import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; import { ScreenshotResults } from '../../../common/lib/screenshots/types'; export function generatePngObservableFactory( - server: ServerFacade, + captureConfig: CaptureConfig, browserDriverFactory: HeadlessChromiumDriverFactory ) { - const screenshotsObservable = screenshotsObservableFactory(server, browserDriverFactory); + const screenshotsObservable = screenshotsObservableFactory(captureConfig, browserDriverFactory); return function generatePngObservable( logger: LevelLogger, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts index 656c99991e1f6..8e1d5404a5984 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/create_job/index.ts @@ -12,14 +12,14 @@ import { CreateJobFactory, ESQueueCreateJobFn, RequestFacade, - ServerFacade, } from '../../../../types'; import { JobParamsPDF } from '../../types'; export const createJobFactory: CreateJobFactory> = function createJobFactoryFn(reporting: ReportingCore, server: ServerFacade) { - const crypto = cryptoFactory(server); +>> = async function createJobFactoryFn(reporting: ReportingCore) { + const config = await reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); return async function createJobFn( { title, relativeUrls, browserTimezone, layout, objectType }: JobParamsPDF, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js index 484842ba18f2a..29769108bf4ac 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js @@ -5,7 +5,6 @@ */ import * as Rx from 'rxjs'; -import { memoize } from 'lodash'; import { createMockReportingCore } from '../../../../test_helpers'; import { cryptoFactory } from '../../../../server/lib/crypto'; import { executeJobFactory } from './index'; @@ -14,57 +13,65 @@ import { LevelLogger } from '../../../../server/lib'; jest.mock('../lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); +let mockReporting; +let mockReportingConfig; + const cancellationToken = { on: jest.fn(), }; -let config; -let mockServer; -let mockReporting; +const mockLoggerFactory = { + get: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + })), +}; +const getMockLogger = () => new LevelLogger(mockLoggerFactory); + +const mockEncryptionKey = 'testencryptionkey'; +const encryptHeaders = async headers => { + const crypto = cryptoFactory(mockEncryptionKey); + return await crypto.encrypt(headers); +}; beforeEach(async () => { mockReporting = await createMockReportingCore(); - config = { - 'xpack.reporting.encryptionKey': 'testencryptionkey', + const kbnConfig = { 'server.basePath': '/sbp', - 'server.host': 'localhost', - 'server.port': 5601, }; - mockServer = { - config: memoize(() => ({ get: jest.fn() })), - info: { - protocol: 'http', + const reportingConfig = { + encryptionKey: mockEncryptionKey, + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + }; + + const mockGetConfig = jest.fn(); + mockReportingConfig = { + get: (...keys) => reportingConfig[keys.join('.')], + kbnConfig: { get: (...keys) => kbnConfig[keys.join('.')] }, + }; + mockGetConfig.mockImplementation(() => Promise.resolve(mockReportingConfig)); + mockReporting.getConfig = mockGetConfig; + + const mockElasticsearch = { + dataClient: { + asScoped: () => ({ callAsCurrentUser: jest.fn() }), }, }; - mockServer.config().get.mockImplementation(key => { - return config[key]; - }); + const mockGetElasticsearch = jest.fn(); + mockGetElasticsearch.mockImplementation(() => Promise.resolve(mockElasticsearch)); + mockReporting.getElasticsearchService = mockGetElasticsearch; generatePdfObservableFactory.mockReturnValue(jest.fn()); }); afterEach(() => generatePdfObservableFactory.mockReset()); -const getMockLogger = () => new LevelLogger(); -const mockElasticsearch = { - dataClient: { - asScoped: () => ({ callAsCurrentUser: jest.fn() }), - }, -}; - -const encryptHeaders = async headers => { - const crypto = cryptoFactory(mockServer); - return await crypto.encrypt(headers); -}; - test(`returns content_type of application/pdf`, async () => { - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger() - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const generatePdfObservable = generatePdfObservableFactory(); @@ -84,12 +91,7 @@ test(`returns content of generatePdf getBuffer base64 encoded`, async () => { const generatePdfObservable = generatePdfObservableFactory(); generatePdfObservable.mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); - const executeJob = await executeJobFactory( - mockReporting, - mockServer, - mockElasticsearch, - getMockLogger() - ); + const executeJob = await executeJobFactory(mockReporting, getMockLogger()); const encryptedHeaders = await encryptHeaders({}); const { content } = await executeJob( 'pdfJobId', diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts index 535c2dcd439a7..e614db46c5730 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/execute_job/index.ts @@ -4,18 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PDF_JOB_TYPE } from '../../../../common/constants'; import { ReportingCore } from '../../../../server'; -import { - ESQueueWorkerExecuteFn, - ExecuteJobFactory, - JobDocOutput, - Logger, - ServerFacade, -} from '../../../../types'; +import { ESQueueWorkerExecuteFn, ExecuteJobFactory, JobDocOutput, Logger } from '../../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -30,23 +23,25 @@ type QueuedPdfExecutorFactory = ExecuteJobFactory = Rx.of(1).pipe( - mergeMap(() => decryptJobHeaders({ server, job, logger })), + mergeMap(() => decryptJobHeaders({ encryptionKey, job, logger })), map(decryptedHeaders => omitBlacklistedHeaders({ job, decryptedHeaders })), - map(filteredHeaders => getConditionalHeaders({ server, job, filteredHeaders })), - mergeMap(conditionalHeaders => getCustomLogo({ reporting, server, job, conditionalHeaders })), + map(filteredHeaders => getConditionalHeaders({ config, job, filteredHeaders })), + mergeMap(conditionalHeaders => getCustomLogo({ reporting, config, job, conditionalHeaders })), mergeMap(({ logo, conditionalHeaders }) => { - const urls = getFullUrls({ server, job }); + const urls = getFullUrls({ config, job }); const { browserTimezone, layout, title } = job; return generatePdfObservable( diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts index d78effaa1fc2f..7021fae983aa2 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/server/lib/generate_pdf.ts @@ -8,7 +8,8 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; import { mergeMap } from 'rxjs/operators'; import { LevelLogger } from '../../../../server/lib'; -import { ConditionalHeaders, HeadlessChromiumDriverFactory, ServerFacade } from '../../../../types'; +import { ReportingConfigType } from '../../../../server/core'; +import { ConditionalHeaders, HeadlessChromiumDriverFactory } from '../../../../types'; import { createLayout } from '../../../common/layouts'; import { LayoutInstance, LayoutParams } from '../../../common/layouts/layout'; import { screenshotsObservableFactory } from '../../../common/lib/screenshots'; @@ -27,10 +28,10 @@ const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { }; export function generatePdfObservableFactory( - server: ServerFacade, + captureConfig: ReportingConfigType['capture'], browserDriverFactory: HeadlessChromiumDriverFactory ) { - const screenshotsObservable = screenshotsObservableFactory(server, browserDriverFactory); + const screenshotsObservable = screenshotsObservableFactory(captureConfig, browserDriverFactory); return function generatePdfObservable( logger: LevelLogger, @@ -41,7 +42,7 @@ export function generatePdfObservableFactory( layoutParams: LayoutParams, logo?: string ): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { - const layout = createLayout(server, layoutParams) as LayoutInstance; + const layout = createLayout(captureConfig, layoutParams) as LayoutInstance; const screenshots$ = screenshotsObservable({ logger, urls, diff --git a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts index 0a9dcfe986ca6..e8dd3c5207d92 100644 --- a/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts +++ b/x-pack/legacy/plugins/reporting/export_types/printable_pdf/types.d.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { JobDocPayload } from '../../types'; import { LayoutInstance, LayoutParams } from '../common/layouts/layout'; -import { JobDocPayload, ServerFacade, RequestFacade } from '../../types'; // Job params: structure of incoming user request data, after being parsed from RISON export interface JobParamsPDF { diff --git a/x-pack/legacy/plugins/reporting/index.test.js b/x-pack/legacy/plugins/reporting/index.test.js deleted file mode 100644 index 0d9a717bd7d81..0000000000000 --- a/x-pack/legacy/plugins/reporting/index.test.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { reporting } from './index'; -import { getConfigSchema } from '../../../test_utils'; - -// The snapshot records the number of cpus available -// to make the snapshot deterministic `os.cpus` needs to be mocked -// but the other members on `os` must remain untouched -jest.mock('os', () => { - const os = jest.requireActual('os'); - os.cpus = () => [{}, {}, {}, {}]; - return os; -}); - -// eslint-disable-next-line jest/valid-describe -const describeWithContext = describe.each([ - [{ dev: false, dist: false }], - [{ dev: true, dist: false }], - [{ dev: false, dist: true }], - [{ dev: true, dist: true }], -]); - -describeWithContext('config schema with context %j', context => { - it('produces correct config', async () => { - const schema = await getConfigSchema(reporting); - const value = await schema.validate({}, { context }); - value.capture.browser.chromium.disableSandbox = ''; - await expect(value).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/index.ts b/x-pack/legacy/plugins/reporting/index.ts index 89e98302cddc9..fb95e2c2edc24 100644 --- a/x-pack/legacy/plugins/reporting/index.ts +++ b/x-pack/legacy/plugins/reporting/index.ts @@ -8,21 +8,16 @@ import { i18n } from '@kbn/i18n'; import { Legacy } from 'kibana'; import { resolve } from 'path'; import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from './common/constants'; -import { config as reportingConfig } from './config'; import { legacyInit } from './server/legacy'; import { ReportingPluginSpecOptions } from './types'; -const kbToBase64Length = (kb: number) => { - return Math.floor((kb * 1024 * 8) / 6); -}; +const kbToBase64Length = (kb: number) => Math.floor((kb * 1024 * 8) / 6); export const reporting = (kibana: any) => { return new kibana.Plugin({ id: PLUGIN_ID, - configPrefix: 'xpack.reporting', publicDir: resolve(__dirname, 'public'), require: ['kibana', 'elasticsearch', 'xpack_main'], - config: reportingConfig, uiExports: { uiSettingDefaults: { @@ -49,14 +44,5 @@ export const reporting = (kibana: any) => { async init(server: Legacy.Server) { return legacyInit(server, this); }, - - deprecations({ unused }: any) { - return [ - unused('capture.concurrency'), - unused('capture.timeout'), - unused('capture.settleTime'), - unused('kibanaApp'), - ]; - }, } as ReportingPluginSpecOptions); }; diff --git a/x-pack/legacy/plugins/reporting/log_configuration.ts b/x-pack/legacy/plugins/reporting/log_configuration.ts index b07475df6304f..7aaed2038bd52 100644 --- a/x-pack/legacy/plugins/reporting/log_configuration.ts +++ b/x-pack/legacy/plugins/reporting/log_configuration.ts @@ -6,22 +6,23 @@ import getosSync, { LinuxOs } from 'getos'; import { promisify } from 'util'; -import { ServerFacade, Logger } from './types'; +import { BROWSER_TYPE } from './common/constants'; +import { CaptureConfig } from './server/types'; +import { Logger } from './types'; const getos = promisify(getosSync); -export async function logConfiguration(server: ServerFacade, logger: Logger) { - const config = server.config(); +export async function logConfiguration(captureConfig: CaptureConfig, logger: Logger) { + const { + browser: { + type: browserType, + chromium: { disableSandbox }, + }, + } = captureConfig; - const browserType = config.get('xpack.reporting.capture.browser.type'); logger.debug(`Browser type: ${browserType}`); - - if (browserType === 'chromium') { - logger.debug( - `Chromium sandbox disabled: ${config.get( - 'xpack.reporting.capture.browser.chromium.disableSandbox' - )}` - ); + if (browserType === BROWSER_TYPE) { + logger.debug(`Chromium sandbox disabled: ${disableSandbox}`); } const os = await getos(); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts index dc79a6b9db2c1..a2f7a1f3ad0da 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/args.ts @@ -4,11 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BrowserConfig } from '../../../../types'; +import { CaptureConfig } from '../../../../server/types'; + +type ViewportConfig = CaptureConfig['viewport']; +type BrowserConfig = CaptureConfig['browser']['chromium']; interface LaunchArgs { userDataDir: BrowserConfig['userDataDir']; - viewport: BrowserConfig['viewport']; + viewport: ViewportConfig; disableSandbox: BrowserConfig['disableSandbox']; proxy: BrowserConfig['proxy']; } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index f90f2c7aee395..cb228150efbcd 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -19,7 +19,8 @@ import { import * as Rx from 'rxjs'; import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber'; import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators'; -import { BrowserConfig, CaptureConfig } from '../../../../types'; +import { BROWSER_TYPE } from '../../../../common/constants'; +import { CaptureConfig } from '../../../../server/types'; import { LevelLogger as Logger } from '../../../lib/level_logger'; import { safeChildProcess } from '../../safe_child_process'; import { HeadlessChromiumDriver } from '../driver'; @@ -28,7 +29,8 @@ import { puppeteerLaunch } from '../puppeteer'; import { args } from './args'; type binaryPath = string; -type ViewportConfig = BrowserConfig['viewport']; +type BrowserConfig = CaptureConfig['browser']['chromium']; +type ViewportConfig = CaptureConfig['viewport']; export class HeadlessChromiumDriverFactory { private binaryPath: binaryPath; @@ -37,15 +39,10 @@ export class HeadlessChromiumDriverFactory { private userDataDir: string; private getChromiumArgs: (viewport: ViewportConfig) => string[]; - constructor( - binaryPath: binaryPath, - logger: Logger, - browserConfig: BrowserConfig, - captureConfig: CaptureConfig - ) { + constructor(binaryPath: binaryPath, logger: Logger, captureConfig: CaptureConfig) { this.binaryPath = binaryPath; - this.browserConfig = browserConfig; this.captureConfig = captureConfig; + this.browserConfig = captureConfig.browser.chromium; this.userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'chromium-')); this.getChromiumArgs = (viewport: ViewportConfig) => @@ -57,7 +54,7 @@ export class HeadlessChromiumDriverFactory { }); } - type = 'chromium'; + type = BROWSER_TYPE; test(logger: Logger) { const chromiumArgs = args({ @@ -153,7 +150,7 @@ export class HeadlessChromiumDriverFactory { // HeadlessChromiumDriver: object to "drive" a browser page const driver = new HeadlessChromiumDriver(page, { - inspect: this.browserConfig.inspect, + inspect: !!this.browserConfig.inspect, networkPolicy: this.captureConfig.networkPolicy, }); diff --git a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts index d32338ae3e311..5f89662c94da2 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/chromium/index.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { BrowserConfig, CaptureConfig } from '../../../types'; +import { CaptureConfig } from '../../../server/types'; import { LevelLogger } from '../../lib'; import { HeadlessChromiumDriverFactory } from './driver_factory'; @@ -13,8 +13,7 @@ export { paths } from './paths'; export async function createDriverFactory( binaryPath: string, logger: LevelLogger, - browserConfig: BrowserConfig, captureConfig: CaptureConfig ): Promise { - return new HeadlessChromiumDriverFactory(binaryPath, logger, browserConfig, captureConfig); + return new HeadlessChromiumDriverFactory(binaryPath, logger, captureConfig); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts index 49c6222c9f276..af3b86919dc50 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/create_browser_driver_factory.ts @@ -4,24 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Logger } from '../../types'; +import { ReportingConfig } from '../types'; +import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; import { ensureBrowserDownloaded } from './download'; -import { installBrowser } from './install'; -import { ServerFacade, CaptureConfig, Logger } from '../../types'; -import { BROWSER_TYPE } from '../../common/constants'; import { chromium } from './index'; -import { HeadlessChromiumDriverFactory } from './chromium/driver_factory'; +import { installBrowser } from './install'; export async function createBrowserDriverFactory( - server: ServerFacade, + config: ReportingConfig, logger: Logger ): Promise { - const config = server.config(); - - const dataDir: string = config.get('path.data'); - const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); - const browserType = captureConfig.browser.type; + const captureConfig = config.get('capture'); + const browserConfig = captureConfig.browser.chromium; const browserAutoDownload = captureConfig.browser.autoDownload; - const browserConfig = captureConfig.browser[BROWSER_TYPE]; + const browserType = captureConfig.browser.type; + const dataDir = config.kbnConfig.get('path', 'data'); if (browserConfig.disableSandbox) { logger.warning(`Enabling the Chromium sandbox provides an additional layer of protection.`); @@ -32,7 +30,7 @@ export async function createBrowserDriverFactory( try { const { binaryPath } = await installBrowser(logger, chromium, dataDir); - return chromium.createDriverFactory(binaryPath, logger, browserConfig, captureConfig); + return chromium.createDriverFactory(binaryPath, logger, captureConfig); } catch (error) { if (error.cause && ['EACCES', 'EEXIST'].includes(error.cause.code)) { logger.error( diff --git a/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts b/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts index 73186966e3d2f..3697c4b86ce3c 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/download/ensure_downloaded.ts @@ -4,16 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { resolve as resolvePath } from 'path'; import { existsSync } from 'fs'; - +import { resolve as resolvePath } from 'path'; +import { BROWSER_TYPE } from '../../../common/constants'; import { chromium } from '../index'; -import { BrowserDownload, BrowserType } from '../types'; - +import { BrowserDownload } from '../types'; import { md5 } from './checksum'; -import { asyncMap } from './util'; -import { download } from './download'; import { clean } from './clean'; +import { download } from './download'; +import { asyncMap } from './util'; /** * Check for the downloaded archive of each requested browser type and @@ -21,7 +20,7 @@ import { clean } from './clean'; * @param {String} browserType * @return {Promise} */ -export async function ensureBrowserDownloaded(browserType: BrowserType) { +export async function ensureBrowserDownloaded(browserType = BROWSER_TYPE) { await ensureDownloaded([chromium]); } diff --git a/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts b/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts index b36345c08bfee..9714c5965a5db 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/network_policy.ts @@ -6,12 +6,7 @@ import * as _ from 'lodash'; import { parse } from 'url'; - -interface FirewallRule { - allow: boolean; - host?: string; - protocol?: string; -} +import { NetworkPolicyRule } from '../../types'; const isHostMatch = (actualHost: string, ruleHost: string) => { const hostParts = actualHost.split('.').reverse(); @@ -20,7 +15,7 @@ const isHostMatch = (actualHost: string, ruleHost: string) => { return _.every(ruleParts, (part, idx) => part === hostParts[idx]); }; -export const allowRequest = (url: string, rules: FirewallRule[]) => { +export const allowRequest = (url: string, rules: NetworkPolicyRule[]) => { const parsed = parse(url); if (!rules.length) { diff --git a/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts b/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts index 0c480fc82752b..f096073ec2f5f 100644 --- a/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/browsers/types.d.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export type BrowserType = 'chromium'; - export interface BrowserDownload { paths: { archivesPath: string; diff --git a/x-pack/legacy/plugins/reporting/server/config/config.js b/x-pack/legacy/plugins/reporting/server/config/config.js deleted file mode 100644 index 08e4db464b003..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/config/config.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { cpus } from 'os'; - -const defaultCPUCount = 2; - -function cpuCount() { - try { - return cpus().length; - } catch (e) { - return defaultCPUCount; - } -} - -export const config = { - concurrency: cpuCount(), -}; diff --git a/x-pack/legacy/plugins/reporting/server/core.ts b/x-pack/legacy/plugins/reporting/server/core.ts index 4506d41e4f5c3..c233a63833950 100644 --- a/x-pack/legacy/plugins/reporting/server/core.ts +++ b/x-pack/legacy/plugins/reporting/server/core.ts @@ -7,12 +7,14 @@ import * as Rx from 'rxjs'; import { first, mapTo } from 'rxjs/operators'; import { + ElasticsearchServiceSetup, IUiSettingsClient, KibanaRequest, SavedObjectsClient, SavedObjectsServiceStart, UiSettingsServiceStart, } from 'src/core/server'; +import { ConfigType as ReportingConfigType } from '../../../../plugins/reporting/server'; // @ts-ignore no module definition import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; @@ -25,14 +27,63 @@ import { ReportingSetupDeps } from './types'; interface ReportingInternalSetup { browserDriverFactory: HeadlessChromiumDriverFactory; + config: ReportingConfig; + elasticsearch: ElasticsearchServiceSetup; } interface ReportingInternalStart { + enqueueJob: EnqueueJobFn; + esqueue: ESQueueInstance; savedObjects: SavedObjectsServiceStart; uiSettings: UiSettingsServiceStart; - esqueue: ESQueueInstance; - enqueueJob: EnqueueJobFn; } +// make config.get() aware of the value type it returns +interface Config { + get(key1: Key1): BaseType[Key1]; + get( + key1: Key1, + key2: Key2 + ): BaseType[Key1][Key2]; + get< + Key1 extends keyof BaseType, + Key2 extends keyof BaseType[Key1], + Key3 extends keyof BaseType[Key1][Key2] + >( + key1: Key1, + key2: Key2, + key3: Key3 + ): BaseType[Key1][Key2][Key3]; + get< + Key1 extends keyof BaseType, + Key2 extends keyof BaseType[Key1], + Key3 extends keyof BaseType[Key1][Key2], + Key4 extends keyof BaseType[Key1][Key2][Key3] + >( + key1: Key1, + key2: Key2, + key3: Key3, + key4: Key4 + ): BaseType[Key1][Key2][Key3][Key4]; +} + +interface KbnServerConfigType { + path: { data: string }; + server: { + basePath: string; + host: string; + name: string; + port: number; + protocol: string; + uuid: string; + }; +} + +export interface ReportingConfig extends Config { + kbnConfig: Config; +} + +export { ReportingConfigType }; + export class ReportingCore { private pluginSetupDeps?: ReportingInternalSetup; private pluginStartDeps?: ReportingInternalStart; @@ -45,6 +96,7 @@ export class ReportingCore { legacySetup( xpackMainPlugin: XPackMainPlugin, reporting: ReportingPluginSpecOptions, + config: ReportingConfig, __LEGACY: ServerFacade, plugins: ReportingSetupDeps ) { @@ -56,7 +108,7 @@ export class ReportingCore { xpackMainPlugin.info.feature(PLUGIN_ID).registerLicenseCheckResultsGenerator(checkLicense); }); // Reporting routes - registerRoutes(this, __LEGACY, plugins, this.logger); + registerRoutes(this, config, __LEGACY, plugins, this.logger); } public pluginSetup(reportingSetupDeps: ReportingInternalSetup) { @@ -90,23 +142,31 @@ export class ReportingCore { return (await this.getPluginSetupDeps()).browserDriverFactory; } + public async getConfig(): Promise { + return (await this.getPluginSetupDeps()).config; + } + /* - * Kibana core module dependencies + * Outside dependencies */ - private async getPluginSetupDeps() { + private async getPluginSetupDeps(): Promise { if (this.pluginSetupDeps) { return this.pluginSetupDeps; } return await this.pluginSetup$.pipe(first()).toPromise(); } - private async getPluginStartDeps() { + private async getPluginStartDeps(): Promise { if (this.pluginStartDeps) { return this.pluginStartDeps; } return await this.pluginStart$.pipe(first()).toPromise(); } + public async getElasticsearchService(): Promise { + return (await this.getPluginSetupDeps()).elasticsearch; + } + public async getSavedObjectsClient(fakeRequest: KibanaRequest): Promise { const { savedObjects } = await this.getPluginStartDeps(); return savedObjects.getScopedClient(fakeRequest) as SavedObjectsClient; diff --git a/x-pack/legacy/plugins/reporting/server/index.ts b/x-pack/legacy/plugins/reporting/server/index.ts index 24e2a954415d9..efcfd6b7f783d 100644 --- a/x-pack/legacy/plugins/reporting/server/index.ts +++ b/x-pack/legacy/plugins/reporting/server/index.ts @@ -11,5 +11,5 @@ export const plugin = (context: PluginInitializerContext) => { return new Plugin(context); }; -export { ReportingCore } from './core'; export { ReportingPlugin } from './plugin'; +export { ReportingConfig, ReportingCore } from './core'; diff --git a/x-pack/legacy/plugins/reporting/server/legacy.ts b/x-pack/legacy/plugins/reporting/server/legacy.ts index 336ff5f4d2ee7..29e5af529767e 100644 --- a/x-pack/legacy/plugins/reporting/server/legacy.ts +++ b/x-pack/legacy/plugins/reporting/server/legacy.ts @@ -4,35 +4,75 @@ * you may not use this file except in compliance with the Elastic License. */ import { Legacy } from 'kibana'; -import { PluginInitializerContext } from 'src/core/server'; +import { get } from 'lodash'; +import { take } from 'rxjs/operators'; +import { CoreSetup, PluginInitializerContext } from 'src/core/server'; +import { ConfigType, PluginsSetup } from '../../../../plugins/reporting/server'; import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { ReportingPluginSpecOptions } from '../types'; import { plugin } from './index'; -import { LegacySetup, ReportingStartDeps } from './types'; +import { LegacySetup, ReportingConfig, ReportingStartDeps } from './types'; const buildLegacyDependencies = ( + coreSetup: CoreSetup, server: Legacy.Server, reportingPlugin: ReportingPluginSpecOptions -): LegacySetup => ({ - config: server.config, - info: server.info, - route: server.route.bind(server), - plugins: { - elasticsearch: server.plugins.elasticsearch, - xpack_main: server.plugins.xpack_main, - reporting: reportingPlugin, - }, -}); +): LegacySetup => { + return { + route: server.route.bind(server), + plugins: { + xpack_main: server.plugins.xpack_main, + reporting: reportingPlugin, + }, + }; +}; + +const buildConfig = ( + coreSetup: CoreSetup, + server: Legacy.Server, + reportingConfig: ConfigType +): ReportingConfig => { + const config = server.config(); + const { http } = coreSetup; + const serverInfo = http.getServerInfo(); + + const kbnConfig = { + path: { + data: config.get('path.data'), // FIXME: get from the real PluginInitializerContext + }, + server: { + basePath: coreSetup.http.basePath.serverBasePath, + host: serverInfo.host, + name: serverInfo.name, + port: serverInfo.port, + uuid: coreSetup.uuid.getInstanceUuid(), + protocol: serverInfo.protocol, + }, + }; + + // spreading arguments as an array allows the return type to be known by the compiler + return { + get: (...keys: string[]) => get(reportingConfig, keys.join('.'), null), + kbnConfig: { + get: (...keys: string[]) => get(kbnConfig, keys.join('.'), null), + }, + }; +}; export const legacyInit = async ( server: Legacy.Server, - reportingPlugin: ReportingPluginSpecOptions + reportingLegacyPlugin: ReportingPluginSpecOptions ) => { - const coreSetup = server.newPlatform.setup.core; - const pluginInstance = plugin(server.newPlatform.coreContext as PluginInitializerContext); + const { core: coreSetup } = server.newPlatform.setup; + const { config$ } = (server.newPlatform.setup.plugins.reporting as PluginsSetup).__legacy; + const reportingConfig = await config$.pipe(take(1)).toPromise(); + const reporting = { config: buildConfig(coreSetup, server, reportingConfig) }; + + const __LEGACY = buildLegacyDependencies(coreSetup, server, reportingLegacyPlugin); - const __LEGACY = buildLegacyDependencies(server, reportingPlugin); + const pluginInstance = plugin(server.newPlatform.coreContext as PluginInitializerContext); // NOTE: mocked-out PluginInitializerContext await pluginInstance.setup(coreSetup, { + reporting, elasticsearch: coreSetup.elasticsearch, security: server.newPlatform.setup.plugins.security as SecurityPluginSetup, usageCollection: server.newPlatform.setup.plugins.usageCollection, @@ -42,7 +82,6 @@ export const legacyInit = async ( // Schedule to call the "start" hook only after start dependencies are ready coreSetup.getStartServices().then(([core, plugins]) => pluginInstance.start(core, { - elasticsearch: coreSetup.elasticsearch, data: (plugins as ReportingStartDeps).data, __LEGACY, }) diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts b/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts index d593e4625cdf4..a05205526dd3e 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_queue.ts @@ -4,22 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; -import { ESQueueInstance, ServerFacade, QueueConfig, Logger } from '../../types'; +import { ESQueueInstance, Logger } from '../../types'; import { ReportingCore } from '../core'; +import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed +import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; -import { createWorkerFactory } from './create_worker'; -import { createTaggedLogger } from './create_tagged_logger'; // TODO remove createTaggedLogger once esqueue is removed export async function createQueueFactory( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: Logger ): Promise { - const queueConfig: QueueConfig = server.config().get('xpack.reporting.queue'); - const index = server.config().get('xpack.reporting.index'); + const [config, elasticsearch] = await Promise.all([ + reporting.getConfig(), + reporting.getElasticsearchService(), + ]); + + const queueConfig = config.get('queue'); + const index = config.get('index'); const queueOptions = { interval: queueConfig.indexInterval, @@ -33,7 +35,7 @@ export async function createQueueFactory( if (queueConfig.pollEnabled) { // create workers to poll the index for idle jobs waiting to be claimed and executed - const createWorker = createWorkerFactory(reporting, server, elasticsearch, logger); + const createWorker = await createWorkerFactory(reporting, config, logger); await createWorker(queue); } else { logger.info( diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts index d4d913243e18d..01a937a49873a 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.test.ts @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import * as sinon from 'sinon'; -import { ReportingCore } from '../../server'; +import { ReportingConfig, ReportingCore } from '../../server/types'; import { createMockReportingCore } from '../../test_helpers'; -import { ServerFacade } from '../../types'; import { createWorkerFactory } from './create_worker'; // @ts-ignore import { Esqueue } from './esqueue'; @@ -17,21 +15,15 @@ import { ClientMock } from './esqueue/__tests__/fixtures/legacy_elasticsearch'; import { ExportTypesRegistry } from './export_types_registry'; const configGetStub = sinon.stub(); -configGetStub.withArgs('xpack.reporting.queue').returns({ +configGetStub.withArgs('queue').returns({ pollInterval: 3300, pollIntervalErrorMultiplier: 10, }); -configGetStub.withArgs('server.name').returns('test-server-123'); -configGetStub.withArgs('server.uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); +configGetStub.withArgs('server', 'name').returns('test-server-123'); +configGetStub.withArgs('server', 'uuid').returns('g9ymiujthvy6v8yrh7567g6fwzgzftzfr'); const executeJobFactoryStub = sinon.stub(); - -const getMockServer = (): ServerFacade => { - return ({ - config: () => ({ get: configGetStub }), - } as unknown) as ServerFacade; -}; -const getMockLogger = jest.fn(); +const getMockLogger = sinon.stub(); const getMockExportTypesRegistry = ( exportTypes: any[] = [{ executeJobFactory: executeJobFactoryStub }] @@ -41,25 +33,23 @@ const getMockExportTypesRegistry = ( } as ExportTypesRegistry); describe('Create Worker', () => { + let mockReporting: ReportingCore; + let mockConfig: ReportingConfig; let queue: Esqueue; let client: ClientMock; - let mockReporting: ReportingCore; beforeEach(async () => { mockReporting = await createMockReportingCore(); + mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); + mockConfig = { get: configGetStub, kbnConfig: { get: configGetStub } }; + mockReporting.getConfig = () => Promise.resolve(mockConfig); client = new ClientMock(); queue = new Esqueue('reporting-queue', { client }); executeJobFactoryStub.reset(); }); test('Creates a single Esqueue worker for Reporting', async () => { - mockReporting.getExportTypesRegistry = () => getMockExportTypesRegistry(); - const createWorker = createWorkerFactory( - mockReporting, - getMockServer(), - {} as ElasticsearchServiceSetup, - getMockLogger() - ); + const createWorker = await createWorkerFactory(mockReporting, mockConfig, getMockLogger()); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); @@ -91,12 +81,7 @@ Object { { executeJobFactory: executeJobFactoryStub }, ]); mockReporting.getExportTypesRegistry = () => exportTypesRegistry; - const createWorker = createWorkerFactory( - mockReporting, - getMockServer(), - {} as ElasticsearchServiceSetup, - getMockLogger() - ); + const createWorker = await createWorkerFactory(mockReporting, mockConfig, getMockLogger()); const registerWorkerSpy = sinon.spy(queue, 'registerWorker'); await createWorker(queue); diff --git a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts index 3567712367608..e9d0acf29c721 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/create_worker.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ElasticsearchServiceSetup } from 'kibana/server'; import { CancellationToken } from '../../common/cancellation_token'; import { PLUGIN_ID } from '../../common/constants'; +import { ReportingConfig } from '../../server/types'; import { ESQueueInstance, ESQueueWorkerExecuteFn, @@ -15,25 +15,22 @@ import { JobDocPayload, JobSource, Logger, - QueueConfig, RequestFacade, - ServerFacade, } from '../../types'; import { ReportingCore } from '../core'; // @ts-ignore untyped dependency import { events as esqueueEvents } from './esqueue'; -export function createWorkerFactory( +export async function createWorkerFactory( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, + config: ReportingConfig, logger: Logger ) { type JobDocPayloadType = JobDocPayload; - const config = server.config(); - const queueConfig: QueueConfig = config.get('xpack.reporting.queue'); - const kibanaName: string = config.get('server.name'); - const kibanaId: string = config.get('server.uuid'); + + const queueConfig = config.get('queue'); + const kibanaName = config.kbnConfig.get('server', 'name'); + const kibanaId = config.kbnConfig.get('server', 'uuid'); // Once more document types are added, this will need to be passed in return async function createWorker(queue: ESQueueInstance) { @@ -47,12 +44,7 @@ export function createWorkerFactory( ExportTypeDefinition >) { // TODO: the executeJobFn should be unwrapped in the register method of the export types registry - const jobExecutor = await exportType.executeJobFactory( - reporting, - server, - elasticsearch, - logger - ); + const jobExecutor = await exportType.executeJobFactory(reporting, logger); jobExecutors.set(exportType.jobType, jobExecutor); } diff --git a/x-pack/legacy/plugins/reporting/server/lib/crypto.ts b/x-pack/legacy/plugins/reporting/server/lib/crypto.ts index dbc01fc947f8b..97876529ecfa7 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/crypto.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/crypto.ts @@ -5,12 +5,7 @@ */ import nodeCrypto from '@elastic/node-crypto'; -import { oncePerServer } from './once_per_server'; -import { ServerFacade } from '../../types'; -function cryptoFn(server: ServerFacade) { - const encryptionKey = server.config().get('xpack.reporting.encryptionKey'); +export function cryptoFactory(encryptionKey: string | undefined) { return nodeCrypto({ encryptionKey }); } - -export const cryptoFactory = oncePerServer(cryptoFn); diff --git a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts index c215bdc398904..bc4754b02ed57 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/enqueue_job.ts @@ -5,22 +5,18 @@ */ import { get } from 'lodash'; -import { ElasticsearchServiceSetup } from 'kibana/server'; -// @ts-ignore -import { events as esqueueEvents } from './esqueue'; import { + ConditionalHeaders, EnqueueJobFn, ESQueueCreateJobFn, ImmediateCreateJobFn, Job, - ServerFacade, - RequestFacade, Logger, - CaptureConfig, - QueueConfig, - ConditionalHeaders, + RequestFacade, } from '../../types'; import { ReportingCore } from '../core'; +// @ts-ignore +import { events as esqueueEvents } from './esqueue'; interface ConfirmedJob { id: string; @@ -29,18 +25,16 @@ interface ConfirmedJob { _primary_term: number; } -export function enqueueJobFactory( +export async function enqueueJobFactory( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, parentLogger: Logger -): EnqueueJobFn { +): Promise { + const config = await reporting.getConfig(); const logger = parentLogger.clone(['queue-job']); - const config = server.config(); - const captureConfig: CaptureConfig = config.get('xpack.reporting.capture'); + const captureConfig = config.get('capture'); const browserType = captureConfig.browser.type; const maxAttempts = captureConfig.maxAttempts; - const queueConfig: QueueConfig = config.get('xpack.reporting.queue'); + const queueConfig = config.get('queue'); return async function enqueueJob( exportTypeId: string, @@ -59,12 +53,7 @@ export function enqueueJobFactory( } // TODO: the createJobFn should be unwrapped in the register method of the export types registry - const createJob = exportType.createJobFactory( - reporting, - server, - elasticsearch, - logger - ) as CreateJobFn; + const createJob = (await exportType.createJobFactory(reporting, logger)) as CreateJobFn; const payload = await createJob(jobParams, headers, request); const options = { diff --git a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js index 6cdbe8f968f75..8e4047e2f22e5 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js +++ b/x-pack/legacy/plugins/reporting/server/lib/esqueue/helpers/index_timestamp.js @@ -8,6 +8,7 @@ import moment from 'moment'; export const intervals = ['year', 'month', 'week', 'day', 'hour', 'minute']; +// TODO: remove this helper by using `schema.duration` objects in the reporting config schema export function indexTimestamp(intervalStr, separator = '-') { if (separator.match(/[a-z]/i)) throw new Error('Interval separator can not be a letter'); diff --git a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts index 49d5c568c3981..5e73fe77ecb79 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/get_user.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/get_user.ts @@ -6,10 +6,10 @@ import { Legacy } from 'kibana'; import { KibanaRequest } from '../../../../../../src/core/server'; -import { ServerFacade } from '../../types'; +import { Logger } from '../../types'; import { ReportingSetupDeps } from '../types'; -export function getUserFactory(server: ServerFacade, security: ReportingSetupDeps['security']) { +export function getUserFactory(security: ReportingSetupDeps['security'], logger: Logger) { /* * Legacy.Request because this is called from routing middleware */ diff --git a/x-pack/legacy/plugins/reporting/server/lib/index.ts b/x-pack/legacy/plugins/reporting/server/lib/index.ts index 0a2db749cb954..f5ccbe493a91f 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/index.ts @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export { getExportTypesRegistry } from './export_types_registry'; export { checkLicenseFactory } from './check_license'; -export { LevelLogger } from './level_logger'; -export { cryptoFactory } from './crypto'; -export { oncePerServer } from './once_per_server'; -export { runValidations } from './validate'; export { createQueueFactory } from './create_queue'; +export { cryptoFactory } from './crypto'; export { enqueueJobFactory } from './enqueue_job'; +export { getExportTypesRegistry } from './export_types_registry'; +export { LevelLogger } from './level_logger'; +export { runValidations } from './validate'; diff --git a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts index c01e6377b039e..0affc111c1368 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/jobs_query.ts @@ -9,7 +9,8 @@ import Boom from 'boom'; import { errors as elasticsearchErrors } from 'elasticsearch'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { get } from 'lodash'; -import { JobSource, ServerFacade } from '../../types'; +import { JobSource } from '../../types'; +import { ReportingConfig } from '../types'; const esErrors = elasticsearchErrors as Record; const defaultSize = 10; @@ -39,8 +40,11 @@ interface CountAggResult { count: number; } -export function jobsQueryFactory(server: ServerFacade, elasticsearch: ElasticsearchServiceSetup) { - const index = server.config().get('xpack.reporting.index'); +export function jobsQueryFactory( + config: ReportingConfig, + elasticsearch: ElasticsearchServiceSetup +) { + const index = config.get('index'); const { callAsInternalUser } = elasticsearch.adminClient; function getUsername(user: any) { diff --git a/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts b/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts deleted file mode 100644 index ae3636079a9bb..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/once_per_server.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { memoize, MemoizedFunction } from 'lodash'; -import { ServerFacade } from '../../types'; - -type ServerFn = (server: ServerFacade) => any; -type Memo = ((server: ServerFacade) => any) & MemoizedFunction; - -/** - * allow this function to be called multiple times, but - * ensure that it only received one argument, the server, - * and cache the return value so that subsequent calls get - * the exact same value. - * - * This is intended to be used by service factories like getObjectQueueFactory - * - * @param {Function} fn - the factory function - * @return {any} - */ -export function oncePerServer(fn: ServerFn) { - const memoized: Memo = memoize(function(server: ServerFacade) { - if (arguments.length !== 1) { - throw new TypeError('This function expects to be called with a single argument'); - } - - // @ts-ignore - return fn.call(this, server); - }); - - // @ts-ignore - // Type 'WeakMap' is not assignable to type 'MapCache - - // use a weak map a the cache so that: - // 1. return values mapped to the actual server instance - // 2. return value lifecycle matches that of the server - memoized.cache = new WeakMap(); - - return memoized; -} diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js deleted file mode 100644 index 10980f702d849..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_encryption_key.js +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import sinon from 'sinon'; -import { validateEncryptionKey } from '../validate_encryption_key'; - -describe('Reporting: Validate config', () => { - const logger = { - warning: sinon.spy(), - }; - - beforeEach(() => { - logger.warning.resetHistory(); - }); - - [undefined, null].forEach(value => { - it(`should log a warning and set xpack.reporting.encryptionKey if encryptionKey is ${value}`, () => { - const config = { - get: sinon.stub().returns(value), - set: sinon.stub(), - }; - - expect(() => validateEncryptionKey({ config: () => config }, logger)).not.to.throwError(); - - sinon.assert.calledWith(config.set, 'xpack.reporting.encryptionKey'); - sinon.assert.calledWithMatch(logger.warning, /Generating a random key/); - sinon.assert.calledWithMatch(logger.warning, /please set xpack.reporting.encryptionKey/); - }); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts deleted file mode 100644 index 04f998fd3e5a5..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/__tests__/validate_server_host.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import sinon from 'sinon'; -import { ServerFacade } from '../../../../types'; -import { validateServerHost } from '../validate_server_host'; - -const configKey = 'xpack.reporting.kibanaServer.hostname'; - -describe('Reporting: Validate server host setting', () => { - it(`should log a warning and set ${configKey} if server.host is "0"`, () => { - const getStub = sinon.stub(); - getStub.withArgs('server.host').returns('0'); - getStub.withArgs(configKey).returns(undefined); - const config = { - get: getStub, - set: sinon.stub(), - }; - - expect(() => - validateServerHost(({ config: () => config } as unknown) as ServerFacade) - ).to.throwError(); - - sinon.assert.calledWith(config.set, configKey); - }); -}); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts index 0fdbd858b8e3c..85d9f727d7fa7 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/index.ts @@ -6,25 +6,22 @@ import { i18n } from '@kbn/i18n'; import { ElasticsearchServiceSetup } from 'kibana/server'; -import { Logger, ServerFacade } from '../../../types'; +import { Logger } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; +import { ReportingConfig } from '../../types'; import { validateBrowser } from './validate_browser'; -import { validateEncryptionKey } from './validate_encryption_key'; import { validateMaxContentLength } from './validate_max_content_length'; -import { validateServerHost } from './validate_server_host'; export async function runValidations( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup, browserFactory: HeadlessChromiumDriverFactory, logger: Logger ) { try { await Promise.all([ - validateBrowser(server, browserFactory, logger), - validateEncryptionKey(server, logger), - validateMaxContentLength(server, elasticsearch, logger), - validateServerHost(server), + validateBrowser(browserFactory, logger), + validateMaxContentLength(config, elasticsearch, logger), ]); logger.debug( i18n.translate('xpack.reporting.selfCheck.ok', { diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts index 89c49123e85bf..d6512d5eb718b 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_browser.ts @@ -3,9 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + import { Browser } from 'puppeteer'; import { BROWSER_TYPE } from '../../../common/constants'; -import { ServerFacade, Logger } from '../../../types'; +import { Logger } from '../../../types'; import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory'; /* @@ -13,7 +14,6 @@ import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_fa * to the locally running Kibana instance. */ export const validateBrowser = async ( - server: ServerFacade, browserFactory: HeadlessChromiumDriverFactory, logger: Logger ) => { diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts deleted file mode 100644 index e0af94cbdc29c..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_encryption_key.ts +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import crypto from 'crypto'; -import { ServerFacade, Logger } from '../../../types'; - -export function validateEncryptionKey(serverFacade: ServerFacade, logger: Logger) { - const config = serverFacade.config(); - - const encryptionKey = config.get('xpack.reporting.encryptionKey'); - if (encryptionKey == null) { - // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. - logger.warning( - i18n.translate('xpack.reporting.selfCheckEncryptionKey.warning', { - defaultMessage: - `Generating a random key for {setting}. To prevent pending reports ` + - `from failing on restart, please set {setting} in kibana.yml`, - values: { - setting: 'xpack.reporting.encryptionKey', - }, - }) - ); - - // @ts-ignore: No set() method on KibanaConfig, just get() and has() - config.set('xpack.reporting.encryptionKey', crypto.randomBytes(16).toString('hex')); // update config in memory to contain a usable encryption key - } -} diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js index 942dcaf842696..2551fd48b91f3 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.test.js @@ -32,11 +32,7 @@ describe('Reporting: Validate Max Content Length', () => { }); it('should log warning messages when reporting has a higher max-size than elasticsearch', async () => { - const server = { - config: () => ({ - get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES), - }), - }; + const config = { get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES) }; const elasticsearch = { dataClient: { callAsInternalUser: () => ({ @@ -49,7 +45,7 @@ describe('Reporting: Validate Max Content Length', () => { }, }; - await validateMaxContentLength(server, elasticsearch, logger); + await validateMaxContentLength(config, elasticsearch, logger); sinon.assert.calledWithMatch( logger.warning, @@ -70,14 +66,10 @@ describe('Reporting: Validate Max Content Length', () => { }); it('should do nothing when reporting has the same max-size as elasticsearch', async () => { - const server = { - config: () => ({ - get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES), - }), - }; + const config = { get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES) }; expect( - async () => await validateMaxContentLength(server, elasticsearch, logger.warning) + async () => await validateMaxContentLength(config, elasticsearch, logger.warning) ).not.toThrow(); sinon.assert.notCalled(logger.warning); }); diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts index ce4a5b93e7431..a20905ba093d4 100644 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts +++ b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_max_content_length.ts @@ -7,17 +7,17 @@ import numeral from '@elastic/numeral'; import { ElasticsearchServiceSetup } from 'kibana/server'; import { defaults, get } from 'lodash'; -import { Logger, ServerFacade } from '../../../types'; +import { Logger } from '../../../types'; +import { ReportingConfig } from '../../types'; -const KIBANA_MAX_SIZE_BYTES_PATH = 'xpack.reporting.csv.maxSizeBytes'; +const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes'; const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length'; export async function validateMaxContentLength( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup, logger: Logger ) { - const config = server.config(); const { callAsInternalUser } = elasticsearch.dataClient; const elasticClusterSettingsResponse = await callAsInternalUser('cluster.getSettings', { @@ -28,13 +28,13 @@ export async function validateMaxContentLength( const elasticSearchMaxContent = get(elasticClusterSettings, 'http.max_content_length', '100mb'); const elasticSearchMaxContentBytes = numeral().unformat(elasticSearchMaxContent.toUpperCase()); - const kibanaMaxContentBytes: number = config.get(KIBANA_MAX_SIZE_BYTES_PATH); + const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes'); if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) { // TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost. logger.warning( - `${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + - `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your ${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` + `xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` + + `Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.` ); } } diff --git a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts b/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts deleted file mode 100644 index f4f4d61246b6a..0000000000000 --- a/x-pack/legacy/plugins/reporting/server/lib/validate/validate_server_host.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ServerFacade } from '../../../types'; - -const configKey = 'xpack.reporting.kibanaServer.hostname'; - -export function validateServerHost(serverFacade: ServerFacade) { - const config = serverFacade.config(); - - const serverHost = config.get('server.host'); - const reportingKibanaHostName = config.get(configKey); - - if (!reportingKibanaHostName && serverHost === '0') { - // @ts-ignore: No set() method on KibanaConfig, just get() and has() - config.set(configKey, '0.0.0.0'); // update config in memory to allow Reporting to work - - throw new Error( - `Found 'server.host: "0"' in settings. This is incompatible with Reporting. ` + - `To enable Reporting to work, '${configKey}: 0.0.0.0' is being automatically to the configuration. ` + - `You can change to 'server.host: 0.0.0.0' or add '${configKey}: 0.0.0.0' in kibana.yml to prevent this message.` - ); - } -} diff --git a/x-pack/legacy/plugins/reporting/server/plugin.ts b/x-pack/legacy/plugins/reporting/server/plugin.ts index 4f24cc16b2277..1d7cc075b690d 100644 --- a/x-pack/legacy/plugins/reporting/server/plugin.ts +++ b/x-pack/legacy/plugins/reporting/server/plugin.ts @@ -12,8 +12,6 @@ import { createQueueFactory, enqueueJobFactory, LevelLogger, runValidations } fr import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; import { registerReportingUsageCollector } from './usage'; -// @ts-ignore no module definition -import { mirrorPluginStatus } from '../../../server/lib/mirror_plugin_status'; export class ReportingPlugin implements Plugin { @@ -26,29 +24,29 @@ export class ReportingPlugin } public async setup(core: CoreSetup, plugins: ReportingSetupDeps) { - const { elasticsearch, usageCollection, __LEGACY } = plugins; + const { reporting: reportingNewPlatform, elasticsearch, __LEGACY } = plugins; + const { config } = reportingNewPlatform; - const browserDriverFactory = await createBrowserDriverFactory(__LEGACY, this.logger); // required for validations :( - runValidations(__LEGACY, elasticsearch, browserDriverFactory, this.logger); // this must run early, as it sets up config defaults + const browserDriverFactory = await createBrowserDriverFactory(config, this.logger); // required for validations :( + runValidations(config, elasticsearch, browserDriverFactory, this.logger); // this must run early, as it sets up config defaults const { xpack_main: xpackMainLegacy, reporting: reportingLegacy } = __LEGACY.plugins; - this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, __LEGACY, plugins); + this.reportingCore.legacySetup(xpackMainLegacy, reportingLegacy, config, __LEGACY, plugins); // Register a function with server to manage the collection of usage stats - registerReportingUsageCollector(this.reportingCore, __LEGACY, usageCollection); + registerReportingUsageCollector(this.reportingCore, config, plugins); // regsister setup internals - this.reportingCore.pluginSetup({ browserDriverFactory }); + this.reportingCore.pluginSetup({ browserDriverFactory, config, elasticsearch }); return {}; } public async start(core: CoreStart, plugins: ReportingStartDeps) { const { reportingCore, logger } = this; - const { elasticsearch, __LEGACY } = plugins; - const esqueue = await createQueueFactory(reportingCore, __LEGACY, elasticsearch, logger); - const enqueueJob = enqueueJobFactory(reportingCore, __LEGACY, elasticsearch, logger); + const esqueue = await createQueueFactory(reportingCore, logger); + const enqueueJob = await enqueueJobFactory(reportingCore, logger); this.reportingCore.pluginStart({ savedObjects: core.savedObjects, @@ -58,7 +56,9 @@ export class ReportingPlugin }); setFieldFormats(plugins.data.fieldFormats); - logConfiguration(__LEGACY, this.logger); + + const config = await reportingCore.getConfig(); + logConfiguration(config.get('capture'), this.logger); return {}; } diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts index 56622617586f7..dc58e97ff3e41 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_jobparams.ts @@ -10,7 +10,7 @@ import { Legacy } from 'kibana'; import rison from 'rison-node'; import { API_BASE_URL } from '../../common/constants'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingSetupDeps } from '../types'; +import { ReportingConfig, ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { GetRouteConfigFactoryFn, @@ -22,6 +22,7 @@ import { HandlerErrorFunction, HandlerFunction } from './types'; const BASE_GENERATE = `${API_BASE_URL}/generate`; export function registerGenerateFromJobParams( + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, handler: HandlerFunction, @@ -30,7 +31,7 @@ export function registerGenerateFromJobParams( ) { const getRouteConfig = () => { const getOriginalRouteConfig: GetRouteConfigFactoryFn = getRouteConfigFactoryReportingPre( - server, + config, plugins, logger ); diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts index 415b6b7d64366..23ab7ee0d9e6b 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject.ts @@ -9,7 +9,7 @@ import { get } from 'lodash'; import { API_BASE_GENERATE_V1, CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../common/constants'; import { getJobParamsFromRequest } from '../../export_types/csv_from_savedobject/server/lib/get_job_params_from_request'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingSetupDeps } from '../types'; +import { ReportingConfig, ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { getRouteOptionsCsv } from './lib/route_config_factories'; import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types'; @@ -24,13 +24,14 @@ import { HandlerErrorFunction, HandlerFunction, QueuedJobPayload } from './types * - local (transient) changes the user made to the saved object */ export function registerGenerateCsvFromSavedObject( + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, handleRoute: HandlerFunction, handleRouteError: HandlerErrorFunction, logger: Logger ) { - const routeOptions = getRouteOptionsCsv(server, plugins, logger); + const routeOptions = getRouteOptionsCsv(config, plugins, logger); server.route({ path: `${API_BASE_GENERATE_V1}/csv/saved-object/{savedObjectType}:{savedObjectId}`, diff --git a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 5d17fa2e82b8c..5bd07aa6049ed 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -16,7 +16,7 @@ import { ResponseFacade, ServerFacade, } from '../../types'; -import { ReportingSetupDeps, ReportingCore } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { makeRequestFacade } from './lib/make_request_facade'; import { getRouteOptionsCsv } from './lib/route_config_factories'; @@ -31,12 +31,12 @@ import { getRouteOptionsCsv } from './lib/route_config_factories'; */ export function registerGenerateCsvFromSavedObjectImmediate( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, parentLogger: Logger ) { - const routeOptions = getRouteOptionsCsv(server, plugins, parentLogger); - const { elasticsearch } = plugins; + const routeOptions = getRouteOptionsCsv(config, plugins, parentLogger); /* * CSV export with the `immediate` option does not queue a job with Reporting's ESQueue to run the job async. Instead, this does: @@ -52,14 +52,10 @@ export function registerGenerateCsvFromSavedObjectImmediate( const logger = parentLogger.clone(['savedobject-csv']); const jobParams = getJobParamsFromRequest(request, { isImmediate: true }); - /* TODO these functions should be made available in the export types registry: - * - * const { createJobFn, executeJobFn } = exportTypesRegistry.getById(CSV_FROM_SAVEDOBJECT_JOB_TYPE) - * - * Calling an execute job factory requires passing a browserDriverFactory option, so we should not call the factory from here - */ - const createJobFn = createJobFactory(reporting, server, elasticsearch, logger); - const executeJobFn = await executeJobFactory(reporting, server, elasticsearch, logger); + const [createJobFn, executeJobFn] = await Promise.all([ + createJobFactory(reporting, logger), + executeJobFactory(reporting, logger), + ]); const jobDocPayload: JobDocPayloadPanelCsv = await createJobFn( jobParams, request.headers, diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts index 54d9671692c5d..44a98dac2d4a9 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.test.ts @@ -7,7 +7,7 @@ import Hapi from 'hapi'; import { createMockReportingCore } from '../../test_helpers'; import { Logger, ServerFacade } from '../../types'; -import { ReportingCore, ReportingSetupDeps } from '../../server/types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; jest.mock('./lib/authorized_user_pre_routing', () => ({ authorizedUserPreRoutingFactory: () => () => ({}), @@ -22,6 +22,8 @@ import { registerJobGenerationRoutes } from './generation'; let mockServer: Hapi.Server; let mockReportingPlugin: ReportingCore; +let mockReportingConfig: ReportingConfig; + const mockLogger = ({ error: jest.fn(), debug: jest.fn(), @@ -33,10 +35,12 @@ beforeEach(async () => { port: 8080, routes: { log: { collect: true } }, }); - mockServer.config = () => ({ get: jest.fn(), has: jest.fn() }); + mockReportingPlugin = await createMockReportingCore(); mockReportingPlugin.getEnqueueJob = async () => jest.fn().mockImplementation(() => ({ toJSON: () => '{ "job": "data" }' })); + + mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; }); const mockPlugins = { @@ -54,6 +58,7 @@ const getErrorsFromRequest = (request: Hapi.Request) => { test(`returns 400 if there are no job params`, async () => { registerJobGenerationRoutes( mockReportingPlugin, + (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger @@ -80,6 +85,7 @@ test(`returns 400 if there are no job params`, async () => { test(`returns 400 if job params is invalid`, async () => { registerJobGenerationRoutes( mockReportingPlugin, + (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger @@ -114,6 +120,7 @@ test(`returns 500 if job handler throws an error`, async () => { registerJobGenerationRoutes( mockReportingPlugin, + (mockReportingConfig as unknown) as ReportingConfig, (mockServer as unknown) as ServerFacade, (mockPlugins as unknown) as ReportingSetupDeps, mockLogger diff --git a/x-pack/legacy/plugins/reporting/server/routes/generation.ts b/x-pack/legacy/plugins/reporting/server/routes/generation.ts index 096ba84b63d1a..0ac6a34dd75bb 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/generation.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/generation.ts @@ -9,7 +9,7 @@ import { errors as elasticsearchErrors } from 'elasticsearch'; import { Legacy } from 'kibana'; import { API_BASE_URL } from '../../common/constants'; import { Logger, ReportingResponseToolkit, ServerFacade } from '../../types'; -import { ReportingSetupDeps, ReportingCore } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { registerGenerateFromJobParams } from './generate_from_jobparams'; import { registerGenerateCsvFromSavedObject } from './generate_from_savedobject'; import { registerGenerateCsvFromSavedObjectImmediate } from './generate_from_savedobject_immediate'; @@ -19,12 +19,13 @@ const esErrors = elasticsearchErrors as Record; export function registerJobGenerationRoutes( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - const config = server.config(); - const DOWNLOAD_BASE_URL = config.get('server.basePath') + `${API_BASE_URL}/jobs/download`; + const DOWNLOAD_BASE_URL = + `${config.kbnConfig.get('server', 'basePath')}` + `${API_BASE_URL}/jobs/download`; /* * Generates enqueued job details to use in responses @@ -66,11 +67,11 @@ export function registerJobGenerationRoutes( return err; } - registerGenerateFromJobParams(server, plugins, handler, handleError, logger); + registerGenerateFromJobParams(config, server, plugins, handler, handleError, logger); // Register beta panel-action download-related API's - if (config.get('xpack.reporting.csv.enablePanelActionDownload')) { - registerGenerateCsvFromSavedObject(server, plugins, handler, handleError, logger); - registerGenerateCsvFromSavedObjectImmediate(reporting, server, plugins, logger); + if (config.get('csv', 'enablePanelActionDownload')) { + registerGenerateCsvFromSavedObject(config, server, plugins, handler, handleError, logger); + registerGenerateCsvFromSavedObjectImmediate(reporting, config, server, plugins, logger); } } diff --git a/x-pack/legacy/plugins/reporting/server/routes/index.ts b/x-pack/legacy/plugins/reporting/server/routes/index.ts index 610ab4907d369..21eeb901d9b96 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/index.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/index.ts @@ -5,16 +5,17 @@ */ import { Logger, ServerFacade } from '../../types'; -import { ReportingCore, ReportingSetupDeps } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { registerJobGenerationRoutes } from './generation'; import { registerJobInfoRoutes } from './jobs'; export function registerRoutes( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { - registerJobGenerationRoutes(reporting, server, plugins, logger); - registerJobInfoRoutes(reporting, server, plugins, logger); + registerJobGenerationRoutes(reporting, config, server, plugins, logger); + registerJobInfoRoutes(reporting, config, server, plugins, logger); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js index 071b401d2321b..b12aa44487523 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.test.js @@ -5,7 +5,6 @@ */ import Hapi from 'hapi'; -import { memoize } from 'lodash'; import { createMockReportingCore } from '../../test_helpers'; import { ExportTypesRegistry } from '../lib/export_types_registry'; @@ -23,6 +22,7 @@ import { registerJobInfoRoutes } from './jobs'; let mockServer; let exportTypesRegistry; let mockReportingPlugin; +let mockReportingConfig; const mockLogger = { error: jest.fn(), debug: jest.fn(), @@ -30,7 +30,6 @@ const mockLogger = { beforeEach(async () => { mockServer = new Hapi.Server({ debug: false, port: 8080, routes: { log: { collect: true } } }); - mockServer.config = memoize(() => ({ get: jest.fn() })); exportTypesRegistry = new ExportTypesRegistry(); exportTypesRegistry.register({ id: 'unencoded', @@ -43,8 +42,11 @@ beforeEach(async () => { jobContentEncoding: 'base64', jobContentExtension: 'pdf', }); + mockReportingPlugin = await createMockReportingCore(); mockReportingPlugin.getExportTypesRegistry = () => exportTypesRegistry; + + mockReportingConfig = { get: jest.fn(), kbnConfig: { get: jest.fn() } }; }); const mockPlugins = { @@ -70,7 +72,13 @@ test(`returns 404 if job not found`, async () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(getHits())), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -89,7 +97,13 @@ test(`returns 401 if not valid job type`, async () => { .mockReturnValue(Promise.resolve(getHits({ jobtype: 'invalidJobType' }))), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -110,7 +124,13 @@ describe(`when job is incomplete`, () => { ), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -152,7 +172,13 @@ describe(`when job is failed`, () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', @@ -197,7 +223,13 @@ describe(`when job is completed`, () => { callAsInternalUser: jest.fn().mockReturnValue(Promise.resolve(hits)), }; - registerJobInfoRoutes(mockReportingPlugin, mockServer, mockPlugins, mockLogger); + registerJobInfoRoutes( + mockReportingPlugin, + mockReportingConfig, + mockServer, + mockPlugins, + mockLogger + ); const request = { method: 'GET', diff --git a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts index b9aa75e0ddd00..4f29e561431fa 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/jobs.ts @@ -17,7 +17,7 @@ import { ServerFacade, } from '../../types'; import { jobsQueryFactory } from '../lib/jobs_query'; -import { ReportingSetupDeps, ReportingCore } from '../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../types'; import { deleteJobResponseHandlerFactory, downloadJobResponseHandlerFactory, @@ -37,13 +37,14 @@ function isResponse(response: Boom | ResponseObject): response is Response export function registerJobInfoRoutes( reporting: ReportingCore, + config: ReportingConfig, server: ServerFacade, plugins: ReportingSetupDeps, logger: Logger ) { const { elasticsearch } = plugins; - const jobsQuery = jobsQueryFactory(server, elasticsearch); - const getRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + const jobsQuery = jobsQueryFactory(config, elasticsearch); + const getRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); // list jobs in the queue, paginated server.route({ @@ -141,8 +142,8 @@ export function registerJobInfoRoutes( // trigger a download of the output from a job const exportTypesRegistry = reporting.getExportTypesRegistry(); - const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger); - const downloadResponseHandler = downloadJobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); // prettier-ignore + const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(config, plugins, logger); + const downloadResponseHandler = downloadJobResponseHandlerFactory(config, elasticsearch, exportTypesRegistry); // prettier-ignore server.route({ path: `${MAIN_ENTRY}/download/{docId}`, method: 'GET', @@ -181,8 +182,8 @@ export function registerJobInfoRoutes( }); // allow a report to be deleted - const getRouteConfigDelete = getRouteConfigFactoryDeletePre(server, plugins, logger); - const deleteResponseHandler = deleteJobResponseHandlerFactory(server, elasticsearch); + const getRouteConfigDelete = getRouteConfigFactoryDeletePre(config, plugins, logger); + const deleteResponseHandler = deleteJobResponseHandlerFactory(config, elasticsearch); server.route({ path: `${MAIN_ENTRY}/delete/{docId}`, method: 'DELETE', diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js index 3460d22592e3d..b5d6ae59ce5dd 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.test.js @@ -7,56 +7,48 @@ import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; describe('authorized_user_pre_routing', function() { - // the getClientShield is using `once` which forces us to use a constant mock - // which makes testing anything that is dependent on `oncePerServer` confusing. - // so createMockServer reuses the same 'instance' of the server and overwrites - // the properties to contain different values - const createMockServer = (function() { - const getUserStub = jest.fn(); - let mockConfig; - - const mockServer = { - expose() {}, - config() { - return { - get(key) { - return mockConfig[key]; - }, - }; - }, - log: function() {}, - plugins: { - xpack_main: {}, - security: { getUser: getUserStub }, - }, + const createMockConfig = (mockConfig = {}) => { + return { + get: (...keys) => mockConfig[keys.join('.')], + kbnConfig: { get: (...keys) => mockConfig[keys.join('.')] }, }; + }; + const createMockPlugins = (function() { + const getUserStub = jest.fn(); return function({ securityEnabled = true, xpackInfoUndefined = false, xpackInfoAvailable = true, + getCurrentUser = undefined, user = undefined, - config = {}, }) { - mockConfig = config; - - mockServer.plugins.xpack_main = { - info: !xpackInfoUndefined && { - isAvailable: () => xpackInfoAvailable, - feature(featureName) { - if (featureName === 'security') { - return { - isEnabled: () => securityEnabled, - isAvailable: () => xpackInfoAvailable, - }; + getUserStub.mockReset(); + getUserStub.mockResolvedValue(user); + return { + security: securityEnabled + ? { + authc: { getCurrentUser }, } + : null, + __LEGACY: { + plugins: { + xpack_main: { + info: !xpackInfoUndefined && { + isAvailable: () => xpackInfoAvailable, + feature(featureName) { + if (featureName === 'security') { + return { + isEnabled: () => securityEnabled, + isAvailable: () => xpackInfoAvailable, + }; + } + }, + }, + }, }, }, }; - - getUserStub.mockReset(); - getUserStub.mockResolvedValue(user); - return mockServer; }; })(); @@ -75,10 +67,6 @@ describe('authorized_user_pre_routing', function() { raw: { req: mockRequestRaw }, }); - const getMockPlugins = pluginSet => { - return pluginSet || { security: null }; - }; - const getMockLogger = () => ({ warn: jest.fn(), error: msg => { @@ -87,11 +75,9 @@ describe('authorized_user_pre_routing', function() { }); it('should return with boom notFound when xpackInfo is undefined', async function() { - const mockServer = createMockServer({ xpackInfoUndefined: true }); - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, - getMockPlugins(), + createMockConfig(), + createMockPlugins({ xpackInfoUndefined: true }), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -100,11 +86,9 @@ describe('authorized_user_pre_routing', function() { }); it(`should return with boom notFound when xpackInfo isn't available`, async function() { - const mockServer = createMockServer({ xpackInfoAvailable: false }); - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, - getMockPlugins(), + createMockConfig(), + createMockPlugins({ xpackInfoAvailable: false }), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -113,11 +97,9 @@ describe('authorized_user_pre_routing', function() { }); it('should return with null user when security is disabled in Elasticsearch', async function() { - const mockServer = createMockServer({ securityEnabled: false }); - const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, - getMockPlugins(), + createMockConfig(), + createMockPlugins({ securityEnabled: false }), getMockLogger() ); const response = await authorizedUserPreRouting(getMockRequest()); @@ -125,16 +107,14 @@ describe('authorized_user_pre_routing', function() { }); it('should return with boom unauthenticated when security is enabled but no authenticated user', async function() { - const mockServer = createMockServer({ + const mockPlugins = createMockPlugins({ user: null, config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, }); - const mockPlugins = getMockPlugins({ - security: { authc: { getCurrentUser: () => null } }, - }); + mockPlugins.security = { authc: { getCurrentUser: () => null } }; const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + createMockConfig(), mockPlugins, getMockLogger() ); @@ -144,16 +124,14 @@ describe('authorized_user_pre_routing', function() { }); it(`should return with boom forbidden when security is enabled but user doesn't have allowed role`, async function() { - const mockServer = createMockServer({ + const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); + const mockPlugins = createMockPlugins({ user: { roles: [] }, - config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, - }); - const mockPlugins = getMockPlugins({ - security: { authc: { getCurrentUser: () => ({ roles: ['something_else'] }) } }, + getCurrentUser: () => ({ roles: ['something_else'] }), }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + mockConfig, mockPlugins, getMockLogger() ); @@ -164,18 +142,14 @@ describe('authorized_user_pre_routing', function() { it('should return with user when security is enabled and user has explicitly allowed role', async function() { const user = { roles: ['.reporting_user', 'something_else'] }; - const mockServer = createMockServer({ + const mockConfig = createMockConfig({ 'roles.allow': ['.reporting_user'] }); + const mockPlugins = createMockPlugins({ user, - config: { 'xpack.reporting.roles.allow': ['.reporting_user'] }, - }); - const mockPlugins = getMockPlugins({ - security: { - authc: { getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }) }, - }, + getCurrentUser: () => ({ roles: ['.reporting_user', 'something_else'] }), }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + mockConfig, mockPlugins, getMockLogger() ); @@ -185,16 +159,13 @@ describe('authorized_user_pre_routing', function() { it('should return with user when security is enabled and user has superuser role', async function() { const user = { roles: ['superuser', 'something_else'] }; - const mockServer = createMockServer({ - user, - config: { 'xpack.reporting.roles.allow': [] }, - }); - const mockPlugins = getMockPlugins({ - security: { authc: { getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }) } }, + const mockConfig = createMockConfig({ 'roles.allow': [] }); + const mockPlugins = createMockPlugins({ + getCurrentUser: () => ({ roles: ['superuser', 'something_else'] }), }); const authorizedUserPreRouting = authorizedUserPreRoutingFactory( - mockServer, + mockConfig, mockPlugins, getMockLogger() ); diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts index c5f8c78016f61..1ca28ca62a7f2 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/authorized_user_pre_routing.ts @@ -7,7 +7,8 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; import { AuthenticatedUser } from '../../../../../../plugins/security/server'; -import { Logger, ServerFacade } from '../../../types'; +import { ReportingConfig } from '../../../server'; +import { Logger } from '../../../types'; import { getUserFactory } from '../../lib/get_user'; import { ReportingSetupDeps } from '../../types'; @@ -18,16 +19,14 @@ export type PreRoutingFunction = ( ) => Promise | AuthenticatedUser | null>; export const authorizedUserPreRoutingFactory = function authorizedUserPreRoutingFn( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ) { - const getUser = getUserFactory(server, plugins.security); - const config = server.config(); + const getUser = getUserFactory(plugins.security, logger); + const { info: xpackInfo } = plugins.__LEGACY.plugins.xpack_main; return async function authorizedUserPreRouting(request: Legacy.Request) { - const xpackInfo = server.plugins.xpack_main.info; - if (!xpackInfo || !xpackInfo.isAvailable()) { logger.warn('Unable to authorize user before xpack info is available.', [ 'authorizedUserPreRouting', @@ -46,10 +45,7 @@ export const authorizedUserPreRoutingFactory = function authorizedUserPreRouting return Boom.unauthorized(`Sorry, you aren't authenticated`); } - const authorizedRoles = [ - superuserRole, - ...(config.get('xpack.reporting.roles.allow') as string[]), - ]; + const authorizedRoles = [superuserRole, ...(config.get('roles', 'allow') as string[])]; if (!user.roles.find(role => authorizedRoles.includes(role))) { return Boom.forbidden(`Sorry, you don't have access to Reporting`); } diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts index fb3944ea33552..aef37754681ec 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -8,13 +8,7 @@ import contentDisposition from 'content-disposition'; import * as _ from 'lodash'; import { CSV_JOB_TYPE } from '../../../common/constants'; -import { - ExportTypeDefinition, - ExportTypesRegistry, - JobDocOutput, - JobSource, - ServerFacade, -} from '../../../types'; +import { ExportTypeDefinition, ExportTypesRegistry, JobDocOutput, JobSource } from '../../../types'; interface ICustomHeaders { [x: string]: any; @@ -22,9 +16,15 @@ interface ICustomHeaders { type ExportTypeType = ExportTypeDefinition; +interface ErrorFromPayload { + message: string; + reason: string | null; +} + +// A camelCase version of JobDocOutput interface Payload { statusCode: number; - content: any; + content: string | Buffer | ErrorFromPayload; contentType: string; headers: Record; } @@ -48,20 +48,17 @@ const getReportingHeaders = (output: JobDocOutput, exportType: ExportTypeType) = return metaDataHeaders; }; -export function getDocumentPayloadFactory( - server: ServerFacade, - exportTypesRegistry: ExportTypesRegistry -) { - function encodeContent(content: string | null, exportType: ExportTypeType) { +export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegistry) { + function encodeContent(content: string | null, exportType: ExportTypeType): Buffer | string { switch (exportType.jobContentEncoding) { case 'base64': - return content ? Buffer.from(content, 'base64') : content; // Buffer.from rejects null + return content ? Buffer.from(content, 'base64') : ''; // convert null to empty string default: - return content; + return content ? content : ''; // convert null to empty string } } - function getCompleted(output: JobDocOutput, jobType: string, title: string) { + function getCompleted(output: JobDocOutput, jobType: string, title: string): Payload { const exportType = exportTypesRegistry.get((item: ExportTypeType) => item.jobType === jobType); const filename = getTitle(exportType, title); const headers = getReportingHeaders(output, exportType); @@ -77,7 +74,7 @@ export function getDocumentPayloadFactory( }; } - function getFailure(output: JobDocOutput) { + function getFailure(output: JobDocOutput): Payload { return { statusCode: 500, content: { diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts index 30627d5b23230..e7e7c866db96a 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -5,11 +5,12 @@ */ import Boom from 'boom'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { ResponseToolkit } from 'hapi'; +import { ElasticsearchServiceSetup } from 'kibana/server'; import { WHITELISTED_JOB_CONTENT_TYPES } from '../../../common/constants'; -import { ExportTypesRegistry, ServerFacade } from '../../../types'; +import { ExportTypesRegistry } from '../../../types'; import { jobsQueryFactory } from '../../lib/jobs_query'; +import { ReportingConfig } from '../../types'; import { getDocumentPayloadFactory } from './get_document_payload'; interface JobResponseHandlerParams { @@ -21,12 +22,12 @@ interface JobResponseHandlerOpts { } export function downloadJobResponseHandlerFactory( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup, exportTypesRegistry: ExportTypesRegistry ) { - const jobsQuery = jobsQueryFactory(server, elasticsearch); - const getDocumentPayload = getDocumentPayloadFactory(server, exportTypesRegistry); + const jobsQuery = jobsQueryFactory(config, elasticsearch); + const getDocumentPayload = getDocumentPayloadFactory(exportTypesRegistry); return function jobResponseHandler( validJobTypes: string[], @@ -70,10 +71,10 @@ export function downloadJobResponseHandlerFactory( } export function deleteJobResponseHandlerFactory( - server: ServerFacade, + config: ReportingConfig, elasticsearch: ElasticsearchServiceSetup ) { - const jobsQuery = jobsQueryFactory(server, elasticsearch); + const jobsQuery = jobsQueryFactory(config, elasticsearch); return async function deleteJobResponseHander( validJobTypes: string[], diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts index 9e618ff1fe40a..8a79566aafae2 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/reporting_feature_pre_routing.ts @@ -6,17 +6,17 @@ import Boom from 'boom'; import { Legacy } from 'kibana'; -import { Logger, ServerFacade } from '../../../types'; -import { ReportingSetupDeps } from '../../types'; +import { Logger } from '../../../types'; +import { ReportingConfig, ReportingSetupDeps } from '../../types'; export type GetReportingFeatureIdFn = (request: Legacy.Request) => string; export const reportingFeaturePreRoutingFactory = function reportingFeaturePreRoutingFn( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ) { - const xpackMainPlugin = server.plugins.xpack_main; + const xpackMainPlugin = plugins.__LEGACY.plugins.xpack_main; const pluginId = 'reporting'; // License checking and enable/disable logic diff --git a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts index 3d275d34e2f7d..06f7efaa9dcbb 100644 --- a/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts +++ b/x-pack/legacy/plugins/reporting/server/routes/lib/route_config_factories.ts @@ -6,8 +6,8 @@ import Joi from 'joi'; import { CSV_FROM_SAVEDOBJECT_JOB_TYPE } from '../../../common/constants'; -import { Logger, ServerFacade } from '../../../types'; -import { ReportingSetupDeps } from '../../types'; +import { Logger } from '../../../types'; +import { ReportingConfig, ReportingSetupDeps } from '../../types'; import { authorizedUserPreRoutingFactory } from './authorized_user_pre_routing'; import { GetReportingFeatureIdFn, @@ -29,12 +29,12 @@ export type GetRouteConfigFactoryFn = ( ) => RouteConfigFactory; export function getRouteConfigFactoryReportingPre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server, plugins, logger); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); + const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); return (getFeatureId?: GetReportingFeatureIdFn): RouteConfigFactory => { const preRouting: any[] = [{ method: authorizedUserPreRouting, assign: 'user' }]; @@ -50,11 +50,11 @@ export function getRouteConfigFactoryReportingPre( } export function getRouteOptionsCsv( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ) { - const getRouteConfig = getRouteConfigFactoryReportingPre(server, plugins, logger); + const getRouteConfig = getRouteConfigFactoryReportingPre(config, plugins, logger); return { ...getRouteConfig(() => CSV_FROM_SAVEDOBJECT_JOB_TYPE), validate: { @@ -75,12 +75,12 @@ export function getRouteOptionsCsv( } export function getRouteConfigFactoryManagementPre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const authorizedUserPreRouting = authorizedUserPreRoutingFactory(server, plugins, logger); - const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(server, plugins, logger); + const authorizedUserPreRouting = authorizedUserPreRoutingFactory(config, plugins, logger); + const reportingFeaturePreRouting = reportingFeaturePreRoutingFactory(config, plugins, logger); const managementPreRouting = reportingFeaturePreRouting(() => 'management'); return (): RouteConfigFactory => { @@ -99,11 +99,11 @@ export function getRouteConfigFactoryManagementPre( // Additionally, the range-request doesn't alleviate any performance issues on the server as the entire // download is loaded into memory. export function getRouteConfigFactoryDownloadPre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), tags: [API_TAG, 'download'], @@ -114,11 +114,11 @@ export function getRouteConfigFactoryDownloadPre( } export function getRouteConfigFactoryDeletePre( - server: ServerFacade, + config: ReportingConfig, plugins: ReportingSetupDeps, logger: Logger ): GetRouteConfigFactoryFn { - const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger); + const getManagementRouteConfig = getRouteConfigFactoryManagementPre(config, plugins, logger); return (): RouteConfigFactory => ({ ...getManagementRouteConfig(), tags: [API_TAG, 'delete'], diff --git a/x-pack/legacy/plugins/reporting/server/types.d.ts b/x-pack/legacy/plugins/reporting/server/types.d.ts index 59b7bc2020ad9..c773e2d556648 100644 --- a/x-pack/legacy/plugins/reporting/server/types.d.ts +++ b/x-pack/legacy/plugins/reporting/server/types.d.ts @@ -11,16 +11,17 @@ import { PluginStart as DataPluginStart } from '../../../../../src/plugins/data/ import { SecurityPluginSetup } from '../../../../plugins/security/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import { ReportingPluginSpecOptions } from '../types'; +import { ReportingConfig, ReportingConfigType } from './core'; export interface ReportingSetupDeps { elasticsearch: ElasticsearchServiceSetup; security: SecurityPluginSetup; usageCollection: UsageCollectionSetup; + reporting: { config: ReportingConfig }; __LEGACY: LegacySetup; } export interface ReportingStartDeps { - elasticsearch: ElasticsearchServiceSetup; data: DataPluginStart; __LEGACY: LegacySetup; } @@ -30,10 +31,7 @@ export type ReportingSetup = object; export type ReportingStart = object; export interface LegacySetup { - config: Legacy.Server['config']; - info: Legacy.Server['info']; plugins: { - elasticsearch: Legacy.Server['plugins']['elasticsearch']; xpack_main: XPackMainPlugin & { status?: any; }; @@ -42,4 +40,7 @@ export interface LegacySetup { route: Legacy.Server['route']; } -export { ReportingCore } from './core'; +export { ReportingConfig, ReportingCore } from './core'; + +export type CaptureConfig = ReportingConfigType['capture']; +export type ScrollConfig = ReportingConfigType['csv']['scroll']; diff --git a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts index bd2d0cb835a79..5f12f2b7f044d 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/get_reporting_usage.ts @@ -5,7 +5,10 @@ */ import { get } from 'lodash'; -import { ServerFacade, ExportTypesRegistry, ESCallCluster } from '../../types'; +import { ESCallCluster, ExportTypesRegistry } from '../../types'; +import { ReportingConfig, ReportingSetupDeps } from '../types'; +import { decorateRangeStats } from './decorate_range_stats'; +import { getExportTypesHandler } from './get_export_type_handler'; import { AggregationBuckets, AggregationResults, @@ -15,8 +18,6 @@ import { RangeAggregationResults, RangeStats, } from './types'; -import { decorateRangeStats } from './decorate_range_stats'; -import { getExportTypesHandler } from './get_export_type_handler'; const JOB_TYPES_KEY = 'jobTypes'; const JOB_TYPES_FIELD = 'jobtype'; @@ -79,10 +80,7 @@ type RangeStatSets = Partial< last7Days: RangeStats; } >; -async function handleResponse( - server: ServerFacade, - response: AggregationResults -): Promise { +async function handleResponse(response: AggregationResults): Promise { const buckets = get(response, 'aggregations.ranges.buckets'); if (!buckets) { return {}; @@ -101,12 +99,12 @@ async function handleResponse( } export async function getReportingUsage( - server: ServerFacade, + config: ReportingConfig, + plugins: ReportingSetupDeps, callCluster: ESCallCluster, exportTypesRegistry: ExportTypesRegistry ) { - const config = server.config(); - const reportingIndex = config.get('xpack.reporting.index'); + const reportingIndex = config.get('index'); const params = { index: `${reportingIndex}-*`, @@ -139,16 +137,18 @@ export async function getReportingUsage( }, }; + const { info: xpackMainInfo } = plugins.__LEGACY.plugins.xpack_main; return callCluster('search', params) - .then((response: AggregationResults) => handleResponse(server, response)) + .then((response: AggregationResults) => handleResponse(response)) .then((usage: RangeStatSets) => { // Allow this to explicitly throw an exception if/when this config is deprecated, // because we shouldn't collect browserType in that case! - const browserType = config.get('xpack.reporting.capture.browser.type'); + const browserType = config.get('capture', 'browser', 'type'); - const xpackInfo = server.plugins.xpack_main.info; const exportTypesHandler = getExportTypesHandler(exportTypesRegistry); - const availability = exportTypesHandler.getAvailability(xpackInfo) as FeatureAvailabilityMap; + const availability = exportTypesHandler.getAvailability( + xpackMainInfo + ) as FeatureAvailabilityMap; const { lastDay, last7Days, ...all } = usage; diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js index a6d753f9b107a..905d2fe9b995c 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.test.js @@ -24,62 +24,52 @@ function getMockUsageCollection() { makeUsageCollector: options => { return new MockUsageCollector(this, options); }, + registerCollector: sinon.stub(), }; } -function getServerMock(customization) { - const getLicenseCheckResults = sinon.stub().returns({}); - const defaultServerMock = { - plugins: { - security: { - isAuthenticated: sinon.stub().returns(true), - }, - xpack_main: { - info: { - isAvailable: sinon.stub().returns(true), - feature: () => ({ - getLicenseCheckResults, - }), - license: { - isOneOf: sinon.stub().returns(false), - getType: sinon.stub().returns('platinum'), - }, - toJSON: () => ({ b: 1 }), - }, +function getPluginsMock( + { license, usageCollection = getMockUsageCollection() } = { license: 'platinum' } +) { + const mockXpackMain = { + info: { + isAvailable: sinon.stub().returns(true), + feature: () => ({ + getLicenseCheckResults: sinon.stub(), + }), + license: { + isOneOf: sinon.stub().returns(false), + getType: sinon.stub().returns(license), }, + toJSON: () => ({ b: 1 }), }, - log: () => {}, - config: () => ({ - get: key => { - if (key === 'xpack.reporting.enabled') { - return true; - } else if (key === 'xpack.reporting.index') { - return '.reporting-index'; - } + }; + return { + usageCollection, + __LEGACY: { + plugins: { + xpack_main: mockXpackMain, }, - }), + }, }; - return Object.assign(defaultServerMock, customization); } const getResponseMock = (customization = {}) => customization; describe('license checks', () => { + let mockConfig; + beforeAll(async () => { + const mockReporting = await createMockReportingCore(); + mockConfig = await mockReporting.getConfig(); + }); + describe('with a basic license', () => { let usageStats; beforeAll(async () => { - const serverWithBasicLicenseMock = getServerMock(); - serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('basic'); + const plugins = getPluginsMock({ license: 'basic' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithBasicLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -98,18 +88,10 @@ describe('license checks', () => { describe('with no license', () => { let usageStats; beforeAll(async () => { - const serverWithNoLicenseMock = getServerMock(); - serverWithNoLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('none'); + const plugins = getPluginsMock({ license: 'none' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithNoLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -128,18 +110,10 @@ describe('license checks', () => { describe('with platinum license', () => { let usageStats; beforeAll(async () => { - const serverWithPlatinumLicenseMock = getServerMock(); - serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('platinum'); + const plugins = getPluginsMock({ license: 'platinum' }); const callClusterMock = jest.fn(() => Promise.resolve(getResponseMock())); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithPlatinumLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -158,18 +132,10 @@ describe('license checks', () => { describe('with no usage data', () => { let usageStats; beforeAll(async () => { - const serverWithBasicLicenseMock = getServerMock(); - serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('basic'); + const plugins = getPluginsMock({ license: 'basic' }); const callClusterMock = jest.fn(() => Promise.resolve({})); - const usageCollection = getMockUsageCollection(); - const { fetch: getReportingUsage } = getReportingUsageCollector( - serverWithBasicLicenseMock, - usageCollection, - exportTypesRegistry - ); - usageStats = await getReportingUsage(callClusterMock, exportTypesRegistry); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); + usageStats = await fetch(callClusterMock, exportTypesRegistry); }); test('sets enables to true', async () => { @@ -183,21 +149,11 @@ describe('license checks', () => { }); describe('data modeling', () => { - let getReportingUsage; - beforeAll(async () => { - const usageCollection = getMockUsageCollection(); - const serverWithPlatinumLicenseMock = getServerMock(); - serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = sinon - .stub() - .returns('platinum'); - ({ fetch: getReportingUsage } = getReportingUsageCollector( - serverWithPlatinumLicenseMock, - usageCollection, - exportTypesRegistry - )); - }); - test('with normal looking usage data', async () => { + const mockReporting = await createMockReportingCore(); + const mockConfig = await mockReporting.getConfig(); + const plugins = getPluginsMock(); + const { fetch } = getReportingUsageCollector(mockConfig, plugins, exportTypesRegistry); const callClusterMock = jest.fn(() => Promise.resolve( getResponseMock({ @@ -320,7 +276,7 @@ describe('data modeling', () => { ) ); - const usageStats = await getReportingUsage(callClusterMock); + const usageStats = await fetch(callClusterMock); expect(usageStats).toMatchInlineSnapshot(` Object { "PNG": Object { @@ -415,20 +371,16 @@ describe('data modeling', () => { }); describe('Ready for collection observable', () => { - let mockReporting; - - beforeEach(async () => { - mockReporting = await createMockReportingCore(); - }); - test('converts observable to promise', async () => { - const serverWithBasicLicenseMock = getServerMock(); + const mockReporting = await createMockReportingCore(); + const mockConfig = await mockReporting.getConfig(); + + const usageCollection = getMockUsageCollection(); const makeCollectorSpy = sinon.spy(); - const usageCollection = { - makeUsageCollector: makeCollectorSpy, - registerCollector: sinon.stub(), - }; - registerReportingUsageCollector(mockReporting, serverWithBasicLicenseMock, usageCollection); + usageCollection.makeUsageCollector = makeCollectorSpy; + + const plugins = getPluginsMock({ usageCollection }); + registerReportingUsageCollector(mockReporting, mockConfig, plugins); const [args] = makeCollectorSpy.firstCall.args; expect(args).toMatchInlineSnapshot(` diff --git a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts index 14202530fb6c7..ab4ec3a0edf57 100644 --- a/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts +++ b/x-pack/legacy/plugins/reporting/server/usage/reporting_usage_collector.ts @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { KIBANA_REPORTING_TYPE } from '../../common/constants'; -import { ReportingCore } from '../../server'; -import { ESCallCluster, ExportTypesRegistry, ServerFacade } from '../../types'; +import { ReportingConfig, ReportingCore, ReportingSetupDeps } from '../../server/types'; +import { ESCallCluster, ExportTypesRegistry } from '../../types'; import { getReportingUsage } from './get_reporting_usage'; import { RangeStats } from './types'; @@ -15,19 +14,19 @@ import { RangeStats } from './types'; const METATYPE = 'kibana_stats'; /* - * @param {Object} server * @return {Object} kibana usage stats type collection object */ export function getReportingUsageCollector( - server: ServerFacade, - usageCollection: UsageCollectionSetup, + config: ReportingConfig, + plugins: ReportingSetupDeps, exportTypesRegistry: ExportTypesRegistry, isReady: () => Promise ) { + const { usageCollection } = plugins; return usageCollection.makeUsageCollector({ type: KIBANA_REPORTING_TYPE, fetch: (callCluster: ESCallCluster) => - getReportingUsage(server, callCluster, exportTypesRegistry), + getReportingUsage(config, plugins, callCluster, exportTypesRegistry), isReady, /* @@ -52,17 +51,17 @@ export function getReportingUsageCollector( export function registerReportingUsageCollector( reporting: ReportingCore, - server: ServerFacade, - usageCollection: UsageCollectionSetup + config: ReportingConfig, + plugins: ReportingSetupDeps ) { const exportTypesRegistry = reporting.getExportTypesRegistry(); const collectionIsReady = reporting.pluginHasStarted.bind(reporting); const collector = getReportingUsageCollector( - server, - usageCollection, + config, + plugins, exportTypesRegistry, collectionIsReady ); - usageCollection.registerCollector(collector); + plugins.usageCollection.registerCollector(collector); } diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts index 883276d43e27e..930aa7601b8cb 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_browserdriverfactory.ts @@ -10,7 +10,8 @@ import * as contexts from '../export_types/common/lib/screenshots/constants'; import { ElementsPositionAndAttribute } from '../export_types/common/lib/screenshots/types'; import { HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../server/browsers'; import { createDriverFactory } from '../server/browsers/chromium'; -import { BrowserConfig, CaptureConfig, Logger } from '../types'; +import { CaptureConfig } from '../server/types'; +import { Logger } from '../types'; interface CreateMockBrowserDriverFactoryOpts { evaluate: jest.Mock, any[]>; @@ -93,24 +94,34 @@ export const createMockBrowserDriverFactory = async ( logger: Logger, opts: Partial ): Promise => { - const browserConfig = { - inspect: true, - userDataDir: '/usr/data/dir', - viewport: { width: 12, height: 12 }, - disableSandbox: false, - proxy: { enabled: false }, - } as BrowserConfig; + const captureConfig = { + timeouts: { openUrl: 30000, waitForElements: 30000, renderComplete: 30000 }, + browser: { + type: 'chromium', + chromium: { + inspect: false, + disableSandbox: false, + userDataDir: '/usr/data/dir', + viewport: { width: 12, height: 12 }, + proxy: { enabled: false, server: undefined, bypass: undefined }, + }, + autoDownload: false, + inspect: true, + userDataDir: '/usr/data/dir', + viewport: { width: 12, height: 12 }, + disableSandbox: false, + proxy: { enabled: false, server: undefined, bypass: undefined }, + maxScreenshotDimension: undefined, + }, + networkPolicy: { enabled: true, rules: [] }, + viewport: { width: 800, height: 600 }, + loadDelay: 2000, + zoom: 1, + maxAttempts: 1, + } as CaptureConfig; const binaryPath = '/usr/local/share/common/secure/'; - const captureConfig = { networkPolicy: {}, timeouts: {} } as CaptureConfig; - - const mockBrowserDriverFactory = await createDriverFactory( - binaryPath, - logger, - browserConfig, - captureConfig - ); - + const mockBrowserDriverFactory = await createDriverFactory(binaryPath, logger, captureConfig); const mockPage = {} as Page; const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, { inspect: true, diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts index 0250e6c0a9afd..be60b56dcc0c1 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_layoutinstance.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createLayout } from '../export_types/common/layouts'; import { LayoutTypes } from '../export_types/common/constants'; +import { createLayout } from '../export_types/common/layouts'; import { LayoutInstance } from '../export_types/common/layouts/layout'; -import { ServerFacade } from '../types'; +import { CaptureConfig } from '../server/types'; -export const createMockLayoutInstance = (__LEGACY: ServerFacade) => { - const mockLayout = createLayout(__LEGACY, { +export const createMockLayoutInstance = (captureConfig: CaptureConfig) => { + const mockLayout = createLayout(captureConfig, { id: LayoutTypes.PRESERVE_LAYOUT, dimensions: { height: 12, width: 12 }, }) as LayoutInstance; diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts index 2cd129d47b3f9..332b37b58cb7d 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_reportingplugin.ts @@ -19,16 +19,24 @@ import { coreMock } from 'src/core/server/mocks'; import { ReportingPlugin, ReportingCore } from '../server'; import { ReportingSetupDeps, ReportingStartDeps } from '../server/types'; -export const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => ({ - elasticsearch: setupMock.elasticsearch, - security: setupMock.security, - usageCollection: {} as any, - __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, -}); +const createMockSetupDeps = (setupMock?: any): ReportingSetupDeps => { + const configGetStub = jest.fn(); + return { + elasticsearch: setupMock.elasticsearch, + security: setupMock.security, + usageCollection: {} as any, + reporting: { + config: { + get: configGetStub, + kbnConfig: { get: configGetStub }, + }, + }, + __LEGACY: { plugins: { xpack_main: { status: new EventEmitter() } } } as any, + }; +}; export const createMockStartDeps = (startMock?: any): ReportingStartDeps => ({ data: startMock.data, - elasticsearch: startMock.elasticsearch, __LEGACY: {} as any, }); diff --git a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts index bb7851ba036a9..531e1dcaf84e0 100644 --- a/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts +++ b/x-pack/legacy/plugins/reporting/test_helpers/create_mock_server.ts @@ -3,36 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { memoize } from 'lodash'; -import { ServerFacade } from '../types'; - -export const createMockServer = ({ settings = {} }: any): ServerFacade => { - const mockServer = { - config: memoize(() => ({ get: jest.fn() })), - info: { - protocol: 'http', - }, - plugins: { - elasticsearch: { - getCluster: memoize(() => { - return { - callWithRequest: jest.fn(), - }; - }), - }, - }, - }; - const defaultSettings: any = { - 'xpack.reporting.encryptionKey': 'testencryptionkey', - 'server.basePath': '/sbp', - 'server.host': 'localhost', - 'server.port': 5601, - 'xpack.reporting.kibanaServer': {}, - }; - mockServer.config().get.mockImplementation((key: any) => { - return key in settings ? settings[key] : defaultSettings[key]; - }); +import { ServerFacade } from '../types'; - return (mockServer as unknown) as ServerFacade; +export const createMockServer = (): ServerFacade => { + const mockServer = {}; + return mockServer as any; }; diff --git a/x-pack/legacy/plugins/reporting/types.d.ts b/x-pack/legacy/plugins/reporting/types.d.ts index 238079ba92a29..76253752be1b7 100644 --- a/x-pack/legacy/plugins/reporting/types.d.ts +++ b/x-pack/legacy/plugins/reporting/types.d.ts @@ -7,14 +7,11 @@ import { EventEmitter } from 'events'; import { ResponseObject } from 'hapi'; import { Legacy } from 'kibana'; -import { ElasticsearchServiceSetup } from 'kibana/server'; import { CallCluster } from '../../../../src/legacy/core_plugins/elasticsearch'; import { CancellationToken } from './common/cancellation_token'; -import { HeadlessChromiumDriverFactory } from './server/browsers/chromium/driver_factory'; -import { BrowserType } from './server/browsers/types'; -import { LevelLogger } from './server/lib/level_logger'; import { ReportingCore } from './server/core'; -import { LegacySetup, ReportingStartDeps, ReportingSetup, ReportingStart } from './server/types'; +import { LevelLogger } from './server/lib/level_logger'; +import { LegacySetup } from './server/types'; export type Job = EventEmitter & { id: string; @@ -25,8 +22,8 @@ export type Job = EventEmitter & { export interface NetworkPolicyRule { allow: boolean; - protocol: string; - host: string; + protocol?: string; + host?: string; } export interface NetworkPolicy { @@ -93,51 +90,6 @@ export type ReportingResponseToolkit = Legacy.ResponseToolkit; export type ESCallCluster = CallCluster; -/* - * Reporting Config - */ - -export interface CaptureConfig { - browser: { - type: BrowserType; - autoDownload: boolean; - chromium: BrowserConfig; - }; - maxAttempts: number; - networkPolicy: NetworkPolicy; - loadDelay: number; - timeouts: { - openUrl: number; - waitForElements: number; - renderComplet: number; - }; -} - -export interface BrowserConfig { - inspect: boolean; - userDataDir: string; - viewport: { width: number; height: number }; - disableSandbox: boolean; - proxy: { - enabled: boolean; - server: string; - bypass?: string[]; - }; -} - -export interface QueueConfig { - indexInterval: string; - pollEnabled: boolean; - pollInterval: number; - pollIntervalErrorMultiplier: number; - timeout: number; -} - -export interface ScrollConfig { - duration: string; - size: number; -} - export interface ElementPosition { boundingClientRect: { // modern browsers support x/y, but older ones don't @@ -274,14 +226,10 @@ export interface ESQueueInstance { export type CreateJobFactory = ( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: LevelLogger -) => CreateJobFnType; +) => Promise; export type ExecuteJobFactory = ( reporting: ReportingCore, - server: ServerFacade, - elasticsearch: ElasticsearchServiceSetup, logger: LevelLogger ) => Promise; diff --git a/x-pack/plugins/reporting/config.ts b/x-pack/plugins/reporting/config.ts deleted file mode 100644 index f1d6b1a8f248f..0000000000000 --- a/x-pack/plugins/reporting/config.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export const reportingPollConfig = { - jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, - jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, -}; diff --git a/x-pack/plugins/reporting/kibana.json b/x-pack/plugins/reporting/kibana.json index a7e2bd288f0b1..d330eb9b7872a 100644 --- a/x-pack/plugins/reporting/kibana.json +++ b/x-pack/plugins/reporting/kibana.json @@ -1,7 +1,11 @@ { + "configPath": [ "xpack", "reporting" ], "id": "reporting", "version": "8.0.0", "kibanaVersion": "kibana", + "optionalPlugins": [ + "usageCollection" + ], "requiredPlugins": [ "home", "management", @@ -11,6 +15,6 @@ "share", "kibanaLegacy" ], - "server": false, + "server": true, "ui": true } diff --git a/x-pack/plugins/reporting/server/config/index.test.ts b/x-pack/plugins/reporting/server/config/index.test.ts new file mode 100644 index 0000000000000..08fe2c5861311 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/index.test.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as Rx from 'rxjs'; +import { CoreSetup, Logger, PluginInitializerContext } from '../../../../../src/core/server'; +import { createConfig$ } from './'; + +interface KibanaServer { + host?: string; + port?: number; + protocol?: string; +} +interface ReportingKibanaServer { + hostname?: string; + port?: number; + protocol?: string; +} + +const makeMockInitContext = (config: { + encryptionKey?: string; + kibanaServer: ReportingKibanaServer; +}): PluginInitializerContext => + ({ + config: { create: () => Rx.of(config) }, + } as PluginInitializerContext); + +const makeMockCoreSetup = (serverInfo: KibanaServer): CoreSetup => + ({ http: { getServerInfo: () => serverInfo } } as any); + +describe('Reporting server createConfig$', () => { + let mockCoreSetup: CoreSetup; + let mockInitContext: PluginInitializerContext; + let mockLogger: Logger; + + beforeEach(() => { + mockCoreSetup = makeMockCoreSetup({ host: 'kibanaHost', port: 5601, protocol: 'http' }); + mockInitContext = makeMockInitContext({ + kibanaServer: {}, + }); + mockLogger = ({ warn: jest.fn() } as unknown) as Logger; + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('creates random encryption key and default config using host, protocol, and port from server info', async () => { + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.encryptionKey).toMatch(/\S{32,}/); + expect(result.kibanaServer).toMatchInlineSnapshot(` + Object { + "hostname": "kibanaHost", + "port": 5601, + "protocol": "http", + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(1); + expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ + 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on restart, please set xpack.reporting.encryptionKey in kibana.yml', + ]); + }); + + it('uses the encryption key', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', + kibanaServer: {}, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.encryptionKey).toMatch('iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii'); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); + + it('uses the encryption key, reporting kibanaServer settings to override server info', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii', + kibanaServer: { + hostname: 'reportingHost', + port: 5677, + protocol: 'httpsa', + }, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result).toMatchInlineSnapshot(` + Object { + "encryptionKey": "iiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiiii", + "kibanaServer": Object { + "hostname": "reportingHost", + "port": 5677, + "protocol": "httpsa", + }, + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(0); + }); + + it('show warning when kibanaServer.hostName === "0"', async () => { + mockInitContext = makeMockInitContext({ + encryptionKey: 'aaaaaaaaaaaaabbbbbbbbbbbbaaaaaaaaa', + kibanaServer: { hostname: '0' }, + }); + const result = await createConfig$(mockCoreSetup, mockInitContext, mockLogger).toPromise(); + + expect(result.kibanaServer).toMatchInlineSnapshot(` + Object { + "hostname": "0.0.0.0", + "port": 5601, + "protocol": "http", + } + `); + expect((mockLogger.warn as any).mock.calls.length).toBe(1); + expect((mockLogger.warn as any).mock.calls[0]).toMatchObject([ + `Found 'server.host: \"0\" in Kibana configuration. This is incompatible with Reporting. To enable Reporting to work, 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' is being automatically ` + + `to the configuration. You can change the setting to 'server.host: 0.0.0.0' or add 'xpack.reporting.kibanaServer.hostname: 0.0.0.0' in kibana.yml to prevent this message.`, + ]); + }); +}); diff --git a/x-pack/plugins/reporting/server/config/index.ts b/x-pack/plugins/reporting/server/config/index.ts new file mode 100644 index 0000000000000..ac51b39ae23b4 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/index.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n/'; +import { TypeOf } from '@kbn/config-schema'; +import crypto from 'crypto'; +import { map } from 'rxjs/operators'; +import { PluginConfigDescriptor } from 'kibana/server'; +import { CoreSetup, Logger, PluginInitializerContext } from '../../../../../src/core/server'; +import { ConfigSchema, ConfigType } from './schema'; + +export function createConfig$(core: CoreSetup, context: PluginInitializerContext, logger: Logger) { + return context.config.create>().pipe( + map(config => { + // encryption key + let encryptionKey = config.encryptionKey; + if (encryptionKey === undefined) { + logger.warn( + i18n.translate('xpack.reporting.serverConfig.randomEncryptionKey', { + defaultMessage: + 'Generating a random key for xpack.reporting.encryptionKey. To prevent sessions from being invalidated on ' + + 'restart, please set xpack.reporting.encryptionKey in kibana.yml', + }) + ); + encryptionKey = crypto.randomBytes(16).toString('hex'); + } + + const { kibanaServer: reportingServer } = config; + const serverInfo = core.http.getServerInfo(); + + // kibanaServer.hostname, default to server.host, don't allow "0" + let kibanaServerHostname = reportingServer.hostname + ? reportingServer.hostname + : serverInfo.host; + if (kibanaServerHostname === '0') { + logger.warn( + i18n.translate('xpack.reporting.serverConfig.invalidServerHostname', { + defaultMessage: + `Found 'server.host: "0" in Kibana configuration. This is incompatible with Reporting. ` + + `To enable Reporting to work, '{configKey}: 0.0.0.0' is being automatically to the configuration. ` + + `You can change the setting to 'server.host: 0.0.0.0' or add '{configKey}: 0.0.0.0' in kibana.yml to prevent this message.`, + values: { configKey: 'xpack.reporting.kibanaServer.hostname' }, + }) + ); + kibanaServerHostname = '0.0.0.0'; + } + + // kibanaServer.port, default to server.port + const kibanaServerPort = reportingServer.port + ? reportingServer.port + : serverInfo.port; // prettier-ignore + + // kibanaServer.protocol, default to server.protocol + const kibanaServerProtocol = reportingServer.protocol + ? reportingServer.protocol + : serverInfo.protocol; + + return { + ...config, + encryptionKey, + kibanaServer: { + hostname: kibanaServerHostname, + port: kibanaServerPort, + protocol: kibanaServerProtocol, + }, + }; + }) + ); +} + +export const config: PluginConfigDescriptor = { + schema: ConfigSchema, + deprecations: ({ unused }) => [ + unused('capture.browser.chromium.maxScreenshotDimension'), + unused('capture.concurrency'), + unused('capture.settleTime'), + unused('capture.timeout'), + unused('kibanaApp'), + ], +}; + +export { ConfigSchema, ConfigType }; diff --git a/x-pack/plugins/reporting/server/config/schema.test.ts b/x-pack/plugins/reporting/server/config/schema.test.ts new file mode 100644 index 0000000000000..d8fe6d1ff084a --- /dev/null +++ b/x-pack/plugins/reporting/server/config/schema.test.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ConfigSchema } from './schema'; + +describe('Reporting Config Schema', () => { + it(`context {"dev":false,"dist":false} produces correct config`, () => { + expect(ConfigSchema.validate({}, { dev: false, dist: false })).toMatchObject({ + capture: { + browser: { + autoDownload: true, + chromium: { disableSandbox: false, proxy: { enabled: false } }, + type: 'chromium', + }, + loadDelay: 3000, + maxAttempts: 1, + networkPolicy: { + enabled: true, + rules: [ + { allow: true, host: undefined, protocol: 'http:' }, + { allow: true, host: undefined, protocol: 'https:' }, + { allow: true, host: undefined, protocol: 'ws:' }, + { allow: true, host: undefined, protocol: 'wss:' }, + { allow: true, host: undefined, protocol: 'data:' }, + { allow: false, host: undefined, protocol: undefined }, + ], + }, + viewport: { height: 1200, width: 1950 }, + zoom: 2, + }, + csv: { + checkForFormulas: true, + enablePanelActionDownload: true, + maxSizeBytes: 10485760, + scroll: { duration: '30s', size: 500 }, + }, + encryptionKey: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + index: '.reporting', + kibanaServer: {}, + poll: { + jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, + jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, + }, + queue: { + indexInterval: 'week', + pollEnabled: true, + pollInterval: 3000, + pollIntervalErrorMultiplier: 10, + timeout: 120000, + }, + roles: { allow: ['reporting_user'] }, + }); + }); + it(`context {"dev":false,"dist":true} produces correct config`, () => { + expect(ConfigSchema.validate({}, { dev: false, dist: true })).toMatchObject({ + capture: { + browser: { + autoDownload: false, + chromium: { disableSandbox: false, inspect: false, proxy: { enabled: false } }, + type: 'chromium', + }, + loadDelay: 3000, + maxAttempts: 3, + networkPolicy: { + enabled: true, + rules: [ + { allow: true, host: undefined, protocol: 'http:' }, + { allow: true, host: undefined, protocol: 'https:' }, + { allow: true, host: undefined, protocol: 'ws:' }, + { allow: true, host: undefined, protocol: 'wss:' }, + { allow: true, host: undefined, protocol: 'data:' }, + { allow: false, host: undefined, protocol: undefined }, + ], + }, + viewport: { height: 1200, width: 1950 }, + zoom: 2, + }, + csv: { + checkForFormulas: true, + enablePanelActionDownload: true, + maxSizeBytes: 10485760, + scroll: { duration: '30s', size: 500 }, + }, + index: '.reporting', + kibanaServer: {}, + poll: { + jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 }, + jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 }, + }, + queue: { + indexInterval: 'week', + pollEnabled: true, + pollInterval: 3000, + pollIntervalErrorMultiplier: 10, + timeout: 120000, + }, + roles: { allow: ['reporting_user'] }, + }); + }); +}); diff --git a/x-pack/plugins/reporting/server/config/schema.ts b/x-pack/plugins/reporting/server/config/schema.ts new file mode 100644 index 0000000000000..0058b7a5096f0 --- /dev/null +++ b/x-pack/plugins/reporting/server/config/schema.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import moment from 'moment'; + +const KibanaServerSchema = schema.object({ + hostname: schema.maybe( + schema.string({ + validate(value) { + if (value === '0') { + return 'must not be "0" for the headless browser to correctly resolve the host'; + } + }, + hostname: true, + }) + ), + port: schema.maybe(schema.number()), + protocol: schema.maybe( + schema.string({ + validate(value) { + if (!/^https?$/.test(value)) { + return 'must be "http" or "https"'; + } + }, + }) + ), +}); + +const QueueSchema = schema.object({ + indexInterval: schema.string({ defaultValue: 'week' }), + pollEnabled: schema.boolean({ defaultValue: true }), + pollInterval: schema.number({ defaultValue: 3000 }), + pollIntervalErrorMultiplier: schema.number({ defaultValue: 10 }), + timeout: schema.number({ defaultValue: moment.duration(2, 'm').asMilliseconds() }), +}); + +const RulesSchema = schema.object({ + allow: schema.boolean(), + host: schema.maybe(schema.string()), + protocol: schema.maybe(schema.string()), +}); + +const CaptureSchema = schema.object({ + timeouts: schema.object({ + openUrl: schema.number({ defaultValue: 30000 }), + waitForElements: schema.number({ defaultValue: 30000 }), + renderComplete: schema.number({ defaultValue: 30000 }), + }), + networkPolicy: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + rules: schema.arrayOf(RulesSchema, { + defaultValue: [ + { host: undefined, allow: true, protocol: 'http:' }, + { host: undefined, allow: true, protocol: 'https:' }, + { host: undefined, allow: true, protocol: 'ws:' }, + { host: undefined, allow: true, protocol: 'wss:' }, + { host: undefined, allow: true, protocol: 'data:' }, + { host: undefined, allow: false, protocol: undefined }, // Default action is to deny! + ], + }), + }), + zoom: schema.number({ defaultValue: 2 }), + viewport: schema.object({ + width: schema.number({ defaultValue: 1950 }), + height: schema.number({ defaultValue: 1200 }), + }), + loadDelay: schema.number({ + defaultValue: moment.duration(3, 's').asMilliseconds(), + }), // TODO: use schema.duration + browser: schema.object({ + autoDownload: schema.conditional( + schema.contextRef('dist'), + true, + schema.boolean({ defaultValue: false }), + schema.boolean({ defaultValue: true }) + ), + chromium: schema.object({ + inspect: schema.conditional( + schema.contextRef('dist'), + true, + schema.boolean({ defaultValue: false }), + schema.maybe(schema.never()) + ), + disableSandbox: schema.boolean({ defaultValue: false }), + proxy: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + server: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.uri({ scheme: ['http', 'https'] }), + schema.maybe(schema.never()) + ), + bypass: schema.conditional( + schema.siblingRef('enabled'), + true, + schema.arrayOf(schema.string({ hostname: true })), + schema.maybe(schema.never()) + ), + }), + userDataDir: schema.maybe(schema.string()), // FIXME unused? + }), + type: schema.string({ defaultValue: 'chromium' }), + }), + maxAttempts: schema.conditional( + schema.contextRef('dist'), + true, + schema.number({ defaultValue: 3 }), + schema.number({ defaultValue: 1 }) + ), +}); + +const CsvSchema = schema.object({ + checkForFormulas: schema.boolean({ defaultValue: true }), + enablePanelActionDownload: schema.boolean({ defaultValue: true }), + maxSizeBytes: schema.number({ + defaultValue: 1024 * 1024 * 10, // 10MB + }), // TODO: use schema.byteSize + scroll: schema.object({ + duration: schema.string({ + defaultValue: '30s', + validate(value) { + if (!/^[0-9]+(d|h|m|s|ms|micros|nanos)$/.test(value)) { + return 'must be a duration string'; + } + }, + }), + size: schema.number({ defaultValue: 500 }), + }), +}); + +const EncryptionKeySchema = schema.conditional( + schema.contextRef('dist'), + true, + schema.maybe(schema.string({ minLength: 32 })), + schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) +); + +const RolesSchema = schema.object({ + allow: schema.arrayOf(schema.string(), { defaultValue: ['reporting_user'] }), +}); + +const IndexSchema = schema.string({ defaultValue: '.reporting' }); + +const PollSchema = schema.object({ + jobCompletionNotifier: schema.object({ + interval: schema.number({ + defaultValue: moment.duration(10, 's').asMilliseconds(), + }), // TODO: use schema.duration + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + }), + jobsRefresh: schema.object({ + interval: schema.number({ + defaultValue: moment.duration(5, 's').asMilliseconds(), + }), // TODO: use schema.duration + intervalErrorMultiplier: schema.number({ defaultValue: 5 }), + }), +}); + +export const ConfigSchema = schema.object({ + kibanaServer: KibanaServerSchema, + queue: QueueSchema, + capture: CaptureSchema, + csv: CsvSchema, + encryptionKey: EncryptionKeySchema, + roles: RolesSchema, + index: IndexSchema, + poll: PollSchema, +}); + +export type ConfigType = TypeOf; diff --git a/x-pack/plugins/reporting/server/index.ts b/x-pack/plugins/reporting/server/index.ts new file mode 100644 index 0000000000000..2b1844cf2e10e --- /dev/null +++ b/x-pack/plugins/reporting/server/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; +import { ReportingPlugin } from './plugin'; + +export { config, ConfigSchema } from './config'; +export { ConfigType, PluginsSetup } from './plugin'; + +export const plugin = (initializerContext: PluginInitializerContext) => + new ReportingPlugin(initializerContext); diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts new file mode 100644 index 0000000000000..53d821cffbb1f --- /dev/null +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { CoreSetup, Logger, Plugin, PluginInitializerContext } from '../../../../src/core/server'; +import { ConfigType, createConfig$ } from './config'; + +export interface PluginsSetup { + /** @deprecated */ + __legacy: { + config$: Observable; + }; +} + +export class ReportingPlugin implements Plugin { + private readonly log: Logger; + + constructor(private readonly initializerContext: PluginInitializerContext) { + this.log = this.initializerContext.logger.get(); + } + + public async setup(core: CoreSetup): Promise { + return { + __legacy: { + config$: createConfig$(core, this.initializerContext, this.log).pipe(first()), + }, + }; + } + + public start() {} + public stop() {} +} + +export { ConfigType }; From e3431752f3a45ce41100201f7c447aa1fb65d439 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 23 Mar 2020 18:09:30 -0500 Subject: [PATCH 20/34] [SIEM] Move Timeline Template field to first step of rule creation (#60840) * Move timeline template to Define step of Rule creation This required a refactor/simplification of the step_define_rule logic to make things work. In retrospect I think that the issue was we were not handling incoming `defaultValues` props well, which was causing local component state to be lost. Now that we're doing a merge and removed a few unneeded local useStates, things are a) working and b) cleaner * Fix Rule details/edit view with updated data We need to fix the other side of the equation to get these to work: the timeline data was moved to a different step during creation, but when viewing on the frontend we split the rule data back into the separate "steps." * Remove unused import * Fix bug in formatDefineStepData I neglected to pass through index in a previous commit. * Update tests now that timeline has movied to a different step * Fix more tests * Update StepRuleDescription snapshots * Fix cypress Rule Creation test Timeline template moved, and so tests broke. * Add unit tests for filterRuleFieldsForType --- .../signal_detection_rules.spec.ts | 10 +- .../siem/cypress/screens/rule_details.ts | 12 +- .../rules/all/__mocks__/mock.ts | 8 +- .../__snapshots__/index.test.tsx.snap | 34 +- .../description_step/index.test.tsx | 9 +- .../step_about_rule/default_value.ts | 5 - .../components/step_about_rule/index.tsx | 10 - .../components/step_about_rule/schema.tsx | 15 - .../components/step_define_rule/index.tsx | 75 ++-- .../components/step_define_rule/schema.tsx | 15 + .../rules/create/helpers.test.ts | 320 +++++++++--------- .../detection_engine/rules/create/helpers.ts | 65 ++-- .../detection_engine/rules/helpers.test.tsx | 36 +- .../pages/detection_engine/rules/helpers.tsx | 36 +- .../pages/detection_engine/rules/types.ts | 6 +- 15 files changed, 308 insertions(+), 348 deletions(-) diff --git a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts index ce73fe1b7c2a5..70e4fb052e172 100644 --- a/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts +++ b/x-pack/legacy/plugins/siem/cypress/integration/signal_detection_rules.spec.ts @@ -13,10 +13,10 @@ import { ABOUT_SEVERITY, ABOUT_STEP, ABOUT_TAGS, - ABOUT_TIMELINE, ABOUT_URLS, DEFINITION_CUSTOM_QUERY, DEFINITION_INDEX_PATTERNS, + DEFINITION_TIMELINE, DEFINITION_STEP, RULE_NAME_HEADER, SCHEDULE_LOOPBACK, @@ -170,10 +170,6 @@ describe('Signal detection rules', () => { .eq(ABOUT_RISK) .invoke('text') .should('eql', newRule.riskScore); - cy.get(ABOUT_STEP) - .eq(ABOUT_TIMELINE) - .invoke('text') - .should('eql', 'Default blank timeline'); cy.get(ABOUT_STEP) .eq(ABOUT_URLS) .invoke('text') @@ -202,6 +198,10 @@ describe('Signal detection rules', () => { .eq(DEFINITION_CUSTOM_QUERY) .invoke('text') .should('eql', `${newRule.customQuery} `); + cy.get(DEFINITION_STEP) + .eq(DEFINITION_TIMELINE) + .invoke('text') + .should('eql', 'Default blank timeline'); cy.get(SCHEDULE_STEP) .eq(SCHEDULE_RUNS) diff --git a/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts b/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts index 6c16735ba5f24..06e535b37708c 100644 --- a/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts +++ b/x-pack/legacy/plugins/siem/cypress/screens/rule_details.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ABOUT_FALSE_POSITIVES = 4; +export const ABOUT_FALSE_POSITIVES = 3; -export const ABOUT_MITRE = 5; +export const ABOUT_MITRE = 4; export const ABOUT_RULE_DESCRIPTION = '[data-test-subj=stepAboutRuleDetailsToggleDescriptionText]'; @@ -16,14 +16,14 @@ export const ABOUT_SEVERITY = 0; export const ABOUT_STEP = '[data-test-subj="aboutRule"] .euiDescriptionList__description'; -export const ABOUT_TAGS = 6; +export const ABOUT_TAGS = 5; -export const ABOUT_TIMELINE = 2; - -export const ABOUT_URLS = 3; +export const ABOUT_URLS = 2; export const DEFINITION_CUSTOM_QUERY = 1; +export const DEFINITION_TIMELINE = 3; + export const DEFINITION_INDEX_PATTERNS = '[data-test-subj=definitionRule] [data-test-subj="listItemColumnStepRuleDescription"] .euiDescriptionList__description .euiBadge__text'; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts index 011a2614c1af9..a6aefefedd5c3 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -155,10 +155,6 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ references: ['www.test.co'], falsePositives: ['test'], tags: ['tag1', 'tag2'], - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: 'Titled timeline', - }, threat: [ { framework: 'mockFramework', @@ -186,6 +182,10 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ machineLearningJobId: '', index: ['filebeat-'], queryBar: mockQueryBar, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Titled timeline', + }, }); export const mockScheduleStepRule = (isNew = false, enabled = false): ScheduleStepRule => ({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap index 4d416e70a096c..9a534297e5e29 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/__snapshots__/index.test.tsx.snap @@ -27,21 +27,6 @@ exports[`description_step StepRuleDescriptionComponent renders correctly against "description": 21, "title": "Risk score", }, - Object { - "description": "Titled timeline", - "title": "Timeline template", - }, - ] - } - /> -
- - , "title": "Reference URLs", }, + ] + } + /> + + + { test('returns expected ListItems array when given valid inputs', () => { const result: ListItems[] = buildListItems(mockAboutStep, schema, mockFilterManager); - expect(result.length).toEqual(10); + expect(result.length).toEqual(9); }); }); @@ -431,10 +431,11 @@ describe('description_step', () => { describe('timeline', () => { test('returns timeline title if one exists', () => { + const mockDefineStep = mockDefineStepRule(); const result: ListItems[] = getDescriptionItem( 'timeline', 'Timeline label', - mockAboutStep, + mockDefineStep, mockFilterManager ); @@ -444,7 +445,7 @@ describe('description_step', () => { test('returns default timeline title if none exists', () => { const mockStep = { - ...mockAboutStep, + ...mockDefineStepRule(), timeline: { id: '12345', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts index 417133f230610..52b0038507b59 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/default_value.ts @@ -5,7 +5,6 @@ */ import { AboutStepRule } from '../../types'; -import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; export const threatDefault = [ { @@ -24,10 +23,6 @@ export const stepAboutDefaultValue: AboutStepRule = { references: [''], falsePositives: [''], tags: [], - timeline: { - id: null, - title: DEFAULT_TIMELINE_TITLE, - }, threat: threatDefault, note: '', }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index bfb123f3f3204..58b6ca54f5bbd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -37,7 +37,6 @@ import { stepAboutDefaultValue } from './default_value'; import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; -import { PickTimeline } from '../pick_timeline'; import { StepContentWrapper } from '../step_content_wrapper'; import { MarkdownEditorForm } from '../../../../../components/markdown_editor/form'; @@ -216,15 +215,6 @@ const StepAboutRuleComponent: FC = ({ buttonContent={AdvancedSettingsAccordionButton} > - { - if (defaultValues != null) { - return { - ...defaultValues, - isNew: false, - }; - } else { - return { - ...stepDefineDefaultValue, - index: indicesConfig != null ? indicesConfig : [], - }; - } -}; - const StepDefineRuleComponent: FC = ({ addPadding = false, defaultValues, @@ -106,18 +94,16 @@ const StepDefineRuleComponent: FC = ({ }) => { const mlCapabilities = useContext(MlCapabilitiesContext); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); - const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); + const [indexModified, setIndexModified] = useState(false); const [localIsMlRule, setIsMlRule] = useState(false); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); - const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( - defaultValues != null ? defaultValues.index : indicesConfig ?? [] - ); + const [myStepData, setMyStepData] = useState({ + ...stepDefineDefaultValue, + index: indicesConfig ?? [], + }); const [ { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - ] = useFetchIndexPatterns(mylocalIndicesConfig); - const [myStepData, setMyStepData] = useState( - getStepDefaultValue(indicesConfig, null) - ); + ] = useFetchIndexPatterns(myStepData.index); const { form } = useForm({ defaultValue: myStepData, @@ -138,15 +124,13 @@ const StepDefineRuleComponent: FC = ({ }, [form]); useEffect(() => { - if (indicesConfig != null && defaultValues != null) { - const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); - if (!deepEqual(myDefaultValues, myStepData)) { - setMyStepData(myDefaultValues); - setLocalUseIndicesConfig(deepEqual(myDefaultValues.index, indicesConfig)); - setFieldValue(form, schema, myDefaultValues); - } + const { isNew, ...values } = myStepData; + if (defaultValues != null && !deepEqual(values, defaultValues)) { + const newValues = { ...values, ...defaultValues, isNew: false }; + setMyStepData(newValues); + setFieldValue(form, schema, newValues); } - }, [defaultValues, indicesConfig]); + }, [defaultValues, setMyStepData, setFieldValue]); useEffect(() => { if (setForm != null) { @@ -195,7 +179,7 @@ const StepDefineRuleComponent: FC = ({ path="index" config={{ ...schema.index, - labelAppend: !localUseIndicesConfig ? ( + labelAppend: indexModified ? ( {i18n.RESET_DEFAULT_INDEX} @@ -253,17 +237,22 @@ const StepDefineRuleComponent: FC = ({ /> + {({ index, ruleType }) => { if (index != null) { - if (deepEqual(index, indicesConfig) && !localUseIndicesConfig) { - setLocalUseIndicesConfig(true); - } - if (!deepEqual(index, indicesConfig) && localUseIndicesConfig) { - setLocalUseIndicesConfig(false); - } - if (index != null && !isEmpty(index) && !deepEqual(index, mylocalIndicesConfig)) { - setMyLocalIndicesConfig(index); + if (deepEqual(index, indicesConfig) && indexModified) { + setIndexModified(false); + } else if (!deepEqual(index, indicesConfig) && !indexModified) { + setIndexModified(true); } } diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index bcfcd4f4ee09d..271c8fabed3a5 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -158,4 +158,19 @@ export const schema: FormSchema = { }, ], }, + timeline: { + label: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateLabel', + { + defaultMessage: 'Timeline template', + } + ), + helpText: i18n.translate( + 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldTimelineTemplateHelpText', + { + defaultMessage: + 'Select an existing timeline to use as a template when investigating generated signals.', + } + ), + }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts index ea6b02924cb3e..dc0459c54adb0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.test.ts @@ -19,6 +19,7 @@ import { formatScheduleStepData, formatAboutStepData, formatRule, + filterRuleFieldsForType, } from './helpers'; import { mockDefineStepRule, @@ -88,6 +89,8 @@ describe('helpers', () => { saved_id: 'test123', index: ['filebeat-'], type: 'saved_query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -109,6 +112,119 @@ describe('helpers', () => { index: ['filebeat-'], saved_id: '', type: 'query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object without timeline_id and timeline_title if timeline.id is null', () => { + const mockStepData = { + ...mockData, + }; + delete mockStepData.timeline.id; + + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object with timeline_id and timeline_title if timeline.id is "', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + id: '', + }, + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + timeline_id: '', + timeline_title: 'Titled timeline', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object without timeline_id and timeline_title if timeline.title is null', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + }, + }; + delete mockStepData.timeline.title; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + }; + + expect(result).toEqual(expected); + }); + + test('returns formatted object with timeline_id and timeline_title if timeline.title is "', () => { + const mockStepData = { + ...mockData, + timeline: { + ...mockData.timeline, + title: '', + }, + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + language: 'kuery', + filters: mockQueryBar.filters, + query: 'test query', + index: ['filebeat-'], + saved_id: 'test123', + type: 'saved_query', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: '', + }; + + expect(result).toEqual(expected); + }); + + test('returns ML fields if type is machine_learning', () => { + const mockStepData: DefineStepRule = { + ...mockData, + ruleType: 'machine_learning', + anomalyThreshold: 44, + machineLearningJobId: 'some_jobert_id', + }; + const result: DefineStepRuleJson = formatDefineStepData(mockStepData); + + const expected = { + type: 'machine_learning', + anomaly_threshold: 44, + machine_learning_job_id: 'some_jobert_id', + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -249,8 +365,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -289,8 +403,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -327,160 +439,6 @@ describe('helpers', () => { ], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object without timeline_id and timeline_title if timeline.id is null', () => { - const mockStepData = { - ...mockData, - }; - delete mockStepData.timeline.id; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], - }, - ], - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object with timeline_id and timeline_title if timeline.id is "', () => { - const mockStepData = { - ...mockData, - timeline: { - ...mockData.timeline, - id: '', - }, - }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], - }, - ], - timeline_id: '', - timeline_title: 'Titled timeline', - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object without timeline_id and timeline_title if timeline.title is null', () => { - const mockStepData = { - ...mockData, - timeline: { - ...mockData.timeline, - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - }, - }; - delete mockStepData.timeline.title; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { - id: '1234', - name: 'tactic1', - reference: 'reference1', - }, - technique: [ - { - id: '456', - name: 'technique1', - reference: 'technique reference', - }, - ], - }, - ], - }; - - expect(result).toEqual(expected); - }); - - test('returns formatted object with timeline_id and timeline_title if timeline.title is "', () => { - const mockStepData = { - ...mockData, - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: '', - }, - }; - const result: AboutStepRuleJson = formatAboutStepData(mockStepData); - const expected = { - description: '24/7', - false_positives: ['test'], - name: 'Query with rule-id', - note: '# this is some markdown documentation', - references: ['www.test.co'], - risk_score: 21, - severity: 'low', - tags: ['tag1', 'tag2'], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: '1234', name: 'tactic1', reference: 'reference1' }, - technique: [{ id: '456', name: 'technique1', reference: 'technique reference' }], - }, - ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: '', }; expect(result).toEqual(expected); @@ -539,8 +497,6 @@ describe('helpers', () => { technique: [{ id: '456', name: 'technique1', reference: 'technique reference' }], }, ], - timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - timeline_title: 'Titled timeline', }; expect(result).toEqual(expected); @@ -583,4 +539,48 @@ describe('helpers', () => { expect(result.id).toBeUndefined(); }); }); + + describe('filterRuleFieldsForType', () => { + let fields: DefineStepRule; + + beforeEach(() => { + fields = mockDefineStepRule(); + }); + + it('removes query fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).not.toHaveProperty('index'); + expect(result).not.toHaveProperty('queryBar'); + }); + + it('leaves ML fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).toHaveProperty('anomalyThreshold'); + expect(result).toHaveProperty('machineLearningJobId'); + }); + + it('leaves arbitrary fields if the type is machine learning', () => { + const result = filterRuleFieldsForType(fields, 'machine_learning'); + expect(result).toHaveProperty('timeline'); + expect(result).toHaveProperty('ruleType'); + }); + + it('removes ML fields if the type is not machine learning', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).not.toHaveProperty('anomalyThreshold'); + expect(result).not.toHaveProperty('machineLearningJobId'); + }); + + it('leaves query fields if the type is query', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).toHaveProperty('index'); + expect(result).toHaveProperty('queryBar'); + }); + + it('leaves arbitrary fields if the type is query', () => { + const result = filterRuleFieldsForType(fields, 'query'); + expect(result).toHaveProperty('timeline'); + expect(result).toHaveProperty('ruleType'); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index 1f3379bf681bb..f8900e6a1129e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -64,27 +64,35 @@ export const filterRuleFieldsForType = (fields: T, type: R export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStepRuleJson => { const ruleFields = filterRuleFieldsForType(defineStepData, defineStepData.ruleType); + const { ruleType, timeline } = ruleFields; + const baseFields = { + type: ruleType, + ...(timeline.id != null && + timeline.title != null && { + timeline_id: timeline.id, + timeline_title: timeline.title, + }), + }; - if (isMlFields(ruleFields)) { - const { anomalyThreshold, machineLearningJobId, isNew, ruleType, ...rest } = ruleFields; - return { - ...rest, - type: ruleType, - anomaly_threshold: anomalyThreshold, - machine_learning_job_id: machineLearningJobId, - }; - } else { - const { queryBar, isNew, ruleType, ...rest } = ruleFields; - return { - ...rest, - type: ruleType, - filters: queryBar?.filters, - language: queryBar?.query?.language, - query: queryBar?.query?.query as string, - saved_id: queryBar?.saved_id, - ...(ruleType === 'query' && queryBar?.saved_id ? { type: 'saved_query' as RuleType } : {}), - }; - } + const typeFields = isMlFields(ruleFields) + ? { + anomaly_threshold: ruleFields.anomalyThreshold, + machine_learning_job_id: ruleFields.machineLearningJobId, + } + : { + index: ruleFields.index, + filters: ruleFields.queryBar?.filters, + language: ruleFields.queryBar?.query?.language, + query: ruleFields.queryBar?.query?.query as string, + saved_id: ruleFields.queryBar?.saved_id, + ...(ruleType === 'query' && + ruleFields.queryBar?.saved_id && { type: 'saved_query' as RuleType }), + }; + + return { + ...baseFields, + ...typeFields, + }; }; export const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRuleJson => { @@ -108,26 +116,11 @@ export const formatScheduleStepData = (scheduleData: ScheduleStepRule): Schedule }; export const formatAboutStepData = (aboutStepData: AboutStepRule): AboutStepRuleJson => { - const { - falsePositives, - references, - riskScore, - threat, - timeline, - isNew, - note, - ...rest - } = aboutStepData; + const { falsePositives, references, riskScore, threat, isNew, note, ...rest } = aboutStepData; return { false_positives: falsePositives.filter(item => !isEmpty(item)), references: references.filter(item => !isEmpty(item)), risk_score: riskScore, - ...(timeline.id != null && timeline.title != null - ? { - timeline_id: timeline.id, - timeline_title: timeline.title, - } - : {}), threat: threat .filter(singleThreat => singleThreat.tactic.name !== 'none') .map(singleThreat => ({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx index ee43ae5f1d6e2..3224c605192e6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.test.tsx @@ -65,6 +65,10 @@ describe('rule helpers', () => { ], saved_id: 'test123', }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Titled timeline', + }, }; const aboutRuleStepData = { description: '24/7', @@ -93,10 +97,6 @@ describe('rule helpers', () => { ], }, ], - timeline: { - id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', - title: 'Titled timeline', - }, }; const scheduleRuleStepData = { enabled: true, from: '0s', interval: '5m', isNew: false }; const aboutRuleDataDetailsData = { @@ -112,16 +112,6 @@ describe('rule helpers', () => { }); describe('getAboutStepsData', () => { - test('returns timeline id and title of null if they do not exist on rule', () => { - const mockedRule = mockRuleWithEverything('test-id'); - delete mockedRule.timeline_id; - delete mockedRule.timeline_title; - const result: AboutStepRule = getAboutStepsData(mockedRule, false); - - expect(result.timeline.id).toBeNull(); - expect(result.timeline.title).toBeNull(); - }); - test('returns name, description, and note as empty string if detailsView is true', () => { const result: AboutStepRule = getAboutStepsData(mockRuleWithEverything('test-id'), true); @@ -195,6 +185,10 @@ describe('rule helpers', () => { filters: [], saved_id: "Garrett's IP", }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Untitled timeline', + }, }; expect(result).toEqual(expected); @@ -220,10 +214,24 @@ describe('rule helpers', () => { filters: [], saved_id: undefined, }, + timeline: { + id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + title: 'Untitled timeline', + }, }; expect(result).toEqual(expected); }); + + test('returns timeline id and title of null if they do not exist on rule', () => { + const mockedRule = mockRuleWithEverything('test-id'); + delete mockedRule.timeline_id; + delete mockedRule.timeline_title; + const result: DefineStepRule = getDefineStepsData(mockedRule); + + expect(result.timeline.id).toBeNull(); + expect(result.timeline.title).toBeNull(); + }); }); describe('getHumanizedDuration', () => { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index e59ca5e7e14e5..2ace154482a27 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -42,20 +42,22 @@ export const getStepsData = ({ return { aboutRuleData, modifiedAboutRuleDetailsData, defineRuleData, scheduleRuleData }; }; -export const getDefineStepsData = (rule: Rule): DefineStepRule => { - return { - isNew: false, - ruleType: rule.type, - anomalyThreshold: rule.anomaly_threshold ?? 50, - machineLearningJobId: rule.machine_learning_job_id ?? '', - index: rule.index ?? [], - queryBar: { - query: { query: rule.query ?? '', language: rule.language ?? '' }, - filters: (rule.filters ?? []) as Filter[], - saved_id: rule.saved_id, - }, - }; -}; +export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ + isNew: false, + ruleType: rule.type, + anomalyThreshold: rule.anomaly_threshold ?? 50, + machineLearningJobId: rule.machine_learning_job_id ?? '', + index: rule.index ?? [], + queryBar: { + query: { query: rule.query ?? '', language: rule.language ?? '' }, + filters: (rule.filters ?? []) as Filter[], + saved_id: rule.saved_id, + }, + timeline: { + id: rule.timeline_id ?? null, + title: rule.timeline_title ?? null, + }, +}); export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { const { enabled, interval, from } = rule; @@ -94,8 +96,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu risk_score: riskScore, tags, threat, - timeline_id: timelineId, - timeline_title: timelineTitle, } = rule; return { @@ -109,10 +109,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu riskScore, falsePositives, threat: threat as IMitreEnterpriseAttack[], - timeline: { - id: timelineId ?? null, - title: timelineTitle ?? null, - }, }; }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index 447b5dc6325ee..d4caa4639f338 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -57,7 +57,6 @@ export interface AboutStepRule extends StepRuleData { references: string[]; falsePositives: string[]; tags: string[]; - timeline: FieldValueTimeline; threat: IMitreEnterpriseAttack[]; note: string; } @@ -73,6 +72,7 @@ export interface DefineStepRule extends StepRuleData { machineLearningJobId: string; queryBar: FieldValueQueryBar; ruleType: RuleType; + timeline: FieldValueTimeline; } export interface ScheduleStepRule extends StepRuleData { @@ -90,6 +90,8 @@ export interface DefineStepRuleJson { saved_id?: string; query?: string; language?: string; + timeline_id?: string; + timeline_title?: string; type: RuleType; } @@ -101,8 +103,6 @@ export interface AboutStepRuleJson { references: string[]; false_positives: string[]; tags: string[]; - timeline_id?: string; - timeline_title?: string; threat: IMitreEnterpriseAttack[]; note?: string; } From f32a8483bcc5d88b56a9a9aa03466aa2d7457555 Mon Sep 17 00:00:00 2001 From: CJ Cenizal Date: Mon, 23 Mar 2020 16:10:12 -0700 Subject: [PATCH 21/34] Create Painless Lab app (#57538) * Create Painless Playground app (#54578) * Replace heart script with smiley face script. (#57755) * Rename Painless Playground -> Painless Lab. (#57545) * Fix i18n namespace. * Improve smiley face proportions. - Add def keyword to Painless spec. - Temporarily fix broken highlighting. - Add small padding to main controls. * [Painless Lab] Minor Fixes (#58135) * Code restructure, improve types, add plugin id, introduced hook Moved the code execution hook to a custom hook outside of main, also chaining off promise to avoid lower level handling of sequencing. * Re-instated formatting code To improve DX the execution error response from the painless API was massaged to a more reader friendly state, only giving non-repeating information. Currently it is hard to determine the line and character information from the painless endpoint. If the user wishes to see this raw information it will be available in the API response flyout. * Remove leading new line in default script * Remove registration of feature flag * Fix types * Restore previous auto-submit request behaviour * Remove use of null and remove old comment Stick with "undefined" as the designation for something not existing. * [Painless Lab] NP migration (#59794) * Fix sample document editor. * [Painless Lab] Fix float -> integer coercion bug (#60201) * Clarify data and persistence flow. Fix floating point precision bug. * Send a string to API and ES client instead of an object. * Rename helpers lib to format. Add tests for formatRequestPayload. * Add query parameter to score context (#60414) * Fix typo and i18n * Make state init lazy Otherwise we are needlessly reading and JSON.parse'ing on every state update * Support the query parameter in requests to Painless * Fix borked i18n * Fix i18n * Another i18n issue * [Painless] Minor state update model refactor (#60532) * Fix typo and i18n * Make state init lazy Otherwise we are needlessly reading and JSON.parse'ing on every state update * Support the query parameter in requests to Painless * WiP on state refactor * Some cleanup after manual testing * Fix types and i18n * Fix i18n in context_tab * i18n * [Painless] Language Service (#60612) * Added language service * Use the correct monaco instance and add wordwise operations * Remove plugin context initializer for now * [Painless] Replace hard-coded links (#60603) * Replace hard-coded links Also remove all props from Main component * Pass the new links object to the request flyout too * Link directly to painless execute API's contexts * Remove responsive stacking from tabs with icons in them. * Resize Painless Lab bottom bar to accommodate nav drawer width (#60833) * Validate Painless Lab index field (#60841) * Make JSON format of parameters field more prominent. Set default parameters to provide an example to users. * Set default document to provide an example to users. * Simplify context's updateState interface. * Refactor store and context file organization. - Remove common directory, move constants and types files to root. - Move initialState into context file, where it's being used. * Add validation for index input. * Create context directory. * Fix bottom bar z-index. * Position flyout help link so it's bottom-aligned with the title and farther from the close button. Co-authored-by: Matthias Wilhelm Co-authored-by: Jean-Louis Leysens Co-authored-by: Elastic Machine Co-authored-by: Alison Goryachev --- .github/CODEOWNERS | 1 + packages/kbn-ui-shared-deps/monaco.ts | 2 + src/plugins/dev_tools/public/plugin.ts | 2 + x-pack/.i18nrc.json | 1 + .../plugins/painless_lab/common/constants.ts | 15 ++ x-pack/plugins/painless_lab/kibana.json | 16 ++ .../public/application/components/editor.tsx | 35 +++ .../public/application/components/main.tsx | 94 ++++++++ .../application/components/main_controls.tsx | 145 +++++++++++++ .../components/output_pane/context_tab.tsx | 202 +++++++++++++++++ .../components/output_pane/index.ts | 7 + .../components/output_pane/output_pane.tsx | 79 +++++++ .../components/output_pane/output_tab.tsx | 26 +++ .../components/output_pane/parameters_tab.tsx | 87 ++++++++ .../application/components/request_flyout.tsx | 98 +++++++++ .../public/application/constants.tsx | 135 ++++++++++++ .../public/application/context/context.tsx | 95 ++++++++ .../public/application/context/index.tsx | 7 + .../application/context/initial_payload.ts | 22 ++ .../public/application/hooks/index.ts | 7 + .../application/hooks/use_submit_code.ts | 64 ++++++ .../painless_lab/public/application/index.tsx | 46 ++++ .../lib/__snapshots__/format.test.ts.snap | 205 ++++++++++++++++++ .../public/application/lib/format.test.ts | 86 ++++++++ .../public/application/lib/format.ts | 117 ++++++++++ .../painless_lab/public/application/types.ts | 58 +++++ x-pack/plugins/painless_lab/public/index.scss | 1 + x-pack/plugins/painless_lab/public/index.ts | 12 + .../plugins/painless_lab/public/lib/index.ts | 7 + .../public/lib/monaco_painless_lang.ts | 174 +++++++++++++++ x-pack/plugins/painless_lab/public/links.ts | 20 ++ x-pack/plugins/painless_lab/public/plugin.tsx | 114 ++++++++++ .../painless_lab/public/services/index.ts | 7 + .../public/services/language_service.ts | 45 ++++ .../painless_lab/public/styles/_index.scss | 58 +++++ x-pack/plugins/painless_lab/public/types.ts | 15 ++ x-pack/plugins/painless_lab/server/index.ts | 11 + .../plugins/painless_lab/server/lib/index.ts | 7 + .../painless_lab/server/lib/is_es_error.ts | 13 ++ x-pack/plugins/painless_lab/server/plugin.ts | 47 ++++ .../painless_lab/server/routes/api/execute.ts | 46 ++++ .../painless_lab/server/routes/api/index.ts | 7 + .../painless_lab/server/services/index.ts | 7 + .../painless_lab/server/services/license.ts | 82 +++++++ x-pack/plugins/painless_lab/server/types.ts | 17 ++ 45 files changed, 2342 insertions(+) create mode 100644 x-pack/plugins/painless_lab/common/constants.ts create mode 100644 x-pack/plugins/painless_lab/kibana.json create mode 100644 x-pack/plugins/painless_lab/public/application/components/editor.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/main.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/main_controls.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/constants.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/context/context.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/context/index.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/context/initial_payload.ts create mode 100644 x-pack/plugins/painless_lab/public/application/hooks/index.ts create mode 100644 x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts create mode 100644 x-pack/plugins/painless_lab/public/application/index.tsx create mode 100644 x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap create mode 100644 x-pack/plugins/painless_lab/public/application/lib/format.test.ts create mode 100644 x-pack/plugins/painless_lab/public/application/lib/format.ts create mode 100644 x-pack/plugins/painless_lab/public/application/types.ts create mode 100644 x-pack/plugins/painless_lab/public/index.scss create mode 100644 x-pack/plugins/painless_lab/public/index.ts create mode 100644 x-pack/plugins/painless_lab/public/lib/index.ts create mode 100644 x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts create mode 100644 x-pack/plugins/painless_lab/public/links.ts create mode 100644 x-pack/plugins/painless_lab/public/plugin.tsx create mode 100644 x-pack/plugins/painless_lab/public/services/index.ts create mode 100644 x-pack/plugins/painless_lab/public/services/language_service.ts create mode 100644 x-pack/plugins/painless_lab/public/styles/_index.scss create mode 100644 x-pack/plugins/painless_lab/public/types.ts create mode 100644 x-pack/plugins/painless_lab/server/index.ts create mode 100644 x-pack/plugins/painless_lab/server/lib/index.ts create mode 100644 x-pack/plugins/painless_lab/server/lib/is_es_error.ts create mode 100644 x-pack/plugins/painless_lab/server/plugin.ts create mode 100644 x-pack/plugins/painless_lab/server/routes/api/execute.ts create mode 100644 x-pack/plugins/painless_lab/server/routes/api/index.ts create mode 100644 x-pack/plugins/painless_lab/server/services/index.ts create mode 100644 x-pack/plugins/painless_lab/server/services/license.ts create mode 100644 x-pack/plugins/painless_lab/server/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index df3a56dd35130..2db898fab68bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -181,6 +181,7 @@ /x-pack/plugins/remote_clusters/ @elastic/es-ui /x-pack/legacy/plugins/rollup/ @elastic/es-ui /x-pack/plugins/searchprofiler/ @elastic/es-ui +/x-pack/plugins/painless_lab/ @elastic/es-ui /x-pack/legacy/plugins/snapshot_restore/ @elastic/es-ui /x-pack/legacy/plugins/upgrade_assistant/ @elastic/es-ui /x-pack/plugins/upgrade_assistant/ @elastic/es-ui diff --git a/packages/kbn-ui-shared-deps/monaco.ts b/packages/kbn-ui-shared-deps/monaco.ts index 570aca86c484c..42801c69a3e2c 100644 --- a/packages/kbn-ui-shared-deps/monaco.ts +++ b/packages/kbn-ui-shared-deps/monaco.ts @@ -25,6 +25,8 @@ import 'monaco-editor/esm/vs/base/worker/defaultWorkerFactory'; import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands.js'; import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget.js'; +import 'monaco-editor/esm/vs/editor/contrib/wordOperations/wordOperations.js'; // Needed for word-wise char navigation + import 'monaco-editor/esm/vs/editor/contrib/suggest/suggestController.js'; // Needed for suggestions import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js'; // Needed for hover import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js'; // Needed for signature diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index 9ebfeb5387b26..df61271baf879 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -132,4 +132,6 @@ export class DevToolsPlugin implements Plugin { getSortedDevTools: this.getSortedDevTools.bind(this), }; } + + public stop() {} } diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index a8bb989f6bff3..2a28e349ace99 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -30,6 +30,7 @@ "xpack.ml": ["plugins/ml", "legacy/plugins/ml"], "xpack.monitoring": ["plugins/monitoring", "legacy/plugins/monitoring"], "xpack.remoteClusters": "plugins/remote_clusters", + "xpack.painlessLab": "plugins/painless_lab", "xpack.reporting": ["plugins/reporting", "legacy/plugins/reporting"], "xpack.rollupJobs": "legacy/plugins/rollup", "xpack.searchProfiler": "plugins/searchprofiler", diff --git a/x-pack/plugins/painless_lab/common/constants.ts b/x-pack/plugins/painless_lab/common/constants.ts new file mode 100644 index 0000000000000..dfc7d8ae85a2c --- /dev/null +++ b/x-pack/plugins/painless_lab/common/constants.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { LicenseType } from '../../licensing/common/types'; + +const basicLicense: LicenseType = 'basic'; + +export const PLUGIN = { + id: 'painlessLab', + minimumLicenseType: basicLicense, +}; + +export const API_BASE_PATH = '/api/painless_lab'; diff --git a/x-pack/plugins/painless_lab/kibana.json b/x-pack/plugins/painless_lab/kibana.json new file mode 100644 index 0000000000000..4b4ea24202846 --- /dev/null +++ b/x-pack/plugins/painless_lab/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "painlessLab", + "version": "8.0.0", + "kibanaVersion": "kibana", + "requiredPlugins": [ + "devTools", + "licensing", + "home" + ], + "configPath": [ + "xpack", + "painless_lab" + ], + "server": true, + "ui": true +} diff --git a/x-pack/plugins/painless_lab/public/application/components/editor.tsx b/x-pack/plugins/painless_lab/public/application/components/editor.tsx new file mode 100644 index 0000000000000..b8891ce6524f5 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/editor.tsx @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { CodeEditor } from '../../../../../../src/plugins/kibana_react/public'; + +interface Props { + code: string; + onChange: (code: string) => void; +} + +export function Editor({ code, onChange }: Props) { + return ( + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/main.tsx b/x-pack/plugins/painless_lab/public/application/components/main.tsx new file mode 100644 index 0000000000000..10907536e9cc2 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/main.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { formatRequestPayload, formatJson } from '../lib/format'; +import { exampleScript } from '../constants'; +import { PayloadFormat } from '../types'; +import { useSubmitCode } from '../hooks'; +import { useAppContext } from '../context'; +import { OutputPane } from './output_pane'; +import { MainControls } from './main_controls'; +import { Editor } from './editor'; +import { RequestFlyout } from './request_flyout'; + +export const Main: React.FunctionComponent = () => { + const { + store: { payload, validation }, + updatePayload, + services: { + http, + chrome: { getIsNavDrawerLocked$ }, + }, + links, + } = useAppContext(); + + const [isRequestFlyoutOpen, setRequestFlyoutOpen] = useState(false); + const { inProgress, response, submit } = useSubmitCode(http); + + // Live-update the output and persist payload state as the user changes it. + useEffect(() => { + if (validation.isValid) { + submit(payload); + } + }, [payload, submit, validation.isValid]); + + const toggleRequestFlyout = () => { + setRequestFlyoutOpen(!isRequestFlyoutOpen); + }; + + const [isNavDrawerLocked, setIsNavDrawerLocked] = useState(false); + + useEffect(() => { + const subscription = getIsNavDrawerLocked$().subscribe((newIsNavDrawerLocked: boolean) => { + setIsNavDrawerLocked(newIsNavDrawerLocked); + }); + + return () => subscription.unsubscribe(); + }); + + return ( +
+ + + +

+ {i18n.translate('xpack.painlessLab.title', { + defaultMessage: 'Painless Lab', + })} +

+
+ + updatePayload({ code: nextCode })} /> +
+ + + + +
+ + updatePayload({ code: exampleScript })} + /> + + {isRequestFlyoutOpen && ( + setRequestFlyoutOpen(false)} + requestBody={formatRequestPayload(payload, PayloadFormat.PRETTY)} + response={response ? formatJson(response.result || response.error) : ''} + /> + )} +
+ ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx b/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx new file mode 100644 index 0000000000000..6307c21e26dc4 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/main_controls.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + EuiPopover, + EuiBottomBar, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Links } from '../../links'; + +interface Props { + toggleRequestFlyout: () => void; + isRequestFlyoutOpen: boolean; + isLoading: boolean; + reset: () => void; + links: Links; + isNavDrawerLocked: boolean; +} + +export function MainControls({ + toggleRequestFlyout, + isRequestFlyoutOpen, + reset, + links, + isNavDrawerLocked, +}: Props) { + const [isHelpOpen, setIsHelpOpen] = useState(false); + + const items = [ + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.walkthroughButtonLabel', { + defaultMessage: 'Walkthrough', + })} + , + + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.apiReferenceButtonLabel', { + defaultMessage: 'API reference', + })} + , + + setIsHelpOpen(false)} + > + {i18n.translate('xpack.painlessLab.languageSpecButtonLabel', { + defaultMessage: 'Language spec', + })} + , + + { + reset(); + setIsHelpOpen(false); + }} + > + {i18n.translate('xpack.painlessLab.resetButtonLabel', { + defaultMessage: 'Reset script', + })} + , + ]; + + const classes = classNames('painlessLab__bottomBar', { + 'painlessLab__bottomBar-isNavDrawerLocked': isNavDrawerLocked, + }); + + return ( + + + + + + setIsHelpOpen(!isHelpOpen)} + > + {i18n.translate('xpack.painlessLab.helpButtonLabel', { + defaultMessage: 'Help', + })} + + } + isOpen={isHelpOpen} + closePopover={() => setIsHelpOpen(false)} + panelPaddingSize="none" + withTitle + anchorPosition="upRight" + > + + + + + + + + {isRequestFlyoutOpen + ? i18n.translate('xpack.painlessLab.hideRequestButtonLabel', { + defaultMessage: 'Hide API request', + }) + : i18n.translate('xpack.painlessLab.showRequestButtonLabel', { + defaultMessage: 'Show API request', + })} + + + + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx new file mode 100644 index 0000000000000..47efd524f092a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/context_tab.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { + EuiFieldText, + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, + EuiSuperSelect, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; + +import { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; +import { painlessContextOptions } from '../../constants'; +import { useAppContext } from '../../context'; + +export const ContextTab: FunctionComponent = () => { + const { + store: { payload, validation }, + updatePayload, + links, + } = useAppContext(); + const { context, document, index, query } = payload; + + return ( + <> + + + + {' '} + + + + } + labelAppend={ + + + {i18n.translate('xpack.painlessLab.contextFieldDocLinkText', { + defaultMessage: 'Context docs', + })} + + + } + fullWidth + > + updatePayload({ context: nextContext })} + itemLayoutAlign="top" + hasDividers + fullWidth + /> + + + {['filter', 'score'].indexOf(context) !== -1 && ( + + + {' '} + + + + } + fullWidth + isInvalid={!validation.fields.index} + error={ + validation.fields.index + ? [] + : [ + i18n.translate('xpack.painlessLab.indexFieldMissingErrorMessage', { + defaultMessage: 'Enter an index name', + }), + ] + } + > + { + const nextIndex = e.target.value; + updatePayload({ index: nextIndex }); + }} + isInvalid={!validation.fields.index} + /> + + )} + {/* Query DSL Code Editor */} + {'score'.indexOf(context) !== -1 && ( + + + {' '} + + + + } + labelAppend={ + + + {i18n.translate('xpack.painlessLab.queryFieldDocLinkText', { + defaultMessage: 'Query DSL docs', + })} + + + } + fullWidth + > + + updatePayload({ query: nextQuery })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + + )} + {['filter', 'score'].indexOf(context) !== -1 && ( + + + {' '} + + + + } + fullWidth + > + + updatePayload({ document: nextDocument })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + /> + + + )} + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts b/x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts new file mode 100644 index 0000000000000..85b7a7816b5aa --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { OutputPane } from './output_pane'; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx new file mode 100644 index 0000000000000..e6a97bb02f738 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_pane.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { + EuiIcon, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiTabbedContent, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import { Response } from '../../types'; +import { OutputTab } from './output_tab'; +import { ParametersTab } from './parameters_tab'; +import { ContextTab } from './context_tab'; + +interface Props { + isLoading: boolean; + response?: Response; +} + +export const OutputPane: FunctionComponent = ({ isLoading, response }) => { + const outputTabLabel = ( + + + {isLoading ? ( + + ) : response && response.error ? ( + + ) : ( + + )} + + + + {i18n.translate('xpack.painlessLab.outputTabLabel', { + defaultMessage: 'Output', + })} + + + ); + + return ( + + , + }, + { + id: 'parameters', + name: i18n.translate('xpack.painlessLab.parametersTabLabel', { + defaultMessage: 'Parameters', + }), + content: , + }, + { + id: 'context', + name: i18n.translate('xpack.painlessLab.contextTabLabel', { + defaultMessage: 'Context', + }), + content: , + }, + ]} + /> + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx new file mode 100644 index 0000000000000..8969e5421640a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/output_tab.tsx @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiCodeBlock, EuiSpacer } from '@elastic/eui'; + +import { formatResponse } from '../../lib/format'; +import { Response } from '../../types'; + +interface Props { + response?: Response; +} + +export function OutputTab({ response }: Props) { + return ( + <> + + + {formatResponse(response)} + + + ); +} diff --git a/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx b/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx new file mode 100644 index 0000000000000..7c8bce0f7b21b --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/output_pane/parameters_tab.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { FunctionComponent } from 'react'; +import { + EuiFormRow, + EuiPanel, + EuiSpacer, + EuiIcon, + EuiToolTip, + EuiLink, + EuiText, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { monaco } from '@kbn/ui-shared-deps/monaco'; +import { i18n } from '@kbn/i18n'; +import { CodeEditor } from '../../../../../../../src/plugins/kibana_react/public'; + +import { useAppContext } from '../../context'; + +export const ParametersTab: FunctionComponent = () => { + const { + store: { payload }, + updatePayload, + links, + } = useAppContext(); + return ( + <> + + + + {' '} + + + + } + fullWidth + labelAppend={ + + + {i18n.translate('xpack.painlessLab.parametersFieldDocLinkText', { + defaultMessage: 'Parameters docs', + })} + + + } + > + + updatePayload({ parameters: nextParams })} + options={{ + fontSize: 12, + minimap: { + enabled: false, + }, + scrollBeyondLastLine: false, + wordWrap: 'on', + wrappingIndent: 'indent', + automaticLayout: true, + }} + editorDidMount={(editor: monaco.editor.IStandaloneCodeEditor) => { + // Updating tab size for the editor + const model = editor.getModel(); + if (model) { + model.updateOptions({ tabSize: 2 }); + } + }} + /> + + + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx b/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx new file mode 100644 index 0000000000000..123df91f4346a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/components/request_flyout.tsx @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { + EuiCodeBlock, + EuiTabbedContent, + EuiTitle, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Links } from '../../links'; + +interface Props { + onClose: any; + requestBody: string; + links: Links; + response?: string; +} + +export const RequestFlyout: FunctionComponent = ({ + onClose, + requestBody, + response, + links, +}) => { + return ( + + + + + {/* We need an extra div to get out of flex grow */} +
+ +

+ {i18n.translate('xpack.painlessLab.flyoutTitle', { + defaultMessage: 'API request', + })} +

+
+
+
+ + + + {i18n.translate('xpack.painlessLab.flyoutDocLink', { + defaultMessage: 'API documentation', + })} + + +
+
+ + + + {'POST _scripts/painless/_execute\n'} + {requestBody} + + ), + }, + { + id: 'response', + name: 'Response', + content: ( + + {response} + + ), + }, + ]} + /> + +
+ + + ); +}; diff --git a/x-pack/plugins/painless_lab/public/application/constants.tsx b/x-pack/plugins/painless_lab/public/application/constants.tsx new file mode 100644 index 0000000000000..d8430dbfc7d9d --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/constants.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +const defaultLabel = i18n.translate('xpack.painlessLab.contextDefaultLabel', { + defaultMessage: 'Basic', +}); + +const filterLabel = i18n.translate('xpack.painlessLab.contextFilterLabel', { + defaultMessage: 'Filter', +}); + +const scoreLabel = i18n.translate('xpack.painlessLab.contextScoreLabel', { + defaultMessage: 'Score', +}); + +export const painlessContextOptions = [ + { + value: 'painless_test', + inputDisplay: defaultLabel, + dropdownDisplay: ( + <> + {defaultLabel} + +

+ {i18n.translate('xpack.painlessLab.context.defaultLabel', { + defaultMessage: 'The script result will be converted to a string', + })} +

+
+ + ), + }, + { + value: 'filter', + inputDisplay: filterLabel, + dropdownDisplay: ( + <> + {filterLabel} + +

+ {i18n.translate('xpack.painlessLab.context.filterLabel', { + defaultMessage: "Use the context of a filter's script query", + })} +

+
+ + ), + }, + { + value: 'score', + inputDisplay: scoreLabel, + dropdownDisplay: ( + <> + {scoreLabel} + +

+ {i18n.translate('xpack.painlessLab.context.scoreLabel', { + defaultMessage: 'Use the context of a script_score function in function_score query', + })} +

+
+ + ), + }, +]; + +// Render a smiley face as an example. +export const exampleScript = `boolean isInCircle(def posX, def posY, def circleX, def circleY, def radius) { + double distanceFromCircleCenter = Math.sqrt(Math.pow(circleX - posX, 2) + Math.pow(circleY - posY, 2)); + return distanceFromCircleCenter <= radius; +} + +boolean isOnCircle(def posX, def posY, def circleX, def circleY, def radius, def thickness, def squashY) { + double distanceFromCircleCenter = Math.sqrt(Math.pow(circleX - posX, 2) + Math.pow((circleY - posY) / squashY, 2)); + return ( + distanceFromCircleCenter >= radius - thickness + && distanceFromCircleCenter <= radius + thickness + ); +} + +def result = ''; +int charCount = 0; + +// Canvas dimensions +int width = 31; +int height = 31; +double halfWidth = Math.floor(width * 0.5); +double halfHeight = Math.floor(height * 0.5); + +// Style constants +double strokeWidth = 0.6; + +// Smiley face configuration +int headSize = 13; +double headSquashY = 0.78; +int eyePositionX = 10; +int eyePositionY = 12; +int eyeSize = 1; +int mouthSize = 15; +int mouthPositionX = width / 2; +int mouthPositionY = 5; +int mouthOffsetY = 11; + +for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + boolean isHead = isOnCircle(x, y, halfWidth, halfHeight, headSize, strokeWidth, headSquashY); + boolean isLeftEye = isInCircle(x, y, eyePositionX, eyePositionY, eyeSize); + boolean isRightEye = isInCircle(x, y, width - eyePositionX - 1, eyePositionY, eyeSize); + boolean isMouth = isOnCircle(x, y, mouthPositionX, mouthPositionY, mouthSize, strokeWidth, 1) && y > mouthPositionY + mouthOffsetY; + + if (isLeftEye || isRightEye || isMouth || isHead) { + result += "*"; + } else { + result += "."; + } + + result += " "; + + // Make sure the smiley face doesn't deform as the container changes width. + charCount++; + if (charCount % width === 0) { + result += "\\\\n"; + } + } +} + +return result;`; diff --git a/x-pack/plugins/painless_lab/public/application/context/context.tsx b/x-pack/plugins/painless_lab/public/application/context/context.tsx new file mode 100644 index 0000000000000..0fb5842dfea58 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/context.tsx @@ -0,0 +1,95 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, ReactNode, useState, useContext } from 'react'; +import { HttpSetup, ChromeStart } from 'src/core/public'; + +import { Links } from '../../links'; +import { Store, Payload, Validation } from '../types'; +import { initialPayload } from './initial_payload'; + +interface AppContextProviderArgs { + children: ReactNode; + value: { + http: HttpSetup; + links: Links; + chrome: ChromeStart; + }; +} + +interface ContextValue { + store: Store; + updatePayload: (changes: Partial) => void; + services: { + http: HttpSetup; + chrome: ChromeStart; + }; + links: Links; +} + +const AppContext = createContext(undefined as any); + +const validatePayload = (payload: Payload): Validation => { + const { index } = payload; + + // For now just validate that the user has entered an index. + const indexExists = Boolean(index || index.trim()); + + return { + isValid: indexExists, + fields: { + index: indexExists, + }, + }; +}; + +export const AppContextProvider = ({ + children, + value: { http, links, chrome }, +}: AppContextProviderArgs) => { + const PAINLESS_LAB_KEY = 'painlessLabState'; + + const [store, setStore] = useState(() => { + // Using a callback here ensures these values are only calculated on the first render. + const defaultPayload = { + ...initialPayload, + ...JSON.parse(localStorage.getItem(PAINLESS_LAB_KEY) || '{}'), + }; + + return { + payload: defaultPayload, + validation: validatePayload(defaultPayload), + }; + }); + + const updatePayload = (changes: Partial): void => { + const nextPayload = { + ...store.payload, + ...changes, + }; + // Persist state locally so we can load it up when the user reopens the app. + localStorage.setItem(PAINLESS_LAB_KEY, JSON.stringify(nextPayload)); + + setStore({ + payload: nextPayload, + validation: validatePayload(nextPayload), + }); + }; + + return ( + + {children} + + ); +}; + +export const useAppContext = () => { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error('AppContext can only be used inside of AppContextProvider!'); + } + return ctx; +}; diff --git a/x-pack/plugins/painless_lab/public/application/context/index.tsx b/x-pack/plugins/painless_lab/public/application/context/index.tsx new file mode 100644 index 0000000000000..7a685137b7a4f --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AppContextProvider, useAppContext } from './context'; diff --git a/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts b/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts new file mode 100644 index 0000000000000..4d9d8ad8b3ae7 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/context/initial_payload.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { exampleScript, painlessContextOptions } from '../constants'; + +export const initialPayload = { + context: painlessContextOptions[0].value, + code: exampleScript, + parameters: `{ + "string_parameter": "string value", + "number_parameter": 1.5, + "boolean_parameter": true +}`, + index: 'my-index', + document: `{ + "my_field": "field_value" +}`, + query: '', +}; diff --git a/x-pack/plugins/painless_lab/public/application/hooks/index.ts b/x-pack/plugins/painless_lab/public/application/hooks/index.ts new file mode 100644 index 0000000000000..159ff96d2278c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/hooks/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { useSubmitCode } from './use_submit_code'; diff --git a/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts b/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts new file mode 100644 index 0000000000000..36cd4f280ac4c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/hooks/use_submit_code.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useRef, useCallback, useState } from 'react'; +import { HttpSetup } from 'kibana/public'; +import { debounce } from 'lodash'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { Response, PayloadFormat, Payload } from '../types'; +import { formatRequestPayload } from '../lib/format'; + +const DEBOUNCE_MS = 800; + +export const useSubmitCode = (http: HttpSetup) => { + const currentRequestIdRef = useRef(0); + const [response, setResponse] = useState(undefined); + const [inProgress, setInProgress] = useState(false); + + const submit = useCallback( + debounce( + async (config: Payload) => { + setInProgress(true); + + // Prevent an older request that resolves after a more recent request from clobbering it. + // We store the resulting ID in this closure for comparison when the request resolves. + const requestId = ++currentRequestIdRef.current; + + try { + const result = await http.post(`${API_BASE_PATH}/execute`, { + // Stringify the string, because http runs it through JSON.parse, and we want to actually + // send a JSON string. + body: JSON.stringify(formatRequestPayload(config, PayloadFormat.UGLY)), + }); + + if (currentRequestIdRef.current === requestId) { + setResponse(result); + setInProgress(false); + } + // else ignore this response... + } catch (error) { + if (currentRequestIdRef.current === requestId) { + setResponse({ + error, + }); + setInProgress(false); + } + // else ignore this response... + } + }, + DEBOUNCE_MS, + { trailing: true } + ), + [http] + ); + + return { + response, + inProgress, + submit, + }; +}; diff --git a/x-pack/plugins/painless_lab/public/application/index.tsx b/x-pack/plugins/painless_lab/public/application/index.tsx new file mode 100644 index 0000000000000..ebcb84bbce83c --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/index.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { CoreSetup, CoreStart } from 'kibana/public'; +import { HttpSetup, ChromeStart } from 'src/core/public'; +import { createKibanaReactContext } from '../../../../../src/plugins/kibana_react/public'; + +import { Links } from '../links'; +import { AppContextProvider } from './context'; +import { Main } from './components/main'; + +interface AppDependencies { + http: HttpSetup; + I18nContext: CoreStart['i18n']['Context']; + uiSettings: CoreSetup['uiSettings']; + links: Links; + chrome: ChromeStart; +} + +export function renderApp( + element: HTMLElement | null, + { http, I18nContext, uiSettings, links, chrome }: AppDependencies +) { + if (!element) { + return () => undefined; + } + const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + uiSettings, + }); + render( + + + +
+ + + , + element + ); + return () => unmountComponentAtNode(element); +} diff --git a/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap b/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap new file mode 100644 index 0000000000000..4df90d1b3abe1 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/__snapshots__/format.test.ts.snap @@ -0,0 +1,205 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`formatRequestPayload pretty formats a complex multi-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"// Here's a comment and a variable, then a loop. + double halfWidth = Math.floor(width * 0.5); + for (int y = 0; y < height; y++) { + return \\"results here\\\\\\\\n\\"; + } + + return result;\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload pretty formats a single-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload pretty formats code and parameters 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats code, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\" + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats code, parameters, and context 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"\\", + \\"document\\": + } +}" +`; + +exports[`formatRequestPayload pretty formats code, parameters, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"return \\"ok\\";\\"\\"\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } + } + } +}" +`; + +exports[`formatRequestPayload pretty formats no script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"\\"\\"\\"\\"\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats a complex multi-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"// Here's a comment and a variable, then a loop.\\\\ndouble halfWidth = Math.floor(width * 0.5);\\\\nfor (int y = 0; y < height; y++) {\\\\n return \\\\\\"results here\\\\\\\\\\\\\\\\n\\\\\\";\\\\n}\\\\n\\\\nreturn result;\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats a single-line script 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\" + } +}" +`; + +exports[`formatRequestPayload ugly formats code and parameters 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats code, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\" + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats code, parameters, and context 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"undefined\\", + \\"document\\": undefined + } +}" +`; + +exports[`formatRequestPayload ugly formats code, parameters, context, index, and document 1`] = ` +"{ + \\"script\\": { + \\"source\\": \\"return \\\\\\"ok\\\\\\";\\", + \\"params\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + }, + \\"context\\": \\"filter\\", + \\"context_setup\\": { + \\"index\\": \\"index\\", + \\"document\\": { + \\"a\\": { + \\"b\\": \\"c\\", + \\"d\\": \\"e\\" + } +} + } +}" +`; + +exports[`formatRequestPayload ugly formats no script 1`] = ` +"{ + \\"script\\": { + \\"source\\": undefined + } +}" +`; diff --git a/x-pack/plugins/painless_lab/public/application/lib/format.test.ts b/x-pack/plugins/painless_lab/public/application/lib/format.test.ts new file mode 100644 index 0000000000000..5f0022ebbc089 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/format.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PayloadFormat } from '../types'; +import { formatRequestPayload } from './format'; + +describe('formatRequestPayload', () => { + Object.values(PayloadFormat).forEach(format => { + describe(`${format} formats`, () => { + test('no script', () => { + expect(formatRequestPayload({}, format)).toMatchSnapshot(); + }); + + test('a single-line script', () => { + const code = 'return "ok";'; + expect(formatRequestPayload({ code }, format)).toMatchSnapshot(); + }); + + test('a complex multi-line script', () => { + const code = `// Here's a comment and a variable, then a loop. +double halfWidth = Math.floor(width * 0.5); +for (int y = 0; y < height; y++) { + return "results here\\\\n"; +} + +return result;`; + expect(formatRequestPayload({ code }, format)).toMatchSnapshot(); + }); + + test('code and parameters', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + expect(formatRequestPayload({ code, parameters }, format)).toMatchSnapshot(); + }); + + test('code, parameters, and context', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + const context = 'filter'; + expect(formatRequestPayload({ code, parameters, context }, format)).toMatchSnapshot(); + }); + + test('code, context, index, and document', () => { + const code = 'return "ok";'; + const context = 'filter'; + const index = 'index'; + const document = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + expect(formatRequestPayload({ code, context, index, document }, format)).toMatchSnapshot(); + }); + + test('code, parameters, context, index, and document', () => { + const code = 'return "ok";'; + const parameters = `{ + "a": { + "b": "c", + "d": "e" + } +}`; + const context = 'filter'; + const index = 'index'; + const document = parameters; + expect( + formatRequestPayload({ code, parameters, context, index, document }, format) + ).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/x-pack/plugins/painless_lab/public/application/lib/format.ts b/x-pack/plugins/painless_lab/public/application/lib/format.ts new file mode 100644 index 0000000000000..15ecdf682d247 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/lib/format.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Response, ExecutionError, PayloadFormat, Payload } from '../types'; + +function prettifyPayload(payload = '', indentationLevel = 0) { + const indentation = new Array(indentationLevel + 1).join(' '); + return payload.replace(/\n/g, `\n${indentation}`); +} + +/** + * Values should be preserved as strings so that floating point precision, + * e.g. 1.0, is preserved instead of being coerced to an integer, e.g. 1. + */ +export function formatRequestPayload( + { code, context, parameters, index, document, query }: Partial, + format: PayloadFormat = PayloadFormat.UGLY +): string { + const isAdvancedContext = context === 'filter' || context === 'score'; + + let formattedCode: string | undefined; + let formattedParameters: string | undefined; + let formattedContext: string | undefined; + let formattedIndex: string | undefined; + let formattedDocument: string | undefined; + let formattedQuery: string | undefined; + + if (format === PayloadFormat.UGLY) { + formattedCode = JSON.stringify(code); + formattedParameters = parameters; + formattedContext = context; + formattedIndex = index; + formattedDocument = document; + formattedQuery = query; + } else { + // Triple quote the code because it's multiline. + formattedCode = `"""${prettifyPayload(code, 4)}"""`; + formattedParameters = prettifyPayload(parameters, 4); + formattedContext = prettifyPayload(context, 6); + formattedIndex = prettifyPayload(index); + formattedDocument = prettifyPayload(document, 4); + formattedQuery = prettifyPayload(query, 4); + } + + const requestPayload = `{ + "script": { + "source": ${formattedCode}${ + parameters + ? `, + "params": ${formattedParameters}` + : `` + } + }${ + isAdvancedContext + ? `, + "context": "${formattedContext}", + "context_setup": { + "index": "${formattedIndex}", + "document": ${formattedDocument}${ + query && context === 'score' + ? `, + "query": ${formattedQuery}` + : '' + } + }` + : `` + } +}`; + return requestPayload; +} + +/** + * Stringify a given object to JSON in a formatted way + */ +export function formatJson(json: unknown): string { + try { + return JSON.stringify(json, null, 2); + } catch (e) { + return `Invalid JSON ${String(json)}`; + } +} + +export function formatResponse(response?: Response): string { + if (!response) { + return ''; + } + if (typeof response.result === 'string') { + return response.result.replace(/\\n/g, '\n'); + } else if (response.error) { + return formatExecutionError(response.error); + } + return formatJson(response); +} + +export function formatExecutionError(executionErrorOrError: ExecutionError | Error): string { + if (executionErrorOrError instanceof Error) { + return executionErrorOrError.message; + } + + if ( + executionErrorOrError.script_stack && + executionErrorOrError.caused_by && + executionErrorOrError.position + ) { + return `Unhandled Exception ${executionErrorOrError.caused_by.type} + +${executionErrorOrError.caused_by.reason} + +Stack: +${formatJson(executionErrorOrError.script_stack)} +`; + } + return formatJson(executionErrorOrError); +} diff --git a/x-pack/plugins/painless_lab/public/application/types.ts b/x-pack/plugins/painless_lab/public/application/types.ts new file mode 100644 index 0000000000000..d800558ef7ecc --- /dev/null +++ b/x-pack/plugins/painless_lab/public/application/types.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Store { + payload: Payload; + validation: Validation; +} + +export interface Payload { + context: string; + code: string; + parameters: string; + index: string; + document: string; + query: string; +} + +export interface Validation { + isValid: boolean; + fields: { + index: boolean; + }; +} + +// TODO: This should be an enumerated list +export type Context = string; + +export enum PayloadFormat { + UGLY = 'ugly', + PRETTY = 'pretty', +} + +export interface Response { + error?: ExecutionError | Error; + result?: string; +} + +export type ExecutionErrorScriptStack = string[]; + +export interface ExecutionErrorPosition { + start: number; + end: number; + offset: number; +} + +export interface ExecutionError { + script_stack?: ExecutionErrorScriptStack; + caused_by?: { + type: string; + reason: string; + }; + message?: string; + position: ExecutionErrorPosition; + script: string; +} diff --git a/x-pack/plugins/painless_lab/public/index.scss b/x-pack/plugins/painless_lab/public/index.scss new file mode 100644 index 0000000000000..29a5761255278 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/index.scss @@ -0,0 +1 @@ +@import 'styles/index'; diff --git a/x-pack/plugins/painless_lab/public/index.ts b/x-pack/plugins/painless_lab/public/index.ts new file mode 100644 index 0000000000000..da357e52af676 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './styles/_index.scss'; +import { PainlessLabUIPlugin } from './plugin'; + +export function plugin() { + return new PainlessLabUIPlugin(); +} diff --git a/x-pack/plugins/painless_lab/public/lib/index.ts b/x-pack/plugins/painless_lab/public/lib/index.ts new file mode 100644 index 0000000000000..2421307b7c107 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { monacoPainlessLang } from './monaco_painless_lang'; diff --git a/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts b/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts new file mode 100644 index 0000000000000..602697064a768 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/lib/monaco_painless_lang.ts @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as monaco from 'monaco-editor'; + +/** + * Extends the default type for a Monarch language so we can use + * attribute references (like @keywords to reference the keywords list) + * in the defined tokenizer + */ +interface Language extends monaco.languages.IMonarchLanguage { + default: string; + brackets: any; + keywords: string[]; + symbols: RegExp; + escapes: RegExp; + digits: RegExp; + primitives: string[]; + octaldigits: RegExp; + binarydigits: RegExp; + constants: string[]; + operators: string[]; +} + +export const monacoPainlessLang = { + default: '', + // painless does not use < >, so we define our own + brackets: [ + ['{', '}', 'delimiter.curly'], + ['[', ']', 'delimiter.square'], + ['(', ')', 'delimiter.parenthesis'], + ], + keywords: [ + 'if', + 'in', + 'else', + 'while', + 'do', + 'for', + 'continue', + 'break', + 'return', + 'new', + 'try', + 'catch', + 'throw', + 'this', + 'instanceof', + ], + primitives: ['void', 'boolean', 'byte', 'short', 'char', 'int', 'long', 'float', 'double', 'def'], + constants: ['true', 'false'], + operators: [ + '=', + '>', + '<', + '!', + '~', + '?', + '?:', + '?.', + ':', + '==', + '===', + '<=', + '>=', + '!=', + '!==', + '&&', + '||', + '++', + '--', + '+', + '-', + '*', + '/', + '&', + '|', + '^', + '%', + '<<', + '>>', + '>>>', + '+=', + '-=', + '*=', + '/=', + '&=', + '|=', + '^=', + '%=', + '<<=', + '>>=', + '>>>=', + '->', + '::', + '=~', + '==~', + ], + symbols: /[=>; + +export const getLinks = ({ DOC_LINK_VERSION, ELASTIC_WEBSITE_URL }: DocLinksStart) => + Object.freeze({ + painlessExecuteAPI: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html`, + painlessExecuteAPIContexts: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-execute-api.html#_contexts`, + painlessAPIReference: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-api-reference.html`, + painlessWalkthrough: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-walkthrough.html`, + painlessLangSpec: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/painless/${DOC_LINK_VERSION}/painless-lang-spec.html`, + esQueryDSL: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/query-dsl.html`, + modulesScriptingPreferParams: `${ELASTIC_WEBSITE_URL}guide/en/elasticsearch/reference/${DOC_LINK_VERSION}/modules-scripting-using.html#prefer-params`, + }); diff --git a/x-pack/plugins/painless_lab/public/plugin.tsx b/x-pack/plugins/painless_lab/public/plugin.tsx new file mode 100644 index 0000000000000..b9ca7031cf670 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/plugin.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { Plugin, CoreStart, CoreSetup } from 'kibana/public'; +import { first } from 'rxjs/operators'; +import { EuiBetaBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; + +import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; +import { LICENSE_CHECK_STATE } from '../../licensing/public'; + +import { PLUGIN } from '../common/constants'; + +import { PluginDependencies } from './types'; +import { getLinks } from './links'; +import { LanguageService } from './services'; + +export class PainlessLabUIPlugin implements Plugin { + languageService = new LanguageService(); + + async setup( + { http, getStartServices, uiSettings }: CoreSetup, + { devTools, home, licensing }: PluginDependencies + ) { + home.featureCatalogue.register({ + id: PLUGIN.id, + title: i18n.translate('xpack.painlessLab.registryProviderTitle', { + defaultMessage: 'Painless Lab (beta)', + }), + description: i18n.translate('xpack.painlessLab.registryProviderDescription', { + defaultMessage: 'Simulate and debug painless code.', + }), + icon: '', + path: '/app/kibana#/dev_tools/painless_lab', + showOnHomePage: false, + category: FeatureCatalogueCategory.ADMIN, + }); + + devTools.register({ + id: 'painless_lab', + order: 7, + title: ( + + + {i18n.translate('xpack.painlessLab.displayName', { + defaultMessage: 'Painless Lab', + })} + + + + + + + ) as any, + enableRouting: false, + disabled: false, + mount: async (ctx, { element }) => { + const [core] = await getStartServices(); + + const { + i18n: { Context: I18nContext }, + notifications, + docLinks, + chrome, + } = core; + + this.languageService.setup(); + + const license = await licensing.license$.pipe(first()).toPromise(); + const { state, message: invalidLicenseMessage } = license.check( + PLUGIN.id, + PLUGIN.minimumLicenseType + ); + const isValidLicense = state === LICENSE_CHECK_STATE.Valid; + + if (!isValidLicense) { + notifications.toasts.addDanger(invalidLicenseMessage as string); + window.location.hash = '/dev_tools'; + return () => {}; + } + + const { renderApp } = await import('./application'); + const tearDownApp = renderApp(element, { + I18nContext, + http, + uiSettings, + links: getLinks(docLinks), + chrome, + }); + + return () => { + tearDownApp(); + }; + }, + }); + } + + async start(core: CoreStart, plugins: any) {} + + async stop() { + this.languageService.stop(); + } +} diff --git a/x-pack/plugins/painless_lab/public/services/index.ts b/x-pack/plugins/painless_lab/public/services/index.ts new file mode 100644 index 0000000000000..20bec9de24550 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { LanguageService } from './language_service'; diff --git a/x-pack/plugins/painless_lab/public/services/language_service.ts b/x-pack/plugins/painless_lab/public/services/language_service.ts new file mode 100644 index 0000000000000..efff9cd0e78d5 --- /dev/null +++ b/x-pack/plugins/painless_lab/public/services/language_service.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// It is important that we use this specific monaco instance so that +// editor settings are registered against the instance our React component +// uses. +import { monaco } from '@kbn/ui-shared-deps/monaco'; + +// @ts-ignore +import workerSrc from 'raw-loader!monaco-editor/min/vs/base/worker/workerMain.js'; + +import { monacoPainlessLang } from '../lib'; + +const LANGUAGE_ID = 'painless'; + +// Safely check whether these globals are present +const CAN_CREATE_WORKER = typeof Blob === 'function' && typeof Worker === 'function'; + +export class LanguageService { + private originalMonacoEnvironment: any; + + public setup() { + monaco.languages.register({ id: LANGUAGE_ID }); + monaco.languages.setMonarchTokensProvider(LANGUAGE_ID, monacoPainlessLang); + + if (CAN_CREATE_WORKER) { + this.originalMonacoEnvironment = (window as any).MonacoEnvironment; + (window as any).MonacoEnvironment = { + getWorker: () => { + const blob = new Blob([workerSrc], { type: 'application/javascript' }); + return new Worker(window.URL.createObjectURL(blob)); + }, + }; + } + } + + public stop() { + if (CAN_CREATE_WORKER) { + (window as any).MonacoEnvironment = this.originalMonacoEnvironment; + } + } +} diff --git a/x-pack/plugins/painless_lab/public/styles/_index.scss b/x-pack/plugins/painless_lab/public/styles/_index.scss new file mode 100644 index 0000000000000..f68dbe302511a --- /dev/null +++ b/x-pack/plugins/painless_lab/public/styles/_index.scss @@ -0,0 +1,58 @@ +@import '@elastic/eui/src/components/header/variables'; +@import '@elastic/eui/src/components/nav_drawer/variables'; + +/** + * This is a very brittle way of preventing the editor and other content from disappearing + * behind the bottom bar. + */ +$bottomBarHeight: calc(#{$euiSize} * 3); + +.painlessLabBottomBarPlaceholder { + height: $bottomBarHeight +} + +.painlessLabRightPane { + border-right: none; + border-top: none; + border-bottom: none; + border-radius: 0; + padding-top: 0; + height: 100%; +} + +.painlessLabRightPane__tabs { + display: flex; + flex-direction: column; + height: 100%; + + [role="tabpanel"] { + height: 100%; + overflow-y: auto; + } +} + +.painlessLab__betaLabelContainer { + line-height: 0; +} + +.painlessLabMainContainer { + height: calc(100vh - calc(#{$euiHeaderChildSize} * 2) - #{$bottomBarHeight}); +} + +.painlessLabPanelsContainer { + // The panels container should adopt the height of the main container + height: 100%; +} + +/** + * 1. Hack EUI so the bottom bar doesn't obscure the nav drawer flyout, but is also not obscured + * by the main content area. + */ +.painlessLab__bottomBar { + z-index: 5; /* 1 */ + left: $euiNavDrawerWidthCollapsed; +} + +.painlessLab__bottomBar-isNavDrawerLocked { + left: $euiNavDrawerWidthExpanded; +} diff --git a/x-pack/plugins/painless_lab/public/types.ts b/x-pack/plugins/painless_lab/public/types.ts new file mode 100644 index 0000000000000..9153f4c28de8d --- /dev/null +++ b/x-pack/plugins/painless_lab/public/types.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; +import { DevToolsSetup } from '../../../../src/plugins/dev_tools/public'; +import { LicensingPluginSetup } from '../../licensing/public'; + +export interface PluginDependencies { + licensing: LicensingPluginSetup; + home: HomePublicPluginSetup; + devTools: DevToolsSetup; +} diff --git a/x-pack/plugins/painless_lab/server/index.ts b/x-pack/plugins/painless_lab/server/index.ts new file mode 100644 index 0000000000000..96ea9a163deca --- /dev/null +++ b/x-pack/plugins/painless_lab/server/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PluginInitializerContext } from 'kibana/server'; +import { PainlessLabServerPlugin } from './plugin'; + +export const plugin = (ctx: PluginInitializerContext) => { + return new PainlessLabServerPlugin(ctx); +}; diff --git a/x-pack/plugins/painless_lab/server/lib/index.ts b/x-pack/plugins/painless_lab/server/lib/index.ts new file mode 100644 index 0000000000000..a9a3c61472d8c --- /dev/null +++ b/x-pack/plugins/painless_lab/server/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { isEsError } from './is_es_error'; diff --git a/x-pack/plugins/painless_lab/server/lib/is_es_error.ts b/x-pack/plugins/painless_lab/server/lib/is_es_error.ts new file mode 100644 index 0000000000000..4137293cf39c0 --- /dev/null +++ b/x-pack/plugins/painless_lab/server/lib/is_es_error.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as legacyElasticsearch from 'elasticsearch'; + +const esErrorsParent = legacyElasticsearch.errors._Abstract; + +export function isEsError(err: Error) { + return err instanceof esErrorsParent; +} diff --git a/x-pack/plugins/painless_lab/server/plugin.ts b/x-pack/plugins/painless_lab/server/plugin.ts new file mode 100644 index 0000000000000..74629a0b035ed --- /dev/null +++ b/x-pack/plugins/painless_lab/server/plugin.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; + +import { PLUGIN } from '../common/constants'; +import { License } from './services'; +import { Dependencies } from './types'; +import { registerExecuteRoute } from './routes/api'; + +export class PainlessLabServerPlugin implements Plugin { + private readonly license: License; + private readonly logger: Logger; + + constructor({ logger }: PluginInitializerContext) { + this.logger = logger.get(); + this.license = new License(); + } + + async setup({ http }: CoreSetup, { licensing }: Dependencies) { + const router = http.createRouter(); + + this.license.setup( + { + pluginId: PLUGIN.id, + minimumLicenseType: PLUGIN.minimumLicenseType, + defaultErrorMessage: i18n.translate('xpack.painlessLab.licenseCheckErrorMessage', { + defaultMessage: 'License check failed', + }), + }, + { + licensing, + logger: this.logger, + } + ); + + registerExecuteRoute({ router, license: this.license }); + } + + start() {} + + stop() {} +} diff --git a/x-pack/plugins/painless_lab/server/routes/api/execute.ts b/x-pack/plugins/painless_lab/server/routes/api/execute.ts new file mode 100644 index 0000000000000..55adb5e0410cc --- /dev/null +++ b/x-pack/plugins/painless_lab/server/routes/api/execute.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { schema } from '@kbn/config-schema'; + +import { API_BASE_PATH } from '../../../common/constants'; +import { RouteDependencies } from '../../types'; +import { isEsError } from '../../lib'; + +const bodySchema = schema.string(); + +export function registerExecuteRoute({ router, license }: RouteDependencies) { + router.post( + { + path: `${API_BASE_PATH}/execute`, + validate: { + body: bodySchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const body = req.body; + + try { + const callAsCurrentUser = ctx.core.elasticsearch.dataClient.callAsCurrentUser; + const response = await callAsCurrentUser('scriptsPainlessExecute', { + body, + }); + + return res.ok({ + body: response, + }); + } catch (e) { + if (isEsError(e)) { + // Assume invalid painless script was submitted + // Return 200 with error object + return res.ok({ + body: e.body, + }); + } + return res.internalError({ body: e }); + } + }) + ); +} diff --git a/x-pack/plugins/painless_lab/server/routes/api/index.ts b/x-pack/plugins/painless_lab/server/routes/api/index.ts new file mode 100644 index 0000000000000..62f05971d59cc --- /dev/null +++ b/x-pack/plugins/painless_lab/server/routes/api/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { registerExecuteRoute } from './execute'; diff --git a/x-pack/plugins/painless_lab/server/services/index.ts b/x-pack/plugins/painless_lab/server/services/index.ts new file mode 100644 index 0000000000000..b7a45e59549eb --- /dev/null +++ b/x-pack/plugins/painless_lab/server/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { License } from './license'; diff --git a/x-pack/plugins/painless_lab/server/services/license.ts b/x-pack/plugins/painless_lab/server/services/license.ts new file mode 100644 index 0000000000000..1c9d77198f928 --- /dev/null +++ b/x-pack/plugins/painless_lab/server/services/license.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { Logger } from 'src/core/server'; +import { + KibanaRequest, + KibanaResponseFactory, + RequestHandler, + RequestHandlerContext, +} from 'kibana/server'; + +import { LicensingPluginSetup } from '../../../licensing/server'; +import { LicenseType, LICENSE_CHECK_STATE } from '../../../licensing/common/types'; + +export interface LicenseStatus { + isValid: boolean; + message?: string; +} + +interface SetupSettings { + pluginId: string; + minimumLicenseType: LicenseType; + defaultErrorMessage: string; +} + +export class License { + private licenseStatus: LicenseStatus = { + isValid: false, + message: 'Invalid License', + }; + + setup( + { pluginId, minimumLicenseType, defaultErrorMessage }: SetupSettings, + { licensing, logger }: { licensing: LicensingPluginSetup; logger: Logger } + ) { + licensing.license$.subscribe(license => { + const { state, message } = license.check(pluginId, minimumLicenseType); + const hasRequiredLicense = state === LICENSE_CHECK_STATE.Valid; + + if (hasRequiredLicense) { + this.licenseStatus = { isValid: true }; + } else { + this.licenseStatus = { + isValid: false, + message: message || defaultErrorMessage, + }; + if (message) { + logger.info(message); + } + } + }); + } + + guardApiRoute(handler: RequestHandler) { + const license = this; + + return function licenseCheck( + ctx: RequestHandlerContext, + request: KibanaRequest, + response: KibanaResponseFactory + ) { + const licenseStatus = license.getStatus(); + + if (!licenseStatus.isValid) { + return response.customError({ + body: { + message: licenseStatus.message || '', + }, + statusCode: 403, + }); + } + + return handler(ctx, request, response); + }; + } + + getStatus() { + return this.licenseStatus; + } +} diff --git a/x-pack/plugins/painless_lab/server/types.ts b/x-pack/plugins/painless_lab/server/types.ts new file mode 100644 index 0000000000000..541a31dd175ec --- /dev/null +++ b/x-pack/plugins/painless_lab/server/types.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IRouter } from 'src/core/server'; +import { LicensingPluginSetup } from '../../licensing/server'; +import { License } from './services'; + +export interface RouteDependencies { + router: IRouter; + license: License; +} + +export interface Dependencies { + licensing: LicensingPluginSetup; +} From 81b372363367e8fe6085ecaf25b009d84674a1b2 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau <189600+XavierM@users.noreply.github.com> Date: Mon, 23 Mar 2020 19:26:49 -0400 Subject: [PATCH 22/34] [SIEM] [CASES] Build lego blocks case details view (#60864) * modify API to get the total comments in _find + Add user action to track what user are doing + create _pushed api to know when case have been pushed * fix rebase * add connector name in case configuration saved object * fix total comment in all cases * totalComment bug on the API * integrate user action API with UI * fix merged issue * integration APi to push to services with UI * Fix bugs * wip to show pushed service in ui * finish the full flow with pushing to service now * review about client discrepency * clean up + review * merge issue * update error msgs to info * add aria label + fix but on add/remove tags * fix i18n Co-authored-by: Christos Nasikas --- .../components/link_to/redirect_to_case.tsx | 7 +- .../siem/public/components/links/index.tsx | 28 ++- .../components/url_state/index.test.tsx | 2 +- .../siem/public/components/url_state/types.ts | 16 +- .../siem/public/containers/case/api.ts | 64 ++++++ .../public/containers/case/configure/types.ts | 1 + .../case/configure/use_configure.tsx | 27 ++- .../public/containers/case/translations.ts | 7 + .../siem/public/containers/case/types.ts | 34 ++- .../case/use_get_action_license.tsx | 74 ++++++ .../public/containers/case/use_get_case.tsx | 3 +- .../case/use_get_case_user_actions.tsx | 126 ++++++++++ .../case/use_post_push_to_service.tsx | 183 +++++++++++++++ .../containers/case/use_update_case.tsx | 13 +- .../containers/case/use_update_comment.tsx | 12 +- .../siem/public/containers/case/utils.ts | 16 ++ .../case/components/add_comment/index.tsx | 129 ++++++----- .../components/all_cases/__mock__/index.tsx | 19 +- .../case/components/all_cases/columns.tsx | 7 +- .../case/components/all_cases/index.test.tsx | 4 +- .../pages/case/components/all_cases/index.tsx | 20 +- .../case/components/case_status/index.tsx | 85 ++++--- .../components/case_view/__mock__/index.tsx | 24 +- .../case/components/case_view/index.test.tsx | 42 +++- .../pages/case/components/case_view/index.tsx | 118 ++++++++-- .../components/case_view/push_to_service.tsx | 185 +++++++++++++++ .../case/components/case_view/translations.ts | 96 +++++++- .../case/components/configure_cases/index.tsx | 10 +- .../errors_push_service_callout/index.tsx | 33 +++ .../translations.ts | 21 ++ .../components/user_action_tree/helpers.tsx | 75 ++++++ .../components/user_action_tree/index.tsx | 215 ++++++++++++++---- .../user_action_tree/translations.ts | 34 +++ .../user_action_tree/user_action_item.tsx | 134 ++++++++--- .../user_action_tree/user_action_title.tsx | 134 ++++++++--- .../plugins/siem/public/pages/case/index.tsx | 4 + .../siem/public/pages/case/translations.ts | 38 +++- .../plugins/siem/public/pages/case/utils.ts | 4 +- x-pack/plugins/case/common/api/cases/case.ts | 85 ++++++- .../plugins/case/common/api/cases/comment.ts | 2 + .../case/common/api/cases/configure.ts | 1 + x-pack/plugins/case/common/api/cases/index.ts | 1 + .../case/common/api/cases/user_actions.ts | 59 +++++ x-pack/plugins/case/server/plugin.ts | 7 +- .../routes/api/__fixtures__/mock_router.ts | 4 + .../api/__fixtures__/mock_saved_objects.ts | 16 +- .../api/cases/comments/delete_all_comments.ts | 29 ++- .../api/cases/comments/delete_comment.ts | 46 ++-- .../api/cases/comments/find_comments.ts | 5 +- .../api/cases/comments/get_all_comment.ts | 3 +- .../routes/api/cases/comments/get_comment.ts | 11 - .../api/cases/comments/patch_comment.ts | 50 ++-- .../routes/api/cases/comments/post_comment.ts | 37 +-- .../api/cases/configure/patch_configure.ts | 3 +- .../api/cases/configure/post_configure.ts | 3 +- .../server/routes/api/cases/delete_cases.ts | 27 ++- .../server/routes/api/cases/find_cases.ts | 39 +++- .../case/server/routes/api/cases/get_case.ts | 5 +- .../case/server/routes/api/cases/helpers.ts | 56 ++++- .../routes/api/cases/patch_cases.test.ts | 6 +- .../server/routes/api/cases/patch_cases.ts | 23 +- .../case/server/routes/api/cases/post_case.ts | 27 ++- .../case/server/routes/api/cases/push_case.ts | 176 ++++++++++++++ .../api/cases/reporters/get_reporters.ts | 3 +- .../routes/api/cases/status/get_status.ts | 5 +- .../server/routes/api/cases/tags/get_tags.ts | 3 +- .../user_actions/get_all_user_actions.ts | 46 ++++ .../plugins/case/server/routes/api/index.ts | 16 +- .../plugins/case/server/routes/api/types.ts | 12 +- .../plugins/case/server/routes/api/utils.ts | 32 ++- .../case/server/saved_object_types/cases.ts | 39 +++- .../server/saved_object_types/comments.ts | 13 ++ .../server/saved_object_types/configure.ts | 9 + .../case/server/saved_object_types/index.ts | 1 + .../server/saved_object_types/user_actions.ts | 47 ++++ x-pack/plugins/case/server/services/index.ts | 50 +++- .../server/services/user_actions/helpers.ts | 195 ++++++++++++++++ .../server/services/user_actions/index.ts | 77 +++++++ 78 files changed, 2875 insertions(+), 438 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx create mode 100644 x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/case_view/push_to_service.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx create mode 100644 x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts create mode 100644 x-pack/plugins/case/common/api/cases/user_actions.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/push_case.ts create mode 100644 x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts create mode 100644 x-pack/plugins/case/server/saved_object_types/user_actions.ts create mode 100644 x-pack/plugins/case/server/services/user_actions/helpers.ts create mode 100644 x-pack/plugins/case/server/services/user_actions/index.ts diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx index 3056b166c1153..20ba0b50f5126 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx @@ -31,6 +31,7 @@ export const RedirectToConfigureCasesPage = () => ( const baseCaseUrl = `#/link-to/${SiemPageName.case}`; export const getCaseUrl = () => baseCaseUrl; -export const getCaseDetailsUrl = (detailName: string) => `${baseCaseUrl}/${detailName}`; -export const getCreateCaseUrl = () => `${baseCaseUrl}/create`; -export const getConfigureCasesUrl = () => `${baseCaseUrl}/configure`; +export const getCaseDetailsUrl = (detailName: string, search: string) => + `${baseCaseUrl}/${detailName}${search}`; +export const getCreateCaseUrl = (search: string) => `${baseCaseUrl}/create${search}`; +export const getConfigureCasesUrl = (search: string) => `${baseCaseUrl}/configure${search}`; diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index 04de0b1d5d3bf..935df9ad3361f 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -23,8 +23,10 @@ import { import { FlowTarget, FlowTargetSourceDest } from '../../graphql/types'; import { useUiSetting$ } from '../../lib/kibana'; import { IP_REPUTATION_LINKS_SETTING } from '../../../common/constants'; +import { navTabs } from '../../pages/home/home_navigations'; import * as i18n from '../page/network/ip_overview/translations'; import { isUrlInvalid } from '../../pages/detection_engine/rules/components/step_about_rule/helpers'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { ExternalLinkIcon } from '../external_link_icon'; export const DEFAULT_NUMBER_OF_LINK = 5; @@ -89,20 +91,24 @@ export const IPDetailsLink = React.memo(IPDetailsLinkComponent); const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailName: string }> = ({ children, detailName, -}) => ( - - {children ? children : detailName} - -); +}) => { + const urlSearch = useGetUrlSearch(navTabs.case); + return ( + + {children ? children : detailName} + + ); +}; export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); CaseDetailsLink.displayName = 'CaseDetailsLink'; -export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => ( - {children} -)); +export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => { + const urlSearch = useGetUrlSearch(navTabs.case); + return {children}; +}); CreateCaseLink.displayName = 'CreateCaseLink'; diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx index 10aa388449d91..6e957313d9b04 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx @@ -158,7 +158,7 @@ describe('UrlStateContainer', () => { hash: '', pathname: examplePath, search: [CONSTANTS.timelinePage].includes(page) - ? '?timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))' + ? `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))` : `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, state: '', }); diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts index 2cb1b0c96ad79..c6f49d8a0e49b 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts @@ -60,8 +60,20 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], - timeline: [CONSTANTS.timeline, CONSTANTS.timerange], - case: [], + timeline: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.timeline, + CONSTANTS.timerange, + ], + case: [ + CONSTANTS.appQuery, + CONSTANTS.filters, + CONSTANTS.savedQuery, + CONSTANTS.timeline, + CONSTANTS.timerange, + ], }; export type LocationTypes = diff --git a/x-pack/legacy/plugins/siem/public/containers/case/api.ts b/x-pack/legacy/plugins/siem/public/containers/case/api.ts index 5ba1f010e0d52..16ee294224bb9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/api.ts @@ -13,9 +13,15 @@ import { CommentRequest, CommentResponse, User, + CaseUserActionsResponse, + CaseExternalServiceRequest, + ServiceConnectorCaseParams, + ServiceConnectorCaseResponse, + ActionTypeExecutorResult, } from '../../../../../../plugins/case/common/api'; import { KibanaServices } from '../../lib/kibana'; import { + ActionLicense, AllCases, BulkUpdateStatus, Case, @@ -23,16 +29,20 @@ import { Comment, FetchCasesProps, SortFieldCase, + CaseUserActions, } from './types'; import { CASES_URL } from './constants'; import { convertToCamelCase, convertAllCasesToCamel, + convertArrayToCamelCase, decodeCaseResponse, decodeCasesResponse, decodeCasesFindResponse, decodeCasesStatusResponse, decodeCommentResponse, + decodeCaseUserActionsResponse, + decodeServiceConnectorCaseResponse, } from './utils'; export const getCase = async (caseId: string, includeComments: boolean = true): Promise => { @@ -71,6 +81,20 @@ export const getReporters = async (signal: AbortSignal): Promise => { return response ?? []; }; +export const getCaseUserActions = async ( + caseId: string, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/user_actions`, + { + method: 'GET', + signal, + } + ); + return convertArrayToCamelCase(decodeCaseUserActionsResponse(response)) as CaseUserActions[]; +}; + export const getCases = async ({ filterOptions = { search: '', @@ -161,3 +185,43 @@ export const deleteCases = async (caseIds: string[]): Promise => { }); return response === 'true' ? true : false; }; + +export const pushCase = async ( + caseId: string, + push: CaseExternalServiceRequest, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `${CASES_URL}/${caseId}/_push`, + { + method: 'POST', + body: JSON.stringify(push), + signal, + } + ); + return convertToCamelCase(decodeCaseResponse(response)); +}; + +export const pushToService = async ( + connectorId: string, + casePushParams: ServiceConnectorCaseParams, + signal: AbortSignal +): Promise => { + const response = await KibanaServices.get().http.fetch( + `/api/action/${connectorId}/_execute`, + { + method: 'POST', + body: JSON.stringify({ params: casePushParams }), + signal, + } + ); + return decodeServiceConnectorCaseResponse(response.data); +}; + +export const getActionLicense = async (signal: AbortSignal): Promise => { + const response = await KibanaServices.get().http.fetch(`/api/action/types`, { + method: 'GET', + signal, + }); + return response; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts index fc7aaa3643d77..d69c23fe02ec9 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/types.ts @@ -26,6 +26,7 @@ export interface CaseConfigure { createdAt: string; createdBy: ElasticUser; connectorId: string; + connectorName: string; closureType: ClosureType; updatedAt: string; updatedBy: ElasticUser; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx index 22ac54093d1dc..a24f8303824c5 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/configure/use_configure.tsx @@ -13,6 +13,7 @@ import { ClosureType } from './types'; interface PersistCaseConfigure { connectorId: string; + connectorName: string; closureType: ClosureType; } @@ -24,12 +25,12 @@ export interface ReturnUseCaseConfigure { } interface UseCaseConfigure { - setConnectorId: (newConnectorId: string) => void; - setClosureType: (newClosureType: ClosureType) => void; + setConnector: (newConnectorId: string, newConnectorName?: string) => void; + setClosureType?: (newClosureType: ClosureType) => void; } export const useCaseConfigure = ({ - setConnectorId, + setConnector, setClosureType, }: UseCaseConfigure): ReturnUseCaseConfigure => { const [, dispatchToaster] = useStateToaster(); @@ -48,8 +49,10 @@ export const useCaseConfigure = ({ if (!didCancel) { setLoading(false); if (res != null) { - setConnectorId(res.connectorId); - setClosureType(res.closureType); + setConnector(res.connectorId, res.connectorName); + if (setClosureType != null) { + setClosureType(res.closureType); + } setVersion(res.version); } } @@ -74,7 +77,7 @@ export const useCaseConfigure = ({ }, []); const persistCaseConfigure = useCallback( - async ({ connectorId, closureType }: PersistCaseConfigure) => { + async ({ connectorId, connectorName, closureType }: PersistCaseConfigure) => { let didCancel = false; const abortCtrl = new AbortController(); const saveCaseConfiguration = async () => { @@ -83,7 +86,11 @@ export const useCaseConfigure = ({ const res = version.length === 0 ? await postCaseConfigure( - { connector_id: connectorId, closure_type: closureType }, + { + connector_id: connectorId, + connector_name: connectorName, + closure_type: closureType, + }, abortCtrl.signal ) : await patchCaseConfigure( @@ -92,8 +99,10 @@ export const useCaseConfigure = ({ ); if (!didCancel) { setPersistLoading(false); - setConnectorId(res.connectorId); - setClosureType(res.closureType); + setConnector(res.connectorId); + if (setClosureType) { + setClosureType(res.closureType); + } setVersion(res.version); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts index 0c8b896e2b426..601db373f041e 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/translations.ts @@ -16,3 +16,10 @@ export const TAG_FETCH_FAILURE = i18n.translate( defaultMessage: 'Failed to fetch Tags', } ); + +export const SUCCESS_SEND_TO_EXTERNAL_SERVICE = i18n.translate( + 'xpack.siem.containers.case.pushToExterService', + { + defaultMessage: 'Successfully sent to ServiceNow', + } +); diff --git a/x-pack/legacy/plugins/siem/public/containers/case/types.ts b/x-pack/legacy/plugins/siem/public/containers/case/types.ts index 44519031e91cb..bbbb13788d53a 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/types.ts @@ -4,30 +4,53 @@ * you may not use this file except in compliance with the Elastic License. */ -import { User } from '../../../../../../plugins/case/common/api'; +import { User, UserActionField, UserAction } from '../../../../../../plugins/case/common/api'; export interface Comment { id: string; createdAt: string; createdBy: ElasticUser; comment: string; + pushedAt: string | null; + pushedBy: string | null; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; } +export interface CaseUserActions { + actionId: string; + actionField: UserActionField; + action: UserAction; + actionAt: string; + actionBy: ElasticUser; + caseId: string; + commentId: string | null; + newValue: string | null; + oldValue: string | null; +} +export interface CaseExternalService { + pushedAt: string; + pushedBy: string; + connectorId: string; + connectorName: string; + externalId: string; + externalTitle: string; + externalUrl: string; +} export interface Case { id: string; closedAt: string | null; closedBy: ElasticUser | null; comments: Comment[]; - commentIds: string[]; createdAt: string; createdBy: ElasticUser; description: string; + externalService: CaseExternalService | null; status: string; tags: string[]; title: string; + totalComment: number; updatedAt: string | null; updatedBy: ElasticUser | null; version: string; @@ -84,3 +107,10 @@ export interface BulkUpdateStatus { id: string; version: string; } +export interface ActionLicense { + id: string; + name: string; + enabled: boolean; + enabledInConfig: boolean; + enabledInLicense: boolean; +} diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx new file mode 100644 index 0000000000000..12f92b2db039b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_action_license.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getActionLicense } from './api'; +import * as i18n from './translations'; +import { ActionLicense } from './types'; + +interface ActionLicenseState { + actionLicense: ActionLicense | null; + isLoading: boolean; + isError: boolean; +} + +const initialData: ActionLicenseState = { + actionLicense: null, + isLoading: true, + isError: false, +}; + +export const useGetActionLicense = (): ActionLicenseState => { + const [actionLicenseState, setActionLicensesState] = useState(initialData); + + const [, dispatchToaster] = useStateToaster(); + + const fetchActionLicense = useCallback(() => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setActionLicensesState({ + ...actionLicenseState, + isLoading: true, + }); + try { + const response = await getActionLicense(abortCtrl.signal); + if (!didCancel) { + setActionLicensesState({ + actionLicense: response.find(l => l.id === '.servicenow') ?? null, + isLoading: false, + isError: false, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setActionLicensesState({ + actionLicense: null, + isLoading: false, + isError: true, + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, [actionLicenseState]); + + useEffect(() => { + fetchActionLicense(); + }, []); + return { ...actionLicenseState }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx index b70195e2c126f..02b41c9fc720f 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case.tsx @@ -53,14 +53,15 @@ const initialData: Case = { closedBy: null, createdAt: '', comments: [], - commentIds: [], createdBy: { username: '', }, description: '', + externalService: null, status: '', tags: [], title: '', + totalComment: 0, updatedAt: null, updatedBy: null, version: '', diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx new file mode 100644 index 0000000000000..4c278bc038134 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_case_user_actions.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isEmpty, uniqBy } from 'lodash/fp'; +import { useCallback, useEffect, useState } from 'react'; + +import { errorToToaster, useStateToaster } from '../../components/toasters'; +import { getCaseUserActions } from './api'; +import * as i18n from './translations'; +import { CaseUserActions, ElasticUser } from './types'; + +interface CaseUserActionsState { + caseUserActions: CaseUserActions[]; + firstIndexPushToService: number; + hasDataToPush: boolean; + participants: ElasticUser[]; + isLoading: boolean; + isError: boolean; + lastIndexPushToService: number; +} + +const initialData: CaseUserActionsState = { + caseUserActions: [], + firstIndexPushToService: -1, + lastIndexPushToService: -1, + hasDataToPush: false, + isLoading: true, + isError: false, + participants: [], +}; + +interface UseGetCaseUserActions extends CaseUserActionsState { + fetchCaseUserActions: (caseId: string) => void; +} + +const getPushedInfo = ( + caseUserActions: CaseUserActions[] +): { firstIndexPushToService: number; lastIndexPushToService: number; hasDataToPush: boolean } => { + const firstIndexPushToService = caseUserActions.findIndex( + cua => cua.action === 'push-to-service' + ); + const lastIndexPushToService = caseUserActions + .map(cua => cua.action) + .lastIndexOf('push-to-service'); + + const hasDataToPush = + lastIndexPushToService === -1 || lastIndexPushToService < caseUserActions.length - 1; + return { + firstIndexPushToService, + lastIndexPushToService, + hasDataToPush, + }; +}; + +export const useGetCaseUserActions = (caseId: string): UseGetCaseUserActions => { + const [caseUserActionsState, setCaseUserActionsState] = useState( + initialData + ); + + const [, dispatchToaster] = useStateToaster(); + + const fetchCaseUserActions = useCallback( + (thisCaseId: string) => { + let didCancel = false; + const abortCtrl = new AbortController(); + const fetchData = async () => { + setCaseUserActionsState({ + ...caseUserActionsState, + isLoading: true, + }); + try { + const response = await getCaseUserActions(thisCaseId, abortCtrl.signal); + if (!didCancel) { + // Attention Future developer + // We are removing the first item because it will always be the creation of the case + // and we do not want it to simplify our life + const participants = !isEmpty(response) + ? uniqBy('actionBy.username', response).map(cau => cau.actionBy) + : []; + const caseUserActions = !isEmpty(response) ? response.slice(1) : []; + setCaseUserActionsState({ + caseUserActions, + ...getPushedInfo(caseUserActions), + isLoading: false, + isError: false, + participants, + }); + } + } catch (error) { + if (!didCancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + setCaseUserActionsState({ + caseUserActions: [], + firstIndexPushToService: -1, + lastIndexPushToService: -1, + hasDataToPush: false, + isLoading: false, + isError: true, + participants: [], + }); + } + } + }; + fetchData(); + return () => { + didCancel = true; + abortCtrl.abort(); + }; + }, + [caseUserActionsState] + ); + + useEffect(() => { + if (!isEmpty(caseId)) { + fetchCaseUserActions(caseId); + } + }, [caseId]); + return { ...caseUserActionsState, fetchCaseUserActions }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx new file mode 100644 index 0000000000000..b6fb15f4fa083 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_post_push_to_service.tsx @@ -0,0 +1,183 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useReducer, useCallback } from 'react'; + +import { + ServiceConnectorCaseResponse, + ServiceConnectorCaseParams, +} from '../../../../../../plugins/case/common/api'; +import { errorToToaster, useStateToaster, displaySuccessToast } from '../../components/toasters'; + +import { getCase, pushToService, pushCase } from './api'; +import * as i18n from './translations'; +import { Case } from './types'; + +interface PushToServiceState { + serviceData: ServiceConnectorCaseResponse | null; + pushedCaseData: Case | null; + isLoading: boolean; + isError: boolean; +} +type Action = + | { type: 'FETCH_INIT' } + | { type: 'FETCH_SUCCESS_PUSH_SERVICE'; payload: ServiceConnectorCaseResponse | null } + | { type: 'FETCH_SUCCESS_PUSH_CASE'; payload: Case | null } + | { type: 'FETCH_FAILURE' }; + +const dataFetchReducer = (state: PushToServiceState, action: Action): PushToServiceState => { + switch (action.type) { + case 'FETCH_INIT': + return { + ...state, + isLoading: true, + isError: false, + }; + case 'FETCH_SUCCESS_PUSH_SERVICE': + return { + ...state, + isLoading: false, + isError: false, + serviceData: action.payload ?? null, + }; + case 'FETCH_SUCCESS_PUSH_CASE': + return { + ...state, + isLoading: false, + isError: false, + pushedCaseData: action.payload ?? null, + }; + case 'FETCH_FAILURE': + return { + ...state, + isLoading: false, + isError: true, + }; + default: + return state; + } +}; + +interface PushToServiceRequest { + caseId: string; + connectorId: string; + connectorName: string; + updateCase: (newCase: Case) => void; +} + +interface UsePostPushToService extends PushToServiceState { + postPushToService: ({ caseId, connectorId, updateCase }: PushToServiceRequest) => void; +} + +export const usePostPushToService = (): UsePostPushToService => { + const [state, dispatch] = useReducer(dataFetchReducer, { + serviceData: null, + pushedCaseData: null, + isLoading: false, + isError: false, + }); + const [, dispatchToaster] = useStateToaster(); + + const postPushToService = useCallback( + async ({ caseId, connectorId, connectorName, updateCase }: PushToServiceRequest) => { + let cancel = false; + const abortCtrl = new AbortController(); + try { + dispatch({ type: 'FETCH_INIT' }); + const casePushData = await getCase(caseId); + const responseService = await pushToService( + connectorId, + formatServiceRequestData(casePushData), + abortCtrl.signal + ); + const responseCase = await pushCase( + caseId, + { + connector_id: connectorId, + connector_name: connectorName, + external_id: responseService.incidentId, + external_title: responseService.number, + external_url: responseService.url, + }, + abortCtrl.signal + ); + if (!cancel) { + dispatch({ type: 'FETCH_SUCCESS_PUSH_SERVICE', payload: responseService }); + dispatch({ type: 'FETCH_SUCCESS_PUSH_CASE', payload: responseCase }); + updateCase(responseCase); + displaySuccessToast(i18n.SUCCESS_SEND_TO_EXTERNAL_SERVICE, dispatchToaster); + } + } catch (error) { + if (!cancel) { + errorToToaster({ + title: i18n.ERROR_TITLE, + error: error.body && error.body.message ? new Error(error.body.message) : error, + dispatchToaster, + }); + dispatch({ type: 'FETCH_FAILURE' }); + } + } + return () => { + cancel = true; + abortCtrl.abort(); + }; + }, + [] + ); + + return { ...state, postPushToService }; +}; + +const formatServiceRequestData = (myCase: Case): ServiceConnectorCaseParams => { + const { + id: caseId, + createdAt, + createdBy, + comments, + description, + externalService, + title, + updatedAt, + updatedBy, + } = myCase; + + return { + caseId, + createdAt, + createdBy: { + fullName: createdBy.fullName ?? null, + username: createdBy?.username, + }, + comments: comments.map(c => ({ + commentId: c.id, + comment: c.comment, + createdAt: c.createdAt, + createdBy: { + fullName: c.createdBy.fullName ?? null, + username: c.createdBy.username, + }, + updatedAt: c.updatedAt, + updatedBy: + c.updatedBy != null + ? { + fullName: c.updatedBy.fullName ?? null, + username: c.updatedBy.username, + } + : null, + })), + description, + incidentId: externalService?.externalId ?? null, + title, + updatedAt, + updatedBy: + updatedBy != null + ? { + fullName: updatedBy.fullName ?? null, + username: updatedBy.username, + } + : null, + }; +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx index 987620469901b..f8af088f7e03b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_case.tsx @@ -25,6 +25,7 @@ interface NewCaseState { export interface UpdateByKey { updateKey: UpdateKey; updateValue: CaseRequest[UpdateKey]; + fetchCaseUserActions?: (caseId: string) => void; } type Action = @@ -64,6 +65,7 @@ const dataFetchReducer = (state: NewCaseState, action: Action): NewCaseState => interface UseUpdateCase extends NewCaseState { updateCaseProperty: (updates: UpdateByKey) => void; + updateCase: (newCase: Case) => void; } export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase => { const [state, dispatch] = useReducer(dataFetchReducer, { @@ -74,8 +76,12 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase }); const [, dispatchToaster] = useStateToaster(); + const updateCase = useCallback((newCase: Case) => { + dispatch({ type: 'FETCH_SUCCESS', payload: newCase }); + }, []); + const dispatchUpdateCaseProperty = useCallback( - async ({ updateKey, updateValue }: UpdateByKey) => { + async ({ fetchCaseUserActions, updateKey, updateValue }: UpdateByKey) => { let cancel = false; try { dispatch({ type: 'FETCH_INIT', payload: updateKey }); @@ -85,6 +91,9 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase state.caseData.version ); if (!cancel) { + if (fetchCaseUserActions != null) { + fetchCaseUserActions(caseId); + } dispatch({ type: 'FETCH_SUCCESS', payload: response[0] }); } } catch (error) { @@ -104,5 +113,5 @@ export const useUpdateCase = (caseId: string, initialData: Case): UseUpdateCase [state] ); - return { ...state, updateCaseProperty: dispatchUpdateCaseProperty }; + return { ...state, updateCase, updateCaseProperty: dispatchUpdateCaseProperty }; }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx index a40a1100ca735..c1b2bfde30126 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_update_comment.tsx @@ -70,8 +70,15 @@ const dataFetchReducer = (state: CommentUpdateState, action: Action): CommentUpd } }; +interface UpdateComment { + caseId: string; + commentId: string; + commentUpdate: string; + fetchUserActions: () => void; +} + interface UseUpdateComment extends CommentUpdateState { - updateComment: (caseId: string, commentId: string, commentUpdate: string) => void; + updateComment: ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => void; addPostedComment: Dispatch; } @@ -84,7 +91,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { const [, dispatchToaster] = useStateToaster(); const dispatchUpdateComment = useCallback( - async (caseId: string, commentId: string, commentUpdate: string) => { + async ({ caseId, commentId, commentUpdate, fetchUserActions }: UpdateComment) => { let cancel = false; try { dispatch({ type: 'FETCH_INIT', payload: commentId }); @@ -98,6 +105,7 @@ export const useUpdateComment = (comments: Comment[]): UseUpdateComment => { currentComment.version ); if (!cancel) { + fetchUserActions(); dispatch({ type: 'FETCH_SUCCESS', payload: { update: response, commentId } }); } } catch (error) { diff --git a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts index 8f24d5a435240..ce23ac6c440b6 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/containers/case/utils.ts @@ -23,6 +23,10 @@ import { CommentResponseRt, CasesConfigureResponse, CaseConfigureResponseRt, + CaseUserActionsResponse, + CaseUserActionsResponseRt, + ServiceConnectorCaseResponseRt, + ServiceConnectorCaseResponse, } from '../../../../../../plugins/case/common/api'; import { ToasterError } from '../../components/toasters'; import { AllCases, Case } from './types'; @@ -86,3 +90,15 @@ export const decodeCaseConfigureResponse = (respCase?: CasesConfigureResponse) = CaseConfigureResponseRt.decode(respCase), fold(throwErrors(createToasterPlainError), identity) ); + +export const decodeCaseUserActionsResponse = (respUserActions?: CaseUserActionsResponse) => + pipe( + CaseUserActionsResponseRt.decode(respUserActions), + fold(throwErrors(createToasterPlainError), identity) + ); + +export const decodeServiceConnectorCaseResponse = (respPushCase?: ServiceConnectorCaseResponse) => + pipe( + ServiceConnectorCaseResponseRt.decode(respPushCase), + fold(throwErrors(createToasterPlainError), identity) + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx index 0b3b0daaf4bbc..836595c7c45d9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/add_comment/index.tsx @@ -30,72 +30,79 @@ const initialCommentValue: CommentRequest = { interface AddCommentProps { caseId: string; + onCommentSaving?: () => void; onCommentPosted: (commentResponse: Comment) => void; + showLoading?: boolean; } -export const AddComment = React.memo(({ caseId, onCommentPosted }) => { - const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); - const { form } = useForm({ - defaultValue: initialCommentValue, - options: { stripEmptyFields: false }, - schema, - }); - const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( - form, - 'comment' - ); +export const AddComment = React.memo( + ({ caseId, showLoading = true, onCommentPosted, onCommentSaving }) => { + const { commentData, isLoading, postComment, resetCommentData } = usePostComment(caseId); + const { form } = useForm({ + defaultValue: initialCommentValue, + options: { stripEmptyFields: false }, + schema, + }); + const { handleCursorChange, handleOnTimelineChange } = useInsertTimeline( + form, + 'comment' + ); - useEffect(() => { - if (commentData !== null) { - onCommentPosted(commentData); - form.reset(); - resetCommentData(); - } - }, [commentData]); + useEffect(() => { + if (commentData !== null) { + onCommentPosted(commentData); + form.reset(); + resetCommentData(); + } + }, [commentData]); - const onSubmit = useCallback(async () => { - const { isValid, data } = await form.submit(); - if (isValid) { - await postComment(data); - } - }, [form]); + const onSubmit = useCallback(async () => { + const { isValid, data } = await form.submit(); + if (isValid) { + if (onCommentSaving != null) { + onCommentSaving(); + } + await postComment(data); + } + }, [form]); - return ( - <> - {isLoading && } -
- - {i18n.ADD_COMMENT} - - ), - topRightContent: ( - - ), - }} - /> - - - ); -}); + return ( + <> + {isLoading && showLoading && } +
+ + {i18n.ADD_COMMENT} + + ), + topRightContent: ( + + ), + }} + /> + + + ); + } +); AddComment.displayName = 'AddComment'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx index 48fbb4e74c407..d4ec32dfd070b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/__mock__/index.tsx @@ -18,12 +18,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:23.627Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -34,12 +35,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '362a5c10-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:13.328Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Bad email', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -50,12 +52,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '34f8b9e0-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:11.328Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Bad email', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', @@ -66,14 +69,15 @@ export const useGetCasesMockState: UseGetCasesState = { id: '31890e90-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:05.563Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'closed', tags: ['phishing'], title: 'Uh oh', - updatedAt: '2020-02-13T19:44:13.328Z', - updatedBy: { username: 'elastic' }, + totalComment: 0, + updatedAt: null, + updatedBy: null, version: 'WzQ3LDFd', }, { @@ -82,12 +86,13 @@ export const useGetCasesMockState: UseGetCasesState = { id: '2f5b3210-4e99-11ea-9290-35d05cb55c15', createdAt: '2020-02-13T19:44:01.901Z', createdBy: { username: 'elastic' }, - commentIds: [], comments: [], description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['phishing'], title: 'Uh oh', + totalComment: 0, updatedAt: null, updatedBy: null, version: 'WzQ3LDFd', diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx index b9e1113c486ad..32a29483e9c75 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/columns.tsx @@ -35,6 +35,7 @@ const Spacer = styled.span` const renderStringField = (field: string, dataTestSubj: string) => field != null ? {field} : getEmptyTagValue(); + export const getCasesColumns = ( actions: Array>, filterStatus: string @@ -108,11 +109,11 @@ export const getCasesColumns = ( }, { align: 'right', - field: 'commentIds', + field: 'totalComment', name: i18n.COMMENTS, sortable: true, - render: (comments: Case['commentIds']) => - renderStringField(`${comments.length}`, `case-table-column-commentCount`), + render: (totalComment: Case['totalComment']) => + renderStringField(`${totalComment}`, `case-table-column-commentCount`), }, filterStatus === 'open' ? { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx index 13869c79c45fd..bdcb87b483851 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.test.tsx @@ -95,7 +95,9 @@ describe('AllCases', () => { .find(`a[data-test-subj="case-details-link"]`) .first() .prop('href') - ).toEqual(`#/link-to/case/${useGetCasesMockState.data.cases[0].id}`); + ).toEqual( + `#/link-to/case/${useGetCasesMockState.data.cases[0].id}?timerange=(global:(linkTo:!(timeline),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)),timeline:(linkTo:!(global),timerange:(from:0,fromStr:now-24h,kind:relative,to:1,toStr:now)))` + ); expect( wrapper .find(`a[data-test-subj="case-details-link"]`) diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index e7e1e624ccba2..87a2ea888831a 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -35,7 +35,9 @@ import { UtilityBarText, } from '../../../../components/utility_bar'; import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to'; - +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../../home/home_navigations'; import { getBulkItems } from '../bulk_actions'; import { CaseHeaderPage } from '../case_header_page'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; @@ -43,10 +45,6 @@ import { OpenClosedStats } from '../open_closed_stats'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; -import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; - -const CONFIGURE_CASES_URL = getConfigureCasesUrl(); -const CREATE_CASE_URL = getCreateCaseUrl(); const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -78,6 +76,7 @@ const getSortField = (field: string): SortFieldCase => { return SortFieldCase.createdAt; }; export const AllCases = React.memo(() => { + const urlSearch = useGetUrlSearch(navTabs.case); const { countClosedCases, countOpenCases, @@ -276,12 +275,12 @@ export const AllCases = React.memo(() => { /> - + {i18n.CONFIGURE_CASES_BUTTON} - + {i18n.CREATE_TITLE} @@ -342,7 +341,12 @@ export const AllCases = React.memo(() => { titleSize="xs" body={i18n.NO_CASES_BODY} actions={ - + {i18n.ADD_NEW_CASE} } diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx index 9dbd71ea3e34c..0420a71fea907 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_status/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback } from 'react'; +import React from 'react'; import styled, { css } from 'styled-components'; import { EuiBadge, @@ -39,7 +39,7 @@ interface CaseStatusProps { isSelected: boolean; status: string; title: string; - toggleStatusCase: (status: string) => void; + toggleStatusCase: (evt: unknown) => void; value: string | null; } const CaseStatusComp: React.FC = ({ @@ -55,51 +55,46 @@ const CaseStatusComp: React.FC = ({ title, toggleStatusCase, value, -}) => { - const onChange = useCallback(e => toggleStatusCase(e.target.checked ? 'closed' : 'open'), [ - toggleStatusCase, - ]); - return ( - - - - - - {i18n.STATUS} - - - {status} - - - - - {title} - - - - - - - - - - - - +}) => ( + + + + - + {i18n.STATUS} + + + {status} + + + + + {title} + + + - - - ); -}; + + + + + + + + + + + + + +); export const CaseStatus = React.memo(CaseStatusComp); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx index e11441eac3a9d..7aadea1a453a7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/__mock__/index.tsx @@ -13,7 +13,6 @@ export const caseProps: CaseProps = { closedAt: null, closedBy: null, id: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', - commentIds: ['a357c6a0-5435-11ea-b427-fb51a1fcb7b8'], comments: [ { comment: 'Solve this fast!', @@ -24,6 +23,8 @@ export const caseProps: CaseProps = { username: 'smilovic', email: 'notmyrealemailfool@elastic.co', }, + pushedAt: null, + pushedBy: null, updatedAt: '2020-02-20T23:06:33.798Z', updatedBy: { username: 'elastic', @@ -34,9 +35,11 @@ export const caseProps: CaseProps = { createdAt: '2020-02-13T19:44:23.627Z', createdBy: { fullName: null, email: 'testemail@elastic.co', username: 'elastic' }, description: 'Security banana Issue', + externalService: null, status: 'open', tags: ['defacement'], title: 'Another horrible breach!!', + totalComment: 1, updatedAt: '2020-02-19T15:02:57.995Z', updatedBy: { username: 'elastic', @@ -44,6 +47,7 @@ export const caseProps: CaseProps = { version: 'WzQ3LDFd', }, }; + export const caseClosedProps: CaseProps = { ...caseProps, initialData: { @@ -63,3 +67,21 @@ export const data: Case = { export const dataClosed: Case = { ...caseClosedProps.initialData, }; + +export const caseUserActions = [ + { + actionField: ['comment'], + action: 'create', + actionAt: '2020-03-20T17:10:09.814Z', + actionBy: { + fullName: 'Steph Milovic', + username: 'smilovic', + email: 'notmyrealemailfool@elastic.co', + }, + newValue: 'Solve this fast!', + oldValue: null, + actionId: '3c4ddcc0-4e99-11ea-9290-35d05cb55c15', + caseId: '9b833a50-6acd-11ea-8fad-af86b1071bd9', + commentId: 'a357c6a0-5435-11ea-b427-fb51a1fcb7b8', + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx index 3f4a83d1bff33..18cc33d8a6d4d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.test.tsx @@ -11,11 +11,18 @@ import { mount } from 'enzyme'; import routeData from 'react-router'; /* eslint-enable @kbn/eslint/module_migration */ import { CaseComponent } from './'; -import { caseProps, caseClosedProps, data, dataClosed } from './__mock__'; +import { caseProps, caseClosedProps, data, dataClosed, caseUserActions } from './__mock__'; import { TestProviders } from '../../../../mock'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; +import { wait } from '../../../../lib/helpers'; +import { usePushToService } from './push_to_service'; jest.mock('../../../../containers/case/use_update_case'); +jest.mock('../../../../containers/case/use_get_case_user_actions'); +jest.mock('./push_to_service'); const useUpdateCaseMock = useUpdateCase as jest.Mock; +const useGetCaseUserActionsMock = useGetCaseUserActions as jest.Mock; +const usePushToServiceMock = usePushToService as jest.Mock; type Action = 'PUSH' | 'POP' | 'REPLACE'; const pop: Action = 'POP'; const location = { @@ -47,6 +54,7 @@ const mockLocation = { describe('CaseView ', () => { const updateCaseProperty = jest.fn(); + const fetchCaseUserActions = jest.fn(); /* eslint-disable no-console */ // Silence until enzyme fixed to use ReactTestUtils.act() const originalError = console.error; @@ -66,13 +74,31 @@ describe('CaseView ', () => { updateCaseProperty, }; + const defaultUseGetCaseUserActions = { + caseUserActions, + fetchCaseUserActions, + firstIndexPushToService: -1, + hasDataToPush: false, + isLoading: false, + isError: false, + lastIndexPushToService: -1, + participants: [data.createdBy], + }; + + const defaultUsePushToServiceMock = { + pushButton: <>{'Hello Button'}, + pushCallouts: null, + }; + beforeEach(() => { jest.resetAllMocks(); useUpdateCaseMock.mockImplementation(() => defaultUpdateCaseState); jest.spyOn(routeData, 'useLocation').mockReturnValue(mockLocation); + useGetCaseUserActionsMock.mockImplementation(() => defaultUseGetCaseUserActions); + usePushToServiceMock.mockImplementation(() => defaultUsePushToServiceMock); }); - it('should render CaseComponent', () => { + it('should render CaseComponent', async () => { const wrapper = mount( @@ -80,6 +106,7 @@ describe('CaseView ', () => { ); + await wait(); expect( wrapper .find(`[data-test-subj="case-view-title"]`) @@ -119,7 +146,7 @@ describe('CaseView ', () => { ).toEqual(data.description); }); - it('should show closed indicators in header when case is closed', () => { + it('should show closed indicators in header when case is closed', async () => { useUpdateCaseMock.mockImplementation(() => ({ ...defaultUpdateCaseState, caseData: dataClosed, @@ -131,6 +158,7 @@ describe('CaseView ', () => { ); + await wait(); expect(wrapper.contains(`[data-test-subj="case-view-createdAt"]`)).toBe(false); expect( wrapper @@ -146,7 +174,7 @@ describe('CaseView ', () => { ).toEqual(dataClosed.status); }); - it('should dispatch update state when button is toggled', () => { + it('should dispatch update state when button is toggled', async () => { const wrapper = mount( @@ -154,18 +182,19 @@ describe('CaseView ', () => { ); - + await wait(); wrapper .find('input[data-test-subj="toggle-case-status"]') .simulate('change', { target: { checked: true } }); expect(updateCaseProperty).toBeCalledWith({ + fetchCaseUserActions, updateKey: 'status', updateValue: 'closed', }); }); - it('should render comments', () => { + it('should render comments', async () => { const wrapper = mount( @@ -173,6 +202,7 @@ describe('CaseView ', () => { ); + await wait(); expect( wrapper .find( diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 0ac3adeb860ff..742921cb9f69e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -4,10 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; - +import { + EuiButtonToggle, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingContent, + EuiLoadingSpinner, + EuiHorizontalRule, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; + import * as i18n from './translations'; import { Case } from '../../../../containers/case/types'; import { getCaseUrl } from '../../../../components/link_to'; @@ -24,6 +31,8 @@ import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; import { SpyRoute } from '../../../../utils/route/spy_routes'; +import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; +import { usePushToService } from './push_to_service'; interface Props { caseId: string; @@ -37,6 +46,13 @@ const MyEuiFlexGroup = styled(EuiFlexGroup)` height: 100%; `; +const MyEuiHorizontalRule = styled(EuiHorizontalRule)` + margin-left: 48px; + &.euiHorizontalRule--full { + width: calc(100% - 48px); + } +`; + export interface CaseProps { caseId: string; initialData: Case; @@ -45,7 +61,20 @@ export interface CaseProps { export const CaseComponent = React.memo(({ caseId, initialData }) => { const basePath = window.location.origin + useBasePath(); const caseLink = `${basePath}/app/siem#/case/${caseId}`; - const { caseData, isLoading, updateKey, updateCaseProperty } = useUpdateCase(caseId, initialData); + const [initLoadingData, setInitLoadingData] = useState(true); + const { + caseUserActions, + fetchCaseUserActions, + firstIndexPushToService, + hasDataToPush, + isLoading: isLoadingUserActions, + lastIndexPushToService, + participants, + } = useGetCaseUserActions(caseId); + const { caseData, isLoading, updateKey, updateCase, updateCaseProperty } = useUpdateCase( + caseId, + initialData + ); // Update Fields const onUpdateField = useCallback( @@ -55,6 +84,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const titleUpdate = getTypedPayload(updateValue); if (titleUpdate.length > 0) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'title', updateValue: titleUpdate, }); @@ -64,6 +94,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const descriptionUpdate = getTypedPayload(updateValue); if (descriptionUpdate.length > 0) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'description', updateValue: descriptionUpdate, }); @@ -72,6 +103,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => case 'tags': const tagsUpdate = getTypedPayload(updateValue); updateCaseProperty({ + fetchCaseUserActions, updateKey: 'tags', updateValue: tagsUpdate, }); @@ -80,6 +112,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => const statusUpdate = getTypedPayload(updateValue); if (caseData.status !== updateValue) { updateCaseProperty({ + fetchCaseUserActions, updateKey: 'status', updateValue: statusUpdate, }); @@ -88,12 +121,29 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => return null; } }, - [caseData.status] + [fetchCaseUserActions, updateCaseProperty, caseData.status] + ); + const handleUpdateCase = useCallback( + (newCase: Case) => { + updateCase(newCase); + fetchCaseUserActions(newCase.id); + }, + [updateCase, fetchCaseUserActions] ); + + const { pushButton, pushCallouts } = usePushToService({ + caseId: caseData.id, + caseStatus: caseData.status, + isNew: caseUserActions.filter(cua => cua.action === 'push-to-service').length === 0, + updateCase: handleUpdateCase, + }); + const onSubmitTags = useCallback(newTags => onUpdateField('tags', newTags), [onUpdateField]); const onSubmitTitle = useCallback(newTitle => onUpdateField('title', newTitle), [onUpdateField]); - const toggleStatusCase = useCallback(status => onUpdateField('status', status), [onUpdateField]); - + const toggleStatusCase = useCallback( + e => onUpdateField('status', e.target.checked ? 'closed' : 'open'), + [onUpdateField] + ); const spyState = useMemo(() => ({ caseTitle: caseData.title }), [caseData.title]); const caseStatusData = useMemo( @@ -111,7 +161,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => } : { 'data-test-subj': 'case-view-closedAt', - value: caseData.closedAt, + value: caseData.closedAt ?? '', title: i18n.CASE_CLOSED, buttonLabel: i18n.REOPEN_CASE, status: caseData.status, @@ -126,8 +176,15 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => subject: i18n.EMAIL_SUBJECT(caseData.title), body: i18n.EMAIL_BODY(caseLink), }), - [caseData.title] + [caseLink, caseData.title] ); + + useEffect(() => { + if (initLoadingData && !isLoadingUserActions) { + setInitLoadingData(false); + } + }, [initLoadingData, isLoadingUserActions]); + return ( <> @@ -157,21 +214,52 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => + {pushCallouts != null && pushCallouts} - + {initLoadingData && } + {!initLoadingData && ( + <> + + + + + + + {hasDataToPush && {pushButton}} + + + )} + void; +} + +interface Connector { + connectorId: string; + connectorName: string; +} + +interface ReturnUsePushToService { + pushButton: JSX.Element; + pushCallouts: JSX.Element | null; +} + +export const usePushToService = ({ + caseId, + caseStatus, + updateCase, + isNew, +}: UsePushToService): ReturnUsePushToService => { + const urlSearch = useGetUrlSearch(navTabs.case); + const [connector, setConnector] = useState(null); + + const { isLoading, postPushToService } = usePostPushToService(); + + const handleSetConnector = useCallback((connectorId: string, connectorName?: string) => { + setConnector({ connectorId, connectorName: connectorName ?? '' }); + }, []); + + const { loading: loadingCaseConfigure } = useCaseConfigure({ + setConnector: handleSetConnector, + }); + + const { isLoading: loadingLicense, actionLicense } = useGetActionLicense(); + + const handlePushToService = useCallback(() => { + if (connector != null) { + postPushToService({ + caseId, + ...connector, + updateCase, + }); + } + }, [caseId, connector, postPushToService, updateCase]); + + const errorsMsg = useMemo(() => { + let errors: Array<{ title: string; description: JSX.Element }> = []; + if (actionLicense != null && !actionLicense.enabledInLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_LICENSE_TITLE, + description: ( + + {i18n.LINK_CLOUD_DEPLOYMENT} + + ), + }} + /> + ), + }, + ]; + } + if (connector == null && !loadingCaseConfigure && !loadingLicense) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE, + description: ( + + {i18n.LINK_CONNECTOR_CONFIGURE} + + ), + }} + /> + ), + }, + ]; + } + if (caseStatus === 'closed') { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE, + description: ( + + ), + }, + ]; + } + if (actionLicense != null && !actionLicense.enabledInConfig) { + errors = [ + ...errors, + { + title: i18n.PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE, + description: ( + + {'coming soon...'} + + ), + }} + /> + ), + }, + ]; + } + return errors; + }, [actionLicense, caseStatus, connector, loadingCaseConfigure, loadingLicense, urlSearch]); + + const pushToServiceButton = useMemo( + () => ( + 0} + isLoading={isLoading} + > + {isNew ? i18n.PUSH_SERVICENOW : i18n.UPDATE_PUSH_SERVICENOW} + + ), + [isNew, handlePushToService, isLoading, loadingLicense, loadingCaseConfigure, errorsMsg] + ); + + const objToReturn = useMemo( + () => ({ + pushButton: + errorsMsg.length > 0 ? ( + {errorsMsg[0].description}

} + > + {pushToServiceButton} +
+ ) : ( + <>{pushToServiceButton} + ), + pushCallouts: errorsMsg.length > 0 ? : null, + }), + [errorsMsg, pushToServiceButton] + ); + return objToReturn; +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts index e5fa3bff51f85..beba80ccd934c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/translations.ts @@ -18,17 +18,40 @@ export const SHOWING_CASES = (actionDate: string, actionName: string, userName: defaultMessage: '{userName} {actionName} on {actionDate}', }); -export const ADDED_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.actionLabel.addDescription', +export const ADDED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.addedField', { + defaultMessage: 'added', +}); + +export const CHANGED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.changededField', { + defaultMessage: 'changed', +}); + +export const EDITED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.editedField', { + defaultMessage: 'edited', +}); + +export const REMOVED_FIELD = i18n.translate('xpack.siem.case.caseView.actionLabel.removedField', { + defaultMessage: 'removed', +}); + +export const PUSHED_NEW_INCIDENT = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.pushedNewIncident', { - defaultMessage: 'added description', + defaultMessage: 'pushed as new incident', + } +); + +export const UPDATE_INCIDENT = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.updateIncident', + { + defaultMessage: 'updated incident', } ); -export const EDITED_DESCRIPTION = i18n.translate( - 'xpack.siem.case.caseView.actionLabel.editDescription', +export const ADDED_DESCRIPTION = i18n.translate( + 'xpack.siem.case.caseView.actionLabel.addDescription', { - defaultMessage: 'edited description', + defaultMessage: 'added description', } ); @@ -52,6 +75,14 @@ export const STATUS = i18n.translate('xpack.siem.case.caseView.statusLabel', { defaultMessage: 'Status', }); +export const CASE = i18n.translate('xpack.siem.case.caseView.case', { + defaultMessage: 'case', +}); + +export const COMMENT = i18n.translate('xpack.siem.case.caseView.comment', { + defaultMessage: 'comment', +}); + export const CASE_OPENED = i18n.translate('xpack.siem.case.caseView.caseOpened', { defaultMessage: 'Case opened', }); @@ -71,3 +102,56 @@ export const EMAIL_BODY = (caseUrl: string) => values: { caseUrl }, defaultMessage: 'Case reference: {caseUrl}', }); + +export const PUSH_SERVICENOW = i18n.translate('xpack.siem.case.caseView.pushAsServicenowIncident', { + defaultMessage: 'Push as ServiceNow incident', +}); + +export const UPDATE_PUSH_SERVICENOW = i18n.translate( + 'xpack.siem.case.caseView.updatePushAsServicenowIncident', + { + defaultMessage: 'Update ServiceNow incident', + } +); + +export const PUSH_DISABLE_BY_NO_CASE_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByNoCaseConfigTitle', + { + defaultMessage: 'Configure external connector', + } +); + +export const PUSH_DISABLE_BECAUSE_CASE_CLOSED_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableBecauseCaseClosedTitle', + { + defaultMessage: 'Reopen the case', + } +); + +export const PUSH_DISABLE_BY_KIBANA_CONFIG_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByConfigTitle', + { + defaultMessage: 'Enable ServiceNow in Kibana configuration file', + } +); + +export const PUSH_DISABLE_BY_LICENSE_TITLE = i18n.translate( + 'xpack.siem.case.caseView.pushToServiceDisableByLicenseTitle', + { + defaultMessage: 'Upgrade to Elastic Platinum', + } +); + +export const LINK_CLOUD_DEPLOYMENT = i18n.translate( + 'xpack.siem.case.caseView.cloudDeploymentLink', + { + defaultMessage: 'cloud deployment', + } +); + +export const LINK_CONNECTOR_CONFIGURE = i18n.translate( + 'xpack.siem.case.caseView.connectorConfigureLink', + { + defaultMessage: 'connector', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx index c8ef6e32595d0..fb4d91492c1d4 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/configure_cases/index.tsx @@ -113,7 +113,7 @@ const ConfigureCasesComponent: React.FC = () => { }, []); const { loading: loadingCaseConfigure, persistLoading, persistCaseConfigure } = useCaseConfigure({ - setConnectorId, + setConnector: setConnectorId, setClosureType, }); const { loading: isLoadingConnectors, connectors, refetchConnectors } = useConnectors(); @@ -128,9 +128,13 @@ const ConfigureCasesComponent: React.FC = () => { // TO DO give a warning/error to user when field are not mapped so they have chance to do it () => { setActionBarVisible(false); - persistCaseConfigure({ connectorId, closureType }); + persistCaseConfigure({ + connectorId, + connectorName: connectors.find(c => c.id === connectorId)?.name ?? '', + closureType, + }); }, - [connectorId, closureType, mapping] + [connectorId, connectors, closureType, mapping] ); const onChangeConnector = useCallback((newConnectorId: string) => { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx new file mode 100644 index 0000000000000..15b50e4b4cd8d --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/index.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiButton, EuiDescriptionList, EuiSpacer } from '@elastic/eui'; +import React, { memo, useCallback, useState } from 'react'; + +import * as i18n from './translations'; + +interface ErrorsPushServiceCallOut { + errors: Array<{ title: string; description: JSX.Element }>; +} + +const ErrorsPushServiceCallOutComponent = ({ errors }: ErrorsPushServiceCallOut) => { + const [showCallOut, setShowCallOut] = useState(true); + const handleCallOut = useCallback(() => setShowCallOut(false), [setShowCallOut]); + + return showCallOut ? ( + <> + + + + {i18n.DISMISS_CALLOUT} + + + + + ) : null; +}; + +export const ErrorsPushServiceCallOut = memo(ErrorsPushServiceCallOutComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts new file mode 100644 index 0000000000000..57712e720f6d0 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/errors_push_service_callout/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_PUSH_SERVICE_CALLOUT_TITLE = i18n.translate( + 'xpack.siem.case.errorsPushServiceCallOutTitle', + { + defaultMessage: 'To send cases to external systems, you need to:', + } +); + +export const DISMISS_CALLOUT = i18n.translate( + 'xpack.siem.case.dismissErrorsPushServiceCallOutTitle', + { + defaultMessage: 'Dismiss', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx new file mode 100644 index 0000000000000..008f4d7048f56 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/helpers.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiBadge, EuiLink } from '@elastic/eui'; +import React from 'react'; + +import { CaseFullExternalService } from '../../../../../../../../plugins/case/common/api'; +import { CaseUserActions } from '../../../../containers/case/types'; +import * as i18n from '../case_view/translations'; + +interface LabelTitle { + action: CaseUserActions; + field: string; + firstIndexPushToService: number; + index: number; +} + +export const getLabelTitle = ({ action, field, firstIndexPushToService, index }: LabelTitle) => { + if (field === 'tags') { + return getTagsLabelTitle(action); + } else if (field === 'title' && action.action === 'update') { + return `${i18n.CHANGED_FIELD.toLowerCase()} ${i18n.CASE_NAME.toLowerCase()} ${i18n.TO} "${ + action.newValue + }"`; + } else if (field === 'description' && action.action === 'update') { + return `${i18n.EDITED_FIELD} ${i18n.DESCRIPTION.toLowerCase()}`; + } else if (field === 'status' && action.action === 'update') { + return `${ + action.newValue === 'open' ? i18n.REOPENED_CASE.toLowerCase() : i18n.CLOSED_CASE.toLowerCase() + } ${i18n.CASE}`; + } else if (field === 'comment' && action.action === 'update') { + return `${i18n.EDITED_FIELD} ${i18n.COMMENT.toLowerCase()}`; + } else if (field === 'pushed' && action.action === 'push-to-service' && action.newValue != null) { + return getPushedServiceLabelTitle(action, firstIndexPushToService, index); + } + return ''; +}; + +const getTagsLabelTitle = (action: CaseUserActions) => ( + + + {action.action === 'add' && i18n.ADDED_FIELD} + {action.action === 'delete' && i18n.REMOVED_FIELD} {i18n.TAGS.toLowerCase()} + + {action.newValue != null && + action.newValue.split(',').map(tag => ( + + {tag} + + ))} + +); + +const getPushedServiceLabelTitle = ( + action: CaseUserActions, + firstIndexPushToService: number, + index: number +) => { + const pushedVal = JSON.parse(action.newValue ?? '') as CaseFullExternalService; + return ( + + + {firstIndexPushToService === index ? i18n.PUSHED_NEW_INCIDENT : i18n.UPDATE_INCIDENT} + + + + {pushedVal?.connector_name} {pushedVal?.external_title} + + + + ); +}; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx index 6a3d319561353..8b77186f76f77 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/index.tsx @@ -4,27 +4,54 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useCallback, useMemo, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner } from '@elastic/eui'; +import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; + import * as i18n from '../case_view/translations'; -import { Case } from '../../../../containers/case/types'; +import { Case, CaseUserActions, Comment } from '../../../../containers/case/types'; import { useUpdateComment } from '../../../../containers/case/use_update_comment'; +import { useCurrentUser } from '../../../../lib/kibana'; +import { AddComment } from '../add_comment'; +import { getLabelTitle } from './helpers'; import { UserActionItem } from './user_action_item'; import { UserActionMarkdown } from './user_action_markdown'; -import { AddComment } from '../add_comment'; -import { useCurrentUser } from '../../../../lib/kibana'; export interface UserActionTreeProps { data: Case; + caseUserActions: CaseUserActions[]; + fetchUserActions: () => void; + firstIndexPushToService: number; isLoadingDescription: boolean; + isLoadingUserActions: boolean; + lastIndexPushToService: number; onUpdateField: (updateKey: keyof Case, updateValue: string | string[]) => void; } -const DescriptionId = 'description'; -const NewId = 'newComment'; +const MyEuiFlexGroup = styled(EuiFlexGroup)` + margin-bottom: 8px; +`; + +const DESCRIPTION_ID = 'description'; +const NEW_ID = 'newComment'; export const UserActionTree = React.memo( - ({ data: caseData, onUpdateField, isLoadingDescription }: UserActionTreeProps) => { + ({ + data: caseData, + caseUserActions, + fetchUserActions, + firstIndexPushToService, + isLoadingDescription, + isLoadingUserActions, + lastIndexPushToService, + onUpdateField, + }: UserActionTreeProps) => { + const { commentId } = useParams(); + const handlerTimeoutId = useRef(0); + const [initLoading, setInitLoading] = useState(true); + const [selectedOutlineCommentId, setSelectedOutlineCommentId] = useState(''); const { comments, isLoadingIds, updateComment, addPostedComment } = useUpdateComment( caseData.comments ); @@ -45,20 +72,54 @@ export const UserActionTree = React.memo( const handleSaveComment = useCallback( (id: string, content: string) => { handleManageMarkdownEditId(id); - updateComment(caseData.id, id, content); + updateComment({ + caseId: caseData.id, + commentId: id, + commentUpdate: content, + fetchUserActions, + }); }, [handleManageMarkdownEditId, updateComment] ); + const handleOutlineComment = useCallback( + (id: string) => { + const moveToTarget = document.getElementById(`${id}-permLink`); + if (moveToTarget != null) { + const yOffset = -60; + const y = moveToTarget.getBoundingClientRect().top + window.pageYOffset + yOffset; + window.scrollTo({ + top: y, + behavior: 'smooth', + }); + } + window.clearTimeout(handlerTimeoutId.current); + setSelectedOutlineCommentId(id); + handlerTimeoutId.current = window.setTimeout(() => { + setSelectedOutlineCommentId(''); + window.clearTimeout(handlerTimeoutId.current); + }, 2400); + }, + [handlerTimeoutId.current] + ); + + const handleUpdate = useCallback( + (comment: Comment) => { + addPostedComment(comment); + fetchUserActions(); + }, + [addPostedComment, fetchUserActions] + ); + const MarkdownDescription = useMemo( () => ( { - handleManageMarkdownEditId(DescriptionId); - onUpdateField(DescriptionId, content); + handleManageMarkdownEditId(DESCRIPTION_ID); + onUpdateField(DESCRIPTION_ID, content); }} onChangeEditable={handleManageMarkdownEditId} /> @@ -67,55 +128,123 @@ export const UserActionTree = React.memo( ); const MarkdownNewComment = useMemo( - () => , - [caseData.id] + () => ( + + ), + [caseData.id, handleUpdate] ); + useEffect(() => { + if (initLoading && !isLoadingUserActions && isLoadingIds.length === 0) { + setInitLoading(false); + if (commentId != null) { + handleOutlineComment(commentId); + } + } + }, [commentId, initLoading, isLoadingUserActions, isLoadingIds]); + return ( <> {i18n.ADDED_DESCRIPTION}} fullName={caseData.createdBy.fullName ?? caseData.createdBy.username} markdown={MarkdownDescription} - onEdit={handleManageMarkdownEditId.bind(null, DescriptionId)} + onEdit={handleManageMarkdownEditId.bind(null, DESCRIPTION_ID)} userName={caseData.createdBy.username} /> - {comments.map(comment => ( - + + {caseUserActions.map((action, index) => { + if (action.commentId != null && action.action === 'create') { + const comment = comments.find(c => c.id === action.commentId); + if (comment != null) { + return ( + {i18n.ADDED_COMMENT}} + fullName={comment.createdBy.fullName ?? comment.createdBy.username} + markdown={ + + } + onEdit={handleManageMarkdownEditId.bind(null, comment.id)} + outlineComment={handleOutlineComment} + userName={comment.createdBy.username} + updatedAt={comment.updatedAt} + /> + ); } - onEdit={handleManageMarkdownEditId.bind(null, comment.id)} - userName={comment.createdBy.username} - /> - ))} + } + if (action.actionField.length === 1) { + const myField = action.actionField[0]; + const labelTitle: string | JSX.Element = getLabelTitle({ + action, + field: myField, + firstIndexPushToService, + index, + }); + + return ( + {labelTitle}} + linkId={ + action.action === 'update' && action.commentId != null ? action.commentId : null + } + fullName={action.actionBy.fullName ?? action.actionBy.username} + outlineComment={handleOutlineComment} + showTopFooter={ + action.action === 'push-to-service' && index === lastIndexPushToService + } + showBottomFooter={ + action.action === 'push-to-service' && + index === lastIndexPushToService && + index < caseUserActions.length - 1 + } + userName={action.actionBy.username} + /> + ); + } + return null; + })} + {(isLoadingUserActions || isLoadingIds.includes(NEW_ID)) && ( + + + + + + )} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts new file mode 100644 index 0000000000000..0ca6bcff513fc --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/translations.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export * from '../case_view/translations'; + +export const ALREADY_PUSHED_TO_SERVICE = i18n.translate( + 'xpack.siem.case.caseView.alreadyPushedToService', + { + defaultMessage: 'Already pushed to Service Now incident', + } +); + +export const REQUIRED_UPDATE_TO_SERVICE = i18n.translate( + 'xpack.siem.case.caseView.requiredUpdateToService', + { + defaultMessage: 'Requires update to ServiceNow incident', + } +); + +export const COPY_LINK_COMMENT = i18n.translate('xpack.siem.case.caseView.copyCommentLinkAria', { + defaultMessage: 'click to copy comment link', +}); + +export const MOVE_TO_ORIGINAL_COMMENT = i18n.translate( + 'xpack.siem.case.caseView.moveToCommentAria', + { + defaultMessage: 'click to highlight the reference comment', + } +); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx index ca73f200f1793..10a7c56e2eb2d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_item.tsx @@ -4,12 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPanel } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiPanel, + EuiHorizontalRule, + EuiText, +} from '@elastic/eui'; import React from 'react'; - import styled, { css } from 'styled-components'; + import { UserActionAvatar } from './user_action_avatar'; import { UserActionTitle } from './user_action_title'; +import * as i18n from './translations'; interface UserActionItemProps { createdAt: string; @@ -17,14 +25,20 @@ interface UserActionItemProps { isEditable: boolean; isLoading: boolean; labelEditAction?: string; - labelTitle?: string; + labelTitle?: JSX.Element; + linkId?: string | null; fullName: string; - markdown: React.ReactNode; - onEdit: (id: string) => void; + markdown?: React.ReactNode; + onEdit?: (id: string) => void; userName: string; + updatedAt?: string | null; + outlineComment?: (id: string) => void; + showBottomFooter?: boolean; + showTopFooter?: boolean; + idToOutline?: string | null; } -const UserActionItemContainer = styled(EuiFlexGroup)` +export const UserActionItemContainer = styled(EuiFlexGroup)` ${({ theme }) => css` & { background-image: linear-gradient( @@ -66,42 +80,102 @@ const UserActionItemContainer = styled(EuiFlexGroup)` `} `; +const MyEuiPanel = styled(EuiPanel)<{ showoutline: string }>` + ${({ theme, showoutline }) => + showoutline === 'true' + ? ` + outline: solid 5px ${theme.eui.euiColorVis1_behindText}; + margin: 0.5em; + transition: 0.8s; + ` + : ''} +`; + +const PushedContainer = styled(EuiFlexItem)` + ${({ theme }) => ` + margin-top: ${theme.eui.euiSizeS}; + margin-bottom: ${theme.eui.euiSizeXL}; + hr { + margin: 5px; + height: ${theme.eui.euiBorderWidthThick}; + } + `} +`; + +const PushedInfoContainer = styled.div` + margin-left: 48px; +`; + export const UserActionItem = ({ createdAt, id, + idToOutline, isEditable, isLoading, labelEditAction, labelTitle, + linkId, fullName, markdown, onEdit, + outlineComment, + showBottomFooter, + showTopFooter, userName, + updatedAt, }: UserActionItemProps) => ( - - - {fullName.length > 0 || userName.length > 0 ? ( - - ) : ( - - )} - - - {isEditable && markdown} - {!isEditable && ( - - - {markdown} - - )} + + + + + {fullName.length > 0 || userName.length > 0 ? ( + + ) : ( + + )} + + + {isEditable && markdown} + {!isEditable && ( + + } + linkId={linkId} + userName={userName} + updatedAt={updatedAt} + onEdit={onEdit} + outlineComment={outlineComment} + /> + {markdown} + + )} + + + {showTopFooter && ( + + + + {i18n.ALREADY_PUSHED_TO_SERVICE} + + + + {showBottomFooter && ( + + + {i18n.REQUIRED_UPDATE_TO_SERVICE} + + + )} + + )} ); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx index 0ed081e8852f0..6ca81667d9712 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/user_action_tree/user_action_title.tsx @@ -4,16 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; -import React, { useMemo } from 'react'; +import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, EuiText, EuiButtonIcon } from '@elastic/eui'; +import { FormattedRelative } from '@kbn/i18n/react'; +import copy from 'copy-to-clipboard'; +import { isEmpty } from 'lodash/fp'; +import React, { useMemo, useCallback } from 'react'; import styled from 'styled-components'; +import { useParams } from 'react-router-dom'; -import { - FormattedRelativePreferenceDate, - FormattedRelativePreferenceLabel, -} from '../../../../components/formatted_date'; -import * as i18n from '../case_view/translations'; +import { LocalizedDateTooltip } from '../../../../components/localized_date_tooltip'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; +import { navTabs } from '../../../home/home_navigations'; import { PropertyActions } from '../property_actions'; +import { SiemPageName } from '../../../home/types'; +import * as i18n from './translations'; const MySpinner = styled(EuiLoadingSpinner)` .euiLoadingSpinner { @@ -25,10 +29,13 @@ interface UserActionTitleProps { createdAt: string; id: string; isLoading: boolean; - labelEditAction: string; - labelTitle: string; + labelEditAction?: string; + labelTitle: JSX.Element; + linkId?: string | null; + updatedAt?: string | null; userName: string; - onEdit: (id: string) => void; + onEdit?: (id: string) => void; + outlineComment?: (id: string) => void; } export const UserActionTitle = ({ @@ -37,32 +44,107 @@ export const UserActionTitle = ({ isLoading, labelEditAction, labelTitle, + linkId, userName, + updatedAt, onEdit, + outlineComment, }: UserActionTitleProps) => { + const { detailName: caseId } = useParams(); + const urlSearch = useGetUrlSearch(navTabs.case); const propertyActions = useMemo(() => { - return [ + if (labelEditAction != null && onEdit != null) { + return [ + { + iconType: 'pencil', + label: labelEditAction, + onClick: () => onEdit(id), + }, + ]; + } + return []; + }, [id, labelEditAction, onEdit]); + + const handleAnchorLink = useCallback(() => { + copy( + `${window.location.origin}${window.location.pathname}#${SiemPageName.case}/${caseId}/${id}${urlSearch}`, { - iconType: 'pencil', - label: labelEditAction, - onClick: () => onEdit(id), - }, - ]; - }, [id, onEdit]); + debug: true, + } + ); + }, [caseId, id, urlSearch]); + + const handleMoveToLink = useCallback(() => { + if (outlineComment != null && linkId != null) { + outlineComment(linkId); + } + }, [linkId, outlineComment]); + return ( - + -

- {userName} - {` ${labelTitle} `} - - -

+ + + {userName} + + {labelTitle} + + + + + + {updatedAt != null && ( + + + {'('} + {i18n.EDITED_FIELD}{' '} + + + + {')'} + + + )} +
- {isLoading && } - {!isLoading && } + + {!isEmpty(linkId) && ( + + + + )} + + + + {propertyActions.length > 0 && ( + + {isLoading && } + {!isLoading && } + + )} +
diff --git a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx index 1bde9de1535b5..124cefa726a8b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/index.tsx @@ -15,6 +15,7 @@ import { ConfigureCasesPage } from './configure_cases'; const casesPagePath = `/:pageName(${SiemPageName.case})`; const caseDetailsPagePath = `${casesPagePath}/:detailName`; +const caseDetailsPagePathWithCommentId = `${casesPagePath}/:detailName/:commentId`; const createCasePagePath = `${casesPagePath}/create`; const configureCasesPagePath = `${casesPagePath}/configure`; @@ -29,6 +30,9 @@ const CaseContainerComponent: React.FC = () => ( + + + diff --git a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts index 341a34240fe49..8f9d2087699f8 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/translations.ts @@ -33,17 +33,15 @@ export const OPENED_ON = i18n.translate('xpack.siem.case.caseView.openedOn', { export const CLOSED_ON = i18n.translate('xpack.siem.case.caseView.closedOn', { defaultMessage: 'Closed on', }); -export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseTable.reopenCase', { - defaultMessage: 'Reopen case', -}); -export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseTable.closeCase', { - defaultMessage: 'Close case', -}); -export const REPORTER = i18n.translate('xpack.siem.case.caseView.createdBy', { +export const REPORTER = i18n.translate('xpack.siem.case.caseView.reporterLabel', { defaultMessage: 'Reporter', }); +export const PARTICIPANTS = i18n.translate('xpack.siem.case.caseView.particpantsLabel', { + defaultMessage: 'Participants', +}); + export const CREATE_BC_TITLE = i18n.translate('xpack.siem.case.caseView.breadcrumb', { defaultMessage: 'Create', }); @@ -90,6 +88,30 @@ export const CREATE_CASE = i18n.translate('xpack.siem.case.caseView.createCase', defaultMessage: 'Create case', }); +export const CLOSED_CASE = i18n.translate('xpack.siem.case.caseView.closedCase', { + defaultMessage: 'Closed case', +}); + +export const CLOSE_CASE = i18n.translate('xpack.siem.case.caseView.closeCase', { + defaultMessage: 'Close case', +}); + +export const REOPEN_CASE = i18n.translate('xpack.siem.case.caseView.reopenCase', { + defaultMessage: 'Reopen case', +}); + +export const REOPENED_CASE = i18n.translate('xpack.siem.case.caseView.reopenedCase', { + defaultMessage: 'Reopened case', +}); + +export const CASE_NAME = i18n.translate('xpack.siem.case.caseView.caseName', { + defaultMessage: 'Case name', +}); + +export const TO = i18n.translate('xpack.siem.case.caseView.to', { + defaultMessage: 'to', +}); + export const TAGS = i18n.translate('xpack.siem.case.caseView.tags', { defaultMessage: 'Tags', }); @@ -130,7 +152,7 @@ export const CONFIGURE_CASES_PAGE_TITLE = i18n.translate( ); export const CONFIGURE_CASES_BUTTON = i18n.translate('xpack.siem.case.configureCasesButton', { - defaultMessage: 'Edit third-party connection', + defaultMessage: 'Edit external connection', }); export const ADD_COMMENT = i18n.translate('xpack.siem.case.caseView.comment.addComment', { diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index ccb3b71a476ec..3f2964b8cdd6d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -21,7 +21,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: i18n.CREATE_BC_TITLE, - href: getCreateCaseUrl(), + href: getCreateCaseUrl(''), }, ]; } else if (params.detailName != null) { @@ -29,7 +29,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: params.state?.caseTitle ?? '', - href: getCaseDetailsUrl(params.detailName), + href: getCaseDetailsUrl(params.detailName, ''), }, ]; } diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 6f58e2702ec5b..ee244dd205113 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -11,6 +11,8 @@ import { UserRT } from '../user'; import { CommentResponseRt } from './comment'; import { CasesStatusResponseRt } from './status'; +export { ActionTypeExecutorResult } from '../../../../actions/server/types'; + const StatusRt = rt.union([rt.literal('open'), rt.literal('closed')]); const CaseBasicRt = rt.type({ @@ -20,14 +22,33 @@ const CaseBasicRt = rt.type({ title: rt.string, }); +const CaseExternalServiceBasicRt = rt.type({ + connector_id: rt.string, + connector_name: rt.string, + external_id: rt.string, + external_title: rt.string, + external_url: rt.string, +}); + +const CaseFullExternalServiceRt = rt.union([ + rt.intersection([ + CaseExternalServiceBasicRt, + rt.type({ + pushed_at: rt.string, + pushed_by: UserRT, + }), + ]), + rt.null, +]); + export const CaseAttributesRt = rt.intersection([ CaseBasicRt, rt.type({ - comment_ids: rt.array(rt.string), closed_at: rt.union([rt.string, rt.null]), closed_by: rt.union([UserRT, rt.null]), created_at: rt.string, created_by: UserRT, + external_service: CaseFullExternalServiceRt, updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), @@ -35,6 +56,8 @@ export const CaseAttributesRt = rt.intersection([ export const CaseRequestRt = CaseBasicRt; +export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; + export const CasesFindRequestRt = rt.partial({ tags: rt.union([rt.array(rt.string), rt.string]), status: StatusRt, @@ -53,6 +76,7 @@ export const CaseResponseRt = rt.intersection([ CaseAttributesRt, rt.type({ id: rt.string, + totalComment: rt.number, version: rt.string, }), rt.partial({ @@ -78,6 +102,60 @@ export const CasePatchRequestRt = rt.intersection([ export const CasesPatchRequestRt = rt.type({ cases: rt.array(CasePatchRequestRt) }); export const CasesResponseRt = rt.array(CaseResponseRt); +/* + * This type are related to this file below + * x-pack/plugins/actions/server/builtin_action_types/servicenow/schema.ts + * why because this schema is not share in a common folder + * so we redefine then so we can use/validate types + */ + +const ServiceConnectorUserParams = rt.type({ + fullName: rt.union([rt.string, rt.null]), + username: rt.string, +}); + +export const ServiceConnectorCommentParamsRt = rt.type({ + commentId: rt.string, + comment: rt.string, + createdAt: rt.string, + createdBy: ServiceConnectorUserParams, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), +}); + +export const ServiceConnectorCaseParamsRt = rt.intersection([ + rt.type({ + caseId: rt.string, + createdAt: rt.string, + createdBy: ServiceConnectorUserParams, + incidentId: rt.union([rt.string, rt.null]), + title: rt.string, + updatedAt: rt.union([rt.string, rt.null]), + updatedBy: rt.union([ServiceConnectorUserParams, rt.null]), + }), + rt.partial({ + description: rt.string, + comments: rt.array(ServiceConnectorCommentParamsRt), + }), +]); + +export const ServiceConnectorCaseResponseRt = rt.intersection([ + rt.type({ + number: rt.string, + incidentId: rt.string, + pushedDate: rt.string, + url: rt.string, + }), + rt.partial({ + comments: rt.array( + rt.type({ + commentId: rt.string, + pushedDate: rt.string, + }) + ), + }), +]); + export type CaseAttributes = rt.TypeOf; export type CaseRequest = rt.TypeOf; export type CaseResponse = rt.TypeOf; @@ -85,3 +163,8 @@ export type CasesResponse = rt.TypeOf; export type CasesFindResponse = rt.TypeOf; export type CasePatchRequest = rt.TypeOf; export type CasesPatchRequest = rt.TypeOf; +export type CaseExternalServiceRequest = rt.TypeOf; +export type ServiceConnectorCaseParams = rt.TypeOf; +export type ServiceConnectorCaseResponse = rt.TypeOf; +export type CaseFullExternalService = rt.TypeOf; +export type ServiceConnectorCommentParams = rt.TypeOf; diff --git a/x-pack/plugins/case/common/api/cases/comment.ts b/x-pack/plugins/case/common/api/cases/comment.ts index cebfa00425728..4549b1c31a7cf 100644 --- a/x-pack/plugins/case/common/api/cases/comment.ts +++ b/x-pack/plugins/case/common/api/cases/comment.ts @@ -17,6 +17,8 @@ export const CommentAttributesRt = rt.intersection([ rt.type({ created_at: rt.string, created_by: UserRT, + pushed_at: rt.union([rt.string, rt.null]), + pushed_by: rt.union([UserRT, rt.null]), updated_at: rt.union([rt.string, rt.null]), updated_by: rt.union([UserRT, rt.null]), }), diff --git a/x-pack/plugins/case/common/api/cases/configure.ts b/x-pack/plugins/case/common/api/cases/configure.ts index e0489ed7270fa..9b210c2aa05ad 100644 --- a/x-pack/plugins/case/common/api/cases/configure.ts +++ b/x-pack/plugins/case/common/api/cases/configure.ts @@ -73,6 +73,7 @@ const ClosureTypeRT = rt.union([rt.literal('close-by-user'), rt.literal('close-b const CasesConfigureBasicRt = rt.type({ connector_id: rt.string, + connector_name: rt.string, closure_type: ClosureTypeRT, }); diff --git a/x-pack/plugins/case/common/api/cases/index.ts b/x-pack/plugins/case/common/api/cases/index.ts index 5fbee98bc57ad..ffcd4d25eecf5 100644 --- a/x-pack/plugins/case/common/api/cases/index.ts +++ b/x-pack/plugins/case/common/api/cases/index.ts @@ -8,3 +8,4 @@ export * from './case'; export * from './configure'; export * from './comment'; export * from './status'; +export * from './user_actions'; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts new file mode 100644 index 0000000000000..2b70a698a5152 --- /dev/null +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { UserRT } from '../user'; + +/* To the next developer, if you add/removed fields here + * make sure to check this file (x-pack/plugins/case/server/services/user_actions/helpers.ts) too + */ +const UserActionFieldRt = rt.array( + rt.union([ + rt.literal('comment'), + rt.literal('description'), + rt.literal('pushed'), + rt.literal('tags'), + rt.literal('title'), + rt.literal('status'), + ]) +); +const UserActionRt = rt.union([ + rt.literal('add'), + rt.literal('create'), + rt.literal('delete'), + rt.literal('update'), + rt.literal('push-to-service'), +]); + +// TO DO change state to status +const CaseUserActionBasicRT = rt.type({ + action_field: UserActionFieldRt, + action: UserActionRt, + action_at: rt.string, + action_by: UserRT, + new_value: rt.union([rt.string, rt.null]), + old_value: rt.union([rt.string, rt.null]), +}); + +const CaseUserActionResponseRT = rt.intersection([ + CaseUserActionBasicRT, + rt.type({ + action_id: rt.string, + case_id: rt.string, + comment_id: rt.union([rt.string, rt.null]), + }), +]); + +export const CaseUserActionAttributesRt = CaseUserActionBasicRT; + +export const CaseUserActionsResponseRt = rt.array(CaseUserActionResponseRT); + +export type CaseUserActionAttributes = rt.TypeOf; +export type CaseUserActionsResponse = rt.TypeOf; + +export type UserAction = rt.TypeOf; +export type UserActionField = rt.TypeOf; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 1d6495c2d81f3..a6a459373b0ed 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -16,8 +16,9 @@ import { caseSavedObjectType, caseConfigureSavedObjectType, caseCommentSavedObjectType, + caseUserActionSavedObjectType, } from './saved_object_types'; -import { CaseConfigureService, CaseService } from './services'; +import { CaseConfigureService, CaseService, CaseUserActionService } from './services'; function createConfig$(context: PluginInitializerContext) { return context.config.create().pipe(map(config => config)); @@ -46,9 +47,11 @@ export class CasePlugin { core.savedObjects.registerType(caseSavedObjectType); core.savedObjects.registerType(caseCommentSavedObjectType); core.savedObjects.registerType(caseConfigureSavedObjectType); + core.savedObjects.registerType(caseUserActionSavedObjectType); const caseServicePlugin = new CaseService(this.log); const caseConfigureServicePlugin = new CaseConfigureService(this.log); + const userActionServicePlugin = new CaseUserActionService(this.log); this.log.debug( `Setting up Case Workflow with core contract [${Object.keys( @@ -60,11 +63,13 @@ export class CasePlugin { authentication: plugins.security.authc, }); const caseConfigureService = await caseConfigureServicePlugin.setup(); + const userActionService = await userActionServicePlugin.setup(); const router = core.http.createRouter(); initCaseApi({ caseConfigureService, caseService, + userActionService, router, }); } diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts index bc41ddbeff1f9..eff91fff32c02 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_router.ts @@ -32,6 +32,10 @@ export const createRoute = async ( caseConfigureService, caseService, router, + userActionService: { + postUserActions: jest.fn(), + getUserActions: jest.fn(), + }, }); return router[method].mock.calls[0][1]; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 5aa8b93f17b08..03da50f886fd5 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -14,7 +14,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: ['mock-comment-1'], created_at: '2019-11-25T21:54:48.952Z', created_by: { full_name: 'elastic', @@ -22,6 +21,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'This is a brand new case of a bad meanie defacing data', + external_service: null, title: 'Super Bad Security Issue', status: 'open', tags: ['defacement'], @@ -42,7 +42,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: [], created_at: '2019-11-25T22:32:00.900Z', created_by: { full_name: 'elastic', @@ -50,6 +49,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie destroying data!', + external_service: null, title: 'Damaging Data Destruction Detected', status: 'open', tags: ['Data Destruction'], @@ -70,7 +70,6 @@ export const mockCases: Array> = [ attributes: { closed_at: null, closed_by: null, - comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -78,6 +77,7 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', + external_service: null, title: 'Another bad one', status: 'open', tags: ['LOLBins'], @@ -102,7 +102,6 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, - comment_ids: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { full_name: 'elastic', @@ -110,8 +109,9 @@ export const mockCases: Array> = [ username: 'elastic', }, description: 'Oh no, a bad meanie going LOLBins all over the place!', - title: 'Another bad one', + external_service: null, status: 'closed', + title: 'Another bad one', tags: ['LOLBins'], updated_at: '2019-11-25T22:32:17.947Z', updated_by: { @@ -147,6 +147,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T21:55:00.177Z', updated_by: { full_name: 'elastic', @@ -175,6 +177,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T21:55:14.633Z', updated_by: { full_name: 'elastic', @@ -204,6 +208,8 @@ export const mockCaseComments: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + pushed_at: null, + pushed_by: null, updated_at: '2019-11-25T22:32:30.608Z', updated_by: { full_name: 'elastic', diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts index 00d06bfdd2677..941ac90f2e90e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_all_comments.ts @@ -5,10 +5,12 @@ */ import { schema } from '@kbn/config-schema'; + +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { +export function initDeleteAllCommentsApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases/{case_id}/comments', @@ -21,9 +23,11 @@ export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; + const { username, full_name, email } = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); const comments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); await Promise.all( @@ -35,15 +39,18 @@ export function initDeleteAllCommentsApi({ caseService, router }: RouteDeps) { ) ); - const updateCase = { - comment_ids: [], - }; - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: comments.saved_objects.map(comment => + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + commentId: comment.id, + fields: ['comment'], + }) + ), }); return response.ok({ body: 'true' }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts index 85c4701f82e1d..44e57fc809e04 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/delete_comment.ts @@ -6,10 +6,13 @@ import Boom from 'boom'; import { schema } from '@kbn/config-schema'; + +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { wrapError } from '../../utils'; -export function initDeleteCommentApi({ caseService, router }: RouteDeps) { +export function initDeleteCommentApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases/{case_id}/comments/{comment_id}', @@ -23,14 +26,22 @@ export function initDeleteCommentApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, + const { username, full_name, email } = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); + + const myComment = await caseService.getComment({ + client, + commentId: request.params.comment_id, }); - if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { + if (myComment == null) { + throw Boom.notFound(`This comment ${request.params.comment_id} does not exist anymore.`); + } + + const caseRef = myComment.references.find(c => c.type === CASE_SAVED_OBJECT); + if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + `This comment ${request.params.comment_id} does not exist in ${request.params.case_id}).` ); } @@ -39,17 +50,18 @@ export function initDeleteCommentApi({ caseService, router }: RouteDeps) { commentId: request.params.comment_id, }); - const updateCase = { - comment_ids: myCase.attributes.comment_ids.filter( - cId => cId !== request.params.comment_id - ), - }; - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'delete', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + commentId: request.params.comment_id, + fields: ['comment'], + }), + ], }); return response.ok({ body: 'true' }); diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts index dcf70d0d9819c..92da64cebee74 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/find_comments.ts @@ -32,6 +32,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( SavedObjectFindOptionsRt.decode(request.query), fold(throwErrors(Boom.badRequest), identity) @@ -39,7 +40,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { const args = query ? { - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, options: { ...query, @@ -47,7 +48,7 @@ export function initFindCaseCommentsApi({ caseService, router }: RouteDeps) { }, } : { - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }; diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts index 65f2de7125236..1500039eb2cc2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_all_comment.ts @@ -22,8 +22,9 @@ export function initGetAllCommentsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const comments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); return response.ok({ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts index 06619abae8487..24f44a5f5129b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/get_comment.ts @@ -5,7 +5,6 @@ */ import { schema } from '@kbn/config-schema'; -import Boom from 'boom'; import { CommentResponseRt } from '../../../../../common/api'; import { RouteDeps } from '../../types'; @@ -25,16 +24,6 @@ export function initGetCommentApi({ caseService, router }: RouteDeps) { async (context, request, response) => { try { const client = context.core.savedObjects.client; - const myCase = await caseService.getCase({ - client, - caseId: request.params.case_id, - }); - - if (!myCase.attributes.comment_ids.includes(request.params.comment_id)) { - throw Boom.notFound( - `This comment ${request.params.comment_id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` - ); - } const comment = await caseService.getComment({ client, diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts index c14a94e84e51c..c67ad1bdaea71 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/patch_comment.ts @@ -11,11 +11,12 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { CommentPatchRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; - +import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { RouteDeps } from '../../types'; import { escapeHatch, wrapError, flattenCommentSavedObject } from '../../utils'; -export function initPatchCommentApi({ caseService, router }: RouteDeps) { +export function initPatchCommentApi({ caseService, router, userActionService }: RouteDeps) { router.patch( { path: '/api/cases/{case_id}/comments', @@ -28,46 +29,63 @@ export function initPatchCommentApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CommentPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, + const myComment = await caseService.getComment({ + client, + commentId: query.id, }); - if (!myCase.attributes.comment_ids.includes(query.id)) { + if (myComment == null) { + throw Boom.notFound(`This comment ${query.id} does not exist anymore.`); + } + + const caseRef = myComment.references.find(c => c.type === CASE_SAVED_OBJECT); + if (caseRef == null || (caseRef != null && caseRef.id !== request.params.case_id)) { throw Boom.notFound( - `This comment ${query.id} does not exist in ${myCase.attributes.title} (id: ${request.params.case_id}).` + `This comment ${query.id} does not exist in ${request.params.case_id}).` ); } - const myComment = await caseService.getComment({ - client: context.core.savedObjects.client, - commentId: query.id, - }); - if (query.version !== myComment.version) { throw Boom.conflict( 'This case has been updated. Please refresh before saving additional updates.' ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); + const updatedDate = new Date().toISOString(); const updatedComment = await caseService.patchComment({ - client: context.core.savedObjects.client, + client, commentId: query.id, updatedAttributes: { comment: query.comment, - updated_at: new Date().toISOString(), + updated_at: updatedDate, updated_by: { email, full_name, username }, }, version: query.version, }); + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'update', + actionAt: updatedDate, + actionBy: { username, full_name, email }, + caseId: request.params.case_id, + commentId: updatedComment.id, + fields: ['comment'], + newValue: query.comment, + oldValue: myComment.attributes.comment, + }), + ], + }); + return response.ok({ body: CommentResponseRt.encode( flattenCommentSavedObject({ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 9e82a8ffaaec7..2410505872a3a 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -12,6 +12,7 @@ import { identity } from 'fp-ts/lib/function'; import { CommentRequestRt, CommentResponseRt, throwErrors } from '../../../../../common/api'; import { CASE_SAVED_OBJECT } from '../../../../saved_object_types'; +import { buildCommentUserActionItem } from '../../../../services/user_actions/helpers'; import { escapeHatch, transformNewComment, @@ -20,7 +21,7 @@ import { } from '../../utils'; import { RouteDeps } from '../../types'; -export function initPostCommentApi({ caseService, router }: RouteDeps) { +export function initPostCommentApi({ caseService, router, userActionService }: RouteDeps) { router.post( { path: '/api/cases/{case_id}/comments', @@ -33,25 +34,28 @@ export function initPostCommentApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CommentRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const myCase = await caseService.getCase({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); - const createdBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const newComment = await caseService.postNewComment({ - client: context.core.savedObjects.client, + client, attributes: transformNewComment({ createdDate, ...query, - ...createdBy, + username, + full_name, + email, }), references: [ { @@ -62,16 +66,19 @@ export function initPostCommentApi({ caseService, router }: RouteDeps) { ], }); - const updateCase = { - comment_ids: [...myCase.attributes.comment_ids, newComment.id], - }; - - await caseService.patchCase({ - client: context.core.savedObjects.client, - caseId: request.params.case_id, - updatedAttributes: { - ...updateCase, - }, + await userActionService.postUserActions({ + client, + actions: [ + buildCommentUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: myCase.id, + commentId: newComment.id, + fields: ['comment'], + newValue: query.comment, + }), + ], }); return response.ok({ diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts index 1542394fc438d..3a1b9d5059cbc 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/patch_configure.ts @@ -48,8 +48,7 @@ export function initPatchCaseConfigure({ caseConfigureService, caseService, rout ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); const updateDate = new Date().toISOString(); const patch = await caseConfigureService.patch({ diff --git a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts index c839d36dcf4df..2a23abf0cbf21 100644 --- a/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts +++ b/x-pack/plugins/case/server/routes/api/cases/configure/post_configure.ts @@ -42,8 +42,7 @@ export function initPostCaseConfigure({ caseConfigureService, caseService, route ) ); } - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { email, full_name, username } = await caseService.getUser({ request, response }); const creationDate = new Date().toISOString(); const post = await caseConfigureService.post({ diff --git a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts index 559a477a83a6c..8b0384c12edce 100644 --- a/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/delete_cases.ts @@ -5,10 +5,12 @@ */ import { schema } from '@kbn/config-schema'; + +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; import { wrapError } from '../utils'; -export function initDeleteCasesApi({ caseService, router }: RouteDeps) { +export function initDeleteCasesApi({ caseService, router, userActionService }: RouteDeps) { router.delete( { path: '/api/cases', @@ -20,10 +22,11 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; await Promise.all( request.query.ids.map(id => caseService.deleteCase({ - client: context.core.savedObjects.client, + client, caseId: id, }) ) @@ -31,7 +34,7 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { const comments = await Promise.all( request.query.ids.map(id => caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: id, }) ) @@ -43,7 +46,7 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { Promise.all( c.saved_objects.map(({ id }) => caseService.deleteComment({ - client: context.core.savedObjects.client, + client, commentId: id, }) ) @@ -51,6 +54,22 @@ export function initDeleteCasesApi({ caseService, router }: RouteDeps) { ) ); } + const { username, full_name, email } = await caseService.getUser({ request, response }); + const deleteDate = new Date().toISOString(); + + await userActionService.postUserActions({ + client, + actions: request.query.ids.map(id => + buildCaseUserActionItem({ + action: 'create', + actionAt: deleteDate, + actionBy: { username, full_name, email }, + caseId: id, + fields: ['comment', 'description', 'status', 'tags', 'title'], + }) + ), + }); + return response.ok({ body: 'true' }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts index 76a1992c64270..e7b2044f2badf 100644 --- a/x-pack/plugins/case/server/routes/api/cases/find_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/find_cases.ts @@ -13,7 +13,7 @@ import { identity } from 'fp-ts/lib/function'; import { isEmpty } from 'lodash'; import { CasesFindResponseRt, CasesFindRequestRt, throwErrors } from '../../../../common/api'; import { transformCases, sortToSnake, wrapError, escapeHatch } from '../utils'; -import { RouteDeps } from '../types'; +import { RouteDeps, TotalCommentByCase } from '../types'; import { CASE_SAVED_OBJECT } from '../../../saved_object_types'; const combineFilters = (filters: string[], operator: 'OR' | 'AND'): string => @@ -97,9 +97,44 @@ export function initFindCasesApi({ caseService, router }: RouteDeps) { caseService.findCases(argsOpenCases), caseService.findCases(argsClosedCases), ]); + + const totalCommentsFindByCases = await Promise.all( + cases.saved_objects.map(c => + caseService.getAllCaseComments({ + client, + caseId: c.id, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }) + ) + ); + + const totalCommentsByCases = totalCommentsFindByCases.reduce( + (acc, itemFind) => { + if (itemFind.saved_objects.length > 0) { + const caseId = + itemFind.saved_objects[0].references.find(r => r.type === CASE_SAVED_OBJECT)?.id ?? + null; + if (caseId != null) { + return [...acc, { caseId, totalComments: itemFind.total }]; + } + } + return [...acc]; + }, + [] + ); + return response.ok({ body: CasesFindResponseRt.encode( - transformCases(cases, openCases.total ?? 0, closesCases.total ?? 0) + transformCases( + cases, + openCases.total ?? 0, + closesCases.total ?? 0, + totalCommentsByCases + ) ), }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/get_case.ts b/x-pack/plugins/case/server/routes/api/cases/get_case.ts index 1415513bca346..e947118a39e8e 100644 --- a/x-pack/plugins/case/server/routes/api/cases/get_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/get_case.ts @@ -25,10 +25,11 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const includeComments = JSON.parse(request.query.includeComments); const theCase = await caseService.getCase({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); @@ -37,7 +38,7 @@ export function initGetCaseApi({ caseService, router }: RouteDeps) { } const theComments = await caseService.getAllCaseComments({ - client: context.core.savedObjects.client, + client, caseId: request.params.case_id, }); diff --git a/x-pack/plugins/case/server/routes/api/cases/helpers.ts b/x-pack/plugins/case/server/routes/api/cases/helpers.ts index 3bf46cadc83c8..747b5195da7ec 100644 --- a/x-pack/plugins/case/server/routes/api/cases/helpers.ts +++ b/x-pack/plugins/case/server/routes/api/cases/helpers.ts @@ -4,10 +4,57 @@ * you may not use this file except in compliance with the Elastic License. */ -import { difference, get } from 'lodash'; +import { get } from 'lodash'; import { CaseAttributes, CasePatchRequest } from '../../../../common/api'; +interface CompareArrays { + addedItems: string[]; + deletedItems: string[]; +} +export const compareArrays = ({ + originalValue, + updatedValue, +}: { + originalValue: string[]; + updatedValue: string[]; +}): CompareArrays => { + const result: CompareArrays = { + addedItems: [], + deletedItems: [], + }; + originalValue.forEach(origVal => { + if (!updatedValue.includes(origVal)) { + result.deletedItems = [...result.deletedItems, origVal]; + } + }); + updatedValue.forEach(updatedVal => { + if (!originalValue.includes(updatedVal)) { + result.addedItems = [...result.addedItems, updatedVal]; + } + }); + + return result; +}; + +export const isTwoArraysDifference = ( + originalValue: unknown, + updatedValue: unknown +): CompareArrays | null => { + if ( + originalValue != null && + updatedValue != null && + Array.isArray(updatedValue) && + Array.isArray(originalValue) + ) { + const compObj = compareArrays({ originalValue, updatedValue }); + if (compObj.addedItems.length > 0 || compObj.deletedItems.length > 0) { + return compObj; + } + } + return null; +}; + export const getCaseToUpdate = ( currentCase: CaseAttributes, queryCase: CasePatchRequest @@ -15,12 +62,7 @@ export const getCaseToUpdate = ( Object.entries(queryCase).reduce( (acc, [key, value]) => { const currentValue = get(currentCase, key); - if ( - currentValue != null && - Array.isArray(value) && - Array.isArray(currentValue) && - difference(value, currentValue).length !== 0 - ) { + if (isTwoArraysDifference(value, currentValue)) { return { ...acc, [key]: value, diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 19ff7f0734a77..ac1e67cec52bd 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -52,15 +52,16 @@ describe('PATCH cases', () => { { closed_at: '2019-11-25T21:54:48.952Z', closed_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, - comment_ids: ['mock-comment-1'], comments: [], created_at: '2019-11-25T21:54:48.952Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'This is a brand new case of a bad meanie defacing data', id: 'mock-id-1', + external_service: null, status: 'closed', tags: ['defacement'], title: 'Super Bad Security Issue', + totalComment: 0, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', @@ -94,15 +95,16 @@ describe('PATCH cases', () => { { closed_at: null, closed_by: null, - comment_ids: [], comments: [], created_at: '2019-11-25T22:32:17.947Z', created_by: { email: 'testemail@elastic.co', full_name: 'elastic', username: 'elastic' }, description: 'Oh no, a bad meanie going LOLBins all over the place!', id: 'mock-id-4', + external_service: null, status: 'open', tags: ['LOLBins'], title: 'Another bad one', + totalComment: 0, updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 4aa0d8daf5b34..3d0b7bc79f88b 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -18,8 +18,9 @@ import { import { escapeHatch, wrapError, flattenCaseSavedObject } from '../utils'; import { RouteDeps } from '../types'; import { getCaseToUpdate } from './helpers'; +import { buildCaseUserActions } from '../../../services/user_actions/helpers'; -export function initPatchCasesApi({ caseService, router }: RouteDeps) { +export function initPatchCasesApi({ caseService, router, userActionService }: RouteDeps) { router.patch( { path: '/api/cases', @@ -29,12 +30,13 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CasesPatchRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); const myCases = await caseService.getCases({ - client: context.core.savedObjects.client, + client, caseIds: query.cases.map(q => q.id), }); let nonExistingCases: CasePatchRequest[] = []; @@ -72,11 +74,10 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { return Object.keys(updateCaseAttributes).length > 0; }); if (updateFilterCases.length > 0) { - const updatedBy = await caseService.getUser({ request, response }); - const { email, full_name, username } = updatedBy; + const { username, full_name, email } = await caseService.getUser({ request, response }); const updatedDt = new Date().toISOString(); const updatedCases = await caseService.patchCases({ - client: context.core.savedObjects.client, + client, cases: updateFilterCases.map(thisCase => { const { id: caseId, version, ...updateCaseAttributes } = thisCase; let closedInfo = {}; @@ -103,6 +104,7 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { }; }), }); + const returnUpdatedCase = myCases.saved_objects .filter(myCase => updatedCases.saved_objects.some(updatedCase => updatedCase.id === myCase.id) @@ -116,6 +118,17 @@ export function initPatchCasesApi({ caseService, router }: RouteDeps) { references: myCase.references, }); }); + + await userActionService.postUserActions({ + client, + actions: buildCaseUserActions({ + originalCases: myCases.saved_objects, + updatedCases: updatedCases.saved_objects, + actionDate: updatedDt, + actionBy: { email, full_name, username }, + }), + }); + return response.ok({ body: CasesResponseRt.encode(returnUpdatedCase), }); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.ts index 9e854c3178e1e..75be68013bcd4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.ts @@ -12,9 +12,10 @@ import { identity } from 'fp-ts/lib/function'; import { flattenCaseSavedObject, transformNewCase, wrapError, escapeHatch } from '../utils'; import { CaseRequestRt, throwErrors, CaseResponseRt } from '../../../../common/api'; +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; import { RouteDeps } from '../types'; -export function initPostCaseApi({ caseService, router }: RouteDeps) { +export function initPostCaseApi({ caseService, router, userActionService }: RouteDeps) { router.post( { path: '/api/cases', @@ -24,21 +25,39 @@ export function initPostCaseApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const query = pipe( CaseRequestRt.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const createdBy = await caseService.getUser({ request, response }); + const { username, full_name, email } = await caseService.getUser({ request, response }); const createdDate = new Date().toISOString(); const newCase = await caseService.postNewCase({ - client: context.core.savedObjects.client, + client, attributes: transformNewCase({ createdDate, newCase: query, - ...createdBy, + username, + full_name, + email, }), }); + + await userActionService.postUserActions({ + client, + actions: [ + buildCaseUserActionItem({ + action: 'create', + actionAt: createdDate, + actionBy: { username, full_name, email }, + caseId: newCase.id, + fields: ['description', 'status', 'tags', 'title'], + newValue: JSON.stringify(query), + }), + ], + }); + return response.ok({ body: CaseResponseRt.encode(flattenCaseSavedObject(newCase, [])) }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/push_case.ts b/x-pack/plugins/case/server/routes/api/cases/push_case.ts new file mode 100644 index 0000000000000..6ae3df180d9e4 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/push_case.ts @@ -0,0 +1,176 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; + +import { flattenCaseSavedObject, wrapError, escapeHatch } from '../utils'; + +import { CaseExternalServiceRequestRt, CaseResponseRt, throwErrors } from '../../../../common/api'; +import { buildCaseUserActionItem } from '../../../services/user_actions/helpers'; +import { RouteDeps } from '../types'; + +export function initPushCaseUserActionApi({ + caseConfigureService, + caseService, + router, + userActionService, +}: RouteDeps) { + router.post( + { + path: '/api/cases/{case_id}/_push', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + body: escapeHatch, + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const caseId = request.params.case_id; + const query = pipe( + CaseExternalServiceRequestRt.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + const { username, full_name, email } = await caseService.getUser({ request, response }); + const pushedDate = new Date().toISOString(); + + const [myCase, myCaseConfigure, totalCommentsFindByCases] = await Promise.all([ + caseService.getCase({ + client, + caseId: request.params.case_id, + }), + caseConfigureService.find({ client }), + caseService.getAllCaseComments({ + client, + caseId, + options: { + fields: [], + page: 1, + perPage: 1, + }, + }), + ]); + + if (myCase.attributes.status === 'closed') { + throw Boom.conflict( + `This case ${myCase.attributes.title} is closed. You can not pushed if the case is closed.` + ); + } + + const comments = await caseService.getAllCaseComments({ + client, + caseId, + options: { + fields: [], + page: 1, + perPage: totalCommentsFindByCases.total, + }, + }); + + const externalService = { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + ...query, + }; + + const [updatedCase, updatedComments] = await Promise.all([ + caseService.patchCase({ + client, + caseId, + updatedAttributes: { + ...(myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? { + status: 'closed', + closed_at: pushedDate, + closed_by: { email, full_name, username }, + } + : {}), + external_service: externalService, + updated_at: pushedDate, + updated_by: { username, full_name, email }, + }, + version: myCase.version, + }), + caseService.patchComments({ + client, + comments: comments.saved_objects.map(comment => ({ + commentId: comment.id, + updatedAttributes: { + pushed_at: pushedDate, + pushed_by: { username, full_name, email }, + updated_at: pushedDate, + updated_by: { username, full_name, email }, + }, + version: comment.version, + })), + }), + userActionService.postUserActions({ + client, + actions: [ + ...(myCaseConfigure.saved_objects[0].attributes.closure_type === 'close-by-pushing' + ? [ + buildCaseUserActionItem({ + action: 'update', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['status'], + newValue: 'closed', + oldValue: myCase.attributes.status, + }), + ] + : []), + buildCaseUserActionItem({ + action: 'push-to-service', + actionAt: pushedDate, + actionBy: { username, full_name, email }, + caseId, + fields: ['pushed'], + newValue: JSON.stringify(externalService), + }), + ], + }), + ]); + + return response.ok({ + body: CaseResponseRt.encode( + flattenCaseSavedObject( + { + ...myCase, + ...updatedCase, + attributes: { ...myCase.attributes, ...updatedCase?.attributes }, + references: myCase.references, + }, + comments.saved_objects.map(origComment => { + const updatedComment = updatedComments.saved_objects.find( + c => c.id === origComment.id + ); + return { + ...origComment, + ...updatedComment, + attributes: { + ...origComment.attributes, + ...updatedComment?.attributes, + }, + version: updatedComment?.version ?? origComment.version, + references: origComment?.references ?? [], + }; + }) + ) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts index 519bb198f5f9e..56862a96e0563 100644 --- a/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts +++ b/x-pack/plugins/case/server/routes/api/cases/reporters/get_reporters.ts @@ -16,8 +16,9 @@ export function initGetReportersApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const reporters = await caseService.getReporters({ - client: context.core.savedObjects.client, + client, }); return response.ok({ body: UsersRt.encode(reporters) }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts index b4fc90d702604..f7431729d398c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts +++ b/x-pack/plugins/case/server/routes/api/cases/status/get_status.ts @@ -18,8 +18,9 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const argsOpenCases = { - client: context.core.savedObjects.client, + client, options: { fields: [], page: 1, @@ -29,7 +30,7 @@ export function initGetCasesStatusApi({ caseService, router }: RouteDeps) { }; const argsClosedCases = { - client: context.core.savedObjects.client, + client, options: { fields: [], page: 1, diff --git a/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts index ca51f421f4f56..55e8fe2af128c 100644 --- a/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts +++ b/x-pack/plugins/case/server/routes/api/cases/tags/get_tags.ts @@ -15,8 +15,9 @@ export function initGetTagsApi({ caseService, router }: RouteDeps) { }, async (context, request, response) => { try { + const client = context.core.savedObjects.client; const tags = await caseService.getTags({ - client: context.core.savedObjects.client, + client, }); return response.ok({ body: tags }); } catch (error) { diff --git a/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts new file mode 100644 index 0000000000000..2d4f16e46d561 --- /dev/null +++ b/x-pack/plugins/case/server/routes/api/cases/user_actions/get_all_user_actions.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; + +import { CaseUserActionsResponseRt } from '../../../../../common/api'; +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../../../saved_object_types'; +import { RouteDeps } from '../../types'; +import { wrapError } from '../../utils'; + +export function initGetAllUserActionsApi({ userActionService, router }: RouteDeps) { + router.get( + { + path: '/api/cases/{case_id}/user_actions', + validate: { + params: schema.object({ + case_id: schema.string(), + }), + }, + }, + async (context, request, response) => { + try { + const client = context.core.savedObjects.client; + const userActions = await userActionService.getUserActions({ + client, + caseId: request.params.case_id, + }); + return response.ok({ + body: CaseUserActionsResponseRt.encode( + userActions.saved_objects.map(ua => ({ + ...ua.attributes, + action_id: ua.id, + case_id: ua.references.find(r => r.type === CASE_SAVED_OBJECT)?.id ?? '', + comment_id: ua.references.find(r => r.type === CASE_COMMENT_SAVED_OBJECT)?.id ?? null, + })) + ), + }); + } catch (error) { + return response.customError(wrapError(error)); + } + } + ); +} diff --git a/x-pack/plugins/case/server/routes/api/index.ts b/x-pack/plugins/case/server/routes/api/index.ts index 60ee57a0efea7..ced88fabf3160 100644 --- a/x-pack/plugins/case/server/routes/api/index.ts +++ b/x-pack/plugins/case/server/routes/api/index.ts @@ -9,6 +9,11 @@ import { initFindCasesApi } from '././cases/find_cases'; import { initGetCaseApi } from './cases/get_case'; import { initPatchCasesApi } from './cases/patch_cases'; import { initPostCaseApi } from './cases/post_case'; +import { initPushCaseUserActionApi } from './cases/push_case'; +import { initGetReportersApi } from './cases/reporters/get_reporters'; +import { initGetCasesStatusApi } from './cases/status/get_status'; +import { initGetTagsApi } from './cases/tags/get_tags'; +import { initGetAllUserActionsApi } from './cases/user_actions/get_all_user_actions'; import { initDeleteCommentApi } from './cases/comments/delete_comment'; import { initDeleteAllCommentsApi } from './cases/comments/delete_all_comments'; @@ -18,18 +23,13 @@ import { initGetCommentApi } from './cases/comments/get_comment'; import { initPatchCommentApi } from './cases/comments/patch_comment'; import { initPostCommentApi } from './cases/comments/post_comment'; -import { initGetReportersApi } from './cases/reporters/get_reporters'; - -import { initGetCasesStatusApi } from './cases/status/get_status'; - -import { initGetTagsApi } from './cases/tags/get_tags'; - -import { RouteDeps } from './types'; import { initCaseConfigureGetActionConnector } from './cases/configure/get_connectors'; import { initGetCaseConfigure } from './cases/configure/get_configure'; import { initPatchCaseConfigure } from './cases/configure/patch_configure'; import { initPostCaseConfigure } from './cases/configure/post_configure'; +import { RouteDeps } from './types'; + export function initCaseApi(deps: RouteDeps) { // Cases initDeleteCasesApi(deps); @@ -37,6 +37,8 @@ export function initCaseApi(deps: RouteDeps) { initGetCaseApi(deps); initPatchCasesApi(deps); initPostCaseApi(deps); + initPushCaseUserActionApi(deps); + initGetAllUserActionsApi(deps); // Comments initDeleteCommentApi(deps); initDeleteAllCommentsApi(deps); diff --git a/x-pack/plugins/case/server/routes/api/types.ts b/x-pack/plugins/case/server/routes/api/types.ts index 7af3e7b70d96f..e532a7b618b5c 100644 --- a/x-pack/plugins/case/server/routes/api/types.ts +++ b/x-pack/plugins/case/server/routes/api/types.ts @@ -5,11 +5,16 @@ */ import { IRouter } from 'src/core/server'; -import { CaseConfigureServiceSetup, CaseServiceSetup } from '../../services'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, +} from '../../services'; export interface RouteDeps { caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; + userActionService: CaseUserActionServiceSetup; router: IRouter; } @@ -18,3 +23,8 @@ export enum SortFieldCase { createdAt = 'created_at', status = 'status', } + +export interface TotalCommentByCase { + caseId: string; + totalComments: number; +} diff --git a/x-pack/plugins/case/server/routes/api/utils.ts b/x-pack/plugins/case/server/routes/api/utils.ts index 19dbb024d1e0b..9d90eb8ef4a6d 100644 --- a/x-pack/plugins/case/server/routes/api/utils.ts +++ b/x-pack/plugins/case/server/routes/api/utils.ts @@ -22,7 +22,8 @@ import { CommentsResponse, CommentAttributes, } from '../../../common/api'; -import { SortFieldCase } from './types'; + +import { SortFieldCase, TotalCommentByCase } from './types'; export const transformNewCase = ({ createdDate, @@ -37,11 +38,11 @@ export const transformNewCase = ({ newCase: CaseRequest; username: string; }): CaseAttributes => ({ - closed_at: newCase.status === 'closed' ? createdDate : null, - closed_by: newCase.status === 'closed' ? { email, full_name, username } : null, - comment_ids: [], + closed_at: null, + closed_by: null, created_at: createdDate, created_by: { email, full_name, username }, + external_service: null, updated_at: null, updated_by: null, ...newCase, @@ -64,6 +65,8 @@ export const transformNewComment = ({ comment, created_at: createdDate, created_by: { email, full_name, username }, + pushed_at: null, + pushed_by: null, updated_at: null, updated_by: null, }); @@ -81,30 +84,41 @@ export function wrapError(error: any): CustomHttpResponseOptions export const transformCases = ( cases: SavedObjectsFindResponse, countOpenCases: number, - countClosedCases: number + countClosedCases: number, + totalCommentByCase: TotalCommentByCase[] ): CasesFindResponse => ({ page: cases.page, per_page: cases.per_page, total: cases.total, - cases: flattenCaseSavedObjects(cases.saved_objects), + cases: flattenCaseSavedObjects(cases.saved_objects, totalCommentByCase), count_open_cases: countOpenCases, count_closed_cases: countClosedCases, }); export const flattenCaseSavedObjects = ( - savedObjects: SavedObjectsFindResponse['saved_objects'] + savedObjects: SavedObjectsFindResponse['saved_objects'], + totalCommentByCase: TotalCommentByCase[] ): CaseResponse[] => savedObjects.reduce((acc: CaseResponse[], savedObject: SavedObject) => { - return [...acc, flattenCaseSavedObject(savedObject, [])]; + return [ + ...acc, + flattenCaseSavedObject( + savedObject, + [], + totalCommentByCase.find(tc => tc.caseId === savedObject.id)?.totalComments ?? 0 + ), + ]; }, []); export const flattenCaseSavedObject = ( savedObject: SavedObject, - comments: Array> = [] + comments: Array> = [], + totalComment: number = 0 ): CaseResponse => ({ id: savedObject.id, version: savedObject.version ?? '0', comments: flattenCommentSavedObjects(comments), + totalComment, ...savedObject.attributes, }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index 8eab040b9ca9c..a4c5dab0feeb7 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -30,9 +30,6 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, - comment_ids: { - type: 'keyword', - }, created_at: { type: 'date', }, @@ -52,6 +49,41 @@ export const caseSavedObjectType: SavedObjectsType = { description: { type: 'text', }, + external_service: { + properties: { + pushed_at: { + type: 'date', + }, + pushed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + email: { + type: 'keyword', + }, + }, + }, + connector_id: { + type: 'keyword', + }, + connector_name: { + type: 'keyword', + }, + external_id: { + type: 'keyword', + }, + external_title: { + type: 'text', + }, + external_url: { + type: 'text', + }, + }, + }, title: { type: 'keyword', }, @@ -61,6 +93,7 @@ export const caseSavedObjectType: SavedObjectsType = { tags: { type: 'keyword', }, + updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/comments.ts b/x-pack/plugins/case/server/saved_object_types/comments.ts index f52da886e7611..8776dd39b11fa 100644 --- a/x-pack/plugins/case/server/saved_object_types/comments.ts +++ b/x-pack/plugins/case/server/saved_object_types/comments.ts @@ -33,6 +33,19 @@ export const caseCommentSavedObjectType: SavedObjectsType = { }, }, }, + pushed_at: { + type: 'date', + }, + pushed_by: { + properties: { + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, updated_at: { type: 'date', }, diff --git a/x-pack/plugins/case/server/saved_object_types/configure.ts b/x-pack/plugins/case/server/saved_object_types/configure.ts index 8ea6f6bba7d4f..d66c38b6ea8ff 100644 --- a/x-pack/plugins/case/server/saved_object_types/configure.ts +++ b/x-pack/plugins/case/server/saved_object_types/configure.ts @@ -19,6 +19,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, created_by: { properties: { + email: { + type: 'keyword', + }, username: { type: 'keyword', }, @@ -30,6 +33,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { connector_id: { type: 'keyword', }, + connector_name: { + type: 'keyword', + }, closure_type: { type: 'keyword', }, @@ -38,6 +44,9 @@ export const caseConfigureSavedObjectType: SavedObjectsType = { }, updated_by: { properties: { + email: { + type: 'keyword', + }, username: { type: 'keyword', }, diff --git a/x-pack/plugins/case/server/saved_object_types/index.ts b/x-pack/plugins/case/server/saved_object_types/index.ts index 978b3d35ee5c6..0e4b9fa3e2eee 100644 --- a/x-pack/plugins/case/server/saved_object_types/index.ts +++ b/x-pack/plugins/case/server/saved_object_types/index.ts @@ -7,3 +7,4 @@ export { caseSavedObjectType, CASE_SAVED_OBJECT } from './cases'; export { caseConfigureSavedObjectType, CASE_CONFIGURE_SAVED_OBJECT } from './configure'; export { caseCommentSavedObjectType, CASE_COMMENT_SAVED_OBJECT } from './comments'; +export { caseUserActionSavedObjectType, CASE_USER_ACTION_SAVED_OBJECT } from './user_actions'; diff --git a/x-pack/plugins/case/server/saved_object_types/user_actions.ts b/x-pack/plugins/case/server/saved_object_types/user_actions.ts new file mode 100644 index 0000000000000..b61bfafc3b33c --- /dev/null +++ b/x-pack/plugins/case/server/saved_object_types/user_actions.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectsType } from 'src/core/server'; + +export const CASE_USER_ACTION_SAVED_OBJECT = 'cases-user-actions'; + +export const caseUserActionSavedObjectType: SavedObjectsType = { + name: CASE_USER_ACTION_SAVED_OBJECT, + hidden: false, + namespaceAgnostic: false, + mappings: { + properties: { + action_field: { + type: 'keyword', + }, + action: { + type: 'keyword', + }, + action_at: { + type: 'date', + }, + action_by: { + properties: { + email: { + type: 'keyword', + }, + username: { + type: 'keyword', + }, + full_name: { + type: 'keyword', + }, + }, + }, + new_value: { + type: 'text', + }, + old_value: { + type: 'text', + }, + }, + }, +}; diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 4bbffddf63251..09d726228d309 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -24,11 +24,17 @@ import { readReporters } from './reporters/read_reporters'; import { readTags } from './tags/read_tags'; export { CaseConfigureService, CaseConfigureServiceSetup } from './configure'; +export { CaseUserActionService, CaseUserActionServiceSetup } from './user_actions'; -interface ClientArgs { +export interface ClientArgs { client: SavedObjectsClientContract; } +interface PushedArgs { + pushed_at: string; + pushed_by: User; +} + interface GetCaseArgs extends ClientArgs { caseId: string; } @@ -37,7 +43,7 @@ interface GetCasesArgs extends ClientArgs { caseIds: string[]; } -interface GetCommentsArgs extends GetCaseArgs { +interface FindCommentsArgs extends GetCaseArgs { options?: SavedObjectFindOptions; } @@ -47,6 +53,7 @@ interface FindCasesArgs extends ClientArgs { interface GetCommentArgs extends ClientArgs { commentId: string; } + interface PostCaseArgs extends ClientArgs { attributes: CaseAttributes; } @@ -58,7 +65,7 @@ interface PostCommentArgs extends ClientArgs { interface PatchCase { caseId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } type PatchCaseArgs = PatchCase & ClientArgs; @@ -68,10 +75,20 @@ interface PatchCasesArgs extends ClientArgs { } interface UpdateCommentArgs extends ClientArgs { commentId: string; - updatedAttributes: Partial; + updatedAttributes: Partial; version?: string; } +interface PatchComment { + commentId: string; + updatedAttributes: Partial; + version?: string; +} + +interface PatchComments extends ClientArgs { + comments: PatchComment[]; +} + interface GetUserArgs { request: KibanaRequest; response: KibanaResponseFactory; @@ -84,7 +101,7 @@ export interface CaseServiceSetup { deleteCase(args: GetCaseArgs): Promise<{}>; deleteComment(args: GetCommentArgs): Promise<{}>; findCases(args: FindCasesArgs): Promise>; - getAllCaseComments(args: GetCommentsArgs): Promise>; + getAllCaseComments(args: FindCommentsArgs): Promise>; getCase(args: GetCaseArgs): Promise>; getCases(args: GetCasesArgs): Promise>; getComment(args: GetCommentArgs): Promise>; @@ -96,6 +113,7 @@ export interface CaseServiceSetup { patchCase(args: PatchCaseArgs): Promise>; patchCases(args: PatchCasesArgs): Promise>; patchComment(args: UpdateCommentArgs): Promise>; + patchComments(args: PatchComments): Promise>; } export class CaseService { @@ -157,7 +175,7 @@ export class CaseService { throw error; } }, - getAllCaseComments: async ({ client, caseId, options }: GetCommentsArgs) => { + getAllCaseComments: async ({ client, caseId, options }: FindCommentsArgs) => { try { this.log.debug(`Attempting to GET all comments for case ${caseId}`); return await client.find({ @@ -261,5 +279,25 @@ export class CaseService { throw error; } }, + patchComments: async ({ client, comments }: PatchComments) => { + try { + this.log.debug( + `Attempting to UPDATE comments ${comments.map(c => c.commentId).join(', ')}` + ); + return await client.bulkUpdate( + comments.map(c => ({ + type: CASE_COMMENT_SAVED_OBJECT, + id: c.commentId, + attributes: c.updatedAttributes, + version: c.version, + })) + ); + } catch (error) { + this.log.debug( + `Error on UPDATE comments ${comments.map(c => c.commentId).join(', ')}: ${error}` + ); + throw error; + } + }, }); } diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts new file mode 100644 index 0000000000000..59d193f0f30d5 --- /dev/null +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -0,0 +1,195 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObject, SavedObjectsUpdateResponse } from 'kibana/server'; +import { get } from 'lodash'; + +import { + CaseUserActionAttributes, + UserAction, + UserActionField, + CaseAttributes, + User, +} from '../../../common/api'; +import { isTwoArraysDifference } from '../../routes/api/cases/helpers'; +import { UserActionItem } from '.'; +import { CASE_SAVED_OBJECT, CASE_COMMENT_SAVED_OBJECT } from '../../saved_object_types'; + +export const transformNewUserAction = ({ + actionField, + action, + actionAt, + email, + full_name, + newValue = null, + oldValue = null, + username, +}: { + actionField: UserActionField; + action: UserAction; + actionAt: string; + email?: string; + full_name?: string; + newValue?: string | null; + oldValue?: string | null; + username: string; +}): CaseUserActionAttributes => ({ + action_field: actionField, + action, + action_at: actionAt, + action_by: { email, full_name, username }, + new_value: newValue, + old_value: oldValue, +}); + +interface BuildCaseUserAction { + action: UserAction; + actionAt: string; + actionBy: User; + caseId: string; + fields: UserActionField | unknown[]; + newValue?: string | unknown; + oldValue?: string | unknown; +} + +interface BuildCommentUserActionItem extends BuildCaseUserAction { + commentId: string; +} + +export const buildCommentUserActionItem = ({ + action, + actionAt, + actionBy, + caseId, + commentId, + fields, + newValue, + oldValue, +}: BuildCommentUserActionItem): UserActionItem => ({ + attributes: transformNewUserAction({ + actionField: fields as UserActionField, + action, + actionAt, + ...actionBy, + newValue: newValue as string, + oldValue: oldValue as string, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + { + type: CASE_COMMENT_SAVED_OBJECT, + name: `associated-${CASE_COMMENT_SAVED_OBJECT}`, + id: commentId, + }, + ], +}); + +export const buildCaseUserActionItem = ({ + action, + actionAt, + actionBy, + caseId, + fields, + newValue, + oldValue, +}: BuildCaseUserAction): UserActionItem => ({ + attributes: transformNewUserAction({ + actionField: fields as UserActionField, + action, + actionAt, + ...actionBy, + newValue: newValue as string, + oldValue: oldValue as string, + }), + references: [ + { + type: CASE_SAVED_OBJECT, + name: `associated-${CASE_SAVED_OBJECT}`, + id: caseId, + }, + ], +}); + +const userActionFieldsAllowed: UserActionField = [ + 'comment', + 'description', + 'tags', + 'title', + 'status', +]; + +export const buildCaseUserActions = ({ + actionDate, + actionBy, + originalCases, + updatedCases, +}: { + actionDate: string; + actionBy: User; + originalCases: Array>; + updatedCases: Array>; +}): UserActionItem[] => + updatedCases.reduce((acc, updatedItem) => { + const originalItem = originalCases.find(oItem => oItem.id === updatedItem.id); + if (originalItem != null) { + let userActions: UserActionItem[] = []; + const updatedFields = Object.keys(updatedItem.attributes) as UserActionField; + updatedFields.forEach(field => { + if (userActionFieldsAllowed.includes(field)) { + const origValue = get(originalItem, ['attributes', field]); + const updatedValue = get(updatedItem, ['attributes', field]); + const compareValues = isTwoArraysDifference(origValue, updatedValue); + if (compareValues != null) { + if (compareValues.addedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'add', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: compareValues.addedItems.join(', '), + }), + ]; + } + if (compareValues.deletedItems.length > 0) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'delete', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: compareValues.deletedItems.join(', '), + }), + ]; + } + } else if (origValue !== updatedValue) { + userActions = [ + ...userActions, + buildCaseUserActionItem({ + action: 'update', + actionAt: actionDate, + actionBy, + caseId: updatedItem.id, + fields: [field], + newValue: updatedValue, + oldValue: origValue, + }), + ]; + } + } + }); + return [...acc, ...userActions]; + } + return acc; + }, []); diff --git a/x-pack/plugins/case/server/services/user_actions/index.ts b/x-pack/plugins/case/server/services/user_actions/index.ts new file mode 100644 index 0000000000000..0e9babf9d81af --- /dev/null +++ b/x-pack/plugins/case/server/services/user_actions/index.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + SavedObjectsFindResponse, + Logger, + SavedObjectsBulkResponse, + SavedObjectReference, +} from 'kibana/server'; + +import { CaseUserActionAttributes } from '../../../common/api'; +import { CASE_USER_ACTION_SAVED_OBJECT, CASE_SAVED_OBJECT } from '../../saved_object_types'; +import { ClientArgs } from '..'; + +interface GetCaseUserActionArgs extends ClientArgs { + caseId: string; +} + +export interface UserActionItem { + attributes: CaseUserActionAttributes; + references: SavedObjectReference[]; +} + +interface PostCaseUserActionArgs extends ClientArgs { + actions: UserActionItem[]; +} + +export interface CaseUserActionServiceSetup { + getUserActions( + args: GetCaseUserActionArgs + ): Promise>; + postUserActions( + args: PostCaseUserActionArgs + ): Promise>; +} + +export class CaseUserActionService { + constructor(private readonly log: Logger) {} + public setup = async (): Promise => ({ + getUserActions: async ({ client, caseId }: GetCaseUserActionArgs) => { + try { + const caseUserActionInfo = await client.find({ + type: CASE_USER_ACTION_SAVED_OBJECT, + fields: [], + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + page: 1, + perPage: 1, + }); + return await client.find({ + type: CASE_USER_ACTION_SAVED_OBJECT, + hasReference: { type: CASE_SAVED_OBJECT, id: caseId }, + page: 1, + perPage: caseUserActionInfo.total, + sortField: 'action_at', + sortOrder: 'asc', + }); + } catch (error) { + this.log.debug(`Error on GET case user action: ${error}`); + throw error; + } + }, + postUserActions: async ({ client, actions }: PostCaseUserActionArgs) => { + try { + this.log.debug(`Attempting to POST a new case user action`); + return await client.bulkCreate( + actions.map(action => ({ type: CASE_USER_ACTION_SAVED_OBJECT, ...action })) + ); + } catch (error) { + this.log.debug(`Error on POST a new case user action: ${error}`); + throw error; + } + }, + }); +} From 35b222a8401b4c429fc6eb0fb847ecb65f571cca Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 24 Mar 2020 02:56:04 +0000 Subject: [PATCH 23/34] fix(NA): log rotation watchers usage (#60956) * fix(NA): log rotation watchers usage * docs(NA): add old value to the example * chore(NA): change warning messages --- docs/setup/settings.asciidoc | 4 ++-- src/legacy/server/config/schema.js | 4 ++-- .../server/logging/rotate/log_rotator.test.ts | 10 ++++++---- src/legacy/server/logging/rotate/log_rotator.ts | 16 +++++++++++++--- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 71bb7b81ea420..a72c15190840a 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -193,7 +193,7 @@ that feature would not take any effect. `logging.rotate.everyBytes:`:: [experimental] *Default: 10485760* The maximum size of a log file (that is `not an exact` limit). After the limit is reached, a new log file is generated. The default size limit is 10485760 (10 MB) and -this option should be in the range of 102400 (100KB) to 1073741824 (1GB). +this option should be in the range of 1048576 (1 MB) to 1073741824 (1 GB). `logging.rotate.keepFiles:`:: [experimental] *Default: 7* The number of most recent rotated log files to keep on disk. Older files are deleted during log rotation. The default value is 7. The `logging.rotate.keepFiles` @@ -203,7 +203,7 @@ option has to be in the range of 2 to 1024 files. the `logging.rotate.usePolling` is enabled. That option has to be in the range of 5000 to 3600000 milliseconds. `logging.rotate.usePolling:`:: [experimental] *Default: false* By default we try to understand the best way to monitoring -the log file. However, there is some systems where it could not be always accurate. In those cases, if needed, +the log file and warning about it. Please be aware there are some systems where watch api is not accurate. In those cases, in order to get the feature working, the `polling` method could be used enabling that option. `logging.silent:`:: *Default: false* Set the value of this setting to `true` to diff --git a/src/legacy/server/config/schema.js b/src/legacy/server/config/schema.js index a24ffcbaaa49f..769d9ba311281 100644 --- a/src/legacy/server/config/schema.js +++ b/src/legacy/server/config/schema.js @@ -133,8 +133,8 @@ export default () => .keys({ enabled: Joi.boolean().default(false), everyBytes: Joi.number() - // > 100KB - .greater(102399) + // > 1MB + .greater(1048576) // < 1GB .less(1073741825) // 10MB diff --git a/src/legacy/server/logging/rotate/log_rotator.test.ts b/src/legacy/server/logging/rotate/log_rotator.test.ts index c2100546364d4..70842d42f5e1f 100644 --- a/src/legacy/server/logging/rotate/log_rotator.test.ts +++ b/src/legacy/server/logging/rotate/log_rotator.test.ts @@ -204,8 +204,8 @@ describe('LogRotator', () => { expect(logRotator.running).toBe(true); expect(logRotator.usePolling).toBe(false); - const usePolling = await logRotator._shouldUsePolling(); - expect(usePolling).toBe(false); + const shouldUsePolling = await logRotator._shouldUsePolling(); + expect(shouldUsePolling).toBe(false); await logRotator.stop(); }); @@ -231,7 +231,8 @@ describe('LogRotator', () => { await logRotator.start(); expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(true); + expect(logRotator.usePolling).toBe(false); + expect(logRotator.shouldUsePolling).toBe(true); await logRotator.stop(); }); @@ -257,7 +258,8 @@ describe('LogRotator', () => { await logRotator.start(); expect(logRotator.running).toBe(true); - expect(logRotator.usePolling).toBe(true); + expect(logRotator.usePolling).toBe(false); + expect(logRotator.shouldUsePolling).toBe(true); await logRotator.stop(); jest.useRealTimers(); diff --git a/src/legacy/server/logging/rotate/log_rotator.ts b/src/legacy/server/logging/rotate/log_rotator.ts index 3662910ca5a7b..eeb91fd0f2636 100644 --- a/src/legacy/server/logging/rotate/log_rotator.ts +++ b/src/legacy/server/logging/rotate/log_rotator.ts @@ -50,6 +50,7 @@ export class LogRotator { public usePolling: boolean; public pollingInterval: number; private stalkerUsePollingPolicyTestTimeout: NodeJS.Timeout | null; + public shouldUsePolling: boolean; constructor(config: KibanaConfig, server: Server) { this.config = config; @@ -64,6 +65,7 @@ export class LogRotator { this.stalker = null; this.usePolling = config.get('logging.rotate.usePolling'); this.pollingInterval = config.get('logging.rotate.pollingInterval'); + this.shouldUsePolling = false; this.stalkerUsePollingPolicyTestTimeout = null; } @@ -150,12 +152,20 @@ export class LogRotator { } async _startLogFileSizeMonitor() { - this.usePolling = await this._shouldUsePolling(); + this.usePolling = this.config.get('logging.rotate.usePolling'); + this.shouldUsePolling = await this._shouldUsePolling(); - if (this.usePolling && this.usePolling !== this.config.get('logging.rotate.usePolling')) { + if (this.usePolling && !this.shouldUsePolling) { this.log( ['warning', 'logging:rotate'], - 'The current environment does not support `fs.watch`. Falling back to polling using `fs.watchFile`' + 'Looks like your current environment support a faster algorithm then polling. You can try to disable `usePolling`' + ); + } + + if (!this.usePolling && this.shouldUsePolling) { + this.log( + ['error', 'logging:rotate'], + 'Looks like within your current environment you need to use polling in order to enable log rotator. Please enable `usePolling`' ); } From 6de7f2a62b2b078b703bbe6f18475909e1224f57 Mon Sep 17 00:00:00 2001 From: spalger Date: Mon, 23 Mar 2020 22:26:15 -0700 Subject: [PATCH 24/34] Revert "[APM] Collect telemetry about data/API performance (#51612)" This reverts commit 13baa5156151af258249afc6468f15b53fffeee0. --- src/dev/run_check_lockfile_symlinks.js | 2 - x-pack/legacy/plugins/apm/index.ts | 19 +- x-pack/legacy/plugins/apm/mappings.json | 773 +----------------- x-pack/legacy/plugins/apm/scripts/.gitignore | 1 - .../legacy/plugins/apm/scripts/package.json | 10 - .../apm/scripts/setup-kibana-security.js | 1 - .../apm/scripts/upload-telemetry-data.js | 21 - .../download-telemetry-template.ts | 26 - .../generate-sample-documents.ts | 124 --- .../scripts/upload-telemetry-data/index.ts | 208 ----- .../elasticsearch_fieldnames.test.ts.snap | 48 +- x-pack/plugins/apm/common/agent_name.ts | 44 +- .../apm/common/apm_saved_object_constants.ts | 10 +- .../common/elasticsearch_fieldnames.test.ts | 15 +- .../apm/common/elasticsearch_fieldnames.ts | 11 +- x-pack/plugins/apm/kibana.json | 7 +- x-pack/plugins/apm/server/index.ts | 6 +- .../lib/apm_telemetry/__test__/index.test.ts | 83 ++ .../collect_data_telemetry/index.ts | 77 -- .../collect_data_telemetry/tasks.ts | 725 ---------------- .../apm/server/lib/apm_telemetry/index.ts | 155 +--- .../apm/server/lib/apm_telemetry/types.ts | 118 --- .../server/lib/helpers/setup_request.test.ts | 13 - x-pack/plugins/apm/server/plugin.ts | 37 +- .../server/routes/create_api/index.test.ts | 1 - x-pack/plugins/apm/server/routes/services.ts | 16 + .../apm/typings/elasticsearch/aggregations.ts | 26 +- .../apm/typings/es_schemas/raw/error_raw.ts | 2 - .../typings/es_schemas/raw/fields/observer.ts | 10 - .../apm/typings/es_schemas/raw/span_raw.ts | 2 - .../typings/es_schemas/raw/transaction_raw.ts | 2 - 31 files changed, 206 insertions(+), 2387 deletions(-) delete mode 100644 x-pack/legacy/plugins/apm/scripts/.gitignore delete mode 100644 x-pack/legacy/plugins/apm/scripts/package.json delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts delete mode 100644 x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts create mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts delete mode 100644 x-pack/plugins/apm/server/lib/apm_telemetry/types.ts delete mode 100644 x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts diff --git a/src/dev/run_check_lockfile_symlinks.js b/src/dev/run_check_lockfile_symlinks.js index 6c6fc54638ee8..54a8cdf638a78 100644 --- a/src/dev/run_check_lockfile_symlinks.js +++ b/src/dev/run_check_lockfile_symlinks.js @@ -36,8 +36,6 @@ const IGNORE_FILE_GLOBS = [ '**/*fixtures*/**/*', // cypress isn't used in production, ignore it 'x-pack/legacy/plugins/apm/e2e/*', - // apm scripts aren't used in production, ignore them - 'x-pack/legacy/plugins/apm/scripts/*', ]; run(async ({ log }) => { diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 594e8a4a7af72..0107997f233fe 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -14,13 +14,7 @@ import mappings from './mappings.json'; export const apm: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ - require: [ - 'kibana', - 'elasticsearch', - 'xpack_main', - 'apm_oss', - 'task_manager' - ], + require: ['kibana', 'elasticsearch', 'xpack_main', 'apm_oss'], id: 'apm', configPrefix: 'xpack.apm', publicDir: resolve(__dirname, 'public'), @@ -77,10 +71,7 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(true), - - // telemetry - telemetryCollectionEnabled: Joi.boolean().default(true) + serviceMapEnabled: Joi.boolean().default(true) }).default(); }, @@ -116,12 +107,10 @@ export const apm: LegacyPluginInitializer = kibana => { } } }); + const apmPlugin = server.newPlatform.setup.plugins .apm as APMPluginContract; - - apmPlugin.registerLegacyAPI({ - server - }); + apmPlugin.registerLegacyAPI({ server }); } }); }; diff --git a/x-pack/legacy/plugins/apm/mappings.json b/x-pack/legacy/plugins/apm/mappings.json index ba4c7a89ceaa8..61bc90da28756 100644 --- a/x-pack/legacy/plugins/apm/mappings.json +++ b/x-pack/legacy/plugins/apm/mappings.json @@ -1,659 +1,20 @@ { - "apm-telemetry": { + "apm-services-telemetry": { "properties": { - "agents": { - "properties": { - "dotnet": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "name": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "go": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "java": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "type": "object" - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "js-base": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "type": "object" - }, - "language": { - "properties": { - "name": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "type": "object" - } - } - } - } - }, - "nodejs": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "name": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "python": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "ruby": { - "properties": { - "agent": { - "properties": { - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "service": { - "properties": { - "framework": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "language": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - }, - "runtime": { - "properties": { - "composite": { - "type": "keyword", - "ignore_above": 256 - }, - "name": { - "type": "keyword", - "ignore_above": 256 - }, - "version": { - "type": "keyword", - "ignore_above": 256 - } - } - } - } - } - } - }, - "rum-js": { - "properties": { - "agent": { - "type": "object" - }, - "service": { - "properties": { - "framework": { - "type": "object" - }, - "language": { - "type": "object" - }, - "runtime": { - "type": "object" - } - } - } - } - } - } - }, - "counts": { - "properties": { - "agent_configuration": { - "properties": { - "all": { - "type": "long" - } - } - }, - "error": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "max_error_groups_per_service": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "max_transaction_groups_per_service": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "metric": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "onboarding": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "services": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "sourcemap": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "span": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - }, - "traces": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "transaction": { - "properties": { - "1d": { - "type": "long" - }, - "all": { - "type": "long" - } - } - } - } - }, - "cardinality": { - "properties": { - "user_agent": { - "properties": { - "original": { - "properties": { - "all_agents": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "rum": { - "properties": { - "1d": { - "type": "long" - } - } - } - } - } - } - }, - "transaction": { - "properties": { - "name": { - "properties": { - "all_agents": { - "properties": { - "1d": { - "type": "long" - } - } - }, - "rum": { - "properties": { - "1d": { - "type": "long" - } - } - } - } - } - } - } - } - }, "has_any_services": { "type": "boolean" }, - "indices": { - "properties": { - "all": { - "properties": { - "total": { - "properties": { - "docs": { - "properties": { - "count": { - "type": "long" - } - } - }, - "store": { - "properties": { - "size_in_bytes": { - "type": "long" - } - } - } - } - } - } - }, - "shards": { - "properties": { - "total": { - "type": "long" - } - } - } - } - }, - "integrations": { - "properties": { - "ml": { - "properties": { - "all_jobs_count": { - "type": "long" - } - } - } - } - }, - "retainment": { - "properties": { - "error": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "metric": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "onboarding": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "span": { - "properties": { - "ms": { - "type": "long" - } - } - }, - "transaction": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, "services_per_agent": { "properties": { - "dotnet": { + "python": { "type": "long", "null_value": 0 }, - "go": { + "java": { "type": "long", "null_value": 0 }, - "java": { + "nodejs": { "type": "long", "null_value": 0 }, @@ -661,11 +22,11 @@ "type": "long", "null_value": 0 }, - "nodejs": { + "rum-js": { "type": "long", "null_value": 0 }, - "python": { + "dotnet": { "type": "long", "null_value": 0 }, @@ -673,131 +34,11 @@ "type": "long", "null_value": 0 }, - "rum-js": { + "go": { "type": "long", "null_value": 0 } } - }, - "tasks": { - "properties": { - "agent_configuration": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "agents": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "cardinality": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "groupings": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "indices_stats": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "integrations": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "processor_events": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "services": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - }, - "versions": { - "properties": { - "took": { - "properties": { - "ms": { - "type": "long" - } - } - } - } - } - } - }, - "version": { - "properties": { - "apm_server": { - "properties": { - "major": { - "type": "long" - }, - "minor": { - "type": "long" - }, - "patch": { - "type": "long" - } - } - } - } } } }, diff --git a/x-pack/legacy/plugins/apm/scripts/.gitignore b/x-pack/legacy/plugins/apm/scripts/.gitignore deleted file mode 100644 index 8ee01d321b721..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/.gitignore +++ /dev/null @@ -1 +0,0 @@ -yarn.lock diff --git a/x-pack/legacy/plugins/apm/scripts/package.json b/x-pack/legacy/plugins/apm/scripts/package.json deleted file mode 100644 index 9121449c53619..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "apm-scripts", - "version": "1.0.0", - "main": "index.js", - "license": "MIT", - "dependencies": { - "@octokit/rest": "^16.35.0", - "console-stamp": "^0.2.9" - } -} diff --git a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js index 61ba2fdc7f7e3..825c1a526fcc5 100644 --- a/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js +++ b/x-pack/legacy/plugins/apm/scripts/setup-kibana-security.js @@ -16,7 +16,6 @@ ******************************/ // compile typescript on the fly -// eslint-disable-next-line import/no-extraneous-dependencies require('@babel/register')({ extensions: ['.ts'], plugins: ['@babel/plugin-proposal-optional-chaining'], diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js deleted file mode 100644 index a99651c62dd7a..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// compile typescript on the fly -// eslint-disable-next-line import/no-extraneous-dependencies -require('@babel/register')({ - extensions: ['.ts'], - plugins: [ - '@babel/plugin-proposal-optional-chaining', - '@babel/plugin-proposal-nullish-coalescing-operator' - ], - presets: [ - '@babel/typescript', - ['@babel/preset-env', { targets: { node: 'current' } }] - ] -}); - -require('./upload-telemetry-data/index.ts'); diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts deleted file mode 100644 index dfed9223ef708..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/download-telemetry-template.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// @ts-ignore -import { Octokit } from '@octokit/rest'; - -export async function downloadTelemetryTemplate(octokit: Octokit) { - const file = await octokit.repos.getContents({ - owner: 'elastic', - repo: 'telemetry', - path: 'config/templates/xpack-phone-home.json', - // @ts-ignore - mediaType: { - format: 'application/vnd.github.VERSION.raw' - } - }); - - if (Array.isArray(file.data)) { - throw new Error('Expected single response, got array'); - } - - return JSON.parse(Buffer.from(file.data.content!, 'base64').toString()); -} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts deleted file mode 100644 index 8d76063a7fdf6..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/generate-sample-documents.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { DeepPartial } from 'utility-types'; -import { - merge, - omit, - defaultsDeep, - range, - mapValues, - isPlainObject, - flatten -} from 'lodash'; -import uuid from 'uuid'; -import { - CollectTelemetryParams, - collectDataTelemetry - // eslint-disable-next-line @kbn/eslint/no-restricted-paths -} from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; - -interface GenerateOptions { - days: number; - instances: number; - variation: { - min: number; - max: number; - }; -} - -const randomize = ( - value: unknown, - instanceVariation: number, - dailyGrowth: number -) => { - if (typeof value === 'boolean') { - return Math.random() > 0.5; - } - if (typeof value === 'number') { - return Math.round(instanceVariation * dailyGrowth * value); - } - return value; -}; - -const mapValuesDeep = ( - obj: Record, - iterator: (value: unknown, key: string, obj: Record) => unknown -): Record => - mapValues(obj, (val, key) => - isPlainObject(val) ? mapValuesDeep(val, iterator) : iterator(val, key!, obj) - ); - -export async function generateSampleDocuments( - options: DeepPartial & { - collectTelemetryParams: CollectTelemetryParams; - } -) { - const { collectTelemetryParams, ...preferredOptions } = options; - - const opts: GenerateOptions = defaultsDeep( - { - days: 100, - instances: 50, - variation: { - min: 0.1, - max: 4 - } - }, - preferredOptions - ); - - const sample = await collectDataTelemetry(collectTelemetryParams); - - console.log('Collected telemetry'); // eslint-disable-line no-console - console.log('\n' + JSON.stringify(sample, null, 2)); // eslint-disable-line no-console - - const dateOfScriptExecution = new Date(); - - return flatten( - range(0, opts.instances).map(instanceNo => { - const instanceId = uuid.v4(); - const defaults = { - cluster_uuid: instanceId, - stack_stats: { - kibana: { - versions: { - version: '8.0.0' - } - } - } - }; - - const instanceVariation = - Math.random() * (opts.variation.max - opts.variation.min) + - opts.variation.min; - - return range(0, opts.days).map(dayNo => { - const dailyGrowth = Math.pow(1.005, opts.days - 1 - dayNo); - - const timestamp = Date.UTC( - dateOfScriptExecution.getFullYear(), - dateOfScriptExecution.getMonth(), - -dayNo - ); - - const generated = mapValuesDeep(omit(sample, 'versions'), value => - randomize(value, instanceVariation, dailyGrowth) - ); - - return merge({}, defaults, { - timestamp, - stack_stats: { - kibana: { - plugins: { - apm: merge({}, sample, generated) - } - } - } - }); - }); - }) - ); -} diff --git a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts b/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts deleted file mode 100644 index bdc57eac412fc..0000000000000 --- a/x-pack/legacy/plugins/apm/scripts/upload-telemetry-data/index.ts +++ /dev/null @@ -1,208 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -// This script downloads the telemetry mapping, runs the APM telemetry tasks, -// generates a bunch of randomized data based on the downloaded sample, -// and uploads it to a cluster of your choosing in the same format as it is -// stored in the telemetry cluster. Its purpose is twofold: -// - Easier testing of the telemetry tasks -// - Validate whether we can run the queries we want to on the telemetry data - -import fs from 'fs'; -import path from 'path'; -// @ts-ignore -import { Octokit } from '@octokit/rest'; -import { merge, chunk, flatten, pick, identity } from 'lodash'; -import axios from 'axios'; -import yaml from 'js-yaml'; -import { Client } from 'elasticsearch'; -import { argv } from 'yargs'; -import { promisify } from 'util'; -import { Logger } from 'kibana/server'; -// @ts-ignore -import consoleStamp from 'console-stamp'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { CollectTelemetryParams } from '../../../../../plugins/apm/server/lib/apm_telemetry/collect_data_telemetry'; -import { downloadTelemetryTemplate } from './download-telemetry-template'; -import mapping from '../../mappings.json'; -import { generateSampleDocuments } from './generate-sample-documents'; - -consoleStamp(console, '[HH:MM:ss.l]'); - -const githubToken = process.env.GITHUB_TOKEN; - -if (!githubToken) { - throw new Error('GITHUB_TOKEN was not provided.'); -} - -const kibanaConfigDir = path.join(__filename, '../../../../../../../config'); -const kibanaDevConfig = path.join(kibanaConfigDir, 'kibana.dev.yml'); -const kibanaConfig = path.join(kibanaConfigDir, 'kibana.yml'); - -const xpackTelemetryIndexName = 'xpack-phone-home'; - -const loadedKibanaConfig = (yaml.safeLoad( - fs.readFileSync( - fs.existsSync(kibanaDevConfig) ? kibanaDevConfig : kibanaConfig, - 'utf8' - ) -) || {}) as {}; - -const cliEsCredentials = pick( - { - 'elasticsearch.username': process.env.ELASTICSEARCH_USERNAME, - 'elasticsearch.password': process.env.ELASTICSEARCH_PASSWORD, - 'elasticsearch.hosts': process.env.ELASTICSEARCH_HOST - }, - identity -) as { - 'elasticsearch.username': string; - 'elasticsearch.password': string; - 'elasticsearch.hosts': string; -}; - -const config = { - 'apm_oss.transactionIndices': 'apm-*', - 'apm_oss.metricsIndices': 'apm-*', - 'apm_oss.errorIndices': 'apm-*', - 'apm_oss.spanIndices': 'apm-*', - 'apm_oss.onboardingIndices': 'apm-*', - 'apm_oss.sourcemapIndices': 'apm-*', - 'elasticsearch.hosts': 'http://localhost:9200', - ...loadedKibanaConfig, - ...cliEsCredentials -}; - -async function uploadData() { - const octokit = new Octokit({ - auth: githubToken - }); - - const telemetryTemplate = await downloadTelemetryTemplate(octokit); - - const kibanaMapping = mapping['apm-telemetry']; - - const httpAuth = - config['elasticsearch.username'] && config['elasticsearch.password'] - ? { - username: config['elasticsearch.username'], - password: config['elasticsearch.password'] - } - : null; - - const client = new Client({ - host: config['elasticsearch.hosts'], - ...(httpAuth - ? { - httpAuth: `${httpAuth.username}:${httpAuth.password}` - } - : {}) - }); - - if (argv.clear) { - try { - await promisify(client.indices.delete.bind(client))({ - index: xpackTelemetryIndexName - }); - } catch (err) { - // 404 = index not found, totally okay - if (err.status !== 404) { - throw err; - } - } - } - - const axiosInstance = axios.create({ - baseURL: config['elasticsearch.hosts'], - ...(httpAuth ? { auth: httpAuth } : {}) - }); - - const newTemplate = merge(telemetryTemplate, { - settings: { - index: { mapping: { total_fields: { limit: 10000 } } } - } - }); - - // override apm mapping instead of merging - newTemplate.mappings.properties.stack_stats.properties.kibana.properties.plugins.properties.apm = kibanaMapping; - - await axiosInstance.put(`/_template/xpack-phone-home`, newTemplate); - - const sampleDocuments = await generateSampleDocuments({ - collectTelemetryParams: { - logger: (console as unknown) as Logger, - indices: { - ...config, - apmCustomLinkIndex: '.apm-custom-links', - apmAgentConfigurationIndex: '.apm-agent-configuration' - }, - search: body => { - return promisify(client.search.bind(client))({ - ...body, - requestTimeout: 120000 - }) as any; - }, - indicesStats: body => { - return promisify(client.indices.stats.bind(client))({ - ...body, - requestTimeout: 120000 - }) as any; - }, - transportRequest: (params => { - return axiosInstance[params.method](params.path); - }) as CollectTelemetryParams['transportRequest'] - } - }); - - const chunks = chunk(sampleDocuments, 250); - - await chunks.reduce>((prev, documents) => { - return prev.then(async () => { - const body = flatten( - documents.map(doc => [{ index: { _index: 'xpack-phone-home' } }, doc]) - ); - - return promisify(client.bulk.bind(client))({ - body, - refresh: true - }).then((response: any) => { - if (response.errors) { - const firstError = response.items.filter( - (item: any) => item.index.status >= 400 - )[0].index.error; - throw new Error(`Failed to upload documents: ${firstError.reason} `); - } - }); - }); - }, Promise.resolve()); -} - -uploadData() - .catch(e => { - if ('response' in e) { - if (typeof e.response === 'string') { - // eslint-disable-next-line no-console - console.log(e.response); - } else { - // eslint-disable-next-line no-console - console.log( - JSON.stringify( - e.response, - ['status', 'statusText', 'headers', 'data'], - 2 - ) - ); - } - } else { - // eslint-disable-next-line no-console - console.log(e); - } - process.exit(1); - }) - .then(() => { - // eslint-disable-next-line no-console - console.log('Finished uploading generated telemetry data'); - }); diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 5de82a9ee8788..897d4e979fce3 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -2,8 +2,6 @@ exports[`Error AGENT_NAME 1`] = `"java"`; -exports[`Error AGENT_VERSION 1`] = `"agent version"`; - exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; @@ -58,7 +56,7 @@ exports[`Error METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Error OBSERVER_LISTENING 1`] = `undefined`; -exports[`Error OBSERVER_VERSION_MAJOR 1`] = `8`; +exports[`Error OBSERVER_VERSION_MAJOR 1`] = `undefined`; exports[`Error PARENT_ID 1`] = `"parentId"`; @@ -70,20 +68,10 @@ exports[`Error SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Error SERVICE_FRAMEWORK_NAME 1`] = `undefined`; -exports[`Error SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; - -exports[`Error SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; - -exports[`Error SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; - exports[`Error SERVICE_NAME 1`] = `"service name"`; exports[`Error SERVICE_NODE_NAME 1`] = `undefined`; -exports[`Error SERVICE_RUNTIME_NAME 1`] = `undefined`; - -exports[`Error SERVICE_RUNTIME_VERSION 1`] = `undefined`; - exports[`Error SERVICE_VERSION 1`] = `undefined`; exports[`Error SPAN_ACTION 1`] = `undefined`; @@ -124,14 +112,10 @@ exports[`Error URL_FULL 1`] = `undefined`; exports[`Error USER_AGENT_NAME 1`] = `undefined`; -exports[`Error USER_AGENT_ORIGINAL 1`] = `undefined`; - exports[`Error USER_ID 1`] = `undefined`; exports[`Span AGENT_NAME 1`] = `"java"`; -exports[`Span AGENT_VERSION 1`] = `"agent version"`; - exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; @@ -186,7 +170,7 @@ exports[`Span METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Span OBSERVER_LISTENING 1`] = `undefined`; -exports[`Span OBSERVER_VERSION_MAJOR 1`] = `8`; +exports[`Span OBSERVER_VERSION_MAJOR 1`] = `undefined`; exports[`Span PARENT_ID 1`] = `"parentId"`; @@ -198,20 +182,10 @@ exports[`Span SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Span SERVICE_FRAMEWORK_NAME 1`] = `undefined`; -exports[`Span SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; - -exports[`Span SERVICE_LANGUAGE_NAME 1`] = `undefined`; - -exports[`Span SERVICE_LANGUAGE_VERSION 1`] = `undefined`; - exports[`Span SERVICE_NAME 1`] = `"service name"`; exports[`Span SERVICE_NODE_NAME 1`] = `undefined`; -exports[`Span SERVICE_RUNTIME_NAME 1`] = `undefined`; - -exports[`Span SERVICE_RUNTIME_VERSION 1`] = `undefined`; - exports[`Span SERVICE_VERSION 1`] = `undefined`; exports[`Span SPAN_ACTION 1`] = `"my action"`; @@ -252,14 +226,10 @@ exports[`Span URL_FULL 1`] = `undefined`; exports[`Span USER_AGENT_NAME 1`] = `undefined`; -exports[`Span USER_AGENT_ORIGINAL 1`] = `undefined`; - exports[`Span USER_ID 1`] = `undefined`; exports[`Transaction AGENT_NAME 1`] = `"java"`; -exports[`Transaction AGENT_VERSION 1`] = `"agent version"`; - exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; @@ -314,7 +284,7 @@ exports[`Transaction METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; exports[`Transaction OBSERVER_LISTENING 1`] = `undefined`; -exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `8`; +exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `undefined`; exports[`Transaction PARENT_ID 1`] = `"parentId"`; @@ -326,20 +296,10 @@ exports[`Transaction SERVICE_ENVIRONMENT 1`] = `undefined`; exports[`Transaction SERVICE_FRAMEWORK_NAME 1`] = `undefined`; -exports[`Transaction SERVICE_FRAMEWORK_VERSION 1`] = `undefined`; - -exports[`Transaction SERVICE_LANGUAGE_NAME 1`] = `"nodejs"`; - -exports[`Transaction SERVICE_LANGUAGE_VERSION 1`] = `"v1337"`; - exports[`Transaction SERVICE_NAME 1`] = `"service name"`; exports[`Transaction SERVICE_NODE_NAME 1`] = `undefined`; -exports[`Transaction SERVICE_RUNTIME_NAME 1`] = `undefined`; - -exports[`Transaction SERVICE_RUNTIME_VERSION 1`] = `undefined`; - exports[`Transaction SERVICE_VERSION 1`] = `undefined`; exports[`Transaction SPAN_ACTION 1`] = `undefined`; @@ -380,6 +340,4 @@ exports[`Transaction URL_FULL 1`] = `"http://www.elastic.co"`; exports[`Transaction USER_AGENT_NAME 1`] = `"Other"`; -exports[`Transaction USER_AGENT_ORIGINAL 1`] = `"test original"`; - exports[`Transaction USER_ID 1`] = `"1337"`; diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index 085828b729ea5..bb68eb88b8e18 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -4,40 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AgentName } from '../typings/es_schemas/ui/fields/agent'; - /* * Agent names can be any string. This list only defines the official agents * that we might want to target specifically eg. linking to their documentation * & telemetry reporting. Support additional agent types by appending * definitions in mappings.json (for telemetry), the AgentName type, and the - * AGENT_NAMES array. + * agentNames object. */ +import { AgentName } from '../typings/es_schemas/ui/fields/agent'; -export const AGENT_NAMES: AgentName[] = [ - 'java', - 'js-base', - 'rum-js', - 'dotnet', - 'go', - 'java', - 'nodejs', - 'python', - 'ruby' -]; +const agentNames: { [agentName in AgentName]: agentName } = { + python: 'python', + java: 'java', + nodejs: 'nodejs', + 'js-base': 'js-base', + 'rum-js': 'rum-js', + dotnet: 'dotnet', + ruby: 'ruby', + go: 'go' +}; -export function isAgentName(agentName: string): agentName is AgentName { - return AGENT_NAMES.includes(agentName as AgentName); +export function isAgentName(agentName: string): boolean { + return Object.values(agentNames).includes(agentName as AgentName); } -export function isRumAgentName( - agentName: string | undefined -): agentName is 'js-base' | 'rum-js' { - return agentName === 'js-base' || agentName === 'rum-js'; +export function isRumAgentName(agentName: string | undefined) { + return ( + agentName === agentNames['js-base'] || agentName === agentNames['rum-js'] + ); } -export function isJavaAgentName( - agentName: string | undefined -): agentName is 'java' { - return agentName === 'java'; +export function isJavaAgentName(agentName: string | undefined) { + return agentName === agentNames.java; } diff --git a/x-pack/plugins/apm/common/apm_saved_object_constants.ts b/x-pack/plugins/apm/common/apm_saved_object_constants.ts index 0529d90fe940a..ac43b700117c6 100644 --- a/x-pack/plugins/apm/common/apm_saved_object_constants.ts +++ b/x-pack/plugins/apm/common/apm_saved_object_constants.ts @@ -4,13 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -// the types have to match the names of the saved object mappings -// in /x-pack/legacy/plugins/apm/mappings.json +// APM Services telemetry +export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE = + 'apm-services-telemetry'; +export const APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID = 'apm-services-telemetry'; // APM indices export const APM_INDICES_SAVED_OBJECT_TYPE = 'apm-indices'; export const APM_INDICES_SAVED_OBJECT_ID = 'apm-indices'; - -// APM telemetry -export const APM_TELEMETRY_SAVED_OBJECT_TYPE = 'apm-telemetry'; -export const APM_TELEMETRY_SAVED_OBJECT_ID = 'apm-telemetry'; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts index 63fa749cd9f2c..1add2427d16a0 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.test.ts @@ -15,10 +15,7 @@ describe('Transaction', () => { const transaction: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: { - version: 'whatever', - version_major: 8 - }, + observer: 'whatever', agent: { name: 'java', version: 'agent version' @@ -66,10 +63,7 @@ describe('Span', () => { const span: AllowUnknownProperties = { '@timestamp': new Date().toString(), '@metadata': 'whatever', - observer: { - version: 'whatever', - version_major: 8 - }, + observer: 'whatever', agent: { name: 'java', version: 'agent version' @@ -113,10 +107,7 @@ describe('Span', () => { describe('Error', () => { const errorDoc: AllowUnknownProperties = { '@metadata': 'whatever', - observer: { - version: 'whatever', - version_major: 8 - }, + observer: 'whatever', agent: { name: 'java', version: 'agent version' diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index bc1b346f50da7..822201baddd88 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -4,24 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ +export const AGENT_NAME = 'agent.name'; export const SERVICE_NAME = 'service.name'; export const SERVICE_ENVIRONMENT = 'service.environment'; export const SERVICE_FRAMEWORK_NAME = 'service.framework.name'; -export const SERVICE_FRAMEWORK_VERSION = 'service.framework.version'; -export const SERVICE_LANGUAGE_NAME = 'service.language.name'; -export const SERVICE_LANGUAGE_VERSION = 'service.language.version'; -export const SERVICE_RUNTIME_NAME = 'service.runtime.name'; -export const SERVICE_RUNTIME_VERSION = 'service.runtime.version'; export const SERVICE_NODE_NAME = 'service.node.name'; export const SERVICE_VERSION = 'service.version'; - -export const AGENT_NAME = 'agent.name'; -export const AGENT_VERSION = 'agent.version'; - export const URL_FULL = 'url.full'; export const HTTP_REQUEST_METHOD = 'http.request.method'; export const USER_ID = 'user.id'; -export const USER_AGENT_ORIGINAL = 'user_agent.original'; export const USER_AGENT_NAME = 'user_agent.name'; export const DESTINATION_ADDRESS = 'destination.address'; diff --git a/x-pack/plugins/apm/kibana.json b/x-pack/plugins/apm/kibana.json index dadb1dff6d7a9..96579377c95e8 100644 --- a/x-pack/plugins/apm/kibana.json +++ b/x-pack/plugins/apm/kibana.json @@ -3,11 +3,8 @@ "server": true, "version": "8.0.0", "kibanaVersion": "kibana", - "configPath": [ - "xpack", - "apm" - ], + "configPath": ["xpack", "apm"], "ui": false, "requiredPlugins": ["apm_oss", "data", "home", "licensing"], - "optionalPlugins": ["cloud", "usageCollection", "taskManager"] + "optionalPlugins": ["cloud", "usageCollection"] } diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index 77655568a7e9c..8afdb9e99c1a3 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -29,8 +29,7 @@ export const config = { enabled: schema.boolean({ defaultValue: true }), transactionGroupBucketSize: schema.number({ defaultValue: 100 }), maxTraceItems: schema.number({ defaultValue: 1000 }) - }), - telemetryCollectionEnabled: schema.boolean({ defaultValue: true }) + }) }) }; @@ -63,8 +62,7 @@ export function mergeConfigs( 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, - 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern, - 'xpack.apm.telemetryCollectionEnabled': apmConfig.telemetryCollectionEnabled + 'xpack.apm.autocreateApmIndexPattern': apmConfig.autocreateApmIndexPattern }; } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts new file mode 100644 index 0000000000000..c45c74a791aee --- /dev/null +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/__test__/index.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectAttributes } from '../../../../../../../src/core/server'; +import { createApmTelementry, storeApmServicesTelemetry } from '../index'; +import { + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID +} from '../../../../common/apm_saved_object_constants'; + +describe('apm_telemetry', () => { + describe('createApmTelementry', () => { + it('should create a ApmTelemetry object with boolean flag and frequency map of the given list of AgentNames', () => { + const apmTelemetry = createApmTelementry([ + 'go', + 'nodejs', + 'go', + 'js-base' + ]); + expect(apmTelemetry.has_any_services).toBe(true); + expect(apmTelemetry.services_per_agent).toMatchObject({ + go: 2, + nodejs: 1, + 'js-base': 1 + }); + }); + it('should ignore undefined or unknown AgentName values', () => { + const apmTelemetry = createApmTelementry([ + 'go', + 'nodejs', + 'go', + 'js-base', + 'example-platform' as any, + undefined as any + ]); + expect(apmTelemetry.services_per_agent).toMatchObject({ + go: 2, + nodejs: 1, + 'js-base': 1 + }); + }); + }); + + describe('storeApmServicesTelemetry', () => { + let apmTelemetry: SavedObjectAttributes; + let savedObjectsClient: any; + + beforeEach(() => { + apmTelemetry = { + has_any_services: true, + services_per_agent: { + go: 2, + nodejs: 1, + 'js-base': 1 + } + }; + savedObjectsClient = { create: jest.fn() }; + }); + + it('should call savedObjectsClient create with the given ApmTelemetry object', () => { + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); + expect(savedObjectsClient.create.mock.calls[0][1]).toBe(apmTelemetry); + }); + + it('should call savedObjectsClient create with the apm-telemetry document type and ID', () => { + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); + expect(savedObjectsClient.create.mock.calls[0][0]).toBe( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE + ); + expect(savedObjectsClient.create.mock.calls[0][2].id).toBe( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID + ); + }); + + it('should call savedObjectsClient create with overwrite: true', () => { + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry); + expect(savedObjectsClient.create.mock.calls[0][2].overwrite).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts deleted file mode 100644 index 729ccb73d73f3..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { merge } from 'lodash'; -import { Logger, CallAPIOptions } from 'kibana/server'; -import { IndicesStatsParams, Client } from 'elasticsearch'; -import { - ESSearchRequest, - ESSearchResponse -} from '../../../../typings/elasticsearch'; -import { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; -import { tasks } from './tasks'; -import { APMDataTelemetry } from '../types'; - -type TelemetryTaskExecutor = (params: { - indices: ApmIndicesConfig; - search( - params: TSearchRequest - ): Promise>; - indicesStats( - params: IndicesStatsParams, - options?: CallAPIOptions - ): ReturnType; - transportRequest: (params: { - path: string; - method: 'get'; - }) => Promise; -}) => Promise; - -export interface TelemetryTask { - name: string; - executor: TelemetryTaskExecutor; -} - -export type CollectTelemetryParams = Parameters[0] & { - logger: Logger; -}; - -export function collectDataTelemetry({ - search, - indices, - logger, - indicesStats, - transportRequest -}: CollectTelemetryParams) { - return tasks.reduce((prev, task) => { - return prev.then(async data => { - logger.debug(`Executing APM telemetry task ${task.name}`); - try { - const time = process.hrtime(); - const next = await task.executor({ - search, - indices, - indicesStats, - transportRequest - }); - const took = process.hrtime(time); - - return merge({}, data, next, { - tasks: { - [task.name]: { - took: { - ms: Math.round(took[0] * 1000 + took[1] / 1e6) - } - } - } - }); - } catch (err) { - logger.warn(`Failed executing APM telemetry task ${task.name}`); - logger.warn(err); - return data; - } - }); - }, Promise.resolve({} as APMDataTelemetry)); -} diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts deleted file mode 100644 index 415076b6ae116..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts +++ /dev/null @@ -1,725 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { flatten, merge, sortBy, sum } from 'lodash'; -import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; -import { AGENT_NAMES } from '../../../../common/agent_name'; -import { Transaction } from '../../../../typings/es_schemas/ui/transaction'; -import { - PROCESSOR_EVENT, - SERVICE_NAME, - AGENT_NAME, - AGENT_VERSION, - ERROR_GROUP_ID, - TRANSACTION_NAME, - PARENT_ID, - SERVICE_FRAMEWORK_NAME, - SERVICE_FRAMEWORK_VERSION, - SERVICE_LANGUAGE_NAME, - SERVICE_LANGUAGE_VERSION, - SERVICE_RUNTIME_NAME, - SERVICE_RUNTIME_VERSION, - USER_AGENT_ORIGINAL -} from '../../../../common/elasticsearch_fieldnames'; -import { Span } from '../../../../typings/es_schemas/ui/span'; -import { APMError } from '../../../../typings/es_schemas/ui/apm_error'; -import { TelemetryTask } from '.'; -import { APMTelemetry } from '../types'; - -const TIME_RANGES = ['1d', 'all'] as const; -type TimeRange = typeof TIME_RANGES[number]; - -export const tasks: TelemetryTask[] = [ - { - name: 'processor_events', - executor: async ({ indices, search }) => { - const indicesByProcessorEvent = { - error: indices['apm_oss.errorIndices'], - metric: indices['apm_oss.metricsIndices'], - span: indices['apm_oss.spanIndices'], - transaction: indices['apm_oss.transactionIndices'], - onboarding: indices['apm_oss.onboardingIndices'], - sourcemap: indices['apm_oss.sourcemapIndices'] - }; - - type ProcessorEvent = keyof typeof indicesByProcessorEvent; - - const jobs: Array<{ - processorEvent: ProcessorEvent; - timeRange: TimeRange; - }> = flatten( - (Object.keys( - indicesByProcessorEvent - ) as ProcessorEvent[]).map(processorEvent => - TIME_RANGES.map(timeRange => ({ processorEvent, timeRange })) - ) - ); - - const allData = await jobs.reduce((prevJob, current) => { - return prevJob.then(async data => { - const { processorEvent, timeRange } = current; - - const response = await search({ - index: indicesByProcessorEvent[processorEvent], - body: { - size: 1, - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: processorEvent } }, - ...(timeRange !== 'all' - ? [ - { - range: { - '@timestamp': { - gte: `now-${timeRange}` - } - } - } - ] - : []) - ] - } - }, - sort: { - '@timestamp': 'asc' - }, - _source: ['@timestamp'], - track_total_hits: true - } - }); - - const event = response.hits.hits[0]?._source as { - '@timestamp': number; - }; - - return merge({}, data, { - counts: { - [processorEvent]: { - [timeRange]: response.hits.total.value - } - }, - ...(timeRange === 'all' && event - ? { - retainment: { - [processorEvent]: { - ms: - new Date().getTime() - - new Date(event['@timestamp']).getTime() - } - } - } - : {}) - }); - }); - }, Promise.resolve({} as Record> }>)); - - return allData; - } - }, - { - name: 'agent_configuration', - executor: async ({ indices, search }) => { - const agentConfigurationCount = ( - await search({ - index: indices.apmAgentConfigurationIndex, - body: { - size: 0, - track_total_hits: true - } - }) - ).hits.total.value; - - return { - counts: { - agent_configuration: { - all: agentConfigurationCount - } - } - }; - } - }, - { - name: 'services', - executor: async ({ indices, search }) => { - const servicesPerAgent = await AGENT_NAMES.reduce( - (prevJob, agentName) => { - return prevJob.then(async data => { - const response = await search({ - index: [ - indices['apm_oss.errorIndices'], - indices['apm_oss.spanIndices'], - indices['apm_oss.metricsIndices'], - indices['apm_oss.transactionIndices'] - ], - body: { - size: 0, - query: { - bool: { - filter: [ - { - term: { - [AGENT_NAME]: agentName - } - }, - { - range: { - '@timestamp': { - gte: 'now-1d' - } - } - } - ] - } - }, - aggs: { - services: { - cardinality: { - field: SERVICE_NAME - } - } - } - } - }); - - return { - ...data, - [agentName]: response.aggregations?.services.value || 0 - }; - }); - }, - Promise.resolve({} as Record) - ); - - return { - has_any_services: sum(Object.values(servicesPerAgent)) > 0, - services_per_agent: servicesPerAgent - }; - } - }, - { - name: 'versions', - executor: async ({ search, indices }) => { - const response = await search({ - index: [ - indices['apm_oss.transactionIndices'], - indices['apm_oss.spanIndices'], - indices['apm_oss.errorIndices'] - ], - terminateAfter: 1, - body: { - query: { - exists: { - field: 'observer.version' - } - }, - size: 1, - sort: { - '@timestamp': 'desc' - } - } - }); - - const hit = response.hits.hits[0]?._source as Pick< - Transaction | Span | APMError, - 'observer' - >; - - if (!hit || !hit.observer?.version) { - return {}; - } - - const [major, minor, patch] = hit.observer.version - .split('.') - .map(part => Number(part)); - - return { - versions: { - apm_server: { - major, - minor, - patch - } - } - }; - } - }, - { - name: 'groupings', - executor: async ({ search, indices }) => { - const range1d = { range: { '@timestamp': { gte: 'now-1d' } } }; - const errorGroupsCount = ( - await search({ - index: indices['apm_oss.errorIndices'], - body: { - size: 0, - query: { - bool: { - filter: [{ term: { [PROCESSOR_EVENT]: 'error' } }, range1d] - } - }, - aggs: { - top_service: { - terms: { - field: SERVICE_NAME, - order: { - error_groups: 'desc' - }, - size: 1 - }, - aggs: { - error_groups: { - cardinality: { - field: ERROR_GROUP_ID - } - } - } - } - } - } - }) - ).aggregations?.top_service.buckets[0]?.error_groups.value; - - const transactionGroupsCount = ( - await search({ - index: indices['apm_oss.transactionIndices'], - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - range1d - ] - } - }, - aggs: { - top_service: { - terms: { - field: SERVICE_NAME, - order: { - transaction_groups: 'desc' - }, - size: 1 - }, - aggs: { - transaction_groups: { - cardinality: { - field: TRANSACTION_NAME - } - } - } - } - } - } - }) - ).aggregations?.top_service.buckets[0]?.transaction_groups.value; - - const tracesPerDayCount = ( - await search({ - index: indices['apm_oss.transactionIndices'], - body: { - query: { - bool: { - filter: [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - range1d - ], - must_not: { - exists: { field: PARENT_ID } - } - } - }, - track_total_hits: true, - size: 0 - } - }) - ).hits.total.value; - - const servicesCount = ( - await search({ - index: [ - indices['apm_oss.transactionIndices'], - indices['apm_oss.errorIndices'], - indices['apm_oss.metricsIndices'] - ], - body: { - size: 0, - query: { - bool: { - filter: [range1d] - } - }, - aggs: { - service_name: { - cardinality: { - field: SERVICE_NAME - } - } - } - } - }) - ).aggregations?.service_name.value; - - return { - counts: { - max_error_groups_per_service: { - '1d': errorGroupsCount || 0 - }, - max_transaction_groups_per_service: { - '1d': transactionGroupsCount || 0 - }, - traces: { - '1d': tracesPerDayCount || 0 - }, - services: { - '1d': servicesCount || 0 - } - } - }; - } - }, - { - name: 'integrations', - executor: async ({ transportRequest }) => { - const apmJobs = ['*-high_mean_response_time']; - - const response = (await transportRequest({ - method: 'get', - path: `/_ml/anomaly_detectors/${apmJobs.join(',')}` - })) as { data?: { count: number } }; - - return { - integrations: { - ml: { - all_jobs_count: response.data?.count ?? 0 - } - } - }; - } - }, - { - name: 'agents', - executor: async ({ search, indices }) => { - const size = 3; - - const agentData = await AGENT_NAMES.reduce(async (prevJob, agentName) => { - const data = await prevJob; - - const response = await search({ - index: [ - indices['apm_oss.errorIndices'], - indices['apm_oss.metricsIndices'], - indices['apm_oss.transactionIndices'] - ], - body: { - size: 0, - query: { - bool: { - filter: [ - { term: { [AGENT_NAME]: agentName } }, - { range: { '@timestamp': { gte: 'now-1d' } } } - ] - } - }, - sort: { - '@timestamp': 'desc' - }, - aggs: { - [AGENT_VERSION]: { - terms: { - field: AGENT_VERSION, - size - } - }, - [SERVICE_FRAMEWORK_NAME]: { - terms: { - field: SERVICE_FRAMEWORK_NAME, - size - }, - aggs: { - [SERVICE_FRAMEWORK_VERSION]: { - terms: { - field: SERVICE_FRAMEWORK_VERSION, - size - } - } - } - }, - [SERVICE_FRAMEWORK_VERSION]: { - terms: { - field: SERVICE_FRAMEWORK_VERSION, - size - } - }, - [SERVICE_LANGUAGE_NAME]: { - terms: { - field: SERVICE_LANGUAGE_NAME, - size - }, - aggs: { - [SERVICE_LANGUAGE_VERSION]: { - terms: { - field: SERVICE_LANGUAGE_VERSION, - size - } - } - } - }, - [SERVICE_LANGUAGE_VERSION]: { - terms: { - field: SERVICE_LANGUAGE_VERSION, - size - } - }, - [SERVICE_RUNTIME_NAME]: { - terms: { - field: SERVICE_RUNTIME_NAME, - size - }, - aggs: { - [SERVICE_RUNTIME_VERSION]: { - terms: { - field: SERVICE_RUNTIME_VERSION, - size - } - } - } - }, - [SERVICE_RUNTIME_VERSION]: { - terms: { - field: SERVICE_RUNTIME_VERSION, - size - } - } - } - } - }); - - const { aggregations } = response; - - if (!aggregations) { - return data; - } - - const toComposite = ( - outerKey: string | number, - innerKey: string | number - ) => `${outerKey}/${innerKey}`; - - return { - ...data, - [agentName]: { - agent: { - version: aggregations[AGENT_VERSION].buckets.map( - bucket => bucket.key as string - ) - }, - service: { - framework: { - name: aggregations[SERVICE_FRAMEWORK_NAME].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - version: aggregations[SERVICE_FRAMEWORK_VERSION].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - composite: sortBy( - flatten( - aggregations[SERVICE_FRAMEWORK_NAME].buckets.map(bucket => - bucket[SERVICE_FRAMEWORK_VERSION].buckets.map( - versionBucket => ({ - doc_count: versionBucket.doc_count, - name: toComposite(bucket.key, versionBucket.key) - }) - ) - ) - ), - 'doc_count' - ) - .reverse() - .slice(0, size) - .map(composite => composite.name) - }, - language: { - name: aggregations[SERVICE_LANGUAGE_NAME].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - version: aggregations[SERVICE_LANGUAGE_VERSION].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - composite: sortBy( - flatten( - aggregations[SERVICE_LANGUAGE_NAME].buckets.map(bucket => - bucket[SERVICE_LANGUAGE_VERSION].buckets.map( - versionBucket => ({ - doc_count: versionBucket.doc_count, - name: toComposite(bucket.key, versionBucket.key) - }) - ) - ) - ), - 'doc_count' - ) - .reverse() - .slice(0, size) - .map(composite => composite.name) - }, - runtime: { - name: aggregations[SERVICE_RUNTIME_NAME].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - version: aggregations[SERVICE_RUNTIME_VERSION].buckets - .map(bucket => bucket.key as string) - .slice(0, size), - composite: sortBy( - flatten( - aggregations[SERVICE_RUNTIME_NAME].buckets.map(bucket => - bucket[SERVICE_RUNTIME_VERSION].buckets.map( - versionBucket => ({ - doc_count: versionBucket.doc_count, - name: toComposite(bucket.key, versionBucket.key) - }) - ) - ) - ), - 'doc_count' - ) - .reverse() - .slice(0, size) - .map(composite => composite.name) - } - } - } - }; - }, Promise.resolve({} as APMTelemetry['agents'])); - - return { - agents: agentData - }; - } - }, - { - name: 'indices_stats', - executor: async ({ indicesStats, indices }) => { - const response = await indicesStats({ - index: [ - indices.apmAgentConfigurationIndex, - indices['apm_oss.errorIndices'], - indices['apm_oss.metricsIndices'], - indices['apm_oss.onboardingIndices'], - indices['apm_oss.sourcemapIndices'], - indices['apm_oss.spanIndices'], - indices['apm_oss.transactionIndices'] - ] - }); - - return { - indices: { - shards: { - total: response._shards.total - }, - all: { - total: { - docs: { - count: response._all.total.docs.count - }, - store: { - size_in_bytes: response._all.total.store.size_in_bytes - } - } - } - } - }; - } - }, - { - name: 'cardinality', - executor: async ({ search }) => { - const allAgentsCardinalityResponse = await search({ - body: { - size: 0, - query: { - bool: { - filter: [{ range: { '@timestamp': { gte: 'now-1d' } } }] - } - }, - aggs: { - [TRANSACTION_NAME]: { - cardinality: { - field: TRANSACTION_NAME - } - }, - [USER_AGENT_ORIGINAL]: { - cardinality: { - field: USER_AGENT_ORIGINAL - } - } - } - } - }); - - const rumAgentCardinalityResponse = await search({ - body: { - size: 0, - query: { - bool: { - filter: [ - { range: { '@timestamp': { gte: 'now-1d' } } }, - { terms: { [AGENT_NAME]: ['rum-js', 'js-base'] } } - ] - } - }, - aggs: { - [TRANSACTION_NAME]: { - cardinality: { - field: TRANSACTION_NAME - } - }, - [USER_AGENT_ORIGINAL]: { - cardinality: { - field: USER_AGENT_ORIGINAL - } - } - } - } - }); - - return { - cardinality: { - transaction: { - name: { - all_agents: { - '1d': - allAgentsCardinalityResponse.aggregations?.[TRANSACTION_NAME] - .value - }, - rum: { - '1d': - rumAgentCardinalityResponse.aggregations?.[TRANSACTION_NAME] - .value - } - } - }, - user_agent: { - original: { - all_agents: { - '1d': - allAgentsCardinalityResponse.aggregations?.[ - USER_AGENT_ORIGINAL - ].value - }, - rum: { - '1d': - rumAgentCardinalityResponse.aggregations?.[ - USER_AGENT_ORIGINAL - ].value - } - } - } - } - }; - } - } -]; diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index c80057a2894dc..a2b0494730826 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -3,127 +3,60 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, Logger } from 'src/core/server'; -import { Observable } from 'rxjs'; -import { take } from 'rxjs/operators'; -import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { - TaskManagerStartContract, - TaskManagerSetupContract -} from '../../../../task_manager/server'; -import { getApmIndices } from '../settings/apm_indices/get_apm_indices'; + +import { countBy } from 'lodash'; +import { SavedObjectAttributes } from '../../../../../../src/core/server'; +import { isAgentName } from '../../../common/agent_name'; import { - APM_TELEMETRY_SAVED_OBJECT_ID, - APM_TELEMETRY_SAVED_OBJECT_TYPE + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID } from '../../../common/apm_saved_object_constants'; -import { - collectDataTelemetry, - CollectTelemetryParams -} from './collect_data_telemetry'; -import { APMConfig } from '../..'; -import { getInternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; - -const APM_TELEMETRY_TASK_NAME = 'apm-telemetry-task'; - -export async function createApmTelemetry({ - core, - config$, - usageCollector, - taskManager, - logger -}: { - core: CoreSetup; - config$: Observable; - usageCollector: UsageCollectionSetup; - taskManager: TaskManagerSetupContract; - logger: Logger; -}) { - const savedObjectsClient = await getInternalSavedObjectsClient(core); - - const collectAndStore = async () => { - const config = await config$.pipe(take(1)).toPromise(); - const esClient = core.elasticsearch.dataClient; - - const indices = await getApmIndices({ - config, - savedObjectsClient - }); - - const search = esClient.callAsInternalUser.bind( - esClient, - 'search' - ) as CollectTelemetryParams['search']; - - const indicesStats = esClient.callAsInternalUser.bind( - esClient, - 'indices.stats' - ) as CollectTelemetryParams['indicesStats']; - - const transportRequest = esClient.callAsInternalUser.bind( - esClient, - 'transport.request' - ) as CollectTelemetryParams['transportRequest']; - - const dataTelemetry = await collectDataTelemetry({ - search, - indices, - logger, - indicesStats, - transportRequest - }); - - await savedObjectsClient.create( - APM_TELEMETRY_SAVED_OBJECT_TYPE, - dataTelemetry, - { id: APM_TELEMETRY_SAVED_OBJECT_TYPE, overwrite: true } - ); +import { UsageCollectionSetup } from '../../../../../../src/plugins/usage_collection/server'; +import { InternalSavedObjectsClient } from '../helpers/get_internal_saved_objects_client'; + +export function createApmTelementry( + agentNames: string[] = [] +): SavedObjectAttributes { + const validAgentNames = agentNames.filter(isAgentName); + return { + has_any_services: validAgentNames.length > 0, + services_per_agent: countBy(validAgentNames) }; +} - taskManager.registerTaskDefinitions({ - [APM_TELEMETRY_TASK_NAME]: { - title: 'Collect APM telemetry', - type: APM_TELEMETRY_TASK_NAME, - createTaskRunner: () => { - return { - run: async () => { - await collectAndStore(); - } - }; - } +export async function storeApmServicesTelemetry( + savedObjectsClient: InternalSavedObjectsClient, + apmTelemetry: SavedObjectAttributes +) { + return savedObjectsClient.create( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + apmTelemetry, + { + id: APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID, + overwrite: true } - }); + ); +} - const collector = usageCollector.makeUsageCollector({ +export function makeApmUsageCollector( + usageCollector: UsageCollectionSetup, + savedObjectsRepository: InternalSavedObjectsClient +) { + const apmUsageCollector = usageCollector.makeUsageCollector({ type: 'apm', fetch: async () => { - const data = ( - await savedObjectsClient.get( - APM_TELEMETRY_SAVED_OBJECT_TYPE, - APM_TELEMETRY_SAVED_OBJECT_ID - ) - ).attributes; - - return data; + try { + const apmTelemetrySavedObject = await savedObjectsRepository.get( + APM_SERVICES_TELEMETRY_SAVED_OBJECT_TYPE, + APM_SERVICES_TELEMETRY_SAVED_OBJECT_ID + ); + return apmTelemetrySavedObject.attributes; + } catch (err) { + return createApmTelementry(); + } }, isReady: () => true }); - usageCollector.registerCollector(collector); - - core.getStartServices().then(([coreStart, pluginsStart]) => { - const { taskManager: taskManagerStart } = pluginsStart as { - taskManager: TaskManagerStartContract; - }; - - taskManagerStart.ensureScheduled({ - id: APM_TELEMETRY_TASK_NAME, - taskType: APM_TELEMETRY_TASK_NAME, - schedule: { - interval: '720m' - }, - scope: ['apm'], - params: {}, - state: {} - }); - }); + usageCollector.registerCollector(apmUsageCollector); } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts deleted file mode 100644 index f68dc517a2227..0000000000000 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { DeepPartial } from 'utility-types'; -import { AgentName } from '../../../typings/es_schemas/ui/fields/agent'; - -export interface TimeframeMap { - '1d': number; - all: number; -} - -export type TimeframeMap1d = Pick; -export type TimeframeMapAll = Pick; - -export type APMDataTelemetry = DeepPartial<{ - has_any_services: boolean; - services_per_agent: Record; - versions: { - apm_server: { - minor: number; - major: number; - patch: number; - }; - }; - counts: { - transaction: TimeframeMap; - span: TimeframeMap; - error: TimeframeMap; - metric: TimeframeMap; - sourcemap: TimeframeMap; - onboarding: TimeframeMap; - agent_configuration: TimeframeMapAll; - max_transaction_groups_per_service: TimeframeMap; - max_error_groups_per_service: TimeframeMap; - traces: TimeframeMap; - services: TimeframeMap; - }; - cardinality: { - user_agent: { - original: { - all_agents: TimeframeMap1d; - rum: TimeframeMap1d; - }; - }; - transaction: { - name: { - all_agents: TimeframeMap1d; - rum: TimeframeMap1d; - }; - }; - }; - retainment: Record< - 'span' | 'transaction' | 'error' | 'metric' | 'sourcemap' | 'onboarding', - { ms: number } - >; - integrations: { - ml: { - all_jobs_count: number; - }; - }; - agents: Record< - AgentName, - { - agent: { - version: string[]; - }; - service: { - framework: { - name: string[]; - version: string[]; - composite: string[]; - }; - language: { - name: string[]; - version: string[]; - composite: string[]; - }; - runtime: { - name: string[]; - version: string[]; - composite: string[]; - }; - }; - } - >; - indices: { - shards: { - total: number; - }; - all: { - total: { - docs: { - count: number; - }; - store: { - size_in_bytes: number; - }; - }; - }; - }; - tasks: Record< - | 'processor_events' - | 'agent_configuration' - | 'services' - | 'versions' - | 'groupings' - | 'integrations' - | 'agents' - | 'indices_stats' - | 'cardinality', - { took: { ms: number } } - >; -}>; - -export type APMTelemetry = APMDataTelemetry; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts index 8e8cf698a84cf..40a2a0e7216a0 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.test.ts @@ -39,19 +39,6 @@ function getMockRequest() { _debug: false } }, - __LEGACY: { - server: { - plugins: { - elasticsearch: { - getCluster: jest.fn().mockReturnValue({ callWithInternalUser: {} }) - } - }, - savedObjects: { - SavedObjectsClient: jest.fn(), - getSavedObjectsRepository: jest.fn() - } - } - }, core: { elasticsearch: { dataClient: { diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index a29b9399d8435..db14730f802a9 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -8,9 +8,9 @@ import { Observable, combineLatest, AsyncSubject } from 'rxjs'; import { map, take } from 'rxjs/operators'; import { Server } from 'hapi'; import { once } from 'lodash'; -import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; -import { TaskManagerSetupContract } from '../../task_manager/server'; +import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; +import { makeApmUsageCollector } from './lib/apm_telemetry'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { createApmCustomLinkIndex } from './lib/settings/custom_link/create_custom_link_index'; import { createApmApi } from './routes/create_apm_api'; @@ -21,7 +21,6 @@ import { tutorialProvider } from './tutorial'; import { CloudSetup } from '../../cloud/server'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { LicensingPluginSetup } from '../../licensing/public'; -import { createApmTelemetry } from './lib/apm_telemetry'; export interface LegacySetup { server: Server; @@ -48,10 +47,9 @@ export class APMPlugin implements Plugin { licensing: LicensingPluginSetup; cloud?: CloudSetup; usageCollection?: UsageCollectionSetup; - taskManager?: TaskManagerSetupContract; } ) { - const logger = this.initContext.logger.get(); + const logger = this.initContext.logger.get('apm'); const config$ = this.initContext.config.create(); const mergedConfig$ = combineLatest(plugins.apm_oss.config$, config$).pipe( map(([apmOssConfig, apmConfig]) => mergeConfigs(apmOssConfig, apmConfig)) @@ -63,20 +61,6 @@ export class APMPlugin implements Plugin { const currentConfig = await mergedConfig$.pipe(take(1)).toPromise(); - if ( - plugins.taskManager && - plugins.usageCollection && - currentConfig['xpack.apm.telemetryCollectionEnabled'] - ) { - createApmTelemetry({ - core, - config$: mergedConfig$, - usageCollector: plugins.usageCollection, - taskManager: plugins.taskManager, - logger - }); - } - // create agent configuration index without blocking setup lifecycle createApmAgentConfigurationIndex({ esClient: core.elasticsearch.dataClient, @@ -105,6 +89,18 @@ export class APMPlugin implements Plugin { }) ); + const usageCollection = plugins.usageCollection; + if (usageCollection) { + getInternalSavedObjectsClient(core) + .then(savedObjectsClient => { + makeApmUsageCollector(usageCollection, savedObjectsClient); + }) + .catch(error => { + logger.error('Unable to initialize use collection'); + logger.error(error.message); + }); + } + return { config$: mergedConfig$, registerLegacyAPI: once((__LEGACY: LegacySetup) => { @@ -119,7 +115,6 @@ export class APMPlugin implements Plugin { }; } - public async start() {} - + public start() {} public stop() {} } diff --git a/x-pack/plugins/apm/server/routes/create_api/index.test.ts b/x-pack/plugins/apm/server/routes/create_api/index.test.ts index 312dae1d1f9d2..e639bb5101e2f 100644 --- a/x-pack/plugins/apm/server/routes/create_api/index.test.ts +++ b/x-pack/plugins/apm/server/routes/create_api/index.test.ts @@ -36,7 +36,6 @@ const getCoreMock = () => { put, createRouter, context: { - measure: () => undefined, config$: new BehaviorSubject({} as APMConfig), logger: ({ error: jest.fn() diff --git a/x-pack/plugins/apm/server/routes/services.ts b/x-pack/plugins/apm/server/routes/services.ts index 1c6561ee24c93..2d4fae9d2707a 100644 --- a/x-pack/plugins/apm/server/routes/services.ts +++ b/x-pack/plugins/apm/server/routes/services.ts @@ -5,6 +5,11 @@ */ import * as t from 'io-ts'; +import { AgentName } from '../../typings/es_schemas/ui/fields/agent'; +import { + createApmTelementry, + storeApmServicesTelemetry +} from '../lib/apm_telemetry'; import { setupRequest } from '../lib/helpers/setup_request'; import { getServiceAgentName } from '../lib/services/get_service_agent_name'; import { getServices } from '../lib/services/get_services'; @@ -13,6 +18,7 @@ import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadat import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getServiceAnnotations } from '../lib/services/annotations'; +import { getInternalSavedObjectsClient } from '../lib/helpers/get_internal_saved_objects_client'; export const servicesRoute = createRoute(core => ({ path: '/api/apm/services', @@ -23,6 +29,16 @@ export const servicesRoute = createRoute(core => ({ const setup = await setupRequest(context, request); const services = await getServices(setup); + // Store telemetry data derived from services + const agentNames = services.items.map( + ({ agentName }) => agentName as AgentName + ); + const apmTelemetry = createApmTelementry(agentNames); + const savedObjectsClient = await getInternalSavedObjectsClient(core); + storeApmServicesTelemetry(savedObjectsClient, apmTelemetry).catch(error => { + context.logger.error(error.message); + }); + return services; } })); diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 8a8d256cf4273..6d3620f11a87b 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -126,16 +126,6 @@ export interface AggregationOptionsByType { combine_script: Script; reduce_script: Script; }; - date_range: { - field: string; - format?: string; - ranges: Array< - | { from: string | number } - | { to: string | number } - | { from: string | number; to: string | number } - >; - keyed?: boolean; - }; } type AggregationType = keyof AggregationOptionsByType; @@ -146,15 +136,6 @@ type AggregationOptionsMap = Unionize< } > & { aggs?: AggregationInputMap }; -interface DateRangeBucket { - key: string; - to?: number; - from?: number; - to_as_string?: string; - from_as_string?: string; - doc_count: number; -} - export interface AggregationInputMap { [key: string]: AggregationOptionsMap; } @@ -295,11 +276,6 @@ interface AggregationResponsePart< scripted_metric: { value: unknown; }; - date_range: { - buckets: TAggregationOptionsMap extends { date_range: { keyed: true } } - ? Record - : { buckets: DateRangeBucket[] }; - }; } // Type for debugging purposes. If you see an error in AggregationResponseMap @@ -309,7 +285,7 @@ interface AggregationResponsePart< // type MissingAggregationResponseTypes = Exclude< // AggregationType, -// keyof AggregationResponsePart<{}, unknown> +// keyof AggregationResponsePart<{}> // >; export type AggregationResponseMap< diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts index 8e49d02beb908..daf65e44980b6 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/error_raw.ts @@ -15,7 +15,6 @@ import { Service } from './fields/service'; import { IStackframe } from './fields/stackframe'; import { Url } from './fields/url'; import { User } from './fields/user'; -import { Observer } from './fields/observer'; interface Processor { name: 'error'; @@ -62,5 +61,4 @@ export interface ErrorRaw extends APMBaseDoc { service: Service; url?: Url; user?: User; - observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts deleted file mode 100644 index 42843130ec47f..0000000000000 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -export interface Observer { - version: string; - version_major: number; -} diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts index 4d5d2c5c4a12e..dbd9e7ede4256 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/span_raw.ts @@ -6,7 +6,6 @@ import { APMBaseDoc } from './apm_base_doc'; import { IStackframe } from './fields/stackframe'; -import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -51,5 +50,4 @@ export interface SpanRaw extends APMBaseDoc { transaction?: { id: string; }; - observer?: Observer; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index b8ebb4cf8da51..3673f1f13c403 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -15,7 +15,6 @@ import { Service } from './fields/service'; import { Url } from './fields/url'; import { User } from './fields/user'; import { UserAgent } from './fields/user_agent'; -import { Observer } from './fields/observer'; interface Processor { name: 'transaction'; @@ -62,5 +61,4 @@ export interface TransactionRaw extends APMBaseDoc { url?: Url; user?: User; user_agent?: UserAgent; - observer?: Observer; } From 3e26777965a2efa56f7a2040619f016f34af5d0e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 24 Mar 2020 07:27:15 +0100 Subject: [PATCH 25/34] Migrate doc view part of discover (#58094) --- .i18nrc.json | 1 + src/core/MIGRATION.md | 6 +- .../kibana/public/discover/build_services.ts | 14 ++- .../kibana/public/discover/kibana_services.ts | 1 - .../np_ready/angular/directives/field_name.js | 2 +- .../angular/{doc_viewer.ts => doc_viewer.tsx} | 8 +- .../discover/np_ready/components/_index.scss | 1 - .../np_ready/components/doc/doc.test.tsx | 8 +- .../discover/np_ready/components/doc/doc.tsx | 5 +- .../components/doc/use_es_doc_search.ts | 3 +- .../kibana/public/discover/plugin.ts | 46 ++------ .../new_platform/new_platform.karma_mock.js | 11 ++ .../ui/public/new_platform/new_platform.ts | 3 + src/plugins/discover/kibana.json | 6 + .../discover/public/components/_index.scss | 1 + .../__snapshots__/doc_viewer.test.tsx.snap | 0 .../doc_viewer_render_tab.test.tsx.snap | 0 .../components/doc_viewer/_doc_viewer.scss | 0 .../public}/components/doc_viewer/_index.scss | 0 .../components/doc_viewer/doc_viewer.test.tsx | 26 ++--- .../components/doc_viewer/doc_viewer.tsx | 4 +- .../doc_viewer/doc_viewer_render_error.tsx | 2 +- .../doc_viewer/doc_viewer_render_tab.test.tsx | 0 .../doc_viewer/doc_viewer_render_tab.tsx | 0 .../components/doc_viewer/doc_viewer_tab.tsx | 0 .../__snapshots__/field_name.test.tsx.snap | 0 .../field_name/field_name.test.tsx | 0 .../components}/field_name/field_name.tsx | 4 +- .../components}/field_name/field_type_name.ts | 24 ++-- .../json_code_block.test.tsx.snap | 0 .../json_code_block/json_code_block.test.tsx | 2 +- .../json_code_block/json_code_block.tsx | 2 +- .../public}/components/table/table.test.tsx | 5 +- .../public}/components/table/table.tsx | 0 .../components/table/table_helper.test.ts | 0 .../public}/components/table/table_helper.tsx | 0 .../public}/components/table/table_row.tsx | 2 +- .../table/table_row_btn_collapse.tsx | 2 +- .../table/table_row_btn_filter_add.tsx | 6 +- .../table/table_row_btn_filter_exists.tsx | 15 +-- .../table/table_row_btn_filter_remove.tsx | 6 +- .../table/table_row_btn_toggle_column.tsx | 20 ++-- .../table/table_row_icon_no_mapping.tsx | 11 +- .../table/table_row_icon_underscore.tsx | 4 +- .../public}/doc_views/doc_views_helpers.tsx | 0 .../public}/doc_views/doc_views_registry.ts | 12 +- .../public}/doc_views/doc_views_types.ts | 7 +- src/plugins/discover/public/helpers/index.ts | 20 ++++ .../public/helpers/shorten_dotted_string.ts | 26 +++++ src/plugins/discover/public/index.scss | 1 + src/plugins/discover/public/index.ts | 13 +++ src/plugins/discover/public/mocks.ts | 47 ++++++++ src/plugins/discover/public/plugin.ts | 110 ++++++++++++++++++ .../public/saved_searches/_saved_search.ts | 8 +- src/plugins/discover/public/services.ts | 25 ++++ test/plugin_functional/config.js | 1 + .../plugins/doc_views_plugin/kibana.json | 8 ++ .../plugins/doc_views_plugin/package.json | 17 +++ .../plugins/doc_views_plugin/public/index.ts | 22 ++++ .../doc_views_plugin/public/plugin.tsx | 60 ++++++++++ .../plugins/doc_views_plugin/tsconfig.json | 14 +++ .../test_suites/doc_views/doc_views.ts | 57 +++++++++ .../test_suites/doc_views/index.ts | 31 +++++ .../translations/translations/ja-JP.json | 22 ++-- .../translations/translations/zh-CN.json | 22 ++-- 65 files changed, 606 insertions(+), 168 deletions(-) rename src/legacy/core_plugins/kibana/public/discover/np_ready/angular/{doc_viewer.ts => doc_viewer.tsx} (87%) create mode 100644 src/plugins/discover/kibana.json create mode 100644 src/plugins/discover/public/components/_index.scss rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/_doc_viewer.scss (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/_index.scss (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer.test.tsx (81%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer.tsx (95%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_render_error.tsx (94%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_render_tab.test.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_render_tab.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/doc_viewer/doc_viewer_tab.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/__snapshots__/field_name.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/field_name.test.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/field_name.tsx (94%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready/angular/directives => plugins/discover/public/components}/field_name/field_type_name.ts (66%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/json_code_block/json_code_block.test.tsx (95%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/json_code_block/json_code_block.tsx (93%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table.test.tsx (98%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_helper.test.ts (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_helper.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row.tsx (98%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_collapse.tsx (94%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_filter_add.tsx (87%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_filter_exists.tsx (80%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_filter_remove.tsx (87%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_btn_toggle_column.tsx (79%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_icon_no_mapping.tsx (86%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/components/table/table_row_icon_underscore.tsx (89%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/doc_views/doc_views_helpers.tsx (100%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/doc_views/doc_views_registry.ts (82%) rename src/{legacy/core_plugins/kibana/public/discover/np_ready => plugins/discover/public}/doc_views/doc_views_types.ts (90%) create mode 100644 src/plugins/discover/public/helpers/index.ts create mode 100644 src/plugins/discover/public/helpers/shorten_dotted_string.ts create mode 100644 src/plugins/discover/public/index.scss create mode 100644 src/plugins/discover/public/mocks.ts create mode 100644 src/plugins/discover/public/plugin.ts create mode 100644 src/plugins/discover/public/services.ts create mode 100644 test/plugin_functional/plugins/doc_views_plugin/kibana.json create mode 100644 test/plugin_functional/plugins/doc_views_plugin/package.json create mode 100644 test/plugin_functional/plugins/doc_views_plugin/public/index.ts create mode 100644 test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx create mode 100644 test/plugin_functional/plugins/doc_views_plugin/tsconfig.json create mode 100644 test/plugin_functional/test_suites/doc_views/doc_views.ts create mode 100644 test/plugin_functional/test_suites/doc_views/index.ts diff --git a/.i18nrc.json b/.i18nrc.json index 36b28a0f5bd34..78c4be6f4a356 100644 --- a/.i18nrc.json +++ b/.i18nrc.json @@ -3,6 +3,7 @@ "common.ui": "src/legacy/ui", "console": "src/plugins/console", "core": "src/core", + "discover": "src/plugins/discover", "dashboard": "src/plugins/dashboard", "data": "src/plugins/data", "embeddableApi": "src/plugins/embeddable", diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index 1ca9b63a51d18..0d5d300ec3b79 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -1233,11 +1233,11 @@ This table shows where these uiExports have moved to in the New Platform. In mos | `chromeNavControls` | [`core.chrome.navControls.register{Left,Right}`](/docs/development/core/public/kibana-plugin-public.chromenavcontrols.md) | | | `contextMenuActions` | | Should be an API on the devTools plugin. | | `devTools` | | | -| `docViews` | | | +| `docViews` | [`plugins.discover.docViews.addDocView`](./src/plugins/discover/public/doc_views) | Should be an API on the discover plugin. | | `embeddableActions` | | Should be an API on the embeddables plugin. | | `embeddableFactories` | | Should be an API on the embeddables plugin. | -| `fieldFormatEditors` | | | -| `fieldFormats` | [`plugins.data.fieldFormats`](./src/plugins/data/public/field_formats) | | +| `fieldFormatEditors` | | | +| `fieldFormats` | [`plugins.data.fieldFormats`](./src/plugins/data/public/field_formats) | | | `hacks` | n/a | Just run the code in your plugin's `start` method. | | `home` | [`plugins.home.featureCatalogue.register`](./src/plugins/home/public/feature_catalogue) | Must add `home` as a dependency in your kibana.json. | | `indexManagement` | | Should be an API on the indexManagement plugin. | diff --git a/src/legacy/core_plugins/kibana/public/discover/build_services.ts b/src/legacy/core_plugins/kibana/public/discover/build_services.ts index 282eef0c983eb..f881eb96e4e81 100644 --- a/src/legacy/core_plugins/kibana/public/discover/build_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/build_services.ts @@ -35,10 +35,13 @@ import { import { DiscoverStartPlugins } from './plugin'; import { SharePluginStart } from '../../../../../plugins/share/public'; -import { DocViewsRegistry } from './np_ready/doc_views/doc_views_registry'; import { ChartsPluginStart } from '../../../../../plugins/charts/public'; import { VisualizationsStart } from '../../../visualizations/public'; -import { createSavedSearchesLoader, SavedSearch } from '../../../../../plugins/discover/public'; +import { + createSavedSearchesLoader, + DocViewerComponent, + SavedSearch, +} from '../../../../../plugins/discover/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -47,7 +50,7 @@ export interface DiscoverServices { core: CoreStart; data: DataPublicPluginStart; docLinks: DocLinksStart; - docViewsRegistry: DocViewsRegistry; + DocViewer: DocViewerComponent; history: History; theme: ChartsPluginStart['theme']; filterManager: FilterManager; @@ -64,8 +67,7 @@ export interface DiscoverServices { } export async function buildServices( core: CoreStart, - plugins: DiscoverStartPlugins, - docViewsRegistry: DocViewsRegistry + plugins: DiscoverStartPlugins ): Promise { const services = { savedObjectsClient: core.savedObjects.client, @@ -81,7 +83,7 @@ export async function buildServices( core, data: plugins.data, docLinks: core.docLinks, - docViewsRegistry, + DocViewer: plugins.discover.docViews.DocViewer, history: createHashHistory(), theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, diff --git a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts index d369eb9679de6..7a3a6949baa94 100644 --- a/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts +++ b/src/legacy/core_plugins/kibana/public/discover/kibana_services.ts @@ -75,7 +75,6 @@ export { EsQuerySortValue, SortDirection, } from '../../../../../plugins/data/public'; -export { ElasticSearchHit } from './np_ready/doc_views/doc_views_types'; export { getFormat } from 'ui/visualize/loader/pipeline_helpers/utilities'; // @ts-ignore export { buildPointSeriesData } from 'ui/agg_response/point_series/point_series'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js index b020113381992..47e50f3cc3d4b 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name.js @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { FieldName } from './field_name/field_name'; +import { FieldName } from '../../../../../../../../plugins/discover/public'; import { getServices, wrapInI18nContext } from '../../../kibana_services'; export function FieldNameDirectiveProvider(reactDirective) { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts rename to src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx index 6ba47b839563b..90e061ac1aa05 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/doc_viewer.tsx @@ -17,11 +17,15 @@ * under the License. */ -import { DocViewer } from '../components/doc_viewer/doc_viewer'; +import React from 'react'; +import { getServices } from '../../kibana_services'; export function createDocViewerDirective(reactDirective: any) { return reactDirective( - DocViewer, + (props: any) => { + const { DocViewer } = getServices(); + return ; + }, [ 'hit', ['indexPattern', { watchDepth: 'reference' }], diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss index 0491430e5fddd..7161560f8fda4 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/_index.scss @@ -1,3 +1,2 @@ @import 'fetch_error/index'; @import 'field_chooser/index'; -@import 'doc_viewer/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx index 2278b243ecc14..1d19dc112d193 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.test.tsx @@ -24,19 +24,13 @@ import { ReactWrapper } from 'enzyme'; import { findTestSubject } from '@elastic/eui/lib/test'; import { Doc, DocProps } from './doc'; -jest.mock('../doc_viewer/doc_viewer', () => ({ - DocViewer: () => null, -})); - jest.mock('../../../kibana_services', () => { return { getServices: () => ({ metadata: { branch: 'test', }, - getDocViewsSorted: () => { - return []; - }, + DocViewer: () => null, }), }; }); diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx index 819eb9df592bd..28a17dbdb67b7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/doc.tsx @@ -20,9 +20,9 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent } from '@elastic/eui'; import { IndexPatternsContract } from 'src/plugins/data/public'; -import { DocViewer } from '../doc_viewer/doc_viewer'; import { ElasticRequestState, useEsDocSearch } from './use_es_doc_search'; -import { ElasticSearchHit, getServices } from '../../../kibana_services'; +import { getServices } from '../../../kibana_services'; +import { ElasticSearchHit } from '../../../../../../../../plugins/discover/public'; export interface ElasticSearchResult { hits: { @@ -61,6 +61,7 @@ export interface DocProps { } export function Doc(props: DocProps) { + const { DocViewer } = getServices(); const [reqState, hit, indexPattern] = useEsDocSearch(props); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts index 6cffc2cc533b0..2cd264578a596 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc/use_es_doc_search.ts @@ -17,8 +17,9 @@ * under the License. */ import { useEffect, useState } from 'react'; -import { ElasticSearchHit, IndexPattern } from '../../../kibana_services'; +import { IndexPattern } from '../../../kibana_services'; import { DocProps } from './doc'; +import { ElasticSearchHit } from '../../../../../../../../plugins/discover/public'; export enum ElasticRequestState { Loading, diff --git a/src/legacy/core_plugins/kibana/public/discover/plugin.ts b/src/legacy/core_plugins/kibana/public/discover/plugin.ts index ba671a64592a5..d3cdeb49fba71 100644 --- a/src/legacy/core_plugins/kibana/public/discover/plugin.ts +++ b/src/legacy/core_plugins/kibana/public/discover/plugin.ts @@ -19,7 +19,6 @@ import { BehaviorSubject } from 'rxjs'; import { filter, map } from 'rxjs/operators'; -import { i18n } from '@kbn/i18n'; import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'kibana/public'; import angular, { auto } from 'angular'; import { UiActionsSetup, UiActionsStart } from 'src/plugins/ui_actions/public'; @@ -41,10 +40,7 @@ import { KibanaLegacySetup, AngularRenderedAppUpdater, } from '../../../../../plugins/kibana_legacy/public'; -import { DocViewsRegistry } from './np_ready/doc_views/doc_views_registry'; -import { DocViewInput, DocViewInputFn } from './np_ready/doc_views/doc_views_types'; -import { DocViewTable } from './np_ready/components/table/table'; -import { JsonCodeBlock } from './np_ready/components/json_code_block/json_code_block'; +import { DiscoverSetup, DiscoverStart } from '../../../../../plugins/discover/public'; import { HomePublicPluginSetup } from '../../../../../plugins/home/public'; import { VisualizationsStart, @@ -52,15 +48,6 @@ import { } from '../../../visualizations/public/np_ready/public'; import { createKbnUrlTracker } from '../../../../../plugins/kibana_utils/public'; -/** - * These are the interfaces with your public contracts. You should export these - * for other plugins to use in _their_ `SetupDeps`/`StartDeps` interfaces. - * @public - */ -export interface DiscoverSetup { - addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; -} -export type DiscoverStart = void; export interface DiscoverSetupPlugins { uiActions: UiActionsSetup; embeddable: EmbeddableSetup; @@ -68,6 +55,7 @@ export interface DiscoverSetupPlugins { home: HomePublicPluginSetup; visualizations: VisualizationsSetup; data: DataPublicPluginSetup; + discover: DiscoverSetup; } export interface DiscoverStartPlugins { uiActions: UiActionsStart; @@ -78,6 +66,7 @@ export interface DiscoverStartPlugins { share: SharePluginStart; inspector: any; visualizations: VisualizationsStart; + discover: DiscoverStart; } const innerAngularName = 'app/discover'; const embeddableAngularName = 'app/discoverEmbeddable'; @@ -87,10 +76,9 @@ const embeddableAngularName = 'app/discoverEmbeddable'; * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular * Discover provides embeddables, those contain a slimmer Angular */ -export class DiscoverPlugin implements Plugin { +export class DiscoverPlugin implements Plugin { private servicesInitialized: boolean = false; private innerAngularInitialized: boolean = false; - private docViewsRegistry: DocViewsRegistry | null = null; private embeddableInjector: auto.IInjectorService | null = null; private getEmbeddableInjector: (() => Promise) | null = null; private appStateUpdater = new BehaviorSubject(() => ({})); @@ -103,7 +91,7 @@ export class DiscoverPlugin implements Plugin { public initializeInnerAngular?: () => void; public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; - setup(core: CoreSetup, plugins: DiscoverSetupPlugins): DiscoverSetup { + setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { const { appMounted, appUnMounted, stop: stopUrlTracker } = createKbnUrlTracker({ baseUrl: core.http.basePath.prepend('/app/kibana'), defaultSubUrl: '#/discover', @@ -130,21 +118,7 @@ export class DiscoverPlugin implements Plugin { }; this.getEmbeddableInjector = this.getInjector.bind(this); - this.docViewsRegistry = new DocViewsRegistry(this.getEmbeddableInjector); - this.docViewsRegistry.addDocView({ - title: i18n.translate('kbn.discover.docViews.table.tableTitle', { - defaultMessage: 'Table', - }), - order: 10, - component: DocViewTable, - }); - this.docViewsRegistry.addDocView({ - title: i18n.translate('kbn.discover.docViews.json.jsonTitle', { - defaultMessage: 'JSON', - }), - order: 20, - component: JsonCodeBlock, - }); + plugins.discover.docViews.setAngularInjectorGetter(this.getEmbeddableInjector); plugins.kibanaLegacy.registerLegacyApp({ id: 'discover', title: 'Discover', @@ -172,14 +146,10 @@ export class DiscoverPlugin implements Plugin { }, }); registerFeature(plugins.home); - this.registerEmbeddable(core, plugins); - return { - addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), - }; } - start(core: CoreStart, plugins: DiscoverStartPlugins): DiscoverStart { + start(core: CoreStart, plugins: DiscoverStartPlugins) { // we need to register the application service at setup, but to render it // there are some start dependencies necessary, for this reason // initializeInnerAngular + initializeServices are assigned at start and used @@ -198,7 +168,7 @@ export class DiscoverPlugin implements Plugin { if (this.servicesInitialized) { return { core, plugins }; } - const services = await buildServices(core, plugins, this.docViewsRegistry!); + const services = await buildServices(core, plugins); setServices(services); this.servicesInitialized = true; diff --git a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js index c58a7d2fbb5cd..809022620e69d 100644 --- a/src/legacy/ui/public/new_platform/new_platform.karma_mock.js +++ b/src/legacy/ui/public/new_platform/new_platform.karma_mock.js @@ -271,6 +271,12 @@ export const npSetup = { }), }, }, + discover: { + docViews: { + addDocView: sinon.fake(), + setAngularInjectorGetter: sinon.fake(), + }, + }, visTypeVega: { config: sinon.fake(), }, @@ -459,6 +465,11 @@ export const npStart = { useChartsTheme: sinon.fake(), }, }, + discover: { + docViews: { + DocViewer: () => null, + }, + }, }, }; diff --git a/src/legacy/ui/public/new_platform/new_platform.ts b/src/legacy/ui/public/new_platform/new_platform.ts index deb8387fee29c..ee14f192a2149 100644 --- a/src/legacy/ui/public/new_platform/new_platform.ts +++ b/src/legacy/ui/public/new_platform/new_platform.ts @@ -65,6 +65,7 @@ import { NavigationPublicPluginStart, } from '../../../../plugins/navigation/public'; import { VisTypeVegaSetup } from '../../../../plugins/vis_type_vega/public'; +import { DiscoverSetup, DiscoverStart } from '../../../../plugins/discover/public'; export interface PluginsSetup { bfetch: BfetchPublicSetup; @@ -83,6 +84,7 @@ export interface PluginsSetup { advancedSettings: AdvancedSettingsSetup; management: ManagementSetup; visTypeVega: VisTypeVegaSetup; + discover: DiscoverSetup; telemetry?: TelemetryPluginSetup; } @@ -100,6 +102,7 @@ export interface PluginsStart { share: SharePluginStart; management: ManagementStart; advancedSettings: AdvancedSettingsStart; + discover: DiscoverStart; telemetry?: TelemetryPluginStart; } diff --git a/src/plugins/discover/kibana.json b/src/plugins/discover/kibana.json new file mode 100644 index 0000000000000..91d6358d44c18 --- /dev/null +++ b/src/plugins/discover/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "discover", + "version": "kibana", + "server": false, + "ui": true +} diff --git a/src/plugins/discover/public/components/_index.scss b/src/plugins/discover/public/components/_index.scss new file mode 100644 index 0000000000000..ff50d4b5dca93 --- /dev/null +++ b/src/plugins/discover/public/components/_index.scss @@ -0,0 +1 @@ +@import 'doc_viewer/index'; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap b/src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap rename to src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap b/src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap rename to src/plugins/discover/public/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_doc_viewer.scss b/src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_doc_viewer.scss rename to src/plugins/discover/public/components/doc_viewer/_doc_viewer.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_index.scss b/src/plugins/discover/public/components/doc_viewer/_index.scss similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/_index.scss rename to src/plugins/discover/public/components/doc_viewer/_index.scss diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx similarity index 81% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx index 15f0f40700abc..6f29f10ddd026 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.test.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer.test.tsx @@ -21,37 +21,33 @@ import { mount, shallow } from 'enzyme'; import { DocViewer } from './doc_viewer'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; -import { getServices } from '../../../kibana_services'; +import { getDocViewsRegistry } from '../../services'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; -jest.mock('../../../kibana_services', () => { +jest.mock('../../services', () => { let registry: any[] = []; return { - getServices: () => ({ - docViewsRegistry: { - addDocView(view: any) { - registry.push(view); - }, - getDocViewsSorted() { - return registry; - }, + getDocViewsRegistry: () => ({ + addDocView(view: any) { + registry.push(view); + }, + getDocViewsSorted() { + return registry; }, resetRegistry: () => { registry = []; }, }), - formatMsg: (x: any) => String(x), - formatStack: (x: any) => String(x), }; }); beforeEach(() => { - (getServices() as any).resetRegistry(); + (getDocViewsRegistry() as any).resetRegistry(); jest.clearAllMocks(); }); test('Render with 3 different tabs', () => { - const registry = getServices().docViewsRegistry; + const registry = getDocViewsRegistry(); registry.addDocView({ order: 10, title: 'Render function', render: jest.fn() }); registry.addDocView({ order: 20, title: 'React component', component: () =>
test
}); registry.addDocView({ order: 30, title: 'Invalid doc view' }); @@ -69,7 +65,7 @@ test('Render with 1 tab displaying error message', () => { return null; } - const registry = getServices().docViewsRegistry; + const registry = getDocViewsRegistry(); registry.addDocView({ order: 10, title: 'React component', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx index a177d8c29304c..792d9c44400d7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; import { EuiTabbedContent } from '@elastic/eui'; -import { getServices } from '../../../kibana_services'; +import { getDocViewsRegistry } from '../../services'; import { DocViewerTab } from './doc_viewer_tab'; import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; @@ -29,7 +29,7 @@ import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; * a `render` function. */ export function DocViewer(renderProps: DocViewRenderProps) { - const { docViewsRegistry } = getServices(); + const docViewsRegistry = getDocViewsRegistry(); const tabs = docViewsRegistry .getDocViewsSorted(renderProps.hit) .map(({ title, render, component }: DocView, idx: number) => { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx index 075217add7b52..387e57dc8a7e3 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_error.tsx +++ b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_error.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; import { EuiCallOut, EuiCodeBlock } from '@elastic/eui'; -import { formatMsg, formatStack } from '../../../kibana_services'; +import { formatMsg, formatStack } from '../../../../kibana_legacy/public'; interface Props { error: Error | string; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.test.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.test.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_render_tab.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_render_tab.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover/public/components/doc_viewer/doc_viewer_tab.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/doc_viewer/doc_viewer_tab.tsx rename to src/plugins/discover/public/components/doc_viewer/doc_viewer_tab.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/__snapshots__/field_name.test.tsx.snap b/src/plugins/discover/public/components/field_name/__snapshots__/field_name.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/__snapshots__/field_name.test.tsx.snap rename to src/plugins/discover/public/components/field_name/__snapshots__/field_name.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.test.tsx b/src/plugins/discover/public/components/field_name/field_name.test.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.test.tsx rename to src/plugins/discover/public/components/field_name/field_name.test.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx b/src/plugins/discover/public/components/field_name/field_name.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx rename to src/plugins/discover/public/components/field_name/field_name.tsx index 1b3b16332fa4f..63518aae28de6 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_name.tsx +++ b/src/plugins/discover/public/components/field_name/field_name.tsx @@ -20,8 +20,8 @@ import React from 'react'; import classNames from 'classnames'; import { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; -import { FieldIcon, FieldIconProps } from '../../../../../../../../../plugins/kibana_react/public'; -import { shortenDottedString } from '../../../helpers'; +import { FieldIcon, FieldIconProps } from '../../../../kibana_react/public'; +import { shortenDottedString } from '../../helpers'; import { getFieldTypeName } from './field_type_name'; // property field is provided at discover's field chooser diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts b/src/plugins/discover/public/components/field_name/field_type_name.ts similarity index 66% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts rename to src/plugins/discover/public/components/field_name/field_type_name.ts index 0cf428ee48b9d..a67c20fc4f353 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/directives/field_name/field_type_name.ts +++ b/src/plugins/discover/public/components/field_name/field_type_name.ts @@ -21,52 +21,52 @@ import { i18n } from '@kbn/i18n'; export function getFieldTypeName(type: string) { switch (type) { case 'boolean': - return i18n.translate('kbn.discover.fieldNameIcons.booleanAriaLabel', { + return i18n.translate('discover.fieldNameIcons.booleanAriaLabel', { defaultMessage: 'Boolean field', }); case 'conflict': - return i18n.translate('kbn.discover.fieldNameIcons.conflictFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.conflictFieldAriaLabel', { defaultMessage: 'Conflicting field', }); case 'date': - return i18n.translate('kbn.discover.fieldNameIcons.dateFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.dateFieldAriaLabel', { defaultMessage: 'Date field', }); case 'geo_point': - return i18n.translate('kbn.discover.fieldNameIcons.geoPointFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.geoPointFieldAriaLabel', { defaultMessage: 'Geo point field', }); case 'geo_shape': - return i18n.translate('kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.geoShapeFieldAriaLabel', { defaultMessage: 'Geo shape field', }); case 'ip': - return i18n.translate('kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.ipAddressFieldAriaLabel', { defaultMessage: 'IP address field', }); case 'murmur3': - return i18n.translate('kbn.discover.fieldNameIcons.murmur3FieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.murmur3FieldAriaLabel', { defaultMessage: 'Murmur3 field', }); case 'number': - return i18n.translate('kbn.discover.fieldNameIcons.numberFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.numberFieldAriaLabel', { defaultMessage: 'Number field', }); case 'source': // Note that this type is currently not provided, type for _source is undefined - return i18n.translate('kbn.discover.fieldNameIcons.sourceFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.sourceFieldAriaLabel', { defaultMessage: 'Source field', }); case 'string': - return i18n.translate('kbn.discover.fieldNameIcons.stringFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.stringFieldAriaLabel', { defaultMessage: 'String field', }); case 'nested': - return i18n.translate('kbn.discover.fieldNameIcons.nestedFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.nestedFieldAriaLabel', { defaultMessage: 'Nested field', }); default: - return i18n.translate('kbn.discover.fieldNameIcons.unknownFieldAriaLabel', { + return i18n.translate('discover.fieldNameIcons.unknownFieldAriaLabel', { defaultMessage: 'Unknown field', }); } diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap b/src/plugins/discover/public/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap rename to src/plugins/discover/public/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx b/src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx similarity index 95% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx rename to src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx index 9cab7974c9eb2..7e7f80c6aaa56 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.test.tsx +++ b/src/plugins/discover/public/components/json_code_block/json_code_block.test.tsx @@ -19,7 +19,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { JsonCodeBlock } from './json_code_block'; -import { IndexPattern } from '../../../kibana_services'; +import { IndexPattern } from '../../../../data/public'; it('returns the `JsonCodeEditor` component', () => { const props = { diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx b/src/plugins/discover/public/components/json_code_block/json_code_block.tsx similarity index 93% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx rename to src/plugins/discover/public/components/json_code_block/json_code_block.tsx index 3331969e351ab..9297ab0dfcf4d 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/json_code_block/json_code_block.tsx +++ b/src/plugins/discover/public/components/json_code_block/json_code_block.tsx @@ -22,7 +22,7 @@ import { i18n } from '@kbn/i18n'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; export function JsonCodeBlock({ hit }: DocViewRenderProps) { - const label = i18n.translate('kbn.discover.docViews.json.codeEditorAriaLabel', { + const label = i18n.translate('discover.docViews.json.codeEditorAriaLabel', { defaultMessage: 'Read only JSON view of an elasticsearch document', }); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx b/src/plugins/discover/public/components/table/table.test.tsx similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx rename to src/plugins/discover/public/components/table/table.test.tsx index 386f405544a61..91e116c4c6696 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.test.tsx +++ b/src/plugins/discover/public/components/table/table.test.tsx @@ -21,10 +21,7 @@ import { mount } from 'enzyme'; // @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { DocViewTable } from './table'; - -import { IndexPattern, indexPatterns } from '../../../kibana_services'; - -jest.mock('ui/new_platform'); +import { indexPatterns, IndexPattern } from '../../../../data/public'; const indexPattern = { fields: [ diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.tsx b/src/plugins/discover/public/components/table/table.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table.tsx rename to src/plugins/discover/public/components/table/table.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.test.ts b/src/plugins/discover/public/components/table/table_helper.test.ts similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.test.ts rename to src/plugins/discover/public/components/table/table_helper.test.ts diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.tsx b/src/plugins/discover/public/components/table/table_helper.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_helper.tsx rename to src/plugins/discover/public/components/table/table_helper.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx b/src/plugins/discover/public/components/table/table_row.tsx similarity index 98% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx rename to src/plugins/discover/public/components/table/table_row.tsx index 5b13f6b3655c3..a4d5c57d10b33 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row.tsx +++ b/src/plugins/discover/public/components/table/table_row.tsx @@ -26,7 +26,7 @@ import { DocViewTableRowBtnCollapse } from './table_row_btn_collapse'; import { DocViewTableRowBtnFilterExists } from './table_row_btn_filter_exists'; import { DocViewTableRowIconNoMapping } from './table_row_icon_no_mapping'; import { DocViewTableRowIconUnderscore } from './table_row_icon_underscore'; -import { FieldName } from '../../angular/directives/field_name/field_name'; +import { FieldName } from '../field_name/field_name'; export interface Props { field: string; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx b/src/plugins/discover/public/components/table/table_row_btn_collapse.tsx similarity index 94% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx rename to src/plugins/discover/public/components/table/table_row_btn_collapse.tsx index e59f607329d4a..bb5ea4bd20f07 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_collapse.tsx +++ b/src/plugins/discover/public/components/table/table_row_btn_collapse.tsx @@ -26,7 +26,7 @@ export interface Props { } export function DocViewTableRowBtnCollapse({ onClick, isCollapsed }: Props) { - const label = i18n.translate('kbn.discover.docViews.table.toggleFieldDetails', { + const label = i18n.translate('discover.docViews.table.toggleFieldDetails', { defaultMessage: 'Toggle field details', }); return ( diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx b/src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx similarity index 87% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx rename to src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx index 8e2668e26cf08..bd842eb5c6f72 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_btn_filter_add.tsx +++ b/src/plugins/discover/public/components/table/table_row_btn_filter_add.tsx @@ -29,12 +29,12 @@ export interface Props { export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props) { const tooltipContent = disabled ? ( ) : ( ); @@ -42,7 +42,7 @@ export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props return ( ) : ( ) ) : ( ); @@ -54,12 +54,9 @@ export function DocViewTableRowBtnFilterExists({ return ( ) : ( ); @@ -42,7 +42,7 @@ export function DocViewTableRowBtnFilterRemove({ onClick, disabled = false }: Pr return ( } > Index Patterns page', diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx b/src/plugins/discover/public/components/table/table_row_icon_underscore.tsx similarity index 89% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx rename to src/plugins/discover/public/components/table/table_row_icon_underscore.tsx index 724b5712cf1fe..791ab18de5175 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/components/table/table_row_icon_underscore.tsx +++ b/src/plugins/discover/public/components/table/table_row_icon_underscore.tsx @@ -22,13 +22,13 @@ import { i18n } from '@kbn/i18n'; export function DocViewTableRowIconUnderscore() { const ariaLabel = i18n.translate( - 'kbn.discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedAriaLabel', + 'discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedAriaLabel', { defaultMessage: 'Warning', } ); const tooltipContent = i18n.translate( - 'kbn.discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedTooltip', + 'discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedTooltip', { defaultMessage: 'Field names beginning with {underscoreSign} are not supported', values: { underscoreSign: '_' }, diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_helpers.tsx b/src/plugins/discover/public/doc_views/doc_views_helpers.tsx similarity index 100% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_helpers.tsx rename to src/plugins/discover/public/doc_views/doc_views_helpers.tsx diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts b/src/plugins/discover/public/doc_views/doc_views_registry.ts similarity index 82% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts rename to src/plugins/discover/public/doc_views/doc_views_registry.ts index 91acf1c7ac4ae..8f4518538be72 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_registry.ts +++ b/src/plugins/discover/public/doc_views/doc_views_registry.ts @@ -23,8 +23,11 @@ import { DocView, DocViewInput, ElasticSearchHit, DocViewInputFn } from './doc_v export class DocViewsRegistry { private docViews: DocView[] = []; + private angularInjectorGetter: (() => Promise) | null = null; - constructor(private getInjector: () => Promise) {} + setAngularInjectorGetter(injectorGetter: () => Promise) { + this.angularInjectorGetter = injectorGetter; + } /** * Extends and adds the given doc view to the registry array @@ -33,7 +36,12 @@ export class DocViewsRegistry { const docView = typeof docViewRaw === 'function' ? docViewRaw() : docViewRaw; if (docView.directive) { // convert angular directive to render function for backwards compatibility - docView.render = convertDirectiveToRenderFn(docView.directive, this.getInjector); + docView.render = convertDirectiveToRenderFn(docView.directive, () => { + if (!this.angularInjectorGetter) { + throw new Error('Angular was not initialized'); + } + return this.angularInjectorGetter(); + }); } if (typeof docView.shouldShow !== 'function') { docView.shouldShow = () => true; diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts b/src/plugins/discover/public/doc_views/doc_views_types.ts similarity index 90% rename from src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts rename to src/plugins/discover/public/doc_views/doc_views_types.ts index a7828f9f0e7ed..0a4b5bb570bd7 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/doc_views/doc_views_types.ts +++ b/src/plugins/discover/public/doc_views/doc_views_types.ts @@ -18,10 +18,10 @@ */ import { ComponentType } from 'react'; import { IScope } from 'angular'; -import { IndexPattern } from '../../kibana_services'; +import { IndexPattern } from '../../../data/public'; export interface AngularDirective { - controller: (scope: AngularScope) => void; + controller: (...injectedServices: any[]) => void; template: string; } @@ -51,13 +51,14 @@ export interface DocViewRenderProps { onAddColumn?: (columnName: string) => void; onRemoveColumn?: (columnName: string) => void; } +export type DocViewerComponent = ComponentType; export type DocViewRenderFn = ( domeNode: HTMLDivElement, renderProps: DocViewRenderProps ) => () => void; export interface DocViewInput { - component?: ComponentType; + component?: DocViewerComponent; directive?: AngularDirective; order: number; render?: DocViewRenderFn; diff --git a/src/plugins/discover/public/helpers/index.ts b/src/plugins/discover/public/helpers/index.ts new file mode 100644 index 0000000000000..7196c96989e97 --- /dev/null +++ b/src/plugins/discover/public/helpers/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { shortenDottedString } from './shorten_dotted_string'; diff --git a/src/plugins/discover/public/helpers/shorten_dotted_string.ts b/src/plugins/discover/public/helpers/shorten_dotted_string.ts new file mode 100644 index 0000000000000..9d78a96784339 --- /dev/null +++ b/src/plugins/discover/public/helpers/shorten_dotted_string.ts @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const DOT_PREFIX_RE = /(.).+?\./g; + +/** + * Convert a dot.notated.string into a short + * version (d.n.string) + */ +export const shortenDottedString = (input: string) => input.replace(DOT_PREFIX_RE, '$1.'); diff --git a/src/plugins/discover/public/index.scss b/src/plugins/discover/public/index.scss new file mode 100644 index 0000000000000..841415620d691 --- /dev/null +++ b/src/plugins/discover/public/index.scss @@ -0,0 +1 @@ +@import 'components/index'; diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index c5050147c3d5a..dbc361ee59f49 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -17,5 +17,18 @@ * under the License. */ +import { DiscoverPlugin } from './plugin'; + +export { DiscoverSetup, DiscoverStart } from './plugin'; +export { DocViewTable } from './components/table/table'; +export { JsonCodeBlock } from './components/json_code_block/json_code_block'; +export { DocViewInput, DocViewInputFn, DocViewerComponent } from './doc_views/doc_views_types'; +export { FieldName } from './components/field_name/field_name'; +export * from './doc_views/doc_views_types'; + +export function plugin() { + return new DiscoverPlugin(); +} + export { createSavedSearchesLoader } from './saved_searches/saved_searches'; export { SavedSearchLoader, SavedSearch } from './saved_searches/types'; diff --git a/src/plugins/discover/public/mocks.ts b/src/plugins/discover/public/mocks.ts new file mode 100644 index 0000000000000..bb05e3d412001 --- /dev/null +++ b/src/plugins/discover/public/mocks.ts @@ -0,0 +1,47 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DiscoverSetup, DiscoverStart } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = { + docViews: { + addDocView: jest.fn(), + setAngularInjectorGetter: jest.fn(), + }, + }; + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = { + docViews: { + DocViewer: jest.fn(() => null), + }, + }; + return startContract; +}; + +export const discoverPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts new file mode 100644 index 0000000000000..d2797586bfdfb --- /dev/null +++ b/src/plugins/discover/public/plugin.ts @@ -0,0 +1,110 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { auto } from 'angular'; +import { CoreSetup, Plugin } from 'kibana/public'; +import { DocViewInput, DocViewInputFn, DocViewRenderProps } from './doc_views/doc_views_types'; +import { DocViewsRegistry } from './doc_views/doc_views_registry'; +import { DocViewTable } from './components/table/table'; +import { JsonCodeBlock } from './components/json_code_block/json_code_block'; +import { DocViewer } from './components/doc_viewer/doc_viewer'; +import { setDocViewsRegistry } from './services'; + +import './index.scss'; + +/** + * @public + */ +export interface DiscoverSetup { + docViews: { + /** + * Add new doc view shown along with table view and json view in the details of each document in Discover. + * Both react and angular doc views are supported. + * @param docViewRaw + */ + addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; + /** + * Set the angular injector for bootstrapping angular doc views. This is only exposed temporarily to aid + * migration to the new platform and will be removed soon. + * @deprecated + * @param injectorGetter + */ + setAngularInjectorGetter(injectorGetter: () => Promise): void; + }; +} +/** + * @public + */ +export interface DiscoverStart { + docViews: { + /** + * Component rendering all the doc views for a given document. + * This is only exposed temporarily to aid migration to the new platform and will be removed soon. + * @deprecated + */ + DocViewer: React.ComponentType; + }; +} + +/** + * Contains Discover, one of the oldest parts of Kibana + * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular + * Discover provides embeddables, those contain a slimmer Angular + */ +export class DiscoverPlugin implements Plugin { + private docViewsRegistry: DocViewsRegistry | null = null; + + setup(core: CoreSetup): DiscoverSetup { + this.docViewsRegistry = new DocViewsRegistry(); + setDocViewsRegistry(this.docViewsRegistry); + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.table.tableTitle', { + defaultMessage: 'Table', + }), + order: 10, + component: DocViewTable, + }); + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.json.jsonTitle', { + defaultMessage: 'JSON', + }), + order: 20, + component: JsonCodeBlock, + }); + + return { + docViews: { + addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), + setAngularInjectorGetter: this.docViewsRegistry.setAngularInjectorGetter.bind( + this.docViewsRegistry + ), + }, + }; + } + + start() { + return { + docViews: { + DocViewer, + }, + }; + } +} diff --git a/src/plugins/discover/public/saved_searches/_saved_search.ts b/src/plugins/discover/public/saved_searches/_saved_search.ts index 72983b7835eee..56360b04a49c8 100644 --- a/src/plugins/discover/public/saved_searches/_saved_search.ts +++ b/src/plugins/discover/public/saved_searches/_saved_search.ts @@ -16,7 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import { createSavedObjectClass, SavedObjectKibanaServices } from '../../../saved_objects/public'; +import { + createSavedObjectClass, + SavedObject, + SavedObjectKibanaServices, +} from '../../../saved_objects/public'; export function createSavedSearchClass(services: SavedObjectKibanaServices) { const SavedObjectClass = createSavedObjectClass(services); @@ -66,5 +70,5 @@ export function createSavedSearchClass(services: SavedObjectKibanaServices) { } } - return SavedSearch; + return SavedSearch as new (id: string) => SavedObject; } diff --git a/src/plugins/discover/public/services.ts b/src/plugins/discover/public/services.ts new file mode 100644 index 0000000000000..3a28759d82b71 --- /dev/null +++ b/src/plugins/discover/public/services.ts @@ -0,0 +1,25 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createGetterSetter } from '../../kibana_utils/common'; +import { DocViewsRegistry } from './doc_views/doc_views_registry'; + +export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( + 'DocViewsRegistry' +); diff --git a/test/plugin_functional/config.js b/test/plugin_functional/config.js index e63054f1b6912..7017c01cc5634 100644 --- a/test/plugin_functional/config.js +++ b/test/plugin_functional/config.js @@ -39,6 +39,7 @@ export default async function({ readConfigFile }) { require.resolve('./test_suites/core_plugins'), require.resolve('./test_suites/management'), require.resolve('./test_suites/bfetch_explorer'), + require.resolve('./test_suites/doc_views'), ], services: { ...functionalConfig.get('services'), diff --git a/test/plugin_functional/plugins/doc_views_plugin/kibana.json b/test/plugin_functional/plugins/doc_views_plugin/kibana.json new file mode 100644 index 0000000000000..f8596aad01e87 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "docViewPlugin", + "version": "0.0.1", + "kibanaVersion": "kibana", + "server": false, + "ui": true, + "requiredPlugins": ["discover"] +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/package.json b/test/plugin_functional/plugins/doc_views_plugin/package.json new file mode 100644 index 0000000000000..0cef1bf65c0e8 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "docViewPlugin", + "version": "1.0.0", + "main": "target/test/plugin_functional/plugins/doc_views_plugin", + "kibana": { + "version": "kibana", + "templateVersion": "1.0.0" + }, + "license": "Apache-2.0", + "scripts": { + "kbn": "node ../../../../scripts/kbn.js", + "build": "rm -rf './target' && tsc" + }, + "devDependencies": { + "typescript": "3.7.2" + } +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/public/index.ts b/test/plugin_functional/plugins/doc_views_plugin/public/index.ts new file mode 100644 index 0000000000000..8097226180763 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/public/index.ts @@ -0,0 +1,22 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DocViewsPlugin } from './plugin'; + +export const plugin = () => new DocViewsPlugin(); diff --git a/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx b/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx new file mode 100644 index 0000000000000..4b9823fda3673 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/public/plugin.tsx @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import angular from 'angular'; +import React from 'react'; +import { Plugin, CoreSetup } from 'kibana/public'; +import { DiscoverSetup } from '../../../../../src/plugins/discover/public'; + +angular.module('myDocView', []).directive('myHit', () => ({ + restrict: 'E', + scope: { + hit: '=hit', + }, + template: '

{{hit._index}}

', +})); + +function MyHit(props: { index: string }) { + return

{props.index}

; +} + +export class DocViewsPlugin implements Plugin { + public setup(core: CoreSetup, { discover }: { discover: DiscoverSetup }) { + discover.docViews.addDocView({ + directive: { + controller: function MyController($injector: any) { + $injector.loadNewModules(['myDocView']); + }, + template: ``, + }, + order: 1, + title: 'Angular doc view', + }); + + discover.docViews.addDocView({ + component: props => { + return ; + }, + order: 2, + title: 'React doc view', + }); + } + + public start() {} +} diff --git a/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json b/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json new file mode 100644 index 0000000000000..4a564ee1e5578 --- /dev/null +++ b/test/plugin_functional/plugins/doc_views_plugin/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "outDir": "./target", + "skipLibCheck": true + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "../../../../typings/**/*" + ], + "exclude": [] +} diff --git a/test/plugin_functional/test_suites/doc_views/doc_views.ts b/test/plugin_functional/test_suites/doc_views/doc_views.ts new file mode 100644 index 0000000000000..8764f45c2c076 --- /dev/null +++ b/test/plugin_functional/test_suites/doc_views/doc_views.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@kbn/expect'; +import { PluginFunctionalProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function({ getService, getPageObjects }: PluginFunctionalProviderContext) { + const testSubjects = getService('testSubjects'); + const find = getService('find'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + + describe('custom doc views', function() { + before(async () => { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + it('should show custom doc views', async () => { + await testSubjects.click('docTableExpandToggleColumn'); + const angularTab = await find.byButtonText('Angular doc view'); + const reactTab = await find.byButtonText('React doc view'); + expect(await angularTab.isDisplayed()).to.be(true); + expect(await reactTab.isDisplayed()).to.be(true); + }); + + it('should render angular doc view', async () => { + const angularTab = await find.byButtonText('Angular doc view'); + await angularTab.click(); + const angularContent = await testSubjects.find('angular-docview'); + expect(await angularContent.getVisibleText()).to.be('logstash-2015.09.22'); + }); + + it('should render react doc view', async () => { + const reactTab = await find.byButtonText('React doc view'); + await reactTab.click(); + const reactContent = await testSubjects.find('react-docview'); + expect(await reactContent.getVisibleText()).to.be('logstash-2015.09.22'); + }); + }); +} diff --git a/test/plugin_functional/test_suites/doc_views/index.ts b/test/plugin_functional/test_suites/doc_views/index.ts new file mode 100644 index 0000000000000..dee3a72e3f2c6 --- /dev/null +++ b/test/plugin_functional/test_suites/doc_views/index.ts @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { PluginFunctionalProviderContext } from '../../services'; + +export default function({ getService, loadTestFile }: PluginFunctionalProviderContext) { + const esArchiver = getService('esArchiver'); + + describe('doc views', function() { + before(async () => { + await esArchiver.loadIfNeeded('../functional/fixtures/es_archiver/discover'); + }); + + loadTestFile(require.resolve('./doc_views')); + }); +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e8d93ba6d3200..fe7ad863945c5 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -464,6 +464,17 @@ "data.search.searchBar.savedQueryPopoverSavedQueryListItemDescriptionAriaLabel": "{savedQueryName} の説明", "data.search.searchBar.savedQueryPopoverSavedQueryListItemSelectedButtonAriaLabel": "選択されたクエリボタン {savedQueryName} を保存しました。変更を破棄するには押してください。", "data.search.searchBar.savedQueryPopoverTitleText": "保存されたクエリ", + "discover.fieldNameIcons.booleanAriaLabel": "ブールフィールド", + "discover.fieldNameIcons.conflictFieldAriaLabel": "矛盾フィールド", + "discover.fieldNameIcons.dateFieldAriaLabel": "日付フィールド", + "discover.fieldNameIcons.geoPointFieldAriaLabel": "地理ポイント", + "discover.fieldNameIcons.geoShapeFieldAriaLabel": "地理情報図形", + "discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP アドレスフィールド", + "discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 フィールド", + "discover.fieldNameIcons.numberFieldAriaLabel": "数値フィールド", + "discover.fieldNameIcons.sourceFieldAriaLabel": "ソースフィールド", + "discover.fieldNameIcons.stringFieldAriaLabel": "文字列フィールド", + "discover.fieldNameIcons.unknownFieldAriaLabel": "不明なフィールド", "charts.colormaps.bluesText": "青", "charts.colormaps.greensText": "緑", "charts.colormaps.greenToRedText": "緑から赤", @@ -1075,17 +1086,6 @@ "kbn.discover.fieldChooser.searchPlaceHolder": "検索フィールド", "kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel": "フィールド設定を非表示", "kbn.discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel": "フィールド設定を表示", - "kbn.discover.fieldNameIcons.booleanAriaLabel": "ブールフィールド", - "kbn.discover.fieldNameIcons.conflictFieldAriaLabel": "矛盾フィールド", - "kbn.discover.fieldNameIcons.dateFieldAriaLabel": "日付フィールド", - "kbn.discover.fieldNameIcons.geoPointFieldAriaLabel": "地理ポイント", - "kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel": "地理情報図形", - "kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP アドレスフィールド", - "kbn.discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 フィールド", - "kbn.discover.fieldNameIcons.numberFieldAriaLabel": "数値フィールド", - "kbn.discover.fieldNameIcons.sourceFieldAriaLabel": "ソースフィールド", - "kbn.discover.fieldNameIcons.stringFieldAriaLabel": "文字列フィールド", - "kbn.discover.fieldNameIcons.unknownFieldAriaLabel": "不明なフィールド", "kbn.discover.histogram.partialData.bucketTooltipText": "選択された時間範囲にはこのバケット全体は含まれていませんが、一部データが含まれている可能性があります。", "kbn.discover.histogramOfFoundDocumentsAriaLabel": "発見されたドキュメントのヒストグラム", "kbn.discover.hitsPluralTitle": "{hits, plural, one {ヒット} other {ヒット}}", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cfab424935c6d..e1cfa5e4ef358 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -464,6 +464,17 @@ "data.search.searchBar.savedQueryPopoverSavedQueryListItemDescriptionAriaLabel": "{savedQueryName} 描述", "data.search.searchBar.savedQueryPopoverSavedQueryListItemSelectedButtonAriaLabel": "已保存查询按钮已选择 {savedQueryName}。按下可清除任何更改。", "data.search.searchBar.savedQueryPopoverTitleText": "已保存查询", + "discover.fieldNameIcons.booleanAriaLabel": "布尔字段", + "discover.fieldNameIcons.conflictFieldAriaLabel": "冲突字段", + "discover.fieldNameIcons.dateFieldAriaLabel": "日期字段", + "discover.fieldNameIcons.geoPointFieldAriaLabel": "地理位置点字段", + "discover.fieldNameIcons.geoShapeFieldAriaLabel": "几何形状字段", + "discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP 地址字段", + "discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 字段", + "discover.fieldNameIcons.numberFieldAriaLabel": "数字字段", + "discover.fieldNameIcons.sourceFieldAriaLabel": "源字段", + "discover.fieldNameIcons.stringFieldAriaLabel": "字符串字段", + "discover.fieldNameIcons.unknownFieldAriaLabel": "未知字段", "charts.colormaps.bluesText": "蓝色", "charts.colormaps.greensText": "绿色", "charts.colormaps.greenToRedText": "绿到红", @@ -1075,17 +1086,6 @@ "kbn.discover.fieldChooser.searchPlaceHolder": "搜索字段", "kbn.discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel": "隐藏字段设置", "kbn.discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel": "显示字段设置", - "kbn.discover.fieldNameIcons.booleanAriaLabel": "布尔字段", - "kbn.discover.fieldNameIcons.conflictFieldAriaLabel": "冲突字段", - "kbn.discover.fieldNameIcons.dateFieldAriaLabel": "日期字段", - "kbn.discover.fieldNameIcons.geoPointFieldAriaLabel": "地理位置点字段", - "kbn.discover.fieldNameIcons.geoShapeFieldAriaLabel": "几何形状字段", - "kbn.discover.fieldNameIcons.ipAddressFieldAriaLabel": "IP 地址字段", - "kbn.discover.fieldNameIcons.murmur3FieldAriaLabel": "Murmur3 字段", - "kbn.discover.fieldNameIcons.numberFieldAriaLabel": "数字字段", - "kbn.discover.fieldNameIcons.sourceFieldAriaLabel": "源字段", - "kbn.discover.fieldNameIcons.stringFieldAriaLabel": "字符串字段", - "kbn.discover.fieldNameIcons.unknownFieldAriaLabel": "未知字段", "kbn.discover.histogram.partialData.bucketTooltipText": "选定的时间范围不包括此整个存储桶,其可能包含部分数据。", "kbn.discover.histogramOfFoundDocumentsAriaLabel": "已找到文档的直方图", "kbn.discover.hitsPluralTitle": "{hits, plural, one {次命中} other {次命中}}", From e6dbc3fc21c89dc82ddf69221695543e4bfa0a4d Mon Sep 17 00:00:00 2001 From: Tudor Golubenco Date: Tue, 24 Mar 2020 08:10:10 +0100 Subject: [PATCH 26/34] [SIEM] Updates process and TLS tables to use ECS 1.5 fields (#60854) * Added new process filter * Use new ECS TLS fields --- .../__snapshots__/index.test.tsx.snap | 35 +- .../page/network/tls_table/columns.tsx | 26 +- .../components/page/network/tls_table/mock.ts | 15 +- .../page/network/tls_table/translations.ts | 2 +- .../public/containers/tls/index.gql_query.ts | 5 +- .../siem/public/graphql/introspection.json | 20 +- .../plugins/siem/public/graphql/types.ts | 12 +- .../siem/server/graphql/tls/schema.gql.ts | 5 +- .../plugins/siem/server/graphql/types.ts | 21 +- .../server/lib/tls/elasticsearch_adapter.ts | 5 +- .../plugins/siem/server/lib/tls/mock.ts | 274 +- .../siem/server/lib/tls/query_tls.dsl.ts | 21 +- .../plugins/siem/server/lib/tls/types.ts | 8 +- .../lib/uncommon_processes/query.dsl.ts | 16 + x-pack/test/api_integration/apis/siem/tls.ts | 68 +- .../es_archives/packetbeat/tls/data.json.gz | Bin 0 -> 3929 bytes .../es_archives/packetbeat/tls/mappings.json | 9583 +++++++++++++++++ 17 files changed, 9739 insertions(+), 377 deletions(-) create mode 100644 x-pack/test/functional/es_archives/packetbeat/tls/data.json.gz create mode 100644 x-pack/test/functional/es_archives/packetbeat/tls/mappings.json diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap index 85b028cf7cd51..8b7d8efa7ac37 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/__snapshots__/index.test.tsx.snap @@ -10,14 +10,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "2fe3bdf168af35b9e0ce5dc583bab007c40d47de", - "alternativeNames": Array [ - "*.elastic.co", - "elastic.co", - ], - "commonNames": Array [ - "*.elastic.co", - ], - "issuerNames": Array [ + "issuers": Array [ "DigiCert SHA2 Secure Server CA", ], "ja3": Array [ @@ -27,6 +20,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2021-04-22T12:00:00.000Z", ], + "subjects": Array [ + "*.elastic.co", + ], }, }, Object { @@ -35,13 +31,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "61749734b3246f1584029deb4f5276c64da00ada", - "alternativeNames": Array [ - "api.snapcraft.io", - ], - "commonNames": Array [ - "api.snapcraft.io", - ], - "issuerNames": Array [ + "issuers": Array [ "DigiCert SHA2 Secure Server CA", ], "ja3": Array [ @@ -50,6 +40,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2019-05-22T12:00:00.000Z", ], + "subjects": Array [ + "api.snapcraft.io", + ], }, }, Object { @@ -58,14 +51,7 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] }, "node": Object { "_id": "6560d3b7dd001c989b85962fa64beb778cdae47a", - "alternativeNames": Array [ - "changelogs.ubuntu.com", - "manpages.ubuntu.com", - ], - "commonNames": Array [ - "changelogs.ubuntu.com", - ], - "issuerNames": Array [ + "issuers": Array [ "Let's Encrypt Authority X3", ], "ja3": Array [ @@ -74,6 +60,9 @@ exports[`Tls Table Component Rendering it renders the default Domains table 1`] "notAfter": Array [ "2019-06-27T01:09:59.000Z", ], + "subjects": Array [ + "changelogs.ubuntu.com", + ], }, }, ] diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx index 44a538871d951..f95475819abc9 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/columns.tsx @@ -32,11 +32,11 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ truncateText: false, hideForMobile: false, sortable: false, - render: ({ _id, issuerNames }) => + render: ({ _id, issuers }) => getRowItemDraggables({ - rowItems: issuerNames, - attrName: 'tls.server_certificate.issuer.common_name', - idPrefix: `${tableId}-${_id}-table-issuerNames`, + rowItems: issuers, + attrName: 'tls.server.issuer', + idPrefix: `${tableId}-${_id}-table-issuers`, }), }, { @@ -45,18 +45,12 @@ export const getTlsColumns = (tableId: string): TlsColumns => [ truncateText: false, hideForMobile: false, sortable: false, - render: ({ _id, alternativeNames, commonNames }) => - alternativeNames != null && alternativeNames.length > 0 - ? getRowItemDraggables({ - rowItems: alternativeNames, - attrName: 'tls.server_certificate.alternative_names', - idPrefix: `${tableId}-${_id}-table-alternative-name`, - }) - : getRowItemDraggables({ - rowItems: commonNames, - attrName: 'tls.server_certificate.subject.common_name', - idPrefix: `${tableId}-${_id}-table-common-name`, - }), + render: ({ _id, subjects }) => + getRowItemDraggables({ + rowItems: subjects, + attrName: 'tls.server.subject', + idPrefix: `${tableId}-${_id}-table-subjects`, + }), }, { field: 'node._id', diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts index 77148bf50c038..453bd8fc84dfa 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/mock.ts @@ -12,10 +12,9 @@ export const mockTlsData: TlsData = { { node: { _id: '2fe3bdf168af35b9e0ce5dc583bab007c40d47de', - alternativeNames: ['*.elastic.co', 'elastic.co'], - commonNames: ['*.elastic.co'], + subjects: ['*.elastic.co'], ja3: ['7851693188210d3b271aa1713d8c68c2', 'fb4726d465c5f28b84cd6d14cedd13a7'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + issuers: ['DigiCert SHA2 Secure Server CA'], notAfter: ['2021-04-22T12:00:00.000Z'], }, cursor: { @@ -25,10 +24,9 @@ export const mockTlsData: TlsData = { { node: { _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], + subjects: ['api.snapcraft.io'], ja3: ['839868ad711dc55bde0d37a87f14740d'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + issuers: ['DigiCert SHA2 Secure Server CA'], notAfter: ['2019-05-22T12:00:00.000Z'], }, cursor: { @@ -38,10 +36,9 @@ export const mockTlsData: TlsData = { { node: { _id: '6560d3b7dd001c989b85962fa64beb778cdae47a', - alternativeNames: ['changelogs.ubuntu.com', 'manpages.ubuntu.com'], - commonNames: ['changelogs.ubuntu.com'], + subjects: ['changelogs.ubuntu.com'], ja3: ['da12c94da8021bbaf502907ad086e7bc'], - issuerNames: ["Let's Encrypt Authority X3"], + issuers: ["Let's Encrypt Authority X3"], notAfter: ['2019-06-27T01:09:59.000Z'], }, cursor: { diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts index 89d0f58684cbe..ff714204144ec 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/page/network/tls_table/translations.ts @@ -16,7 +16,7 @@ export const TRANSPORT_LAYER_SECURITY = i18n.translate( export const UNIT = (totalCount: number) => i18n.translate('xpack.siem.network.ipDetails.tlsTable.unit', { values: { totalCount }, - defaultMessage: `{totalCount, plural, =1 {issuer} other {issuers}}`, + defaultMessage: `{totalCount, plural, =1 {server certificate} other {server certificates}}`, }); // Columns diff --git a/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts b/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts index bbb92282bee83..f513a94d69667 100644 --- a/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts +++ b/x-pack/legacy/plugins/siem/public/containers/tls/index.gql_query.ts @@ -33,10 +33,9 @@ export const tlsQuery = gql` edges { node { _id - alternativeNames - commonNames + subjects ja3 - issuerNames + issuers notAfter } cursor { diff --git a/x-pack/legacy/plugins/siem/public/graphql/introspection.json b/x-pack/legacy/plugins/siem/public/graphql/introspection.json index 9802a5f5bd3bf..5d43024625d0d 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/introspection.json +++ b/x-pack/legacy/plugins/siem/public/graphql/introspection.json @@ -9213,22 +9213,6 @@ "isDeprecated": false, "deprecationReason": null }, - { - "name": "alternativeNames", - "description": "", - "args": [], - "type": { - "kind": "LIST", - "name": null, - "ofType": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "String", "ofType": null } - } - }, - "isDeprecated": false, - "deprecationReason": null - }, { "name": "notAfter", "description": "", @@ -9246,7 +9230,7 @@ "deprecationReason": null }, { - "name": "commonNames", + "name": "subjects", "description": "", "args": [], "type": { @@ -9278,7 +9262,7 @@ "deprecationReason": null }, { - "name": "issuerNames", + "name": "issuers", "description": "", "args": [], "type": { diff --git a/x-pack/legacy/plugins/siem/public/graphql/types.ts b/x-pack/legacy/plugins/siem/public/graphql/types.ts index 3528ee6e13a38..a5d1e3fbcba27 100644 --- a/x-pack/legacy/plugins/siem/public/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/public/graphql/types.ts @@ -1859,15 +1859,13 @@ export interface TlsNode { timestamp?: Maybe; - alternativeNames?: Maybe; - notAfter?: Maybe; - commonNames?: Maybe; + subjects?: Maybe; ja3?: Maybe; - issuerNames?: Maybe; + issuers?: Maybe; } export interface UncommonProcessesData { @@ -5679,13 +5677,11 @@ export namespace GetTlsQuery { _id: Maybe; - alternativeNames: Maybe; - - commonNames: Maybe; + subjects: Maybe; ja3: Maybe; - issuerNames: Maybe; + issuers: Maybe; notAfter: Maybe; }; diff --git a/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts b/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts index 301960cea33ef..452c615c65aa5 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/tls/schema.gql.ts @@ -13,11 +13,10 @@ export const tlsSchema = gql` type TlsNode { _id: String timestamp: Date - alternativeNames: [String!] notAfter: [String!] - commonNames: [String!] + subjects: [String!] ja3: [String!] - issuerNames: [String!] + issuers: [String!] } input TlsSortField { field: TlsFields! diff --git a/x-pack/legacy/plugins/siem/server/graphql/types.ts b/x-pack/legacy/plugins/siem/server/graphql/types.ts index f42da48f2c1da..e2b365f8bfa5b 100644 --- a/x-pack/legacy/plugins/siem/server/graphql/types.ts +++ b/x-pack/legacy/plugins/siem/server/graphql/types.ts @@ -1861,15 +1861,13 @@ export interface TlsNode { timestamp?: Maybe; - alternativeNames?: Maybe; - notAfter?: Maybe; - commonNames?: Maybe; + subjects?: Maybe; ja3?: Maybe; - issuerNames?: Maybe; + issuers?: Maybe; } export interface UncommonProcessesData { @@ -7824,15 +7822,13 @@ export namespace TlsNodeResolvers { timestamp?: TimestampResolver, TypeParent, TContext>; - alternativeNames?: AlternativeNamesResolver, TypeParent, TContext>; - notAfter?: NotAfterResolver, TypeParent, TContext>; - commonNames?: CommonNamesResolver, TypeParent, TContext>; + subjects?: SubjectsResolver, TypeParent, TContext>; ja3?: Ja3Resolver, TypeParent, TContext>; - issuerNames?: IssuerNamesResolver, TypeParent, TContext>; + issuers?: IssuersResolver, TypeParent, TContext>; } export type _IdResolver, Parent = TlsNode, TContext = SiemContext> = Resolver< @@ -7845,17 +7841,12 @@ export namespace TlsNodeResolvers { Parent = TlsNode, TContext = SiemContext > = Resolver; - export type AlternativeNamesResolver< - R = Maybe, - Parent = TlsNode, - TContext = SiemContext - > = Resolver; export type NotAfterResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext > = Resolver; - export type CommonNamesResolver< + export type SubjectsResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext @@ -7865,7 +7856,7 @@ export namespace TlsNodeResolvers { Parent, TContext >; - export type IssuerNamesResolver< + export type IssuersResolver< R = Maybe, Parent = TlsNode, TContext = SiemContext diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts b/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts index 716eea3f8df5b..10929c3d03641 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/elasticsearch_adapter.ts @@ -66,10 +66,9 @@ export const formatTlsEdges = (buckets: TlsBuckets[]): TlsEdges[] => { const edge: TlsEdges = { node: { _id: bucket.key, - alternativeNames: bucket.alternative_names.buckets.map(({ key }) => key), - commonNames: bucket.common_names.buckets.map(({ key }) => key), + subjects: bucket.subjects.buckets.map(({ key }) => key), ja3: bucket.ja3.buckets.map(({ key }) => key), - issuerNames: bucket.issuer_names.buckets.map(({ key }) => key), + issuers: bucket.issuers.buckets.map(({ key }) => key), // eslint-disable-next-line @typescript-eslint/camelcase notAfter: bucket.not_after.buckets.map(({ key_as_string }) => key_as_string), }, diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts b/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts index 4b27d541ec992..b97a6fa509ef2 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/mock.ts @@ -20,11 +20,10 @@ export const mockTlsQuery = { order: { _key: 'desc' }, }, aggs: { - issuer_names: { terms: { field: 'tls.server_certificate.issuer.common_name' } }, - common_names: { terms: { field: 'tls.server_certificate.subject.common_name' } }, - alternative_names: { terms: { field: 'tls.server_certificate.alternative_names' } }, - not_after: { terms: { field: 'tls.server_certificate.not_after' } }, - ja3: { terms: { field: 'tls.fingerprints.ja3.hash' } }, + issuers: { terms: { field: 'tls.server.issuer' } }, + subjects: { terms: { field: 'tls.server.subject' } }, + not_after: { terms: { field: 'tls.server.not_after' } }, + ja3: { terms: { field: 'tls.server.ja3s' } }, }, }, }, @@ -44,16 +43,8 @@ export const expectedTlsEdges = [ }, node: { _id: 'fff8dc95436e0e25ce46b1526a1a547e8cf3bb82', - alternativeNames: [ - '*.1.nflxso.net', - '*.a.nflxso.net', - 'assets.nflxext.com', - 'cast.netflix.com', - 'codex.nflxext.com', - 'tvui.netflix.com', - ], - commonNames: ['*.1.nflxso.net'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], + subjects: ['*.1.nflxso.net'], + issuers: ['DigiCert SHA2 Secure Server CA'], ja3: ['95d2dd53a89b334cddd5c22e81e7fe61'], notAfter: ['2019-10-27T12:00:00.000Z'], }, @@ -65,9 +56,8 @@ export const expectedTlsEdges = [ }, node: { _id: 'fd8440c4b20978b173e0910e2639d114f0d405c5', - alternativeNames: ['*.cogocast.net', 'cogocast.net'], - commonNames: ['cogocast.net'], - issuerNames: ['Amazon'], + subjects: ['cogocast.net'], + issuers: ['Amazon'], ja3: ['a111d93cdf31f993c40a8a9ef13e8d7e'], notAfter: ['2020-02-01T12:00:00.000Z'], }, @@ -76,12 +66,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fcdc16645ebb3386adc96e7ba735c4745709b9dd' }, node: { _id: 'fcdc16645ebb3386adc96e7ba735c4745709b9dd', - alternativeNames: [ - 'player-devintever2-imperva.mountain.siriusxm.com', - 'player-devintever2.mountain.siriusxm.com', - ], - commonNames: ['player-devintever2.mountain.siriusxm.com'], - issuerNames: ['Trustwave Organization Validation SHA256 CA, Level 1'], + subjects: ['player-devintever2.mountain.siriusxm.com'], + issuers: ['Trustwave Organization Validation SHA256 CA, Level 1'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-03-06T21:57:09.000Z'], }, @@ -90,15 +76,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fccf375789cb7e671502a7b0cc969f218a4b2c70' }, node: { _id: 'fccf375789cb7e671502a7b0cc969f218a4b2c70', - alternativeNames: [ - 'appleid-nc-s.apple.com', - 'appleid-nwk-s.apple.com', - 'appleid-prn-s.apple.com', - 'appleid-rno-s.apple.com', - 'appleid.apple.com', - ], - commonNames: ['appleid.apple.com'], - issuerNames: ['DigiCert SHA2 Extended Validation Server CA'], + subjects: ['appleid.apple.com'], + issuers: ['DigiCert SHA2 Extended Validation Server CA'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-07-04T12:00:00.000Z'], }, @@ -107,20 +86,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fc4a296b706fa18ac50b96f5c0327c69db4a8981' }, node: { _id: 'fc4a296b706fa18ac50b96f5c0327c69db4a8981', - alternativeNames: [ - 'api.itunes.apple.com', - 'appsto.re', - 'ax.init.itunes.apple.com', - 'bag.itunes.apple.com', - 'bookkeeper.itunes.apple.com', - 'c.itunes.apple.com', - 'carrierbundle.itunes.apple.com', - 'client-api.itunes.apple.com', - 'cma.itunes.apple.com', - 'courses.apple.com', - ], - commonNames: ['itunes.apple.com'], - issuerNames: ['DigiCert SHA2 Extended Validation Server CA'], + subjects: ['itunes.apple.com'], + issuers: ['DigiCert SHA2 Extended Validation Server CA'], ja3: ['a441a33aaee795f498d6b764cc78989a'], notAfter: ['2020-03-24T12:00:00.000Z'], }, @@ -129,20 +96,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fc2cbc41f6a0e9c0118de4fe40f299f7207b797e' }, node: { _id: 'fc2cbc41f6a0e9c0118de4fe40f299f7207b797e', - alternativeNames: [ - '*.adlercasino.com', - '*.allaustraliancasino.com', - '*.alletf.com', - '*.appareldesignpartners.com', - '*.atmosfir.net', - '*.cityofboston.gov', - '*.cp.mytoyotaentune.com', - '*.decathlon.be', - '*.decathlon.co.uk', - '*.decathlon.de', - ], - commonNames: ['incapsula.com'], - issuerNames: ['GlobalSign CloudSSL CA - SHA256 - G3'], + subjects: ['incapsula.com'], + issuers: ['GlobalSign CloudSSL CA - SHA256 - G3'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-04-04T14:05:06.000Z'], }, @@ -151,9 +106,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fb70d78ffa663a3a4374d841b3288d2de9759566' }, node: { _id: 'fb70d78ffa663a3a4374d841b3288d2de9759566', - alternativeNames: ['*.siriusxm.com', 'siriusxm.com'], - commonNames: ['*.siriusxm.com'], - issuerNames: ['DigiCert Baltimore CA-2 G2'], + subjects: ['*.siriusxm.com'], + issuers: ['DigiCert Baltimore CA-2 G2'], ja3: ['535aca3d99fc247509cd50933cd71d37', '6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2021-10-27T12:00:00.000Z'], }, @@ -162,16 +116,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'fb59038dcec33ab3a01a6ae60d0835ad0e04ccf0' }, node: { _id: 'fb59038dcec33ab3a01a6ae60d0835ad0e04ccf0', - alternativeNames: [ - 'photos.amazon.co.uk', - 'photos.amazon.de', - 'photos.amazon.es', - 'photos.amazon.eu', - 'photos.amazon.fr', - 'photos.amazon.it', - ], - commonNames: ['photos.amazon.eu'], - issuerNames: ['Amazon'], + subjects: ['photos.amazon.eu'], + issuers: ['Amazon'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2020-04-23T12:00:00.000Z'], }, @@ -180,20 +126,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'f9815293c883a6006f0b2d95a4895bdc501fd174' }, node: { _id: 'f9815293c883a6006f0b2d95a4895bdc501fd174', - alternativeNames: [ - '*.api.cdn.hbo.com', - '*.artist.cdn.hbo.com', - '*.cdn.hbo.com', - '*.lv3.cdn.hbo.com', - 'artist.api.cdn.hbo.com', - 'artist.api.lv3.cdn.hbo.com', - 'artist.staging.cdn.hbo.com', - 'artist.staging.hurley.lv3.cdn.hbo.com', - 'atv.api.lv3.cdn.hbo.com', - 'atv.staging.hurley.lv3.cdn.hbo.com', - ], - commonNames: ['cdn.hbo.com'], - issuerNames: ['Sectigo RSA Organization Validation Secure Server CA'], + subjects: ['cdn.hbo.com'], + issuers: ['Sectigo RSA Organization Validation Secure Server CA'], ja3: ['6fa3244afc6bb6f9fad207b6b52af26b'], notAfter: ['2021-02-10T23:59:59.000Z'], }, @@ -202,9 +136,8 @@ export const expectedTlsEdges = [ cursor: { tiebreaker: null, value: 'f8db6a69797e383dca2529727369595733123386' }, node: { _id: 'f8db6a69797e383dca2529727369595733123386', - alternativeNames: ['www.google.com'], - commonNames: ['www.google.com'], - issuerNames: ['GTS CA 1O1'], + subjects: ['www.google.com'], + issuers: ['GTS CA 1O1'], ja3: ['a111d93cdf31f993c40a8a9ef13e8d7e'], notAfter: ['2019-12-10T13:32:54.000Z'], }, @@ -226,7 +159,7 @@ export const mockRequest = { timerange: { interval: '12h', from: 1570716261267, to: 1570802661267 }, }, query: - 'query GetTlsQuery($sourceId: ID!, $filterQuery: String, $flowTarget: FlowTarget!, $ip: String!, $pagination: PaginationInputPaginated!, $sort: TlsSortField!, $timerange: TimerangeInput!, $defaultIndex: [String!]!, $inspect: Boolean!) {\n source(id: $sourceId) {\n id\n Tls(filterQuery: $filterQuery, flowTarget: $flowTarget, ip: $ip, pagination: $pagination, sort: $sort, timerange: $timerange, defaultIndex: $defaultIndex) {\n totalCount\n edges {\n node {\n _id\n alternativeNames\n commonNames\n ja3\n issuerNames\n notAfter\n __typename\n }\n cursor {\n value\n __typename\n }\n __typename\n }\n pageInfo {\n activePage\n fakeTotalCount\n showMorePagesIndicator\n __typename\n }\n inspect @include(if: $inspect) {\n dsl\n response\n __typename\n }\n __typename\n }\n __typename\n }\n}\n', + 'query GetTlsQuery($sourceId: ID!, $filterQuery: String, $flowTarget: FlowTarget!, $ip: String!, $pagination: PaginationInputPaginated!, $sort: TlsSortField!, $timerange: TimerangeInput!, $defaultIndex: [String!]!, $inspect: Boolean!) {\n source(id: $sourceId) {\n id\n Tls(filterQuery: $filterQuery, flowTarget: $flowTarget, ip: $ip, pagination: $pagination, sort: $sort, timerange: $timerange, defaultIndex: $defaultIndex) {\n totalCount\n edges {\n node {\n _id\n subjects\n ja3\n issuers\n notAfter\n __typename\n }\n cursor {\n value\n __typename\n }\n __typename\n }\n pageInfo {\n activePage\n fakeTotalCount\n showMorePagesIndicator\n __typename\n }\n inspect @include(if: $inspect) {\n dsl\n response\n __typename\n }\n __typename\n }\n __typename\n }\n}\n', }, }; @@ -250,28 +183,16 @@ export const mockResponse = { { key: 1572177600000, key_as_string: '2019-10-27T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Secure Server CA', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: '*.1.nflxso.net', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.1.nflxso.net', doc_count: 1 }, - { key: '*.a.nflxso.net', doc_count: 1 }, - { key: 'assets.nflxext.com', doc_count: 1 }, - { key: 'cast.netflix.com', doc_count: 1 }, - { key: 'codex.nflxext.com', doc_count: 1 }, - { key: 'tvui.netflix.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -288,24 +209,16 @@ export const mockResponse = { { key: 1580558400000, key_as_string: '2020-02-01T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'Amazon', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'cogocast.net', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.cogocast.net', doc_count: 1 }, - { key: 'cogocast.net', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -322,26 +235,18 @@ export const mockResponse = { { key: 1583531829000, key_as_string: '2020-03-06T21:57:09.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { key: 'Trustwave Organization Validation SHA256 CA, Level 1', doc_count: 1 }, ], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'player-devintever2.mountain.siriusxm.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'player-devintever2-imperva.mountain.siriusxm.com', doc_count: 1 }, - { key: 'player-devintever2.mountain.siriusxm.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -358,27 +263,16 @@ export const mockResponse = { { key: 1593864000000, key_as_string: '2020-07-04T12:00:00.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Extended Validation Server CA', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'appleid.apple.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'appleid-nc-s.apple.com', doc_count: 1 }, - { key: 'appleid-nwk-s.apple.com', doc_count: 1 }, - { key: 'appleid-prn-s.apple.com', doc_count: 1 }, - { key: 'appleid-rno-s.apple.com', doc_count: 1 }, - { key: 'appleid.apple.com', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -395,32 +289,16 @@ export const mockResponse = { { key: 1585051200000, key_as_string: '2020-03-24T12:00:00.000Z', doc_count: 2 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert SHA2 Extended Validation Server CA', doc_count: 2 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'itunes.apple.com', doc_count: 2 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 156, - buckets: [ - { key: 'api.itunes.apple.com', doc_count: 2 }, - { key: 'appsto.re', doc_count: 2 }, - { key: 'ax.init.itunes.apple.com', doc_count: 2 }, - { key: 'bag.itunes.apple.com', doc_count: 2 }, - { key: 'bookkeeper.itunes.apple.com', doc_count: 2 }, - { key: 'c.itunes.apple.com', doc_count: 2 }, - { key: 'carrierbundle.itunes.apple.com', doc_count: 2 }, - { key: 'client-api.itunes.apple.com', doc_count: 2 }, - { key: 'cma.itunes.apple.com', doc_count: 2 }, - { key: 'courses.apple.com', doc_count: 2 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -437,32 +315,16 @@ export const mockResponse = { { key: 1586009106000, key_as_string: '2020-04-04T14:05:06.000Z', doc_count: 1 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'GlobalSign CloudSSL CA - SHA256 - G3', doc_count: 1 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'incapsula.com', doc_count: 1 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 110, - buckets: [ - { key: '*.adlercasino.com', doc_count: 1 }, - { key: '*.allaustraliancasino.com', doc_count: 1 }, - { key: '*.alletf.com', doc_count: 1 }, - { key: '*.appareldesignpartners.com', doc_count: 1 }, - { key: '*.atmosfir.net', doc_count: 1 }, - { key: '*.cityofboston.gov', doc_count: 1 }, - { key: '*.cp.mytoyotaentune.com', doc_count: 1 }, - { key: '*.decathlon.be', doc_count: 1 }, - { key: '*.decathlon.co.uk', doc_count: 1 }, - { key: '*.decathlon.de', doc_count: 1 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -479,24 +341,16 @@ export const mockResponse = { { key: 1635336000000, key_as_string: '2021-10-27T12:00:00.000Z', doc_count: 325 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'DigiCert Baltimore CA-2 G2', doc_count: 325 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: '*.siriusxm.com', doc_count: 325 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: '*.siriusxm.com', doc_count: 325 }, - { key: 'siriusxm.com', doc_count: 325 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -516,28 +370,16 @@ export const mockResponse = { { key: 1587643200000, key_as_string: '2020-04-23T12:00:00.000Z', doc_count: 5 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'Amazon', doc_count: 5 }], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'photos.amazon.eu', doc_count: 5 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { key: 'photos.amazon.co.uk', doc_count: 5 }, - { key: 'photos.amazon.de', doc_count: 5 }, - { key: 'photos.amazon.es', doc_count: 5 }, - { key: 'photos.amazon.eu', doc_count: 5 }, - { key: 'photos.amazon.fr', doc_count: 5 }, - { key: 'photos.amazon.it', doc_count: 5 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -554,34 +396,18 @@ export const mockResponse = { { key: 1613001599000, key_as_string: '2021-02-10T23:59:59.000Z', doc_count: 29 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [ { key: 'Sectigo RSA Organization Validation Secure Server CA', doc_count: 29 }, ], }, - common_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'cdn.hbo.com', doc_count: 29 }], }, - alternative_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 29, - buckets: [ - { key: '*.api.cdn.hbo.com', doc_count: 29 }, - { key: '*.artist.cdn.hbo.com', doc_count: 29 }, - { key: '*.cdn.hbo.com', doc_count: 29 }, - { key: '*.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.api.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.api.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.staging.cdn.hbo.com', doc_count: 29 }, - { key: 'artist.staging.hurley.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'atv.api.lv3.cdn.hbo.com', doc_count: 29 }, - { key: 'atv.staging.hurley.lv3.cdn.hbo.com', doc_count: 29 }, - ], - }, ja3: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, @@ -598,17 +424,12 @@ export const mockResponse = { { key: 1575984774000, key_as_string: '2019-12-10T13:32:54.000Z', doc_count: 5 }, ], }, - issuer_names: { + issuers: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'GTS CA 1O1', doc_count: 5 }], }, - common_names: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [{ key: 'www.google.com', doc_count: 5 }], - }, - alternative_names: { + subjects: { doc_count_error_upper_bound: 0, sum_other_doc_count: 0, buckets: [{ key: 'www.google.com', doc_count: 5 }], @@ -643,10 +464,9 @@ export const mockOptions = { fields: [ 'totalCount', '_id', - 'alternativeNames', - 'commonNames', + 'subjects', 'ja3', - 'issuerNames', + 'issuers', 'notAfter', 'edges.cursor.value', 'pageInfo.activePage', diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts index 2ff33a800fcd5..bc65be642dabc 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/query_tls.dsl.ts @@ -12,41 +12,36 @@ import { TlsSortField, Direction, TlsFields } from '../../graphql/types'; const getAggs = (querySize: number, sort: TlsSortField) => ({ count: { cardinality: { - field: 'tls.server_certificate.fingerprint.sha1', + field: 'tls.server.hash.sha1', }, }, sha1: { terms: { - field: 'tls.server_certificate.fingerprint.sha1', + field: 'tls.server.hash.sha1', size: querySize, order: { ...getQueryOrder(sort), }, }, aggs: { - issuer_names: { + issuers: { terms: { - field: 'tls.server_certificate.issuer.common_name', + field: 'tls.server.issuer', }, }, - common_names: { + subjects: { terms: { - field: 'tls.server_certificate.subject.common_name', - }, - }, - alternative_names: { - terms: { - field: 'tls.server_certificate.alternative_names', + field: 'tls.server.subject', }, }, not_after: { terms: { - field: 'tls.server_certificate.not_after', + field: 'tls.server.not_after', }, }, ja3: { terms: { - field: 'tls.fingerprints.ja3.hash', + field: 'tls.server.ja3s', }, }, }, diff --git a/x-pack/legacy/plugins/siem/server/lib/tls/types.ts b/x-pack/legacy/plugins/siem/server/lib/tls/types.ts index bac5426f72e08..1fbb31ba3e0f3 100644 --- a/x-pack/legacy/plugins/siem/server/lib/tls/types.ts +++ b/x-pack/legacy/plugins/siem/server/lib/tls/types.ts @@ -18,11 +18,7 @@ export interface TlsBuckets { value_as_string: string; }; - alternative_names: { - buckets: Readonly>; - }; - - common_names: { + subjects: { buckets: Readonly>; }; @@ -30,7 +26,7 @@ export interface TlsBuckets { buckets: Readonly>; }; - issuer_names: { + issuers: { buckets: Readonly>; }; diff --git a/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts b/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts index dc38824989da3..24cae53d5d353 100644 --- a/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts +++ b/x-pack/legacy/plugins/siem/server/lib/uncommon_processes/query.dsl.ts @@ -191,6 +191,22 @@ export const buildQuery = ({ ], }, }, + { + bool: { + filter: [ + { + term: { + 'event.category': 'process', + }, + }, + { + term: { + 'event.type': 'start', + }, + }, + ], + }, + }, ], minimum_should_match: 1, filter, diff --git a/x-pack/test/api_integration/apis/siem/tls.ts b/x-pack/test/api_integration/apis/siem/tls.ts index 949ed530e9b27..8467308d709af 100644 --- a/x-pack/test/api_integration/apis/siem/tls.ts +++ b/x-pack/test/api_integration/apis/siem/tls.ts @@ -16,17 +16,16 @@ import { FtrProviderContext } from '../../ftr_provider_context'; const FROM = new Date('2000-01-01T00:00:00.000Z').valueOf(); const TO = new Date('3000-01-01T00:00:00.000Z').valueOf(); -const SOURCE_IP = '157.230.208.30'; -const DESTINATION_IP = '91.189.92.20'; +const SOURCE_IP = '10.128.0.35'; +const DESTINATION_IP = '74.125.129.95'; const expectedResult = { __typename: 'TlsNode', - _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], - ja3: ['839868ad711dc55bde0d37a87f14740d'], - notAfter: ['2019-05-22T12:00:00.000Z'], + _id: '16989191B1A93ECECD5FE9E63EBD4B5C3B606D26', + subjects: ['CN=edgecert.googleapis.com,O=Google LLC,L=Mountain View,ST=California,C=US'], + issuers: ['CN=GTS CA 1O1,O=Google Trust Services,C=US'], + ja3: [], + notAfter: ['2020-05-06T11:52:15.000Z'], }; const expectedOverviewDestinationResult = { @@ -36,27 +35,29 @@ const expectedOverviewDestinationResult = { __typename: 'TlsEdges', cursor: { __typename: 'CursorType', - value: '61749734b3246f1584029deb4f5276c64da00ada', + value: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', }, node: { __typename: 'TlsNode', - _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], - ja3: ['839868ad711dc55bde0d37a87f14740d'], - notAfter: ['2019-05-22T12:00:00.000Z'], + _id: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', + subjects: [ + 'CN=*.cdn.mozilla.net,OU=Cloud Services,O=Mozilla Corporation,L=Mountain View,ST=California,C=US', + ], + issuers: ['CN=DigiCert SHA2 Secure Server CA,O=DigiCert Inc,C=US'], + ja3: [], + notAfter: ['2020-12-09T12:00:00.000Z'], }, }, ], pageInfo: { __typename: 'PageInfoPaginated', activePage: 0, - fakeTotalCount: 1, + fakeTotalCount: 3, showMorePagesIndicator: false, }, - totalCount: 1, + totalCount: 3, }; + const expectedOverviewSourceResult = { __typename: 'TlsData', edges: [ @@ -64,26 +65,27 @@ const expectedOverviewSourceResult = { __typename: 'TlsEdges', cursor: { __typename: 'CursorType', - value: '61749734b3246f1584029deb4f5276c64da00ada', + value: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', }, node: { __typename: 'TlsNode', - _id: '61749734b3246f1584029deb4f5276c64da00ada', - alternativeNames: ['api.snapcraft.io'], - commonNames: ['api.snapcraft.io'], - issuerNames: ['DigiCert SHA2 Secure Server CA'], - ja3: ['839868ad711dc55bde0d37a87f14740d'], - notAfter: ['2019-05-22T12:00:00.000Z'], + _id: 'EB4E81DD7C55BA9715652ECF5647FB8877E55A8F', + subjects: [ + 'CN=*.cdn.mozilla.net,OU=Cloud Services,O=Mozilla Corporation,L=Mountain View,ST=California,C=US', + ], + issuers: ['CN=DigiCert SHA2 Secure Server CA,O=DigiCert Inc,C=US'], + ja3: [], + notAfter: ['2020-12-09T12:00:00.000Z'], }, }, ], pageInfo: { __typename: 'PageInfoPaginated', activePage: 0, - fakeTotalCount: 1, + fakeTotalCount: 3, showMorePagesIndicator: false, }, - totalCount: 1, + totalCount: 3, }; export default function({ getService }: FtrProviderContext) { @@ -91,8 +93,8 @@ export default function({ getService }: FtrProviderContext) { const client = getService('siemGraphQLClient'); describe('Tls Test with Packetbeat', () => { describe('Tls Test', () => { - before(() => esArchiver.load('packetbeat/default')); - after(() => esArchiver.unload('packetbeat/default')); + before(() => esArchiver.load('packetbeat/tls')); + after(() => esArchiver.unload('packetbeat/tls')); it('Ensure data is returned for FlowTarget.Source', () => { return client @@ -160,8 +162,8 @@ export default function({ getService }: FtrProviderContext) { }); describe('Tls Overview Test', () => { - before(() => esArchiver.load('packetbeat/default')); - after(() => esArchiver.unload('packetbeat/default')); + before(() => esArchiver.load('packetbeat/tls')); + after(() => esArchiver.unload('packetbeat/tls')); it('Ensure data is returned for FlowTarget.Source', () => { return client @@ -189,7 +191,8 @@ export default function({ getService }: FtrProviderContext) { }) .then(resp => { const tls = resp.data.source.Tls; - expect(tls).to.eql(expectedOverviewSourceResult); + expect(tls.pageInfo).to.eql(expectedOverviewSourceResult.pageInfo); + expect(tls.edges[0]).to.eql(expectedOverviewSourceResult.edges[0]); }); }); @@ -219,7 +222,8 @@ export default function({ getService }: FtrProviderContext) { }) .then(resp => { const tls = resp.data.source.Tls; - expect(tls).to.eql(expectedOverviewDestinationResult); + expect(tls.pageInfo).to.eql(expectedOverviewDestinationResult.pageInfo); + expect(tls.edges[0]).to.eql(expectedOverviewDestinationResult.edges[0]); }); }); }); diff --git a/x-pack/test/functional/es_archives/packetbeat/tls/data.json.gz b/x-pack/test/functional/es_archives/packetbeat/tls/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..cf7a5e5f0d4467ffe4a051ee92be733018ff792c GIT binary patch literal 3929 zcmV-f52o-RiwFox#dux-17u-zVJ>QOZ*BnXU29X@$eRA1U*Y1@+1=RN_e-i$b-<7? zH6+XgX3k``rd+L7%jh6W9!VyFQ~B@PEn|=+*_I(dNJJ4QsNcT5_wMHv`Ok}9ZzsQ; znw?j@9Y4`KFWrL+70uj}|JWbwkD>2cp*h`4gqHJQ4RT*toa#ZCc#h2bmA z1`LotulrkiH8ycx8o)j>(=#(MsftGPH46m*LO$(lUxGgI0@fFXz`l^i2v1-EIlCFq z_CHIqJXVu}8)>4?ZH`fuDgt2)!{uHJ?%uMd+?CXe|RwZaI!o4*H5R1quqm(k)3$-cKB{|a=44=Qm}3ajP~ij z6)sE!0gn(Hm_0$U36NAC=h((>=mAU+T2`bv*GCJDY{6#NX*P8nF#c%13AeNVD-rbO zds@4R8>->quKh+}^vm(lM~DGkL+eJgO^Xqi4gQ8|&P)5*ikp>g?L@G8u5GqcnQ{BB zl|8m)&4;^mYM5)dH+0>$lEKD4H34kbW5c?(onhPYw;B6Ru45F zRhn6sQS%i=JHnM+7f(|Y+dM4~i(G3I#zE3VHFvm+#Wzt<*z}aprl&|WK7~7#hksS6 zcGPqlIX=3zM(WbK)kUBjmA28m|1;Dy>sg*p{}&Eg%x&~&nuKwlja=HG^14ed$Y!yP z`cs?!SxIajv=kgH^Zo0+HIKY9aj(e#T(3^>|md)Ep2iVa$i-h+cimp5-}6ve!$aRc)vP+Q^B&tH~1-Wc9I z{RaU-5Gm;+tbG#%!oa{lk1?Oy1Fn$FaIA>sk)hhB08lLg&j??58X1miSxPBMymmQm zu8GOd62FNpJMVuxI$GX9g~8=h54PMZH7;#!;kQbWtsbsal-qNgb!Zo+aBz)F`tP-Q za6z)x-BmXbX4$NA##szw4`Y@+i#&Wk9EU@@{GQ7IdnZO)*JuB7+v*Lg2}03*7U%Q( zjNeY`%}F{|ad@?G-?zj66ziR}Iua-ONCo-)pi*2#K-35F6rxuE*zW-VOBWh9ojemH z^P2z!pme<;I4!!Y>NTBtHhhlG&E>6=cl~YZvxYPauiPxocx&pNhRTNC*~D8cp9_d7 zgaS!O1cQJuZWjk2#ZpMl8RE4(q$%{c!RD!XzhQCN%xk!UYm#ys^;$33DACH@Ub^;o zj?^BPd%uU~(`FrbH=njQOj2v7^T%sDSzfy9g5u)B-K7@#7;2N%yA{`n^Q+RGpR?nmvTVq- zBCAe~sgUbjWIk(?Ge{}-fRvaLtQ2615(?5qXGySA-*NS(prkI zf@)sdI+H5VEfUklI|hedTBFX+imQV>os}>4?S^NQ#w-7ZJ70I|RoPix4T}SYUB|&5 zIS_DvpARYB+m#$rM$y6W4P}JC*%N~E14?(ro7J1Ti3K~u_pjHR@5|%YW#fA}d|k2F zJ0wVVNsme%$t}8;IJ`Xm?e%g{wkvOl@Gp;EFQo}DPflOo2I%?r8U?%rvn}0)U$=~^ zpumgDAUD_lH;v9AeR=Wa#S16twpje^>9Kf%IxN0eNlSpQUiM+56yMUfUWcEJ4#iVnE1_V0lEhXU>mU=%A(aNtuaDD=2t(kKOh7a&d?SnnPx z5@;VW1}O8i7ho0Wz@wB^{pxoD+;2>bZzf#t_`W{~AN4_T+x#3Md@P`Y`3Qgjar-4C z5CS4<|0M|GRM-PlU7;Tm!EdK$9gM%;%sUvrftL-o+rju9j9)%OHksAa9{C>n;Pq68 zs|N@C9~F|1tu`znwo6CauZ-9edSE@!F5v{qeFOlJRD!*OJ$5j}co!1J_x6WxAY8rY z2Sw~R=R;owv2RKgejM1o|KzZ}1hRweiKY#sv ze)LQ7S&qe%L=s>5zR!@5IPfJ<5P^UOTrlk!UjVKGL_h!#<%Ws? zLDs!)-@R^M?(_{}_1)|C9Zuij^c_y$;q*Vy>-HVG-{y7xAV8WbAPrH#W`F_5Mrnz> z0C^ZIj*Rg+Q_L5VDFg%&3MmgOYjheCgKI(a`>h@5-uC~I-}Zke=4AuN$G>IgZ=+v6 zpS}O}QOgFzfU)jv|M^OmaU0x-0U<_N!)RUEz@wB6M9Fv@#^b>_Nyd>;(=cm(0hbdi zL~8%Y=UgfPB@-BX4be zz(zjzT9*Xe17rUhp!FT_{5#?5_d@dTQ9;leGQT%4e;4?FTj+efq_)(DhXUp+ys5$S z>xTbMv3!UjZ$zy&1NH4LexUenFMHYp?kl|V<~ds9=id&3-5$wrgLSt@`?o;F+g1eJ zBIK*bdN%m^7K?0yfpISAp(XO?n)>Rj8PfvQo-5xxyJDzPT zEZ!FD>z$^vEWZWH*ZQdXRX(8ZvIoLf6upA9RjGyRh58V!@3ji9Epqo3#TJy+{A&fP zg_v>*0AZA1K{1wqyDL#bDW+6N!kLgjR(=0N7F)D-a1U2UGy65BB*U81h=->qTi!bLO>#!lvLVeg$jTf zkT!?G*18Z84Jqfp+F83ogTjvQ z{a*3RT?~pi&vvOP~M%xznae literal 0 HcmV?d00001 diff --git a/x-pack/test/functional/es_archives/packetbeat/tls/mappings.json b/x-pack/test/functional/es_archives/packetbeat/tls/mappings.json new file mode 100644 index 0000000000000..2b5ed05c7e8a9 --- /dev/null +++ b/x-pack/test/functional/es_archives/packetbeat/tls/mappings.json @@ -0,0 +1,9583 @@ +{ + "type": "index", + "value": { + "aliases": { + "packetbeat-7.6.0": { + "is_write_index": false + }, + "packetbeat-tls": { + "filter": { + "term": { + "event.dataset": "tls" + } + } + } + }, + "index": "packetbeat-7.6.0-2020.03.03-000001", + "mappings": { + "_meta": { + "beat": "packetbeat", + "version": "7.6.0" + }, + "date_detection": false, + "dynamic_templates": [ + { + "labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "labels.*" + } + }, + { + "container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "container.labels.*" + } + }, + { + "dns.answers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "dns.answers.*" + } + }, + { + "log.syslog": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "log.syslog.*" + } + }, + { + "fields": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "fields.*" + } + }, + { + "docker.container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "docker.container.labels.*" + } + }, + { + "kubernetes.labels.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.labels.*" + } + }, + { + "kubernetes.annotations.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.annotations.*" + } + }, + { + "amqp.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "amqp.headers.*" + } + }, + { + "cassandra.response.supported": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "cassandra.response.supported.*" + } + }, + { + "http.request.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.request.headers.*" + } + }, + { + "http.response.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.response.headers.*" + } + }, + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "amqp": { + "properties": { + "app-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "arguments": { + "type": "object" + }, + "auto-delete": { + "type": "boolean" + }, + "class-id": { + "type": "long" + }, + "consumer-count": { + "type": "long" + }, + "consumer-tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-encoding": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "correlation-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-tag": { + "type": "long" + }, + "durable": { + "type": "boolean" + }, + "exchange": { + "ignore_above": 1024, + "type": "keyword" + }, + "exchange-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "exclusive": { + "type": "boolean" + }, + "expiration": { + "ignore_above": 1024, + "type": "keyword" + }, + "headers": { + "type": "object" + }, + "if-empty": { + "type": "boolean" + }, + "if-unused": { + "type": "boolean" + }, + "immediate": { + "type": "boolean" + }, + "mandatory": { + "type": "boolean" + }, + "message-count": { + "type": "long" + }, + "message-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "method-id": { + "type": "long" + }, + "multiple": { + "type": "boolean" + }, + "no-ack": { + "type": "boolean" + }, + "no-local": { + "type": "boolean" + }, + "no-wait": { + "type": "boolean" + }, + "passive": { + "type": "boolean" + }, + "priority": { + "type": "long" + }, + "queue": { + "ignore_above": 1024, + "type": "keyword" + }, + "redelivered": { + "type": "boolean" + }, + "reply-code": { + "type": "long" + }, + "reply-text": { + "ignore_above": 1024, + "type": "keyword" + }, + "reply-to": { + "ignore_above": 1024, + "type": "keyword" + }, + "routing-key": { + "ignore_above": 1024, + "type": "keyword" + }, + "timestamp": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "user-id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes_in": { + "path": "source.bytes", + "type": "alias" + }, + "bytes_out": { + "path": "destination.bytes", + "type": "alias" + }, + "cassandra": { + "properties": { + "no_request": { + "type": "boolean" + }, + "request": { + "properties": { + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "authentication": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "type": "long" + }, + "details": { + "properties": { + "alive": { + "type": "long" + }, + "arg_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "blockfor": { + "type": "long" + }, + "data_present": { + "type": "boolean" + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_failures": { + "ignore_above": 1024, + "type": "keyword" + }, + "read_consistency": { + "ignore_above": 1024, + "type": "keyword" + }, + "received": { + "type": "long" + }, + "required": { + "type": "long" + }, + "stmt_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "write_type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "host": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "result": { + "properties": { + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "prepared": { + "properties": { + "prepared_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "req_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resp_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "rows": { + "properties": { + "meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "num_rows": { + "type": "long" + } + } + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "supported": { + "type": "object" + }, + "warnings": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "project": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dhcpv4": { + "properties": { + "assigned_ip": { + "type": "ip" + }, + "client_ip": { + "type": "ip" + }, + "client_mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "hardware_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "hops": { + "type": "long" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "option": { + "properties": { + "boot_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "broadcast_address": { + "type": "ip" + }, + "class_identifier": { + "ignore_above": 1024, + "type": "keyword" + }, + "dns_servers": { + "type": "ip" + }, + "domain_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip_address_lease_time_sec": { + "type": "long" + }, + "max_dhcp_message_size": { + "type": "long" + }, + "message": { + "norms": false, + "type": "text" + }, + "message_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "ntp_servers": { + "type": "ip" + }, + "parameter_request_list": { + "ignore_above": 1024, + "type": "keyword" + }, + "rebinding_time_sec": { + "type": "long" + }, + "renewal_time_sec": { + "type": "long" + }, + "requested_ip_address": { + "type": "ip" + }, + "router": { + "type": "ip" + }, + "server_identifier": { + "type": "ip" + }, + "subnet_mask": { + "type": "ip" + }, + "time_servers": { + "type": "ip" + }, + "utc_time_offset_sec": { + "type": "long" + }, + "vendor_identifying_options": { + "type": "object" + } + } + }, + "relay_ip": { + "type": "ip" + }, + "seconds": { + "type": "long" + }, + "server_ip": { + "type": "ip" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "transaction_id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "dns": { + "properties": { + "additionals": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "additionals_count": { + "type": "long" + }, + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "answers_count": { + "type": "long" + }, + "authorities": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "authorities_count": { + "type": "long" + }, + "flags": { + "properties": { + "authentic_data": { + "type": "boolean" + }, + "authoritative": { + "type": "boolean" + }, + "checking_disabled": { + "type": "boolean" + }, + "recursion_available": { + "type": "boolean" + }, + "recursion_desired": { + "type": "boolean" + }, + "truncated_response": { + "type": "boolean" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "opt": { + "properties": { + "cookie": { + "ignore_above": 1024, + "type": "keyword" + }, + "do": { + "type": "boolean" + }, + "ext_rcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "udp_size": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "etld_plus_one": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "docker": { + "properties": { + "container": { + "properties": { + "labels": { + "type": "object" + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fields": { + "properties": { + "labels": { + "properties": { + "responsible_human": { + "type": "keyword" + } + } + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "flow": { + "properties": { + "final": { + "type": "boolean" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "type": "long" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "containerized": { + "type": "boolean" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "build": { + "ignore_above": 1024, + "type": "keyword" + }, + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + } + } + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + } + } + }, + "status_code": { + "type": "long" + }, + "status_phrase": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "icmp": { + "properties": { + "request": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "response": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "jolokia": { + "properties": { + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "secured": { + "type": "boolean" + }, + "server": { + "properties": { + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "kubernetes": { + "properties": { + "annotations": { + "properties": { + "*": { + "type": "object" + } + } + }, + "container": { + "properties": { + "image": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "deployment": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "properties": { + "*": { + "type": "object" + } + } + }, + "namespace": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "replicaset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "statefulset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "memcache": { + "properties": { + "protocol_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "request": { + "properties": { + "automove": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "delta": { + "type": "long" + }, + "dest_class": { + "type": "long" + }, + "exptime": { + "type": "long" + }, + "flags": { + "type": "long" + }, + "initial": { + "type": "long" + }, + "line": { + "ignore_above": 1024, + "type": "keyword" + }, + "noreply": { + "type": "boolean" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "quiet": { + "type": "boolean" + }, + "raw_args": { + "ignore_above": 1024, + "type": "keyword" + }, + "sleep_us": { + "type": "long" + }, + "source_class": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vbucket": { + "type": "long" + }, + "verbosity": { + "type": "long" + } + } + }, + "response": { + "properties": { + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "error_msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "type": "long" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "status_code": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "mongodb": { + "properties": { + "cursorId": { + "ignore_above": 1024, + "type": "keyword" + }, + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "fullCollectionName": { + "ignore_above": 1024, + "type": "keyword" + }, + "numberReturned": { + "type": "long" + }, + "numberToReturn": { + "type": "long" + }, + "numberToSkip": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "returnFieldsSelector": { + "ignore_above": 1024, + "type": "keyword" + }, + "selector": { + "ignore_above": 1024, + "type": "keyword" + }, + "startingFrom": { + "ignore_above": 1024, + "type": "keyword" + }, + "update": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "mysql": { + "properties": { + "affected_rows": { + "type": "long" + }, + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "insert_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "nfs": { + "properties": { + "minor_version": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "notes": { + "path": "error.message", + "type": "alias" + }, + "observer": { + "properties": { + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "params": { + "norms": false, + "type": "text" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pgsql": { + "properties": { + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "error_severity": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "redis": { + "properties": { + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "request": { + "norms": false, + "type": "text" + }, + "resource": { + "ignore_above": 1024, + "type": "keyword" + }, + "response": { + "norms": false, + "type": "text" + }, + "rpc": { + "properties": { + "auth_flavor": { + "ignore_above": 1024, + "type": "keyword" + }, + "cred": { + "properties": { + "gid": { + "type": "long" + }, + "gids": { + "ignore_above": 1024, + "type": "keyword" + }, + "machinename": { + "ignore_above": 1024, + "type": "keyword" + }, + "stamp": { + "type": "long" + }, + "uid": { + "type": "long" + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "xid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "thrift": { + "properties": { + "exceptions": { + "ignore_above": 1024, + "type": "keyword" + }, + "params": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + }, + "service": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "timeseries": { + "properties": { + "instance": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tls": { + "properties": { + "alert_types": { + "path": "tls.detailed.alert_types", + "type": "alias" + }, + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "client_certificate": { + "properties": { + "alternative_names": { + "path": "tls.detailed.client_certificate.alternative_names", + "type": "alias" + }, + "issuer": { + "properties": { + "common_name": { + "path": "tls.detailed.client_certificate.issuer.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.client_certificate.issuer.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.client_certificate.issuer.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.client_certificate.issuer.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.client_certificate.issuer.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.client_certificate.issuer.province", + "type": "alias" + } + } + }, + "not_after": { + "path": "tls.detailed.client_certificate.not_after", + "type": "alias" + }, + "not_before": { + "path": "tls.detailed.client_certificate.not_before", + "type": "alias" + }, + "public_key_algorithm": { + "path": "tls.detailed.client_certificate.public_key_algorithm", + "type": "alias" + }, + "public_key_size": { + "path": "tls.detailed.client_certificate.public_key_size", + "type": "alias" + }, + "serial_number": { + "path": "tls.detailed.client_certificate.serial_number", + "type": "alias" + }, + "signature_algorithm": { + "path": "tls.detailed.client_certificate.signature_algorithm", + "type": "alias" + }, + "subject": { + "properties": { + "common_name": { + "path": "tls.detailed.client_certificate.subject.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.client_certificate.subject.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.client_certificate.subject.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.client_certificate.subject.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.client_certificate.subject.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.client_certificate.subject.province", + "type": "alias" + } + } + }, + "version": { + "path": "tls.detailed.client_certificate.version", + "type": "alias" + } + } + }, + "client_certificate_requested": { + "path": "tls.detailed.client_certificate_requested", + "type": "alias" + }, + "client_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "path": "tls.detailed.client_hello.extensions._unparsed_", + "type": "alias" + }, + "application_layer_protocol_negotiation": { + "path": "tls.detailed.client_hello.extensions.application_layer_protocol_negotiation", + "type": "alias" + }, + "ec_points_formats": { + "path": "tls.detailed.client_hello.extensions.ec_points_formats", + "type": "alias" + }, + "server_name_indication": { + "path": "tls.detailed.client_hello.extensions.server_name_indication", + "type": "alias" + }, + "session_ticket": { + "path": "tls.detailed.client_hello.extensions.session_ticket", + "type": "alias" + }, + "signature_algorithms": { + "path": "tls.detailed.client_hello.extensions.signature_algorithms", + "type": "alias" + }, + "supported_groups": { + "path": "tls.detailed.client_hello.extensions.supported_groups", + "type": "alias" + }, + "supported_versions": { + "path": "tls.detailed.client_hello.extensions.supported_versions", + "type": "alias" + } + } + }, + "session_id": { + "path": "tls.detailed.client_hello.session_id", + "type": "alias" + }, + "supported_ciphers": { + "path": "tls.client.supported_ciphers", + "type": "alias" + }, + "supported_compression_methods": { + "path": "tls.detailed.client_hello.supported_compression_methods", + "type": "alias" + }, + "version": { + "path": "tls.detailed.client_hello.version", + "type": "alias" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "detailed": { + "properties": { + "alert_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "client_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "client_certificate_requested": { + "type": "boolean" + }, + "client_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_name_indication": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithms": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_groups": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_compression_methods": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resumption_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_certificate_chain": { + "properties": { + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_before": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "selected_compression_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "established": { + "type": "boolean" + }, + "fingerprints": { + "properties": { + "ja3": { + "path": "tls.client.ja3", + "type": "alias" + } + } + }, + "handshake_completed": { + "path": "tls.established", + "type": "alias" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "resumption_method": { + "path": "tls.detailed.resumption_method", + "type": "alias" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server_certificate": { + "properties": { + "alternative_names": { + "path": "tls.detailed.server_certificate.alternative_names", + "type": "alias" + }, + "issuer": { + "properties": { + "common_name": { + "path": "tls.detailed.server_certificate.issuer.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.server_certificate.issuer.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.server_certificate.issuer.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.server_certificate.issuer.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.server_certificate.issuer.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.server_certificate.issuer.province", + "type": "alias" + } + } + }, + "not_after": { + "path": "tls.detailed.server_certificate.not_after", + "type": "alias" + }, + "not_before": { + "path": "tls.detailed.server_certificate.not_before", + "type": "alias" + }, + "public_key_algorithm": { + "path": "tls.detailed.server_certificate.public_key_algorithm", + "type": "alias" + }, + "public_key_size": { + "path": "tls.detailed.server_certificate.public_key_size", + "type": "alias" + }, + "serial_number": { + "path": "tls.detailed.server_certificate.serial_number", + "type": "alias" + }, + "signature_algorithm": { + "path": "tls.detailed.server_certificate.signature_algorithm", + "type": "alias" + }, + "subject": { + "properties": { + "common_name": { + "path": "tls.detailed.server_certificate.subject.common_name", + "type": "alias" + }, + "country": { + "path": "tls.detailed.server_certificate.subject.country", + "type": "alias" + }, + "locality": { + "path": "tls.detailed.server_certificate.subject.locality", + "type": "alias" + }, + "organization": { + "path": "tls.detailed.server_certificate.subject.organization", + "type": "alias" + }, + "organizational_unit": { + "path": "tls.detailed.server_certificate.subject.organizational_unit", + "type": "alias" + }, + "province": { + "path": "tls.detailed.server_certificate.subject.province", + "type": "alias" + } + } + }, + "version": { + "path": "tls.detailed.server_certificate.version", + "type": "alias" + } + } + }, + "server_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "path": "tls.detailed.server_hello.extensions._unparsed_", + "type": "alias" + }, + "application_layer_protocol_negotiation": { + "path": "tls.detailed.server_hello.extensions.application_layer_protocol_negotiation", + "type": "alias" + }, + "ec_points_formats": { + "path": "tls.detailed.server_hello.extensions.ec_points_formats", + "type": "alias" + }, + "session_ticket": { + "path": "tls.detailed.server_hello.extensions.session_ticket", + "type": "alias" + }, + "supported_versions": { + "path": "tls.detailed.server_hello.extensions.supported_versions", + "type": "alias" + } + } + }, + "selected_cipher": { + "path": "tls.cipher", + "type": "alias" + }, + "selected_compression_method": { + "path": "tls.detailed.server_hello.selected_compression_method", + "type": "alias" + }, + "session_id": { + "path": "tls.detailed.server_hello.session_id", + "type": "alias" + }, + "version": { + "path": "tls.detailed.server_hello.version", + "type": "alias" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tracing": { + "properties": { + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "lifecycle": { + "indexing_complete": "true", + "name": "packetbeat", + "rollover_alias": "packetbeat-7.6.0" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "query": { + "default_field": [ + "message", + "tags", + "agent.ephemeral_id", + "agent.id", + "agent.name", + "agent.type", + "agent.version", + "as.organization.name", + "client.address", + "client.as.organization.name", + "client.domain", + "client.geo.city_name", + "client.geo.continent_name", + "client.geo.country_iso_code", + "client.geo.country_name", + "client.geo.name", + "client.geo.region_iso_code", + "client.geo.region_name", + "client.mac", + "client.registered_domain", + "client.top_level_domain", + "client.user.domain", + "client.user.email", + "client.user.full_name", + "client.user.group.domain", + "client.user.group.id", + "client.user.group.name", + "client.user.hash", + "client.user.id", + "client.user.name", + "cloud.account.id", + "cloud.availability_zone", + "cloud.instance.id", + "cloud.instance.name", + "cloud.machine.type", + "cloud.provider", + "cloud.region", + "container.id", + "container.image.name", + "container.image.tag", + "container.name", + "container.runtime", + "destination.address", + "destination.as.organization.name", + "destination.domain", + "destination.geo.city_name", + "destination.geo.continent_name", + "destination.geo.country_iso_code", + "destination.geo.country_name", + "destination.geo.name", + "destination.geo.region_iso_code", + "destination.geo.region_name", + "destination.mac", + "destination.registered_domain", + "destination.top_level_domain", + "destination.user.domain", + "destination.user.email", + "destination.user.full_name", + "destination.user.group.domain", + "destination.user.group.id", + "destination.user.group.name", + "destination.user.hash", + "destination.user.id", + "destination.user.name", + "dns.answers.class", + "dns.answers.data", + "dns.answers.name", + "dns.answers.type", + "dns.header_flags", + "dns.id", + "dns.op_code", + "dns.question.class", + "dns.question.name", + "dns.question.registered_domain", + "dns.question.subdomain", + "dns.question.top_level_domain", + "dns.question.type", + "dns.response_code", + "dns.type", + "ecs.version", + "error.code", + "error.id", + "error.message", + "error.stack_trace", + "error.type", + "event.action", + "event.category", + "event.code", + "event.dataset", + "event.hash", + "event.id", + "event.kind", + "event.module", + "event.original", + "event.outcome", + "event.provider", + "event.timezone", + "event.type", + "file.device", + "file.directory", + "file.extension", + "file.gid", + "file.group", + "file.hash.md5", + "file.hash.sha1", + "file.hash.sha256", + "file.hash.sha512", + "file.inode", + "file.mode", + "file.name", + "file.owner", + "file.path", + "file.target_path", + "file.type", + "file.uid", + "geo.city_name", + "geo.continent_name", + "geo.country_iso_code", + "geo.country_name", + "geo.name", + "geo.region_iso_code", + "geo.region_name", + "group.domain", + "group.id", + "group.name", + "hash.md5", + "hash.sha1", + "hash.sha256", + "hash.sha512", + "host.architecture", + "host.geo.city_name", + "host.geo.continent_name", + "host.geo.country_iso_code", + "host.geo.country_name", + "host.geo.name", + "host.geo.region_iso_code", + "host.geo.region_name", + "host.hostname", + "host.id", + "host.mac", + "host.name", + "host.os.family", + "host.os.full", + "host.os.kernel", + "host.os.name", + "host.os.platform", + "host.os.version", + "host.type", + "host.user.domain", + "host.user.email", + "host.user.full_name", + "host.user.group.domain", + "host.user.group.id", + "host.user.group.name", + "host.user.hash", + "host.user.id", + "host.user.name", + "http.request.body.content", + "http.request.method", + "http.request.referrer", + "http.response.body.content", + "http.version", + "log.level", + "log.logger", + "log.origin.file.name", + "log.origin.function", + "log.original", + "log.syslog.facility.name", + "log.syslog.severity.name", + "network.application", + "network.community_id", + "network.direction", + "network.iana_number", + "network.name", + "network.protocol", + "network.transport", + "network.type", + "observer.geo.city_name", + "observer.geo.continent_name", + "observer.geo.country_iso_code", + "observer.geo.country_name", + "observer.geo.name", + "observer.geo.region_iso_code", + "observer.geo.region_name", + "observer.hostname", + "observer.mac", + "observer.name", + "observer.os.family", + "observer.os.full", + "observer.os.kernel", + "observer.os.name", + "observer.os.platform", + "observer.os.version", + "observer.product", + "observer.serial_number", + "observer.type", + "observer.vendor", + "observer.version", + "organization.id", + "organization.name", + "os.family", + "os.full", + "os.kernel", + "os.name", + "os.platform", + "os.version", + "package.architecture", + "package.checksum", + "package.description", + "package.install_scope", + "package.license", + "package.name", + "package.path", + "package.version", + "process.args", + "text", + "process.executable", + "process.hash.md5", + "process.hash.sha1", + "process.hash.sha256", + "process.hash.sha512", + "process.name", + "text", + "text", + "text", + "text", + "text", + "process.thread.name", + "process.title", + "process.working_directory", + "server.address", + "server.as.organization.name", + "server.domain", + "server.geo.city_name", + "server.geo.continent_name", + "server.geo.country_iso_code", + "server.geo.country_name", + "server.geo.name", + "server.geo.region_iso_code", + "server.geo.region_name", + "server.mac", + "server.registered_domain", + "server.top_level_domain", + "server.user.domain", + "server.user.email", + "server.user.full_name", + "server.user.group.domain", + "server.user.group.id", + "server.user.group.name", + "server.user.hash", + "server.user.id", + "server.user.name", + "service.ephemeral_id", + "service.id", + "service.name", + "service.node.name", + "service.state", + "service.type", + "service.version", + "source.address", + "source.as.organization.name", + "source.domain", + "source.geo.city_name", + "source.geo.continent_name", + "source.geo.country_iso_code", + "source.geo.country_name", + "source.geo.name", + "source.geo.region_iso_code", + "source.geo.region_name", + "source.mac", + "source.registered_domain", + "source.top_level_domain", + "source.user.domain", + "source.user.email", + "source.user.full_name", + "source.user.group.domain", + "source.user.group.id", + "source.user.group.name", + "source.user.hash", + "source.user.id", + "source.user.name", + "threat.framework", + "threat.tactic.id", + "threat.tactic.name", + "threat.tactic.reference", + "threat.technique.id", + "threat.technique.name", + "threat.technique.reference", + "tracing.trace.id", + "tracing.transaction.id", + "url.domain", + "url.extension", + "url.fragment", + "url.full", + "url.original", + "url.password", + "url.path", + "url.query", + "url.registered_domain", + "url.scheme", + "url.top_level_domain", + "url.username", + "user.domain", + "user.email", + "user.full_name", + "user.group.domain", + "user.group.id", + "user.group.name", + "user.hash", + "user.id", + "user.name", + "user_agent.device.name", + "user_agent.name", + "text", + "user_agent.original", + "user_agent.os.family", + "user_agent.os.full", + "user_agent.os.kernel", + "user_agent.os.name", + "user_agent.os.platform", + "user_agent.os.version", + "user_agent.version", + "text", + "agent.hostname", + "timeseries.instance", + "cloud.project.id", + "cloud.image.id", + "host.os.build", + "host.os.codename", + "kubernetes.pod.name", + "kubernetes.pod.uid", + "kubernetes.namespace", + "kubernetes.node.name", + "kubernetes.replicaset.name", + "kubernetes.deployment.name", + "kubernetes.statefulset.name", + "kubernetes.container.name", + "kubernetes.container.image", + "jolokia.agent.version", + "jolokia.agent.id", + "jolokia.server.product", + "jolokia.server.version", + "jolokia.server.vendor", + "jolokia.url", + "type", + "server.process.name", + "server.process.args", + "server.process.executable", + "server.process.working_directory", + "server.process.start", + "client.process.name", + "client.process.args", + "client.process.executable", + "client.process.working_directory", + "client.process.start", + "flow.id", + "status", + "method", + "resource", + "path", + "query", + "params", + "request", + "response", + "amqp.reply-text", + "amqp.exchange", + "amqp.exchange-type", + "amqp.consumer-tag", + "amqp.routing-key", + "amqp.queue", + "amqp.content-type", + "amqp.content-encoding", + "amqp.delivery-mode", + "amqp.correlation-id", + "amqp.reply-to", + "amqp.expiration", + "amqp.message-id", + "amqp.timestamp", + "amqp.type", + "amqp.user-id", + "amqp.app-id", + "cassandra.request.headers.flags", + "cassandra.request.headers.stream", + "cassandra.request.headers.op", + "cassandra.request.query", + "cassandra.response.headers.flags", + "cassandra.response.headers.stream", + "cassandra.response.headers.op", + "cassandra.response.result.type", + "cassandra.response.result.rows.meta.keyspace", + "cassandra.response.result.rows.meta.table", + "cassandra.response.result.rows.meta.flags", + "cassandra.response.result.rows.meta.paging_state", + "cassandra.response.result.keyspace", + "cassandra.response.result.schema_change.change", + "cassandra.response.result.schema_change.keyspace", + "cassandra.response.result.schema_change.table", + "cassandra.response.result.schema_change.object", + "cassandra.response.result.schema_change.target", + "cassandra.response.result.schema_change.name", + "cassandra.response.result.schema_change.args", + "cassandra.response.result.prepared.prepared_id", + "cassandra.response.result.prepared.req_meta.keyspace", + "cassandra.response.result.prepared.req_meta.table", + "cassandra.response.result.prepared.req_meta.flags", + "cassandra.response.result.prepared.req_meta.paging_state", + "cassandra.response.result.prepared.resp_meta.keyspace", + "cassandra.response.result.prepared.resp_meta.table", + "cassandra.response.result.prepared.resp_meta.flags", + "cassandra.response.result.prepared.resp_meta.paging_state", + "cassandra.response.authentication.class", + "cassandra.response.warnings", + "cassandra.response.event.type", + "cassandra.response.event.change", + "cassandra.response.event.host", + "cassandra.response.event.schema_change.change", + "cassandra.response.event.schema_change.keyspace", + "cassandra.response.event.schema_change.table", + "cassandra.response.event.schema_change.object", + "cassandra.response.event.schema_change.target", + "cassandra.response.event.schema_change.name", + "cassandra.response.event.schema_change.args", + "cassandra.response.error.msg", + "cassandra.response.error.type", + "cassandra.response.error.details.read_consistency", + "cassandra.response.error.details.write_type", + "cassandra.response.error.details.keyspace", + "cassandra.response.error.details.table", + "cassandra.response.error.details.stmt_id", + "cassandra.response.error.details.num_failures", + "cassandra.response.error.details.function", + "cassandra.response.error.details.arg_types", + "dhcpv4.transaction_id", + "dhcpv4.flags", + "dhcpv4.client_mac", + "dhcpv4.server_name", + "dhcpv4.op_code", + "dhcpv4.hardware_type", + "dhcpv4.option.message_type", + "dhcpv4.option.parameter_request_list", + "dhcpv4.option.class_identifier", + "dhcpv4.option.domain_name", + "dhcpv4.option.hostname", + "dhcpv4.option.message", + "dhcpv4.option.boot_file_name", + "dns.question.etld_plus_one", + "dns.authorities.name", + "dns.authorities.type", + "dns.authorities.class", + "dns.additionals.name", + "dns.additionals.type", + "dns.additionals.class", + "dns.additionals.data", + "dns.opt.version", + "dns.opt.ext_rcode", + "http.response.status_phrase", + "icmp.version", + "icmp.request.message", + "icmp.response.message", + "memcache.protocol_type", + "memcache.request.line", + "memcache.request.command", + "memcache.response.command", + "memcache.request.type", + "memcache.response.type", + "memcache.response.error_msg", + "memcache.request.opcode", + "memcache.response.opcode", + "memcache.response.status", + "memcache.request.raw_args", + "memcache.request.automove", + "memcache.response.version", + "mongodb.error", + "mongodb.fullCollectionName", + "mongodb.startingFrom", + "mongodb.query", + "mongodb.returnFieldsSelector", + "mongodb.selector", + "mongodb.update", + "mongodb.cursorId", + "mysql.insert_id", + "mysql.num_fields", + "mysql.num_rows", + "mysql.query", + "mysql.error_message", + "nfs.tag", + "nfs.opcode", + "nfs.status", + "rpc.xid", + "rpc.status", + "rpc.auth_flavor", + "rpc.cred.gids", + "rpc.cred.machinename", + "pgsql.error_message", + "pgsql.error_severity", + "pgsql.num_fields", + "pgsql.num_rows", + "redis.return_value", + "redis.error", + "thrift.params", + "thrift.service", + "thrift.return_value", + "thrift.exceptions", + "tls.detailed.version", + "tls.detailed.resumption_method", + "tls.detailed.client_hello.version", + "tls.detailed.client_hello.session_id", + "tls.detailed.client_hello.supported_compression_methods", + "tls.detailed.client_hello.extensions.server_name_indication", + "tls.detailed.client_hello.extensions.application_layer_protocol_negotiation", + "tls.detailed.client_hello.extensions.session_ticket", + "tls.detailed.client_hello.extensions.supported_versions", + "tls.detailed.client_hello.extensions.supported_groups", + "tls.detailed.client_hello.extensions.signature_algorithms", + "tls.detailed.client_hello.extensions.ec_points_formats", + "tls.detailed.client_hello.extensions._unparsed_", + "tls.detailed.server_hello.version", + "tls.detailed.server_hello.selected_compression_method", + "tls.detailed.server_hello.session_id", + "tls.detailed.server_hello.extensions.application_layer_protocol_negotiation", + "tls.detailed.server_hello.extensions.session_ticket", + "tls.detailed.server_hello.extensions.supported_versions", + "tls.detailed.server_hello.extensions.ec_points_formats", + "tls.detailed.server_hello.extensions._unparsed_", + "tls.detailed.client_certificate.serial_number", + "tls.detailed.client_certificate.public_key_algorithm", + "tls.detailed.client_certificate.signature_algorithm", + "tls.detailed.client_certificate.alternative_names", + "tls.detailed.client_certificate.subject.country", + "tls.detailed.client_certificate.subject.organization", + "tls.detailed.client_certificate.subject.organizational_unit", + "tls.detailed.client_certificate.subject.province", + "tls.detailed.client_certificate.subject.common_name", + "tls.detailed.client_certificate.subject.locality", + "tls.detailed.client_certificate.issuer.country", + "tls.detailed.client_certificate.issuer.organization", + "tls.detailed.client_certificate.issuer.organizational_unit", + "tls.detailed.client_certificate.issuer.province", + "tls.detailed.client_certificate.issuer.common_name", + "tls.detailed.client_certificate.issuer.locality", + "tls.detailed.server_certificate.serial_number", + "tls.detailed.server_certificate.public_key_algorithm", + "tls.detailed.server_certificate.signature_algorithm", + "tls.detailed.server_certificate.alternative_names", + "tls.detailed.server_certificate.subject.country", + "tls.detailed.server_certificate.subject.organization", + "tls.detailed.server_certificate.subject.organizational_unit", + "tls.detailed.server_certificate.subject.province", + "tls.detailed.server_certificate.subject.common_name", + "tls.detailed.server_certificate.subject.locality", + "tls.detailed.server_certificate.issuer.country", + "tls.detailed.server_certificate.issuer.organization", + "tls.detailed.server_certificate.issuer.organizational_unit", + "tls.detailed.server_certificate.issuer.province", + "tls.detailed.server_certificate.issuer.common_name", + "tls.detailed.server_certificate.issuer.locality", + "tls.detailed.alert_types", + "fields.*" + ] + }, + "refresh_interval": "5s" + } + } + } +} + +{ + "type": "index", + "value": { + "aliases": { + "beats": { + }, + "packetbeat-8.0.0": { + "is_write_index": true + }, + "packetbeat-tls": { + "filter": { + "term": { + "event.dataset": "tls" + } + } + }, + "siem-read-alias": { + } + }, + "index": "packetbeat-8.0.0-2019.08.29-000010", + "mappings": { + "_meta": { + "beat": "packetbeat", + "version": "8.0.0" + }, + "date_detection": false, + "dynamic_templates": [ + { + "labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "labels.*" + } + }, + { + "container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "container.labels.*" + } + }, + { + "dns.answers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "dns.answers.*" + } + }, + { + "fields": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "fields.*" + } + }, + { + "docker.container.labels": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "docker.container.labels.*" + } + }, + { + "kubernetes.labels.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.labels.*" + } + }, + { + "kubernetes.annotations.*": { + "mapping": { + "type": "keyword" + }, + "path_match": "kubernetes.annotations.*" + } + }, + { + "amqp.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "amqp.headers.*" + } + }, + { + "cassandra.response.supported": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "cassandra.response.supported.*" + } + }, + { + "http.request.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.request.headers.*" + } + }, + { + "http.response.headers": { + "mapping": { + "type": "keyword" + }, + "match_mapping_type": "string", + "path_match": "http.response.headers.*" + } + }, + { + "strings_as_keyword": { + "mapping": { + "ignore_above": 1024, + "type": "keyword" + }, + "match_mapping_type": "string" + } + } + ], + "properties": { + "@timestamp": { + "type": "date" + }, + "@version": { + "ignore_above": 1024, + "type": "keyword" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "amqp": { + "properties": { + "app-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "arguments": { + "type": "object" + }, + "auto-delete": { + "type": "boolean" + }, + "class-id": { + "type": "long" + }, + "consumer-count": { + "type": "long" + }, + "consumer-tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-encoding": { + "ignore_above": 1024, + "type": "keyword" + }, + "content-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "correlation-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "delivery-tag": { + "type": "long" + }, + "durable": { + "type": "boolean" + }, + "exchange": { + "ignore_above": 1024, + "type": "keyword" + }, + "exchange-type": { + "ignore_above": 1024, + "type": "keyword" + }, + "exclusive": { + "type": "boolean" + }, + "expiration": { + "ignore_above": 1024, + "type": "keyword" + }, + "headers": { + "type": "object" + }, + "if-empty": { + "type": "boolean" + }, + "if-unused": { + "type": "boolean" + }, + "immediate": { + "type": "boolean" + }, + "mandatory": { + "type": "boolean" + }, + "message-count": { + "type": "long" + }, + "message-id": { + "ignore_above": 1024, + "type": "keyword" + }, + "method-id": { + "type": "long" + }, + "multiple": { + "type": "boolean" + }, + "no-ack": { + "type": "boolean" + }, + "no-local": { + "type": "boolean" + }, + "no-wait": { + "type": "boolean" + }, + "passive": { + "type": "boolean" + }, + "priority": { + "type": "long" + }, + "queue": { + "ignore_above": 1024, + "type": "keyword" + }, + "redelivered": { + "type": "boolean" + }, + "reply-code": { + "type": "long" + }, + "reply-text": { + "ignore_above": 1024, + "type": "keyword" + }, + "reply-to": { + "ignore_above": 1024, + "type": "keyword" + }, + "routing-key": { + "ignore_above": 1024, + "type": "keyword" + }, + "timestamp": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "user-id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes_in": { + "path": "source.bytes", + "type": "alias" + }, + "bytes_out": { + "path": "destination.bytes", + "type": "alias" + }, + "cassandra": { + "properties": { + "no_request": { + "type": "boolean" + }, + "request": { + "properties": { + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "authentication": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "type": "long" + }, + "details": { + "properties": { + "alive": { + "type": "long" + }, + "arg_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "blockfor": { + "type": "long" + }, + "data_present": { + "type": "boolean" + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_failures": { + "ignore_above": 1024, + "type": "keyword" + }, + "read_consistency": { + "ignore_above": 1024, + "type": "keyword" + }, + "received": { + "type": "long" + }, + "required": { + "type": "long" + }, + "stmt_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "write_type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "host": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "headers": { + "properties": { + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "length": { + "type": "long" + }, + "op": { + "ignore_above": 1024, + "type": "keyword" + }, + "stream": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "result": { + "properties": { + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "prepared": { + "properties": { + "prepared_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "req_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resp_meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "rows": { + "properties": { + "meta": { + "properties": { + "col_count": { + "type": "long" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "paging_state": { + "ignore_above": 1024, + "type": "keyword" + }, + "pkey_columns": { + "type": "long" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "num_rows": { + "type": "long" + } + } + }, + "schema_change": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "change": { + "ignore_above": 1024, + "type": "keyword" + }, + "keyspace": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "object": { + "ignore_above": 1024, + "type": "keyword" + }, + "table": { + "ignore_above": 1024, + "type": "keyword" + }, + "target": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "supported": { + "type": "object" + }, + "warnings": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "project": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain_top1m_rank": { + "type": "long" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dhcpv4": { + "properties": { + "assigned_ip": { + "type": "ip" + }, + "client_ip": { + "type": "ip" + }, + "client_mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "hardware_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "hops": { + "type": "long" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "option": { + "properties": { + "boot_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "broadcast_address": { + "type": "ip" + }, + "class_identifier": { + "ignore_above": 1024, + "type": "keyword" + }, + "dns_servers": { + "type": "ip" + }, + "domain_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip_address_lease_time_sec": { + "type": "long" + }, + "max_dhcp_message_size": { + "type": "long" + }, + "message": { + "norms": false, + "type": "text" + }, + "message_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "ntp_servers": { + "type": "ip" + }, + "parameter_request_list": { + "ignore_above": 1024, + "type": "keyword" + }, + "rebinding_time_sec": { + "type": "long" + }, + "renewal_time_sec": { + "type": "long" + }, + "requested_ip_address": { + "type": "ip" + }, + "router": { + "type": "ip" + }, + "server_identifier": { + "type": "ip" + }, + "subnet_mask": { + "type": "ip" + }, + "time_servers": { + "type": "ip" + }, + "utc_time_offset_sec": { + "type": "long" + }, + "vendor_identifying_options": { + "type": "object" + } + } + }, + "relay_ip": { + "type": "ip" + }, + "seconds": { + "type": "long" + }, + "server_ip": { + "type": "ip" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "transaction_id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "dns": { + "properties": { + "additionals": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "additionals_count": { + "type": "long" + }, + "answers": { + "properties": { + "algorithm": { + "type": "keyword" + }, + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "digest_type": { + "type": "keyword" + }, + "expiration": { + "type": "keyword" + }, + "expire": { + "type": "long" + }, + "flags": { + "type": "keyword" + }, + "inception": { + "type": "keyword" + }, + "key_tag": { + "type": "keyword" + }, + "labels": { + "type": "keyword" + }, + "minimum": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_ttl": { + "type": "keyword" + }, + "protocol": { + "type": "keyword" + }, + "refresh": { + "type": "long" + }, + "retry": { + "type": "long" + }, + "rname": { + "type": "keyword" + }, + "serial": { + "type": "long" + }, + "signer_name": { + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "type_covered": { + "type": "keyword" + } + } + }, + "answers_count": { + "type": "long" + }, + "authorities": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "expire": { + "type": "long" + }, + "minimum": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "refresh": { + "type": "long" + }, + "retry": { + "type": "long" + }, + "rname": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial": { + "type": "long" + }, + "ttl": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "authorities_count": { + "type": "long" + }, + "flags": { + "properties": { + "authentic_data": { + "type": "boolean" + }, + "authoritative": { + "type": "boolean" + }, + "checking_disabled": { + "type": "boolean" + }, + "recursion_available": { + "type": "boolean" + }, + "recursion_desired": { + "type": "boolean" + }, + "truncated_response": { + "type": "boolean" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "opt": { + "properties": { + "do": { + "type": "boolean" + }, + "ext_rcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "udp_size": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "etld_plus_one": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "docker": { + "properties": { + "container": { + "properties": { + "labels": { + "type": "object" + } + } + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fields": { + "type": "object" + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "target_path": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "flow": { + "properties": { + "final": { + "type": "boolean" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "type": "long" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "containerized": { + "type": "boolean" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "build": { + "ignore_above": 1024, + "type": "keyword" + }, + "codename": { + "ignore_above": 1024, + "type": "keyword" + }, + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "referer": { + "type": "keyword" + }, + "user-agent": { + "type": "keyword" + } + } + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "headers": { + "properties": { + "content-length": { + "type": "long" + }, + "content-type": { + "type": "keyword" + }, + "host": { + "type": "keyword" + }, + "location": { + "type": "keyword" + }, + "user-agent": { + "type": "keyword" + } + } + }, + "status_code": { + "type": "long" + }, + "status_phrase": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "icmp": { + "properties": { + "request": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "response": { + "properties": { + "code": { + "type": "long" + }, + "message": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "jolokia": { + "properties": { + "agent": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "secured": { + "type": "boolean" + }, + "server": { + "properties": { + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "kubernetes": { + "properties": { + "annotations": { + "properties": { + "*": { + "type": "object" + } + } + }, + "container": { + "properties": { + "image": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "deployment": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "properties": { + "*": { + "type": "object" + } + } + }, + "namespace": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pod": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "replicaset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "statefulset": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "memcache": { + "properties": { + "protocol_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "request": { + "properties": { + "automove": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "delta": { + "type": "long" + }, + "dest_class": { + "type": "long" + }, + "exptime": { + "type": "long" + }, + "flags": { + "type": "long" + }, + "initial": { + "type": "long" + }, + "line": { + "ignore_above": 1024, + "type": "keyword" + }, + "noreply": { + "type": "boolean" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "quiet": { + "type": "boolean" + }, + "raw_args": { + "ignore_above": 1024, + "type": "keyword" + }, + "sleep_us": { + "type": "long" + }, + "source_class": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vbucket": { + "type": "long" + }, + "verbosity": { + "type": "long" + } + } + }, + "response": { + "properties": { + "bytes": { + "type": "long" + }, + "cas_unique": { + "type": "long" + }, + "command": { + "ignore_above": 1024, + "type": "keyword" + }, + "count_values": { + "type": "long" + }, + "error_msg": { + "ignore_above": 1024, + "type": "keyword" + }, + "flags": { + "type": "long" + }, + "opaque": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "opcode_value": { + "type": "long" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "status_code": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "type": "long" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "mongodb": { + "properties": { + "cursorId": { + "ignore_above": 1024, + "type": "keyword" + }, + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "fullCollectionName": { + "ignore_above": 1024, + "type": "keyword" + }, + "numberReturned": { + "type": "long" + }, + "numberToReturn": { + "type": "long" + }, + "numberToSkip": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "returnFieldsSelector": { + "ignore_above": 1024, + "type": "keyword" + }, + "selector": { + "ignore_above": 1024, + "type": "keyword" + }, + "startingFrom": { + "ignore_above": 1024, + "type": "keyword" + }, + "update": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "mysql": { + "properties": { + "affected_rows": { + "type": "long" + }, + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "insert_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "nfs": { + "properties": { + "minor_version": { + "type": "long" + }, + "opcode": { + "ignore_above": 1024, + "type": "keyword" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "type": "long" + } + } + }, + "notes": { + "path": "error.message", + "type": "alias" + }, + "observer": { + "properties": { + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "params": { + "norms": false, + "type": "text" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pgsql": { + "properties": { + "error_code": { + "type": "long" + }, + "error_message": { + "ignore_above": 1024, + "type": "keyword" + }, + "error_severity": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_fields": { + "ignore_above": 1024, + "type": "keyword" + }, + "num_rows": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "redis": { + "properties": { + "error": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "ip": { + "type": "ip" + } + } + }, + "request": { + "norms": false, + "type": "text" + }, + "resource": { + "ignore_above": 1024, + "type": "keyword" + }, + "response": { + "norms": false, + "type": "text" + }, + "rpc": { + "properties": { + "auth_flavor": { + "ignore_above": 1024, + "type": "keyword" + }, + "cred": { + "properties": { + "gid": { + "type": "long" + }, + "gids": { + "ignore_above": 1024, + "type": "keyword" + }, + "machinename": { + "ignore_above": 1024, + "type": "keyword" + }, + "stamp": { + "type": "long" + }, + "uid": { + "type": "long" + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "xid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "start": { + "ignore_above": 1024, + "type": "keyword" + }, + "working_directory": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain_top1m_rank": { + "type": "long" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "thrift": { + "properties": { + "exceptions": { + "ignore_above": 1024, + "type": "keyword" + }, + "params": { + "ignore_above": 1024, + "type": "keyword" + }, + "return_value": { + "ignore_above": 1024, + "type": "keyword" + }, + "service": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "timeseries": { + "properties": { + "instance": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tls": { + "properties": { + "alert_types": { + "ignore_above": 1024, + "type": "keyword" + }, + "alerts": { + "properties": { + "code": { + "type": "long" + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + }, + "source": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "client_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "fingerprint": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "raw": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "client_certificate_chain": { + "properties": { + "fingerprint": { + "properties": { + "sha1": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_before": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "client_certificate_requested": { + "type": "boolean" + }, + "client_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_name_indication": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithms": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_groups": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_compression_methods": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "fingerprints": { + "properties": { + "ja3": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "str": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "handshake_completed": { + "type": "boolean" + }, + "resumed": { + "type": "boolean" + }, + "resumption_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "server_certificate": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "fingerprint": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "raw": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "postal_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "street_address": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_certificate_chain": { + "properties": { + "alternative_names": { + "ignore_above": 1024, + "type": "keyword" + }, + "fingerprint": { + "properties": { + "sha1": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "not_after": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_before": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "signature_algorithm": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "properties": { + "common_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country": { + "ignore_above": 1024, + "type": "keyword" + }, + "locality": { + "ignore_above": 1024, + "type": "keyword" + }, + "organization": { + "ignore_above": 1024, + "type": "keyword" + }, + "organizational_unit": { + "ignore_above": 1024, + "type": "keyword" + }, + "province": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "type": "long" + } + } + }, + "server_hello": { + "properties": { + "extensions": { + "properties": { + "_unparsed_": { + "ignore_above": 1024, + "type": "keyword" + }, + "application_layer_protocol_negotiation": { + "ignore_above": 1024, + "type": "keyword" + }, + "ec_points_formats": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_ticket": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_versions": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "selected_cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "selected_compression_method": { + "ignore_above": 1024, + "type": "keyword" + }, + "session_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "tracing": { + "properties": { + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "codec": "best_compression", + "lifecycle": { + "name": "packetbeat-8.0.0", + "rollover_alias": "packetbeat-8.0.0" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "query": { + "default_field": [ + "message", + "tags", + "agent.ephemeral_id", + "agent.id", + "agent.name", + "agent.type", + "agent.version", + "as.organization.name", + "client.address", + "client.as.organization.name", + "client.domain", + "client.geo.city_name", + "client.geo.continent_name", + "client.geo.country_iso_code", + "client.geo.country_name", + "client.geo.name", + "client.geo.region_iso_code", + "client.geo.region_name", + "client.mac", + "client.user.domain", + "client.user.email", + "client.user.full_name", + "client.user.group.id", + "client.user.group.name", + "client.user.hash", + "client.user.id", + "client.user.name", + "cloud.account.id", + "cloud.availability_zone", + "cloud.instance.id", + "cloud.instance.name", + "cloud.machine.type", + "cloud.provider", + "cloud.region", + "container.id", + "container.image.name", + "container.image.tag", + "container.name", + "container.runtime", + "destination.address", + "destination.as.organization.name", + "destination.domain", + "destination.geo.city_name", + "destination.geo.continent_name", + "destination.geo.country_iso_code", + "destination.geo.country_name", + "destination.geo.name", + "destination.geo.region_iso_code", + "destination.geo.region_name", + "destination.mac", + "destination.user.domain", + "destination.user.email", + "destination.user.full_name", + "destination.user.group.id", + "destination.user.group.name", + "destination.user.hash", + "destination.user.id", + "destination.user.name", + "dns.answers.class", + "dns.answers.data", + "dns.answers.name", + "dns.answers.type", + "dns.header_flags", + "dns.id", + "dns.op_code", + "dns.question.class", + "dns.question.name", + "dns.question.registered_domain", + "dns.question.type", + "dns.response_code", + "dns.type", + "ecs.version", + "error.code", + "error.id", + "error.message", + "event.action", + "event.category", + "event.code", + "event.dataset", + "event.hash", + "event.id", + "event.kind", + "event.module", + "event.original", + "event.outcome", + "event.provider", + "event.timezone", + "event.type", + "file.device", + "file.directory", + "file.extension", + "file.gid", + "file.group", + "file.hash.md5", + "file.hash.sha1", + "file.hash.sha256", + "file.hash.sha512", + "file.inode", + "file.mode", + "file.name", + "file.owner", + "file.path", + "file.target_path", + "file.type", + "file.uid", + "geo.city_name", + "geo.continent_name", + "geo.country_iso_code", + "geo.country_name", + "geo.name", + "geo.region_iso_code", + "geo.region_name", + "group.id", + "group.name", + "hash.md5", + "hash.sha1", + "hash.sha256", + "hash.sha512", + "host.architecture", + "host.geo.city_name", + "host.geo.continent_name", + "host.geo.country_iso_code", + "host.geo.country_name", + "host.geo.name", + "host.geo.region_iso_code", + "host.geo.region_name", + "host.hostname", + "host.id", + "host.mac", + "host.name", + "host.os.family", + "host.os.full", + "host.os.kernel", + "host.os.name", + "host.os.platform", + "host.os.version", + "host.type", + "host.user.domain", + "host.user.email", + "host.user.full_name", + "host.user.group.id", + "host.user.group.name", + "host.user.hash", + "host.user.id", + "host.user.name", + "http.request.body.content", + "http.request.method", + "http.request.referrer", + "http.response.body.content", + "http.version", + "log.level", + "log.logger", + "log.original", + "network.application", + "network.community_id", + "network.direction", + "network.iana_number", + "network.name", + "network.protocol", + "network.transport", + "network.type", + "observer.geo.city_name", + "observer.geo.continent_name", + "observer.geo.country_iso_code", + "observer.geo.country_name", + "observer.geo.name", + "observer.geo.region_iso_code", + "observer.geo.region_name", + "observer.hostname", + "observer.mac", + "observer.os.family", + "observer.os.full", + "observer.os.kernel", + "observer.os.name", + "observer.os.platform", + "observer.os.version", + "observer.serial_number", + "observer.type", + "observer.vendor", + "observer.version", + "organization.id", + "organization.name", + "os.family", + "os.full", + "os.kernel", + "os.name", + "os.platform", + "os.version", + "process.args", + "process.executable", + "process.hash.md5", + "process.hash.sha1", + "process.hash.sha256", + "process.hash.sha512", + "process.name", + "process.thread.name", + "process.title", + "process.working_directory", + "server.address", + "server.as.organization.name", + "server.domain", + "server.geo.city_name", + "server.geo.continent_name", + "server.geo.country_iso_code", + "server.geo.country_name", + "server.geo.name", + "server.geo.region_iso_code", + "server.geo.region_name", + "server.mac", + "server.user.domain", + "server.user.email", + "server.user.full_name", + "server.user.group.id", + "server.user.group.name", + "server.user.hash", + "server.user.id", + "server.user.name", + "service.ephemeral_id", + "service.id", + "service.name", + "service.state", + "service.type", + "service.version", + "source.address", + "source.as.organization.name", + "source.domain", + "source.geo.city_name", + "source.geo.continent_name", + "source.geo.country_iso_code", + "source.geo.country_name", + "source.geo.name", + "source.geo.region_iso_code", + "source.geo.region_name", + "source.mac", + "source.user.domain", + "source.user.email", + "source.user.full_name", + "source.user.group.id", + "source.user.group.name", + "source.user.hash", + "source.user.id", + "source.user.name", + "tracing.trace.id", + "tracing.transaction.id", + "url.domain", + "url.fragment", + "url.full", + "url.original", + "url.password", + "url.path", + "url.query", + "url.scheme", + "url.username", + "user.domain", + "user.email", + "user.full_name", + "user.group.id", + "user.group.name", + "user.hash", + "user.id", + "user.name", + "user_agent.device.name", + "user_agent.name", + "user_agent.original", + "user_agent.os.family", + "user_agent.os.full", + "user_agent.os.kernel", + "user_agent.os.name", + "user_agent.os.platform", + "user_agent.os.version", + "user_agent.version", + "agent.hostname", + "error.type", + "timeseries.instance", + "cloud.project.id", + "cloud.image.id", + "host.os.build", + "host.os.codename", + "kubernetes.pod.name", + "kubernetes.pod.uid", + "kubernetes.namespace", + "kubernetes.node.name", + "kubernetes.replicaset.name", + "kubernetes.deployment.name", + "kubernetes.statefulset.name", + "kubernetes.container.name", + "kubernetes.container.image", + "jolokia.agent.version", + "jolokia.agent.id", + "jolokia.server.product", + "jolokia.server.version", + "jolokia.server.vendor", + "jolokia.url", + "type", + "server.process.name", + "server.process.args", + "server.process.executable", + "server.process.working_directory", + "server.process.start", + "client.process.name", + "client.process.args", + "client.process.executable", + "client.process.working_directory", + "client.process.start", + "flow.id", + "status", + "method", + "resource", + "path", + "query", + "params", + "request", + "response", + "amqp.reply-text", + "amqp.exchange", + "amqp.exchange-type", + "amqp.consumer-tag", + "amqp.routing-key", + "amqp.queue", + "amqp.content-type", + "amqp.content-encoding", + "amqp.delivery-mode", + "amqp.correlation-id", + "amqp.reply-to", + "amqp.expiration", + "amqp.message-id", + "amqp.timestamp", + "amqp.type", + "amqp.user-id", + "amqp.app-id", + "cassandra.request.headers.flags", + "cassandra.request.headers.stream", + "cassandra.request.headers.op", + "cassandra.request.query", + "cassandra.response.headers.flags", + "cassandra.response.headers.stream", + "cassandra.response.headers.op", + "cassandra.response.result.type", + "cassandra.response.result.rows.meta.keyspace", + "cassandra.response.result.rows.meta.table", + "cassandra.response.result.rows.meta.flags", + "cassandra.response.result.rows.meta.paging_state", + "cassandra.response.result.keyspace", + "cassandra.response.result.schema_change.change", + "cassandra.response.result.schema_change.keyspace", + "cassandra.response.result.schema_change.table", + "cassandra.response.result.schema_change.object", + "cassandra.response.result.schema_change.target", + "cassandra.response.result.schema_change.name", + "cassandra.response.result.schema_change.args", + "cassandra.response.result.prepared.prepared_id", + "cassandra.response.result.prepared.req_meta.keyspace", + "cassandra.response.result.prepared.req_meta.table", + "cassandra.response.result.prepared.req_meta.flags", + "cassandra.response.result.prepared.req_meta.paging_state", + "cassandra.response.result.prepared.resp_meta.keyspace", + "cassandra.response.result.prepared.resp_meta.table", + "cassandra.response.result.prepared.resp_meta.flags", + "cassandra.response.result.prepared.resp_meta.paging_state", + "cassandra.response.authentication.class", + "cassandra.response.warnings", + "cassandra.response.event.type", + "cassandra.response.event.change", + "cassandra.response.event.host", + "cassandra.response.event.schema_change.change", + "cassandra.response.event.schema_change.keyspace", + "cassandra.response.event.schema_change.table", + "cassandra.response.event.schema_change.object", + "cassandra.response.event.schema_change.target", + "cassandra.response.event.schema_change.name", + "cassandra.response.event.schema_change.args", + "cassandra.response.error.msg", + "cassandra.response.error.type", + "cassandra.response.error.details.read_consistency", + "cassandra.response.error.details.write_type", + "cassandra.response.error.details.keyspace", + "cassandra.response.error.details.table", + "cassandra.response.error.details.stmt_id", + "cassandra.response.error.details.num_failures", + "cassandra.response.error.details.function", + "cassandra.response.error.details.arg_types", + "dhcpv4.transaction_id", + "dhcpv4.flags", + "dhcpv4.client_mac", + "dhcpv4.server_name", + "dhcpv4.op_code", + "dhcpv4.hardware_type", + "dhcpv4.option.message_type", + "dhcpv4.option.parameter_request_list", + "dhcpv4.option.class_identifier", + "dhcpv4.option.domain_name", + "dhcpv4.option.hostname", + "dhcpv4.option.message", + "dhcpv4.option.boot_file_name", + "dns.question.etld_plus_one", + "dns.authorities.name", + "dns.authorities.type", + "dns.authorities.class", + "dns.additionals.name", + "dns.additionals.type", + "dns.additionals.class", + "dns.additionals.data", + "dns.opt.version", + "dns.opt.ext_rcode", + "http.response.status_phrase", + "icmp.version", + "icmp.request.message", + "icmp.response.message", + "memcache.protocol_type", + "memcache.request.line", + "memcache.request.command", + "memcache.response.command", + "memcache.request.type", + "memcache.response.type", + "memcache.response.error_msg", + "memcache.request.opcode", + "memcache.response.opcode", + "memcache.response.status", + "memcache.request.raw_args", + "memcache.request.automove", + "memcache.response.version", + "mongodb.error", + "mongodb.fullCollectionName", + "mongodb.startingFrom", + "mongodb.query", + "mongodb.returnFieldsSelector", + "mongodb.selector", + "mongodb.update", + "mongodb.cursorId", + "mysql.insert_id", + "mysql.num_fields", + "mysql.num_rows", + "mysql.query", + "mysql.error_message", + "nfs.tag", + "nfs.opcode", + "nfs.status", + "rpc.xid", + "rpc.status", + "rpc.auth_flavor", + "rpc.cred.gids", + "rpc.cred.machinename", + "pgsql.error_message", + "pgsql.error_severity", + "pgsql.num_fields", + "pgsql.num_rows", + "redis.return_value", + "redis.error", + "thrift.params", + "thrift.service", + "thrift.return_value", + "thrift.exceptions", + "tls.version", + "tls.resumption_method", + "tls.client_hello.version", + "tls.client_hello.extensions.server_name_indication", + "tls.client_hello.extensions.application_layer_protocol_negotiation", + "tls.client_hello.extensions.session_ticket", + "tls.client_hello.extensions.supported_versions", + "tls.client_hello.extensions.supported_groups", + "tls.client_hello.extensions.signature_algorithms", + "tls.client_hello.extensions.ec_points_formats", + "tls.client_hello.extensions._unparsed_", + "tls.server_hello.version", + "tls.server_hello.selected_cipher", + "tls.server_hello.selected_compression_method", + "tls.server_hello.session_id", + "tls.server_hello.extensions.session_ticket", + "tls.server_hello.extensions.supported_versions", + "tls.server_hello.extensions.ec_points_formats", + "tls.server_hello.extensions._unparsed_", + "tls.client_certificate.serial_number", + "tls.client_certificate.public_key_algorithm", + "tls.client_certificate.signature_algorithm", + "tls.client_certificate.raw", + "tls.client_certificate.subject.country", + "tls.client_certificate.subject.organization", + "tls.client_certificate.subject.organizational_unit", + "tls.client_certificate.subject.province", + "tls.client_certificate.subject.common_name", + "tls.client_certificate.issuer.country", + "tls.client_certificate.issuer.organization", + "tls.client_certificate.issuer.organizational_unit", + "tls.client_certificate.issuer.province", + "tls.client_certificate.issuer.common_name", + "tls.client_certificate.fingerprint.md5", + "tls.client_certificate.fingerprint.sha1", + "tls.client_certificate.fingerprint.sha256", + "tls.server_certificate.serial_number", + "tls.server_certificate.public_key_algorithm", + "tls.server_certificate.signature_algorithm", + "tls.server_certificate.raw", + "tls.server_certificate.subject.country", + "tls.server_certificate.subject.organization", + "tls.server_certificate.subject.organizational_unit", + "tls.server_certificate.subject.province", + "tls.server_certificate.subject.common_name", + "tls.server_certificate.issuer.country", + "tls.server_certificate.issuer.organization", + "tls.server_certificate.issuer.organizational_unit", + "tls.server_certificate.issuer.province", + "tls.server_certificate.issuer.common_name", + "tls.server_certificate.fingerprint.md5", + "tls.server_certificate.fingerprint.sha1", + "tls.server_certificate.fingerprint.sha256", + "tls.alert_types", + "tls.fingerprints.ja3.hash", + "tls.fingerprints.ja3.str", + "fields.*" + ] + }, + "refresh_interval": "5s" + } + } + } +} \ No newline at end of file From 1a872752b76bcd66f517dc5afbbbd7e17fe5d081 Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Tue, 24 Mar 2020 08:11:38 +0100 Subject: [PATCH 27/34] [ML] Functional tests - stabilize df analytics clone tests (#60497) This PR stabilizes the data frame analytics clone tests. --- .../classification_creation.ts | 2 +- .../data_frame_analytics/cloning.ts | 9 ++++++--- .../outlier_detection_creation.ts | 2 +- .../regression_creation.ts | 2 +- .../services/machine_learning/api.ts | 19 ++++++++++++------- .../data_frame_analytics_creation.ts | 11 +++++++---- x-pack/test/functional/services/ml.ts | 3 ++- 7 files changed, 30 insertions(+), 18 deletions(-) diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts index 1bcdeef394c00..a7c92cac2072f 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/classification_creation.ts @@ -111,7 +111,7 @@ export default function({ getService }: FtrProviderContext) { it('creates the analytics job', async () => { await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('starts the analytics job', async () => { diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts index 51155fccc358d..caf382b532273 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/cloning.ts @@ -13,8 +13,7 @@ export default function({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const ml = getService('ml'); - // failing test, see https://github.com/elastic/kibana/issues/60389 - describe.skip('jobs cloning supported by UI form', function() { + describe('jobs cloning supported by UI form', function() { this.tags(['smoke']); const testDataList: Array<{ @@ -173,7 +172,7 @@ export default function({ getService }: FtrProviderContext) { }); it('should create a clone job', async () => { - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(cloneJobId); }); it('should start the clone analytics job', async () => { @@ -186,6 +185,10 @@ export default function({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.closeCreateAnalyticsJobFlyout(); }); + it('finishes analytics processing', async () => { + await ml.dataFrameAnalytics.waitForAnalyticsCompletion(cloneJobId); + }); + it('displays the created job in the analytics table', async () => { await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); await ml.dataFrameAnalyticsTable.filterWithSearchString(cloneJobId); diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts index 173430706f7e7..5481977351d8b 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/outlier_detection_creation.ts @@ -107,7 +107,7 @@ export default function({ getService }: FtrProviderContext) { it('creates the analytics job', async () => { await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('starts the analytics job', async () => { diff --git a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts index 3bc524a88f787..aa1a133c81187 100644 --- a/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/machine_learning/data_frame_analytics/regression_creation.ts @@ -111,7 +111,7 @@ export default function({ getService }: FtrProviderContext) { it('creates the analytics job', async () => { await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); - await ml.dataFrameAnalyticsCreation.createAnalyticsJob(); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); }); it('starts the analytics job', async () => { diff --git a/x-pack/test/functional/services/machine_learning/api.ts b/x-pack/test/functional/services/machine_learning/api.ts index e305d23c1a124..74dc5912df36f 100644 --- a/x-pack/test/functional/services/machine_learning/api.ts +++ b/x-pack/test/functional/services/machine_learning/api.ts @@ -358,9 +358,20 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { }, async getDataFrameAnalyticsJob(analyticsId: string) { + log.debug(`Fetching data frame analytics job '${analyticsId}'...`); return await esSupertest.get(`/_ml/data_frame/analytics/${analyticsId}`).expect(200); }, + async waitForDataFrameAnalyticsJobToExist(analyticsId: string) { + await retry.waitForWithTimeout(`'${analyticsId}' to exist`, 5 * 1000, async () => { + if (await this.getDataFrameAnalyticsJob(analyticsId)) { + return true; + } else { + throw new Error(`expected data frame analytics job '${analyticsId}' to exist`); + } + }); + }, + async createDataFrameAnalyticsJob(jobConfig: DataFrameAnalyticsConfig) { const { id: analyticsId, ...analyticsConfig } = jobConfig; log.debug(`Creating data frame analytic job with id '${analyticsId}'...`); @@ -369,13 +380,7 @@ export function MachineLearningAPIProvider({ getService }: FtrProviderContext) { .send(analyticsConfig) .expect(200); - await retry.waitForWithTimeout(`'${analyticsId}' to be created`, 5 * 1000, async () => { - if (await this.getDataFrameAnalyticsJob(analyticsId)) { - return true; - } else { - throw new Error(`expected data frame analytics job '${analyticsId}' to be created`); - } - }); + await this.waitForDataFrameAnalyticsJobToExist(analyticsId); }, }; } diff --git a/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts b/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts index 9d5f5753e8b04..49f9b01c96895 100644 --- a/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/machine_learning/data_frame_analytics_creation.ts @@ -12,6 +12,7 @@ import { import { FtrProviderContext } from '../../ftr_provider_context'; import { MlCommon } from './common'; +import { MlApi } from './api'; enum ANALYSIS_CONFIG_TYPE { OUTLIER_DETECTION = 'outlier_detection', @@ -31,7 +32,8 @@ const isClassificationAnalysis = (arg: any): arg is ClassificationAnalysis => { export function MachineLearningDataFrameAnalyticsCreationProvider( { getService }: FtrProviderContext, - mlCommon: MlCommon + mlCommon: MlCommon, + mlApi: MlApi ) { const testSubjects = getService('testSubjects'); const comboBox = getService('comboBox'); @@ -333,12 +335,13 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( return !isEnabled; }, - async createAnalyticsJob() { + async createAnalyticsJob(analyticsId: string) { await testSubjects.click('mlAnalyticsCreateJobFlyoutCreateButton'); await retry.tryForTime(5000, async () => { await this.assertCreateButtonMissing(); await this.assertStartButtonExists(); }); + await mlApi.waitForDataFrameAnalyticsJobToExist(analyticsId); }, async assertStartButtonExists() { @@ -362,8 +365,8 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( }, async closeCreateAnalyticsJobFlyout() { - await testSubjects.click('mlAnalyticsCreateJobFlyoutCloseButton'); - await retry.tryForTime(5000, async () => { + await retry.tryForTime(10 * 1000, async () => { + await testSubjects.click('mlAnalyticsCreateJobFlyoutCloseButton'); await testSubjects.missingOrFail('mlAnalyticsCreateJobFlyout'); }); }, diff --git a/x-pack/test/functional/services/ml.ts b/x-pack/test/functional/services/ml.ts index f3981c9edf92f..af7cb51f4e3f0 100644 --- a/x-pack/test/functional/services/ml.ts +++ b/x-pack/test/functional/services/ml.ts @@ -45,7 +45,8 @@ export function MachineLearningProvider(context: FtrProviderContext) { const dataFrameAnalytics = MachineLearningDataFrameAnalyticsProvider(context, api); const dataFrameAnalyticsCreation = MachineLearningDataFrameAnalyticsCreationProvider( context, - common + common, + api ); const dataFrameAnalyticsTable = MachineLearningDataFrameAnalyticsTableProvider(context); const dataVisualizer = MachineLearningDataVisualizerProvider(context); From 462be16879539559818649352531b35e9329908a Mon Sep 17 00:00:00 2001 From: Andrew Goldstein Date: Tue, 24 Mar 2020 01:14:41 -0600 Subject: [PATCH 28/34] [SIEM] Overview: Recent cases widget (#60993) ## [SIEM] Overview: Recent cases widget Implements the new `Recent cases` widget on the Overview page. Recent cases shows the last 3 recently created cases, per the following animated gif: ![recent-cases](https://user-images.githubusercontent.com/4459398/77357982-ae550a80-6d0e-11ea-90d0-62fa5407eea5.gif) ### Markdown case descriptions Markdown case descriptions are rendered, per the following animated gif: ![markdown-description](https://user-images.githubusercontent.com/4459398/77358163-f7a55a00-6d0e-11ea-8b85-dd4b3ff093ee.gif) ### My recently reported cases My recently reported cases filters the widget to show only cases created by the logged-in user, per the following animated gif: ![my-recent-cases](https://user-images.githubusercontent.com/4459398/77358223-14419200-6d0f-11ea-8e4a-25cd55fdfc44.gif) ### No cases state A message welcoming the user to create a case is displayed when no cases exist, per the following screenshot: ![no-cases-created](https://user-images.githubusercontent.com/4459398/77358338-4ce16b80-6d0f-11ea-98d3-5de1be19a935.png) ### Other changes - [x] Case-related links were updated to ensure URL state parameters, e.g. global date selection, carry-over as the user navigates through case views - [x] Recent timelines was updated to only show the last 3 recent timelines (down from 5) - [x] All sidebar widgets have slightly more compact spacing Tested in: * Chrome `80.0.3987.149` * Firefox `74.0` * Safari `13.0.5` --- .../components/link_to/redirect_to_case.tsx | 17 ++- .../siem/public/components/links/index.tsx | 13 +- .../public/components/markdown/index.test.tsx | 11 ++ .../siem/public/components/markdown/index.tsx | 90 ++++++------- .../navigation/breadcrumbs/index.ts | 17 ++- .../public/components/news_feed/news_feed.tsx | 8 +- .../components/news_feed/post/index.tsx | 1 + .../components/recent_cases/filters/index.tsx | 51 ++++++++ .../public/components/recent_cases/index.tsx | 80 ++++++++++++ .../recent_cases/no_cases/index.tsx | 34 +++++ .../components/recent_cases/recent_cases.tsx | 56 ++++++++ .../components/recent_cases/translations.ts | 37 ++++++ .../public/components/recent_cases/types.ts | 7 + .../recent_timelines/counts/index.tsx | 2 +- .../recent_timelines/filters/index.tsx | 6 +- .../components/recent_timelines/index.tsx | 12 +- .../recent_timelines/recent_timelines.tsx | 9 +- .../recent_timelines/translations.ts | 8 ++ .../components/url_state/index.test.tsx | 4 +- .../siem/public/components/url_state/types.ts | 4 +- .../public/containers/case/use_get_cases.tsx | 31 +++-- .../pages/case/components/all_cases/index.tsx | 7 +- .../pages/case/components/case_view/index.tsx | 6 +- .../case/components/configure_cases/index.tsx | 10 +- .../public/pages/case/configure_cases.tsx | 47 ++++--- .../siem/public/pages/case/create_case.tsx | 37 ++++-- .../plugins/siem/public/pages/case/utils.ts | 12 +- .../public/pages/home/home_navigations.tsx | 2 +- .../public/pages/overview/sidebar/index.tsx | 20 ++- .../public/pages/overview/sidebar/sidebar.tsx | 123 +++++++++++++----- .../public/pages/overview/translations.ts | 4 + 31 files changed, 594 insertions(+), 172 deletions(-) create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts create mode 100644 x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts diff --git a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx index 20ba0b50f5126..6ec15b55ba83d 100644 --- a/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx +++ b/x-pack/legacy/plugins/siem/public/components/link_to/redirect_to_case.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { RouteComponentProps } from 'react-router-dom'; +import { appendSearch } from './helpers'; import { RedirectWrapper } from './redirect_wrapper'; import { SiemPageName } from '../../pages/home/types'; @@ -30,8 +31,14 @@ export const RedirectToConfigureCasesPage = () => ( const baseCaseUrl = `#/link-to/${SiemPageName.case}`; -export const getCaseUrl = () => baseCaseUrl; -export const getCaseDetailsUrl = (detailName: string, search: string) => - `${baseCaseUrl}/${detailName}${search}`; -export const getCreateCaseUrl = (search: string) => `${baseCaseUrl}/create${search}`; -export const getConfigureCasesUrl = (search: string) => `${baseCaseUrl}/configure${search}`; +export const getCaseUrl = (search: string | null) => + `${baseCaseUrl}${appendSearch(search ?? undefined)}`; + +export const getCaseDetailsUrl = ({ id, search }: { id: string; search: string | null }) => + `${baseCaseUrl}/${encodeURIComponent(id)}${appendSearch(search ?? undefined)}`; + +export const getCreateCaseUrl = (search: string | null) => + `${baseCaseUrl}/create${appendSearch(search ?? undefined)}`; + +export const getConfigureCasesUrl = (search: string) => + `${baseCaseUrl}/configure${appendSearch(search ?? undefined)}`; diff --git a/x-pack/legacy/plugins/siem/public/components/links/index.tsx b/x-pack/legacy/plugins/siem/public/components/links/index.tsx index 935df9ad3361f..14dc5e7999a65 100644 --- a/x-pack/legacy/plugins/siem/public/components/links/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/links/index.tsx @@ -23,11 +23,11 @@ import { import { FlowTarget, FlowTargetSourceDest } from '../../graphql/types'; import { useUiSetting$ } from '../../lib/kibana'; import { IP_REPUTATION_LINKS_SETTING } from '../../../common/constants'; -import { navTabs } from '../../pages/home/home_navigations'; import * as i18n from '../page/network/ip_overview/translations'; import { isUrlInvalid } from '../../pages/detection_engine/rules/components/step_about_rule/helpers'; -import { useGetUrlSearch } from '../navigation/use_get_url_search'; import { ExternalLinkIcon } from '../external_link_icon'; +import { navTabs } from '../../pages/home/home_navigations'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; export const DEFAULT_NUMBER_OF_LINK = 5; @@ -92,10 +92,11 @@ const CaseDetailsLinkComponent: React.FC<{ children?: React.ReactNode; detailNam children, detailName, }) => { - const urlSearch = useGetUrlSearch(navTabs.case); + const search = useGetUrlSearch(navTabs.case); + return ( {children ? children : detailName} @@ -106,8 +107,8 @@ export const CaseDetailsLink = React.memo(CaseDetailsLinkComponent); CaseDetailsLink.displayName = 'CaseDetailsLink'; export const CreateCaseLink = React.memo<{ children: React.ReactNode }>(({ children }) => { - const urlSearch = useGetUrlSearch(navTabs.case); - return {children}; + const search = useGetUrlSearch(navTabs.case); + return {children}; }); CreateCaseLink.displayName = 'CreateCaseLink'; diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx index de662c162fc0a..89af9202a597e 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/markdown/index.test.tsx @@ -126,6 +126,17 @@ describe('Markdown', () => { ).toHaveProperty('href', 'https://google.com/'); }); + test('it does NOT render the href if links are disabled', () => { + const wrapper = mount(); + + expect( + wrapper + .find('[data-test-subj="markdown-link"]') + .first() + .getDOMNode() + ).not.toHaveProperty('href'); + }); + test('it opens links in a new tab via target="_blank"', () => { const wrapper = mount(); diff --git a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx index 30695c9d0c7e2..1368c13619d6b 100644 --- a/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/markdown/index.tsx @@ -26,51 +26,53 @@ const REL_NOFOLLOW = 'nofollow'; /** prevents the browser from sending the current address as referrer via the Referer HTTP header */ const REL_NOREFERRER = 'noreferrer'; -export const Markdown = React.memo<{ raw?: string; size?: 'xs' | 's' | 'm' }>( - ({ raw, size = 's' }) => { - const markdownRenderers = { - root: ({ children }: { children: React.ReactNode[] }) => ( - +export const Markdown = React.memo<{ + disableLinks?: boolean; + raw?: string; + size?: 'xs' | 's' | 'm'; +}>(({ disableLinks = false, raw, size = 's' }) => { + const markdownRenderers = { + root: ({ children }: { children: React.ReactNode[] }) => ( + + {children} + + ), + table: ({ children }: { children: React.ReactNode[] }) => ( + + {children} +
+ ), + tableHead: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + tableRow: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + tableCell: ({ children }: { children: React.ReactNode[] }) => ( + {children} + ), + link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( + + {children} -
- ), - table: ({ children }: { children: React.ReactNode[] }) => ( - - {children} -
- ), - tableHead: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableRow: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - tableCell: ({ children }: { children: React.ReactNode[] }) => ( - {children} - ), - link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( - - - {children} - - - ), - }; +
+
+ ), + }; - return ( - - ); - } -); + return ( + + ); +}); Markdown.displayName = 'Markdown'; diff --git a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts index e25fb4374bb14..155f63145ca95 100644 --- a/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts +++ b/x-pack/legacy/plugins/siem/public/components/navigation/breadcrumbs/index.ts @@ -107,7 +107,22 @@ export const getBreadcrumbsForRoute = ( ]; } if (isCaseRoutes(spyState) && object.navTabs) { - return [...siemRootBreadcrumb, ...getCaseDetailsBreadcrumbs(spyState)]; + const tempNav: SearchNavTab = { urlKey: 'case', isDetailPage: false }; + let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + if (spyState.tabName != null) { + urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + } + + return [ + ...siemRootBreadcrumb, + ...getCaseDetailsBreadcrumbs( + spyState, + urlStateKeys.reduce( + (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], + [] + ) + ), + ]; } if ( spyState != null && diff --git a/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx b/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx index 98eea1eaa6454..cd356212b4400 100644 --- a/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx +++ b/x-pack/legacy/plugins/siem/public/components/news_feed/news_feed.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiSpacer } from '@elastic/eui'; import React from 'react'; import { LoadingPlaceholders } from '../page/overview/loading_placeholders'; @@ -30,12 +29,7 @@ const NewsFeedComponent: React.FC = ({ news }) => ( ) : news.length === 0 ? ( ) : ( - news.map((n: NewsItem) => ( - - - - - )) + news.map((n: NewsItem) => ) )} ); diff --git a/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx b/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx index cb2542a497f08..9cab78c9f20b1 100644 --- a/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/news_feed/post/index.tsx @@ -45,6 +45,7 @@ export const Post = React.memo<{ newsItem: NewsItem }>(({ newsItem }) => {
{description}
+
diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx new file mode 100644 index 0000000000000..edb0b99cbff8b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/filters/index.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonGroup, EuiButtonGroupOption } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; + +import { FilterMode } from '../types'; + +import * as i18n from '../translations'; + +const MY_RECENTLY_REPORTED_ID = 'myRecentlyReported'; + +const toggleButtonIcons: EuiButtonGroupOption[] = [ + { + id: 'recentlyCreated', + label: i18n.RECENTLY_CREATED_CASES, + iconType: 'folderExclamation', + }, + { + id: MY_RECENTLY_REPORTED_ID, + label: i18n.MY_RECENTLY_REPORTED_CASES, + iconType: 'reporter', + }, +]; + +export const Filters = React.memo<{ + filterBy: FilterMode; + setFilterBy: (filterBy: FilterMode) => void; + showMyRecentlyReported: boolean; +}>(({ filterBy, setFilterBy, showMyRecentlyReported }) => { + const options = useMemo( + () => + showMyRecentlyReported + ? toggleButtonIcons + : toggleButtonIcons.filter(x => x.id !== MY_RECENTLY_REPORTED_ID), + [showMyRecentlyReported] + ); + const onChange = useCallback( + (filterMode: string) => { + setFilterBy(filterMode as FilterMode); + }, + [setFilterBy] + ); + + return ; +}); + +Filters.displayName = 'Filters'; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx new file mode 100644 index 0000000000000..07246c6c6ec88 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/index.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiHorizontalRule, EuiLink, EuiText } from '@elastic/eui'; +import React, { useEffect, useMemo, useRef } from 'react'; + +import { FilterOptions, QueryParams } from '../../containers/case/types'; +import { DEFAULT_QUERY_PARAMS, useGetCases } from '../../containers/case/use_get_cases'; +import { getCaseUrl } from '../link_to/redirect_to_case'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; +import { LoadingPlaceholders } from '../page/overview/loading_placeholders'; +import { navTabs } from '../../pages/home/home_navigations'; + +import { NoCases } from './no_cases'; +import { RecentCases } from './recent_cases'; +import * as i18n from './translations'; + +const usePrevious = (value: FilterOptions) => { + const ref = useRef(); + useEffect(() => { + (ref.current as unknown) = value; + }); + return ref.current; +}; + +const MAX_CASES_TO_SHOW = 3; + +const queryParams: QueryParams = { + ...DEFAULT_QUERY_PARAMS, + perPage: MAX_CASES_TO_SHOW, +}; + +const StatefulRecentCasesComponent = React.memo( + ({ filterOptions }: { filterOptions: FilterOptions }) => { + const previousFilterOptions = usePrevious(filterOptions); + const { data, loading, setFilters } = useGetCases(queryParams); + const isLoadingCases = useMemo( + () => loading.indexOf('cases') > -1 || loading.indexOf('caseUpdate') > -1, + [loading] + ); + const search = useGetUrlSearch(navTabs.case); + const allCasesLink = useMemo( + () => {i18n.VIEW_ALL_CASES}, + [search] + ); + + useEffect(() => { + if (previousFilterOptions !== undefined && previousFilterOptions !== filterOptions) { + setFilters(filterOptions); + } + }, [previousFilterOptions, filterOptions, setFilters]); + + const content = useMemo( + () => + isLoadingCases ? ( + + ) : !isLoadingCases && data.cases.length === 0 ? ( + + ) : ( + + ), + [isLoadingCases, data] + ); + + return ( + + {content} + + {allCasesLink} + + ); + } +); + +StatefulRecentCasesComponent.displayName = 'StatefulRecentCasesComponent'; + +export const StatefulRecentCases = React.memo(StatefulRecentCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx new file mode 100644 index 0000000000000..9f0361311b7b6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/no_cases/index.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiLink } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { getCreateCaseUrl } from '../../link_to/redirect_to_case'; +import { useGetUrlSearch } from '../../navigation/use_get_url_search'; +import { navTabs } from '../../../pages/home/home_navigations'; + +import * as i18n from '../translations'; + +const NoCasesComponent = () => { + const urlSearch = useGetUrlSearch(navTabs.case); + const newCaseLink = useMemo( + () => {` ${i18n.START_A_NEW_CASE}`}, + [urlSearch] + ); + + return ( + <> + {i18n.NO_CASES} + {newCaseLink} + {'!'} + + ); +}; + +NoCasesComponent.displayName = 'NoCasesComponent'; + +export const NoCases = React.memo(NoCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx b/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx new file mode 100644 index 0000000000000..eb17c75f4111b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/recent_cases.tsx @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import React from 'react'; +import styled from 'styled-components'; + +import { Case } from '../../containers/case/types'; +import { getCaseDetailsUrl } from '../link_to/redirect_to_case'; +import { Markdown } from '../markdown'; +import { useGetUrlSearch } from '../navigation/use_get_url_search'; +import { navTabs } from '../../pages/home/home_navigations'; +import { IconWithCount } from '../recent_timelines/counts'; + +import * as i18n from './translations'; + +const MarkdownContainer = styled.div` + max-height: 150px; + overflow-y: auto; + width: 300px; +`; + +const RecentCasesComponent = ({ cases }: { cases: Case[] }) => { + const search = useGetUrlSearch(navTabs.case); + + return ( + <> + {cases.map((c, i) => ( + + + + {c.title} + + + + {c.description && c.description.length && ( + + + + + + )} + {i !== cases.length - 1 && } + + + ))} + + ); +}; + +RecentCasesComponent.displayName = 'RecentCasesComponent'; + +export const RecentCases = React.memo(RecentCasesComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts b/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts new file mode 100644 index 0000000000000..d2318e5db88c3 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/translations.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const COMMENTS = i18n.translate('xpack.siem.recentCases.commentsTooltip', { + defaultMessage: 'Comments', +}); + +export const MY_RECENTLY_REPORTED_CASES = i18n.translate( + 'xpack.siem.overview.myRecentlyReportedCasesButtonLabel', + { + defaultMessage: 'My recently reported cases', + } +); + +export const NO_CASES = i18n.translate('xpack.siem.recentCases.noCasesMessage', { + defaultMessage: 'No cases have been created yet. Put your detective hat on and', +}); + +export const RECENTLY_CREATED_CASES = i18n.translate( + 'xpack.siem.overview.recentlyCreatedCasesButtonLabel', + { + defaultMessage: 'Recently created cases', + } +); + +export const START_A_NEW_CASE = i18n.translate('xpack.siem.recentCases.startNewCaseLink', { + defaultMessage: 'start a new case', +}); + +export const VIEW_ALL_CASES = i18n.translate('xpack.siem.recentCases.viewAllCasesLink', { + defaultMessage: 'View all cases', +}); diff --git a/x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts b/x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts new file mode 100644 index 0000000000000..29c7072ce0be6 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/components/recent_cases/types.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type FilterMode = 'recentlyCreated' | 'myRecentlyReported'; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx index e04b6319cfb24..c80530b245cf3 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/counts/index.tsx @@ -21,7 +21,7 @@ const FlexGroup = styled(EuiFlexGroup)` margin-right: 16px; `; -const IconWithCount = React.memo<{ count: number; icon: string; tooltip: string }>( +export const IconWithCount = React.memo<{ count: number; icon: string; tooltip: string }>( ({ count, icon, tooltip }) => ( diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx index de8a3de8094d0..d7271197b9cea 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/filters/index.tsx @@ -9,15 +9,17 @@ import React from 'react'; import { FilterMode } from '../types'; +import * as i18n from '../translations'; + const toggleButtonIcons: EuiButtonGroupOption[] = [ { id: 'favorites', - label: 'Favorites', + label: i18n.FAVORITES, iconType: 'starFilled', }, { id: `recently-updated`, - label: 'Last updated', + label: i18n.LAST_UPDATED, iconType: 'documentEdit', }, ]; diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx b/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx index 007665b47dedb..5b851701b973c 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/index.tsx @@ -31,6 +31,8 @@ interface OwnProps { export type Props = OwnProps & PropsFromRedux; +const PAGE_SIZE = 3; + const StatefulRecentTimelinesComponent = React.memo( ({ apolloClient, filterBy, updateIsLoading, updateTimeline }) => { const onOpenTimeline: OnOpenTimeline = useCallback( @@ -53,12 +55,18 @@ const StatefulRecentTimelinesComponent = React.memo( () => {i18n.VIEW_ALL_TIMELINES}, [urlSearch] ); + const loadingPlaceholders = useMemo( + () => ( + + ), + [filterBy] + ); return ( ( {({ timelines, loading }) => ( <> {loading ? ( - + loadingPlaceholders ) : ( {t.description && t.description.length && ( - <> - - - {t.description} - - + + {t.description} + )}
diff --git a/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts b/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts index e547272fde6e1..f5934aa317242 100644 --- a/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts +++ b/x-pack/legacy/plugins/siem/public/components/recent_timelines/translations.ts @@ -13,6 +13,10 @@ export const ERROR_RETRIEVING_USER_DETAILS = i18n.translate( } ); +export const FAVORITES = i18n.translate('xpack.siem.recentTimelines.favoritesButtonLabel', { + defaultMessage: 'Favorites', +}); + export const NO_FAVORITE_TIMELINES = i18n.translate( 'xpack.siem.recentTimelines.noFavoriteTimelinesMessage', { @@ -21,6 +25,10 @@ export const NO_FAVORITE_TIMELINES = i18n.translate( } ); +export const LAST_UPDATED = i18n.translate('xpack.siem.recentTimelines.lastUpdatedButtonLabel', { + defaultMessage: 'Last updated', +}); + export const NO_TIMELINES = i18n.translate('xpack.siem.recentTimelines.noTimelinesMessage', { defaultMessage: "You haven't created any timelines yet. Get out there and start threat hunting!", }); diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx index 6e957313d9b04..4d2a717153894 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/url_state/index.test.tsx @@ -157,9 +157,7 @@ describe('UrlStateContainer', () => { ).toEqual({ hash: '', pathname: examplePath, - search: [CONSTANTS.timelinePage].includes(page) - ? `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))` - : `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, + search: `?query=(language:kuery,query:'host.name:%22siem-es%22')&timerange=(global:(linkTo:!(timeline),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)),timeline:(linkTo:!(global),timerange:(from:1558048243696,fromStr:now-24h,kind:relative,to:1558134643697,toStr:now)))`, state: '', }); } diff --git a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts index c6f49d8a0e49b..9d8a4a8e6a908 100644 --- a/x-pack/legacy/plugins/siem/public/components/url_state/types.ts +++ b/x-pack/legacy/plugins/siem/public/components/url_state/types.ts @@ -64,15 +64,15 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.appQuery, CONSTANTS.filters, CONSTANTS.savedQuery, - CONSTANTS.timeline, CONSTANTS.timerange, + CONSTANTS.timeline, ], case: [ CONSTANTS.appQuery, CONSTANTS.filters, CONSTANTS.savedQuery, - CONSTANTS.timeline, CONSTANTS.timerange, + CONSTANTS.timeline, ], }; diff --git a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx index 6c4a6ac4fe58a..ae7b8f3c043fa 100644 --- a/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/containers/case/use_get_cases.tsx @@ -88,6 +88,20 @@ const dataFetchReducer = (state: UseGetCasesState, action: Action): UseGetCasesS } }; +export const DEFAULT_FILTER_OPTIONS: FilterOptions = { + search: '', + reporters: [], + status: 'open', + tags: [], +}; + +export const DEFAULT_QUERY_PARAMS: QueryParams = { + page: DEFAULT_TABLE_ACTIVE_PAGE, + perPage: DEFAULT_TABLE_LIMIT, + sortField: SortFieldCase.createdAt, + sortOrder: 'desc', +}; + const initialData: AllCases = { cases: [], countClosedCases: null, @@ -109,23 +123,14 @@ interface UseGetCases extends UseGetCasesState { setQueryParams: (queryParams: QueryParams) => void; setSelectedCases: (mySelectedCases: Case[]) => void; } -export const useGetCases = (): UseGetCases => { + +export const useGetCases = (initialQueryParams?: QueryParams): UseGetCases => { const [state, dispatch] = useReducer(dataFetchReducer, { data: initialData, - filterOptions: { - search: '', - reporters: [], - status: 'open', - tags: [], - }, + filterOptions: DEFAULT_FILTER_OPTIONS, isError: false, loading: [], - queryParams: { - page: DEFAULT_TABLE_ACTIVE_PAGE, - perPage: DEFAULT_TABLE_LIMIT, - sortField: SortFieldCase.createdAt, - sortOrder: 'desc', - }, + queryParams: initialQueryParams ?? DEFAULT_QUERY_PARAMS, selectedCases: [], }); const [, dispatchToaster] = useStateToaster(); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx index 87a2ea888831a..cbb9ddae22d04 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/all_cases/index.tsx @@ -26,6 +26,7 @@ import { useGetCases, UpdateCase } from '../../../../containers/case/use_get_cas import { useGetCasesStatus } from '../../../../containers/case/use_get_cases_status'; import { useDeleteCases } from '../../../../containers/case/use_delete_cases'; import { EuiBasicTableOnChange } from '../../../detection_engine/rules/types'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { Panel } from '../../../../components/panel'; import { UtilityBar, @@ -35,16 +36,15 @@ import { UtilityBarText, } from '../../../../components/utility_bar'; import { getConfigureCasesUrl, getCreateCaseUrl } from '../../../../components/link_to'; -import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; -import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; -import { navTabs } from '../../../home/home_navigations'; import { getBulkItems } from '../bulk_actions'; import { CaseHeaderPage } from '../case_header_page'; import { ConfirmDeleteCaseModal } from '../confirm_delete_case'; import { OpenClosedStats } from '../open_closed_stats'; +import { navTabs } from '../../../home/home_navigations'; import { getActions } from './actions'; import { CasesTableFilters } from './table_filters'; +import { useUpdateCases } from '../../../../containers/case/use_bulk_update_case'; const Div = styled.div` margin-top: ${({ theme }) => theme.eui.paddingSizes.m}; @@ -77,6 +77,7 @@ const getSortField = (field: string): SortFieldCase => { }; export const AllCases = React.memo(() => { const urlSearch = useGetUrlSearch(navTabs.case); + const { countClosedCases, countOpenCases, diff --git a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx index 742921cb9f69e..5c20b53f5fcb9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/components/case_view/index.tsx @@ -25,11 +25,13 @@ import { useGetCase } from '../../../../containers/case/use_get_case'; import { UserActionTree } from '../user_action_tree'; import { UserList } from '../user_list'; import { useUpdateCase } from '../../../../containers/case/use_update_case'; +import { useGetUrlSearch } from '../../../../components/navigation/use_get_url_search'; import { WrapperPage } from '../../../../components/wrapper_page'; import { getTypedPayload } from '../../../../containers/case/utils'; import { WhitePageWrapper } from '../wrappers'; import { useBasePath } from '../../../../lib/kibana'; import { CaseStatus } from '../case_status'; +import { navTabs } from '../../../home/home_navigations'; import { SpyRoute } from '../../../../utils/route/spy_routes'; import { useGetCaseUserActions } from '../../../../containers/case/use_get_case_user_actions'; import { usePushToService } from './push_to_service'; @@ -61,6 +63,8 @@ export interface CaseProps { export const CaseComponent = React.memo(({ caseId, initialData }) => { const basePath = window.location.origin + useBasePath(); const caseLink = `${basePath}/app/siem#/case/${caseId}`; + const search = useGetUrlSearch(navTabs.case); + const [initLoadingData, setInitLoadingData] = useState(true); const { caseUserActions, @@ -190,7 +194,7 @@ export const CaseComponent = React.memo(({ caseId, initialData }) => css` @@ -73,6 +72,7 @@ const actionTypes: ActionType[] = [ ]; const ConfigureCasesComponent: React.FC = () => { + const search = useGetUrlSearch(navTabs.case); const { http, triggers_actions_ui, notifications, application } = useKibana().services; const [connectorIsValid, setConnectorIsValid] = useState(true); @@ -235,7 +235,7 @@ const ConfigureCasesComponent: React.FC = () => { isDisabled={isLoadingAny} isLoading={persistLoading} aria-label="Cancel" - href={CASE_URL} + href={getCaseUrl(search)} > {i18n.CANCEL} diff --git a/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx b/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx index b546a88744439..b7e7ced308331 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/configure_cases.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { WrapperPage } from '../../components/wrapper_page'; import { CaseHeaderPage } from './components/case_header_page'; @@ -13,11 +13,8 @@ import { getCaseUrl } from '../../components/link_to'; import { WhitePageWrapper, SectionWrapper } from './components/wrappers'; import * as i18n from './translations'; import { ConfigureCases } from './components/configure_cases'; - -const backOptions = { - href: getCaseUrl(), - text: i18n.BACK_TO_ALL, -}; +import { useGetUrlSearch } from '../../components/navigation/use_get_url_search'; +import { navTabs } from '../home/home_navigations'; const wrapperPageStyle: Record = { paddingLeft: '0', @@ -25,18 +22,30 @@ const wrapperPageStyle: Record = { paddingBottom: '0', }; -const ConfigureCasesPageComponent: React.FC = () => ( - <> - - - - - - - - - - -); +const ConfigureCasesPageComponent: React.FC = () => { + const search = useGetUrlSearch(navTabs.case); + + const backOptions = useMemo( + () => ({ + href: getCaseUrl(search), + text: i18n.BACK_TO_ALL, + }), + [search] + ); + + return ( + <> + + + + + + + + + + + ); +}; export const ConfigureCasesPage = React.memo(ConfigureCasesPageComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx b/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx index 2c7525264f71b..bd1f6da0ca28b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/case/create_case.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { WrapperPage } from '../../components/wrapper_page'; import { Create } from './components/create'; @@ -12,20 +12,29 @@ import { SpyRoute } from '../../utils/route/spy_routes'; import { CaseHeaderPage } from './components/case_header_page'; import * as i18n from './translations'; import { getCaseUrl } from '../../components/link_to'; +import { useGetUrlSearch } from '../../components/navigation/use_get_url_search'; +import { navTabs } from '../home/home_navigations'; -const backOptions = { - href: getCaseUrl(), - text: i18n.BACK_TO_ALL, -}; +export const CreateCasePage = React.memo(() => { + const search = useGetUrlSearch(navTabs.case); -export const CreateCasePage = React.memo(() => ( - <> - - - - - - -)); + const backOptions = useMemo( + () => ({ + href: getCaseUrl(search), + text: i18n.BACK_TO_ALL, + }), + [search] + ); + + return ( + <> + + + + + + + ); +}); CreateCasePage.displayName = 'CreateCasePage'; diff --git a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts index 3f2964b8cdd6d..df9f0d08e728c 100644 --- a/x-pack/legacy/plugins/siem/public/pages/case/utils.ts +++ b/x-pack/legacy/plugins/siem/public/pages/case/utils.ts @@ -4,16 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import { Breadcrumb } from 'ui/chrome'; + import { getCaseDetailsUrl, getCaseUrl, getCreateCaseUrl } from '../../components/link_to'; import { RouteSpyState } from '../../utils/route/types'; import * as i18n from './translations'; -export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { +export const getBreadcrumbs = (params: RouteSpyState, search: string[]): Breadcrumb[] => { + const queryParameters = !isEmpty(search[0]) ? search[0] : null; + let breadcrumb = [ { text: i18n.PAGE_TITLE, - href: getCaseUrl(), + href: getCaseUrl(queryParameters), }, ]; if (params.detailName === 'create') { @@ -21,7 +25,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: i18n.CREATE_BC_TITLE, - href: getCreateCaseUrl(''), + href: getCreateCaseUrl(queryParameters), }, ]; } else if (params.detailName != null) { @@ -29,7 +33,7 @@ export const getBreadcrumbs = (params: RouteSpyState): Breadcrumb[] => { ...breadcrumb, { text: params.state?.caseTitle ?? '', - href: getCaseDetailsUrl(params.detailName, ''), + href: getCaseDetailsUrl({ id: params.detailName, search: queryParameters }), }, ]; } diff --git a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx index a087dca38de00..543469e2fddb7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/home/home_navigations.tsx @@ -54,7 +54,7 @@ export const navTabs: SiemNavTab = { [SiemPageName.case]: { id: SiemPageName.case, name: i18n.CASE, - href: getCaseUrl(), + href: getCaseUrl(null), disabled: false, urlKey: 'case', }, diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx index ad2821edde411..3797eae2bb853 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/index.tsx @@ -6,13 +6,27 @@ import React, { useState } from 'react'; -import { FilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentTimelinesFilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentCasesFilterMode } from '../../../components/recent_cases/types'; + import { Sidebar } from './sidebar'; export const StatefulSidebar = React.memo(() => { - const [filterBy, setFilterBy] = useState('favorites'); + const [recentTimelinesFilterBy, setRecentTimelinesFilterBy] = useState( + 'favorites' + ); + const [recentCasesFilterBy, setRecentCasesFilterBy] = useState( + 'recentlyCreated' + ); - return ; + return ( + + ); }); StatefulSidebar.displayName = 'StatefulSidebar'; diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx index d3b85afe62a2a..52e36b472a0ec 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/overview/sidebar/sidebar.tsx @@ -8,12 +8,17 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { Filters } from '../../../components/recent_timelines/filters'; +import { Filters as RecentCasesFilters } from '../../../components/recent_cases/filters'; +import { Filters as RecentTimelinesFilters } from '../../../components/recent_timelines/filters'; import { ENABLE_NEWS_FEED_SETTING, NEWS_FEED_URL_SETTING } from '../../../../common/constants'; +import { StatefulRecentCases } from '../../../components/recent_cases'; import { StatefulRecentTimelines } from '../../../components/recent_timelines'; import { StatefulNewsFeed } from '../../../components/news_feed'; -import { FilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentTimelinesFilterMode } from '../../../components/recent_timelines/types'; +import { FilterMode as RecentCasesFilterMode } from '../../../components/recent_cases/types'; +import { DEFAULT_FILTER_OPTIONS } from '../../../containers/case/use_get_cases'; import { SidebarHeader } from '../../../components/sidebar_header'; +import { useCurrentUser } from '../../../lib/kibana'; import { useApolloClient } from '../../../utils/apollo_context'; import * as i18n from '../translations'; @@ -22,35 +27,93 @@ const SidebarFlexGroup = styled(EuiFlexGroup)` width: 305px; `; +const SidebarSpacerComponent = () => ( + + + +); + +SidebarSpacerComponent.displayName = 'SidebarSpacerComponent'; +const Spacer = React.memo(SidebarSpacerComponent); + export const Sidebar = React.memo<{ - filterBy: FilterMode; - setFilterBy: (filterBy: FilterMode) => void; -}>(({ filterBy, setFilterBy }) => { - const apolloClient = useApolloClient(); - const RecentTimelinesFilters = useMemo( - () => , - [filterBy, setFilterBy] - ); - - return ( - - - {RecentTimelinesFilters} - - - - - - - - - void; + setRecentTimelinesFilterBy: (filterBy: RecentTimelinesFilterMode) => void; +}>( + ({ + recentCasesFilterBy, + recentTimelinesFilterBy, + setRecentCasesFilterBy, + setRecentTimelinesFilterBy, + }) => { + const currentUser = useCurrentUser(); + const apolloClient = useApolloClient(); + const recentCasesFilters = useMemo( + () => ( + - - - ); -}); + ), + [currentUser, recentCasesFilterBy, setRecentCasesFilterBy] + ); + const recentCasesFilterOptions = useMemo( + () => + recentCasesFilterBy === 'myRecentlyReported' && currentUser != null + ? { + ...DEFAULT_FILTER_OPTIONS, + reporters: [ + { + email: currentUser.email, + full_name: currentUser.fullName, + username: currentUser.username, + }, + ], + } + : DEFAULT_FILTER_OPTIONS, + [currentUser, recentCasesFilterBy] + ); + const recentTimelinesFilters = useMemo( + () => ( + + ), + [recentTimelinesFilterBy, setRecentTimelinesFilterBy] + ); + + return ( + + + {recentCasesFilters} + + + + + + + {recentTimelinesFilters} + + + + + + + + + + ); + } +); Sidebar.displayName = 'Sidebar'; diff --git a/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts b/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts index 5ccd25984bc40..601a629d86e57 100644 --- a/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts +++ b/x-pack/legacy/plugins/siem/public/pages/overview/translations.ts @@ -26,6 +26,10 @@ export const PAGE_SUBTITLE = i18n.translate('xpack.siem.overview.pageSubtitle', defaultMessage: 'Security Information & Event Management with the Elastic Stack', }); +export const RECENT_CASES = i18n.translate('xpack.siem.overview.recentCasesSidebarTitle', { + defaultMessage: 'Recent cases', +}); + export const RECENT_TIMELINES = i18n.translate('xpack.siem.overview.recentTimelinesSidebarTitle', { defaultMessage: 'Recent timelines', }); From e96ed69bf69a48cd6575d9e10925bf4e1e8685f0 Mon Sep 17 00:00:00 2001 From: Thomas Watson Date: Tue, 24 Mar 2020 08:26:09 +0100 Subject: [PATCH 29/34] Upgrade mocha dev-dependency from 6.2.2 to 7.1.1 (#60779) Co-authored-by: spalger --- package.json | 4 +- x-pack/package.json | 4 +- yarn.lock | 237 ++++++++++++++++++++------------------------ 3 files changed, 111 insertions(+), 134 deletions(-) diff --git a/package.json b/package.json index 3421bf938cd80..08668730f9a9d 100644 --- a/package.json +++ b/package.json @@ -348,7 +348,7 @@ "@types/lru-cache": "^5.1.0", "@types/markdown-it": "^0.0.7", "@types/minimatch": "^2.0.29", - "@types/mocha": "^5.2.7", + "@types/mocha": "^7.0.2", "@types/moment-timezone": "^0.5.12", "@types/mustache": "^0.8.31", "@types/node": ">=10.17.17 <10.20.0", @@ -456,7 +456,7 @@ "license-checker": "^16.0.0", "listr": "^0.14.1", "load-grunt-config": "^3.0.1", - "mocha": "^6.2.2", + "mocha": "^7.1.1", "mock-http-server": "1.3.0", "multistream": "^2.1.1", "murmurhash3js": "3.0.1", diff --git a/x-pack/package.json b/x-pack/package.json index 116bbb92007e7..41674cac01725 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -78,7 +78,7 @@ "@types/mapbox-gl": "^0.54.1", "@types/memoize-one": "^4.1.0", "@types/mime": "^2.0.1", - "@types/mocha": "^5.2.7", + "@types/mocha": "^7.0.2", "@types/nock": "^10.0.3", "@types/node": ">=10.17.17 <10.20.0", "@types/node-fetch": "^2.5.0", @@ -145,7 +145,7 @@ "loader-utils": "^1.2.3", "madge": "3.4.4", "marge": "^1.0.1", - "mocha": "^6.2.2", + "mocha": "^7.1.1", "mocha-junit-reporter": "^1.23.1", "mochawesome": "^4.1.0", "mochawesome-merge": "^2.0.1", diff --git a/yarn.lock b/yarn.lock index bb5032f51c6c7..22b087d5a8338 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4841,10 +4841,10 @@ dependencies: "@types/node" "*" -"@types/mocha@^5.2.7": - version "5.2.7" - resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.7.tgz#315d570ccb56c53452ff8638738df60726d5b6ea" - integrity sha512-NYrtPht0wGzhwe9+/idPaBB+TqkY9AhTvOLMkThm0IoEfLaiVQZwBwyJ5puCkO3AUCWrmcoePjp2mbFocKy4SQ== +"@types/mocha@^7.0.2": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-7.0.2.tgz#b17f16cf933597e10d6d78eae3251e692ce8b0ce" + integrity sha512-ZvO2tAcjmMi8V/5Z3JsyofMe3hasRcaw88cto5etSVMwVQfeivGAlEYmaQgceUSVYFofVjT+ioHsATjdWcFt1w== "@types/moment-timezone@^0.5.12": version "0.5.12" @@ -7934,6 +7934,13 @@ binaryextensions@2: resolved "https://registry.yarnpkg.com/binaryextensions/-/binaryextensions-2.1.1.tgz#3209a51ca4a4ad541a3b8d3d6a6d5b83a2485935" integrity sha512-XBaoWE9RW8pPdPQNibZsW2zh8TW6gcarXp1FZPwT8Uop8ScSNldJEWf2k9l3HeTqdrEwsOsFcq74RiJECW34yA== +bindings@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/bindings/-/bindings-1.5.0.tgz#10353c9e945334bc0511a6d90b38fbc7c9c504df" + integrity sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ== + dependencies: + file-uri-to-path "1.0.0" + bit-twiddle@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/bit-twiddle/-/bit-twiddle-1.0.2.tgz#0c6c1fabe2b23d17173d9a61b7b7093eb9e1769e" @@ -9084,6 +9091,21 @@ chokidar@3.2.1: optionalDependencies: fsevents "~2.1.0" +chokidar@3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.3.0.tgz#12c0714668c55800f659e262d4962a97faf554a6" + integrity sha512-dGmKLDdT3Gdl7fBUe8XK+gAtGmzy5Fn0XkkWQuYxGIgWVPPse2CxFA5mtrlD0TOHaHjEUqkWNyP1XdHoJES/4A== + dependencies: + anymatch "~3.1.1" + braces "~3.0.2" + glob-parent "~5.1.0" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.2.0" + optionalDependencies: + fsevents "~2.1.1" + chokidar@^2.0.0, chokidar@^2.1.2, chokidar@^2.1.8: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" @@ -11084,7 +11106,7 @@ debug-fabulous@1.X: memoizee "0.4.X" object-assign "4.X" -debug@2.6.9, debug@^2.0.0, debug@^2.1.0, debug@^2.1.1, debug@^2.1.2, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: +debug@2.6.9, debug@^2.0.0, debug@^2.1.0, debug@^2.1.1, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== @@ -11478,11 +11500,6 @@ detect-indent@^4.0.0: dependencies: repeating "^2.0.0" -detect-libc@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" - integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups= - detect-newline@2.X, detect-newline@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" @@ -13852,6 +13869,11 @@ file-type@^9.0.0: resolved "https://registry.yarnpkg.com/file-type/-/file-type-9.0.0.tgz#a68d5ad07f486414dfb2c8866f73161946714a18" integrity sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw== +file-uri-to-path@1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" + integrity sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw== + filename-reserved-regex@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/filename-reserved-regex/-/filename-reserved-regex-2.0.0.tgz#abf73dfab735d045440abfea2d91f389ebbfa229" @@ -14473,17 +14495,17 @@ fs.realpath@^1.0.0: integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= fsevents@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.7.tgz#4851b664a3783e52003b3c66eb0eee1074933aa4" - integrity sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw== + version "1.2.12" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.12.tgz#db7e0d8ec3b0b45724fd4d83d43554a8f1f0de5c" + integrity sha512-Ggd/Ktt7E7I8pxZRbGIs7vwqAPscSESMrCSkx2FtWeqmheJgCo2R74fTsZFCifr0VTPwqRpPv17+6b8Zp7th0Q== dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" + bindings "^1.5.0" + nan "^2.12.1" -fsevents@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.0.tgz#ce1a5f9ac71c6d75278b0c5bd236d7dfece4cbaa" - integrity sha512-+iXhW3LuDQsno8dOIrCIT/CBjeBWuP7PXe8w9shnj9Lebny/Gx1ZjVBYwexLz36Ri2jKuXMNpV6CYNh8lHHgrQ== +fsevents@~2.1.0, fsevents@~2.1.1: + version "2.1.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.2.tgz#4c0a1fb34bc68e543b4b82a9ec392bfbda840805" + integrity sha512-R4wDiBwZ0KzpgOWetKDug1FZcYhqYnUYKtfZYt4mD5SBz76q0KR4Q9o7GIPamsVPGmW3EYPPJ0dOOjvx32ldZA== fstream@^1.0.0, fstream@^1.0.12: version "1.0.12" @@ -16502,7 +16524,7 @@ icalendar@0.7.1: resolved "https://registry.yarnpkg.com/icalendar/-/icalendar-0.7.1.tgz#d0d3486795f8f1c5cf4f8cafac081b4b4e7a32ae" integrity sha1-0NNIZ5X48cXPT4yvrAgbS056Mq4= -iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.22, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: +iconv-lite@0.4, iconv-lite@0.4.24, iconv-lite@^0.4.17, iconv-lite@^0.4.22, iconv-lite@^0.4.24, iconv-lite@~0.4.13: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -16576,13 +16598,6 @@ iferr@^0.1.5: resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= -ignore-walk@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.1.tgz#a83e62e7d272ac0e3b551aaa82831a19b69f82f8" - integrity sha512-DTVlMx3IYPe0/JJcYP7Gxg7ttZZu3IInhuEhbchuqneY9wWe5Ojy2mXLBaQFUQmo0AW2r3qG7m1mg86js+gnlQ== - dependencies: - minimatch "^3.0.4" - ignore@^3.1.2, ignore@^3.3.5: version "3.3.10" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" @@ -20712,6 +20727,11 @@ minimist@^0.1.0: resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.1.0.tgz#99df657a52574c21c9057497df742790b2b4c0de" integrity sha1-md9lelJXTCHJBXSX33QnkLK0wN4= +minimist@^1.2.5: + version "1.2.5" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" + integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== + minimist@~0.0.1: version "0.0.10" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.10.tgz#de3f98543dbf96082be48ad1a0c7cda836301dcf" @@ -20754,7 +20774,7 @@ minipass@^2.2.1: safe-buffer "^5.1.1" yallist "^3.0.0" -minipass@^2.2.4, minipass@^2.3.4: +minipass@^2.2.4: version "2.3.5" resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.3.5.tgz#cacebe492022497f656b0f0f51e2682a9ed2d848" integrity sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA== @@ -20784,13 +20804,6 @@ minizlib@^1.1.0, minizlib@^1.2.1: dependencies: minipass "^2.2.1" -minizlib@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.1.1.tgz#6734acc045a46e61d596a43bb9d9cd326e19cc42" - integrity sha512-TrfjCjk4jLhcJyGMYymBH6oTXcWjYbUAXTHDbtnWHjZC25h0cdajHuPE1zxb4DVmu8crfh+HwH/WMuyLG0nHBg== - dependencies: - minipass "^2.2.1" - mississippi@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022" @@ -20842,6 +20855,13 @@ mkdirp@0.5.1, mkdirp@0.5.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdirp@^0.5.1, mkdi dependencies: minimist "0.0.8" +mkdirp@0.5.3: + version "0.5.3" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.3.tgz#5a514b7179259287952881e94410ec5465659f8c" + integrity sha512-P+2gwrFqx8lhew375MQHHeTlY8AuOJSrGf0R5ddkEndUkmwpgUob/vQuBD1V22/Cw1/lJr4x+EjllSezBThzBg== + dependencies: + minimist "^1.2.5" + mkdirp@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.3.tgz#4cf2e30ad45959dddea53ad97d518b6c8205e1ea" @@ -20858,13 +20878,14 @@ mocha-junit-reporter@^1.23.1: strip-ansi "^4.0.0" xml "^1.0.0" -mocha@^6.2.2: - version "6.2.2" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-6.2.2.tgz#5d8987e28940caf8957a7d7664b910dc5b2fea20" - integrity sha512-FgDS9Re79yU1xz5d+C4rv1G7QagNGHZ+iXF81hO8zY35YZZcLEsJVfFolfsqKFWunATEvNzMK0r/CwWd/szO9A== +mocha@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-7.1.1.tgz#89fbb30d09429845b1bb893a830bf5771049a441" + integrity sha512-3qQsu3ijNS3GkWcccT5Zw0hf/rWvu1fTN9sPvEd81hlwsr30GX2GcDSSoBxo24IR8FelmrAydGC6/1J5QQP4WA== dependencies: ansi-colors "3.2.3" browser-stdout "1.3.1" + chokidar "3.3.0" debug "3.2.6" diff "3.5.0" escape-string-regexp "1.0.5" @@ -20873,18 +20894,18 @@ mocha@^6.2.2: growl "1.10.5" he "1.2.0" js-yaml "3.13.1" - log-symbols "2.2.0" + log-symbols "3.0.0" minimatch "3.0.4" - mkdirp "0.5.1" + mkdirp "0.5.3" ms "2.1.1" - node-environment-flags "1.0.5" + node-environment-flags "1.0.6" object.assign "4.1.0" strip-json-comments "2.0.1" supports-color "6.0.0" which "1.3.1" wide-align "1.1.3" - yargs "13.3.0" - yargs-parser "13.1.1" + yargs "13.3.2" + yargs-parser "13.1.2" yargs-unparser "1.6.0" mochawesome-merge@^2.0.1: @@ -21140,7 +21161,7 @@ mute-stream@0.0.8: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nan@^2.13.2, nan@^2.9.2: +nan@^2.12.1, nan@^2.13.2: version "2.14.0" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.0.tgz#7818f722027b2459a86f0295d434d1fc2336c52c" integrity sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg== @@ -21222,15 +21243,6 @@ nearley@^2.7.10: randexp "0.4.6" semver "^5.4.1" -needle@^2.2.1: - version "2.2.4" - resolved "https://registry.yarnpkg.com/needle/-/needle-2.2.4.tgz#51931bff82533b1928b7d1d69e01f1b00ffd2a4e" - integrity sha512-HyoqEb4wr/rsoaIDfTH2aVL9nWtQqba2/HvMv+++m8u0dz808MaagKILxtfeSN7QU7nvbQ79zk3vYOJp9zsNEA== - dependencies: - debug "^2.1.2" - iconv-lite "^0.4.4" - sax "^1.2.4" - negotiator@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9" @@ -21351,10 +21363,10 @@ node-dir@^0.1.10: dependencies: minimatch "^3.0.2" -node-environment-flags@1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.5.tgz#fa930275f5bf5dae188d6192b24b4c8bbac3d76a" - integrity sha512-VNYPRfGfmZLx0Ye20jWzHUjyTW/c+6Wq+iLhDzUI4XmhrDd9l/FozXV3F2xOaXjvp0co0+v1YSR3CMP6g+VvLQ== +node-environment-flags@1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/node-environment-flags/-/node-environment-flags-1.0.6.tgz#a30ac13621f6f7d674260a54dede048c3982c088" + integrity sha512-5Evy2epuL+6TM0lCQGpFIj6KwiEsGh1SrHUhTbNX+sLbBtjidPZFAnVK9y5yU1+h//RitLbRHTIMyxQPtxMdHw== dependencies: object.getownpropertydescriptors "^2.0.3" semver "^5.7.0" @@ -21515,22 +21527,6 @@ node-notifier@^5.4.2: shellwords "^0.1.1" which "^1.3.0" -node-pre-gyp@^0.10.0: - version "0.10.3" - resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" - integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - node-releases@^1.1.25, node-releases@^1.1.46: version "1.1.47" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.47.tgz#c59ef739a1fd7ecbd9f0b7cf5b7871e8a8b591e4" @@ -21600,14 +21596,6 @@ nopt@^2.2.0: dependencies: abbrev "1" -nopt@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.1.tgz#d0d4685afd5415193c8c7505602d0d17cd64474d" - integrity sha1-0NRoWv1UFRk8jHUFYC0NF81kR00= - dependencies: - abbrev "1" - osenv "^0.1.4" - normalize-package-data@^2.0.0, normalize-package-data@^2.3.2, normalize-package-data@^2.3.4: version "2.4.0" resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.4.0.tgz#12f95a307d58352075a04907b84ac8be98ac012f" @@ -21676,11 +21664,6 @@ now-and-later@^2.0.0: dependencies: once "^1.3.2" -npm-bundled@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.0.3.tgz#7e71703d973af3370a9591bafe3a63aca0be2308" - integrity sha512-ByQ3oJ/5ETLyglU2+8dBObvhfWXX8dtPZDMePCahptliFX2iIuhyEszyFk401PZUNQH20vvdW5MLjJxkwU80Ow== - npm-conf@^1.1.0, npm-conf@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/npm-conf/-/npm-conf-1.1.3.tgz#256cc47bd0e218c259c4e9550bf413bc2192aff9" @@ -21697,14 +21680,6 @@ npm-keyword@^5.0.0: got "^7.1.0" registry-url "^3.0.3" -npm-packlist@^1.1.6: - version "1.1.10" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.1.10.tgz#1039db9e985727e464df066f4cf0ab6ef85c398a" - integrity sha512-AQC0Dyhzn4EiYEfIUjCdMl0JJ61I2ER9ukf/sLxJUcZHfo+VyEfz2rMJgLZSS1v30OxPQe1cN0LZA1xbcaVfWA== - dependencies: - ignore-walk "^3.0.1" - npm-bundled "^1.0.1" - npm-run-path@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-1.0.0.tgz#f5c32bf595fe81ae927daec52e82f8b000ac3c8f" @@ -21742,7 +21717,7 @@ npmconf@^2.1.3: semver "2 || 3 || 4" uid-number "0.0.5" -"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2, npmlog@^4.1.2: +"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b" integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg== @@ -22324,14 +22299,6 @@ osenv@0, osenv@^0.1.0: os-homedir "^1.0.0" os-tmpdir "^1.0.0" -osenv@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.4.tgz#42fe6d5953df06c8064be6f176c3d05aaaa34644" - integrity sha1-Qv5tWVPfBsgGS+bxdsPQWqqjRkQ= - dependencies: - os-homedir "^1.0.0" - os-tmpdir "^1.0.0" - output-file-sync@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/output-file-sync/-/output-file-sync-2.0.1.tgz#f53118282f5f553c2799541792b723a4c71430c0" @@ -25191,6 +25158,13 @@ readdirp@~3.1.3: dependencies: picomatch "^2.0.4" +readdirp@~3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.2.0.tgz#c30c33352b12c96dfb4b895421a49fd5a9593839" + integrity sha512-crk4Qu3pmXwgxdSgGhgA/eXiJAPQiX4GMOZZMXnqKxHX7TaoL+3gQVo/WeuAiogr07DpnfjIMpXXa+PAIvwPGQ== + dependencies: + picomatch "^2.0.4" + readline2@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/readline2/-/readline2-1.0.1.tgz#41059608ffc154757b715d9989d199ffbf372e35" @@ -26564,7 +26538,7 @@ sass-resources-loader@^2.0.1: glob "^7.1.1" loader-utils "^1.0.4" -sax@>=0.6.0, sax@^1.2.1, sax@^1.2.4, sax@~1.2.4: +sax@>=0.6.0, sax@^1.2.1, sax@~1.2.4: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== @@ -28627,19 +28601,6 @@ tar@^2.0.0: fstream "^1.0.12" inherits "2" -tar@^4: - version "4.4.8" - resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d" - integrity sha512-LzHF64s5chPQQS0IYBn9IN5h3i98c12bo4NCO7e0sGM2llXQ3p2FGC5sdENN4cTW48O915Sh+x+EXx7XW96xYQ== - dependencies: - chownr "^1.1.1" - fs-minipass "^1.2.5" - minipass "^2.3.4" - minizlib "^1.1.1" - mkdirp "^0.5.0" - safe-buffer "^5.1.2" - yallist "^3.0.2" - tcomb-validation@^3.3.0: version "3.4.1" resolved "https://registry.yarnpkg.com/tcomb-validation/-/tcomb-validation-3.4.1.tgz#a7696ec176ce56a081d9e019f8b732a5a8894b65" @@ -32114,10 +32075,10 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yargs-parser@13.1.1, yargs-parser@^13.0.0, yargs-parser@^13.1.0, yargs-parser@^13.1.1: - version "13.1.1" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.1.tgz#d26058532aa06d365fe091f6a1fc06b2f7e5eca0" - integrity sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ== +yargs-parser@13.1.2, yargs-parser@^13.0.0, yargs-parser@^13.1.0, yargs-parser@^13.1.1, yargs-parser@^13.1.2: + version "13.1.2" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38" + integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -32138,9 +32099,9 @@ yargs-parser@^11.1.1: decamelize "^1.2.0" yargs-parser@^15.0.0: - version "15.0.0" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.0.tgz#cdd7a97490ec836195f59f3f4dbe5ea9e8f75f08" - integrity sha512-xLTUnCMc4JhxrPEPUYD5IBR1mWCK/aT6+RJ/K29JY2y1vD+FhtgKK0AXRWvI262q3QSffAQuTouFIKUuHX89wQ== + version "15.0.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-15.0.1.tgz#54786af40b820dcb2fb8025b11b4d659d76323b3" + integrity sha512-0OAMV2mAZQrs3FkNpDQcBk1x5HXb8X4twADss4S0Iuk+2dGnLOE/fRHrsYm542GduMveyA77OF4wrNJuanRCWw== dependencies: camelcase "^5.0.0" decamelize "^1.2.0" @@ -32211,10 +32172,10 @@ yargs@13.2.4: y18n "^4.0.0" yargs-parser "^13.1.0" -yargs@13.3.0, yargs@^13.2.2, yargs@^13.3.0: - version "13.3.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" - integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== +yargs@13.3.2: + version "13.3.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" + integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw== dependencies: cliui "^5.0.0" find-up "^3.0.0" @@ -32225,7 +32186,7 @@ yargs@13.3.0, yargs@^13.2.2, yargs@^13.3.0: string-width "^3.0.0" which-module "^2.0.0" y18n "^4.0.0" - yargs-parser "^13.1.1" + yargs-parser "^13.1.2" yargs@4.8.1: version "4.8.1" @@ -32265,6 +32226,22 @@ yargs@^11.0.0: y18n "^3.2.1" yargs-parser "^9.0.2" +yargs@^13.2.2, yargs@^13.3.0: + version "13.3.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.0.tgz#4c657a55e07e5f2cf947f8a366567c04a0dedc83" + integrity sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA== + dependencies: + cliui "^5.0.0" + find-up "^3.0.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^3.0.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^13.1.1" + yargs@^14.2.0: version "14.2.0" resolved "https://registry.yarnpkg.com/yargs/-/yargs-14.2.0.tgz#f116a9242c4ed8668790b40759b4906c276e76c3" From 5abb2c8c7dbc6dfca05059261708f800ca1807bb Mon Sep 17 00:00:00 2001 From: Vadim Dalecky Date: Tue, 24 Mar 2020 08:31:29 +0100 Subject: [PATCH 30/34] Drilldowns (#59632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add drilldown wizard components * Dynamic actions (#58216) * feat: 🎸 add DynamicAction and FactoryAction types * feat: 🎸 add Mutable type to @kbn/utility-types * feat: 🎸 add ActionInternal and ActionContract * chore: 🤖 remove unused file * feat: 🎸 improve action interfaces * docs: ✏️ add JSDocs * feat: 🎸 simplify ui_actions interfaces * fix: 🐛 fix TypeScript types * feat: 🎸 add AbstractPresentable interface * feat: 🎸 add AbstractConfigurable interface * feat: 🎸 use AbstractPresentable in ActionInternal * test: 💍 fix ui_actions Jest tests * feat: 🎸 add state container to action * perf: ⚡️ convert MenuItem to React component on Action instance * refactor: 💡 rename AbsractPresentable -> Presentable * refactor: 💡 rename AbstractConfigurable -> Configurable * feat: 🎸 add Storybook to ui_actions * feat: 🎸 add component * feat: 🎸 improve component * chore: 🤖 use .story file extension prefix for Storybook * feat: 🎸 improve component * feat: 🎸 show error if dynamic action has CollectConfig missing * feat: 🎸 render sample action configuration component * feat: 🎸 connect action config to * feat: 🎸 improve stories * test: 💍 add ActionInternal serialize/deserialize tests * feat: 🎸 add ActionContract * feat: 🎸 split action Context into Execution and Presentation * fix: 🐛 fix TypeScript error * refactor: 💡 extract state container hooks to module scope * docs: ✏️ fix typos * chore: 🤖 remove Mutable type * test: 💍 don't cast to any getActions() function * style: 💄 avoid using unnecessary types * chore: 🤖 address PR review comments * chore: 🤖 rename ActionContext generic * chore: 🤖 remove order from state container * chore: 🤖 remove deprecation notice on getHref * test: 💍 fix tests after order field change * remove comments Co-authored-by: Matt Kime Co-authored-by: Elastic Machine * Drilldown context menu (#59638) * fix: 🐛 fix TypeScript error * feat: 🎸 add CONTEXT_MENU_DRILLDOWNS_TRIGGER trigger * fix: 🐛 correctly order context menu items * fix: 🐛 set correct order on drilldown flyout actions * fix: 🐛 clean up context menu building functions * feat: 🎸 add context menu separator action * Add basic ActionFactoryService. Pass data from it into components instead of mocks * Dashboard x pack (#59653) * feat: 🎸 add dashboard_enhanced plugin to x-pack * feat: 🎸 improve context menu separator * feat: 🎸 move drilldown flyout actions to dashboard_enhanced * fix: 🐛 fix exports from ui_actions plugin * feat: 🎸 "implement" registerDrilldown() method * fix ConfigurableBaseConfig type * Implement connected flyout_manage_drilldowns component * Simplify connected flyout manage drilldowns component. Remove intermediate component * clean up data-testid workaround in new components * Connect welcome message to storage Not sure, but use LocalStorage. Didn’t find a way to persist user settings. looks like uiSettings are not user scoped. * require `context` in Presentable. drill context down through wizard components * Drilldown factory (#59823) * refactor: 💡 import storage interface from ui_actions plugin * refactor: 💡 make actions not-dynamic * feat: 🎸 fix TypeScript errors, reshuffle types and code * fix: 🐛 fix more TypeScript errors * fix: 🐛 fix TypeScript import error * Drilldown registration (#59834) * feat: 🎸 improve drilldown registration method * fix: 🐛 set up translations for dashboard_enhanced plugin * Drilldown events 3 (#59854) * feat: 🎸 add serialize/unserialize to action * feat: 🎸 pass in uiActions service into Embeddable * feat: 🎸 merge ui_actions oss and basic plugins * refactor: 💡 move action factory registry to OSS * fix: 🐛 fix TypeScript errors * Drilldown events 4 (#59876) * feat: 🎸 mock sample drilldown execute methods * feat: 🎸 add .dynamicActions manager to Embeddable * feat: 🎸 add first version of dynamic action manager * Drilldown events 5 (#59885) * feat: 🎸 display drilldowns in context menu only on one embed * feat: 🎸 clear dynamic actions from registry when embed unloads * fix: 🐛 fix OSS TypeScript errors * basic integration of components with dynamicActionManager * fix: 🐛 don't overwrite explicitInput with combined input (#59938) * display drilldown count in embeddable edit mode * display drilldown count in embeddable edit mode * improve wizard components. more tests. * partial progress, dashboard drilldowns (#59977) * partial progress, dashboard drilldowns * partial progress, dashboard drilldowns * feat: 🎸 improve dashboard drilldown setup * feat: 🎸 wire in services into dashboard drilldown * chore: 🤖 add Storybook to dashboard_enhanced * feat: 🎸 create presentational * test: 💍 add stories * test: 💍 use presentation dashboar config component * feat: 🎸 wire in services into React component * docs: ✏️ add README to /components folder * feat: 🎸 increase importance of Dashboard drilldown * feat: 🎸 improve icon definition in drilldowns * chore: 🤖 remove unnecessary comment * chore: 🤖 add todos Co-authored-by: streamich * Manage drilldowns toasts. Add basic error handling. * support order in action factory selector * fix column order in manage drilldowns list * remove accidental debug info * bunch of nit ui fixes * Drilldowns reactive action manager (#60099) * feat: 🎸 improve isConfigValid return type * feat: 🎸 make DynamicActionManager reactive * docs: ✏️ add JSDocs to public mehtods of DynamicActionManager * feat: 🎸 make panel top-right corner number badge reactive * fix: 🐛 correctly await for .deleteEvents() * Drilldowns various 2 (#60103) * chore: 🤖 address review comments * test: 💍 fix embeddable_panel.test.tsx tests * chore: 🤖 clean up ActionInternal * chore: 🤖 make isConfigValid a simple predicate * chore: 🤖 fix TypeScript type errors * test: 💍 stub DynamicActionManager tests (#60104) * Drilldowns review 1 (#60139) * refactor: 💡 improve generic types * fix: 🐛 don't overwrite icon * fix: 🐛 fix x-pack TypeScript errors * fix: 🐛 fix TypeScript error * fix: 🐛 correct merge * Drilldowns various 4 (#60264) * feat: 🎸 hide "Create drilldown" from context menu when needed * style: 💄 remove AnyDrilldown type * feat: 🎸 add drilldown factory context * chore: 🤖 remove sample drilldown * fix: 🐛 increase spacing between action factory picker * workaround issue with closing flyout when navigating away Adds overlay just like other flyouts which makes this defect harder to bump in * fix react key issue in action_wizard * don’t open 2 flyouts * fix action order https://github.com/elastic/kibana/issues/60138 * Drilldowns reload stored (#60336) * style: 💄 don't use double equals __ * feat: 🎸 add reload$ to ActionStorage interface * feat: 🎸 add reload$ to embeddable event storage * feat: 🎸 add storage syncing to DynamicActionManager * refactor: 💡 use state from DynamicActionManager in React * fix: 🐛 add check for manager being stopped * Drilldowns triggers (#60339) * feat: 🎸 make use of supportedTriggers() * feat: 🎸 pass in context to configuration component * feat: 🎸 augment factory context * fix: 🐛 stop infinite re-rendering * Drilldowns multitrigger (#60357) * feat: 🎸 add support for multiple triggers * feat: 🎸 enable Drilldowns for TSVB Although TSVB brushing event is now broken on master, KibanaApp plans to fix it in 7.7 * "Create drilldown" flyout - design cleanup (#60309) * create drilldown flyout cleanup * remove border from selectedActionFactoryContainer * adjust callout in DrilldownHello * update form labels * remove unused file * fix type error Co-authored-by: Anton Dosov * basic unit tests for flyout_create_drildown action * Drilldowns finalize (#60371) * fix: 🐛 align flyout content to left side * fix: 🐛 move context menu item number 1px lower * fix: 🐛 move flyout back nav chevron up * fix: 🐛 fix type check after refactor * basic unit tests for drilldown actions * Drilldowns finalize 2 (#60510) * test: 💍 fix test mock * chore: 🤖 remove unused UiActionsService methods * refactor: 💡 cleanup UiActionsService action registration * fix: 🐛 add missing functionality after refactor * test: 💍 add action factory tests * test: 💍 add DynamicActionManager tests * feat: 🎸 capture error if it happens during initial load * fix: 🐛 register correctly CSV action * feat: 🎸 don't show "OPTIONS" title on drilldown context menus * feat: 🎸 add server-side for x-pack dashboard plugin * feat: 🎸 disable Drilldowns for TSVB * feat: 🎸 enable drilldowns on kibana.yml feature flag * feat: 🎸 add feature flag comment to kibana.yml * feat: 🎸 remove places from drilldown interface * refactor: 💡 remove place in factory context * chore: 🤖 remove doExecute * remove not needed now error_configure_action component * remove workaround for storybook * feat: 🎸 improve DrilldownDefinition interface * style: 💄 replace any by unknown * chore: 🤖 remove any * chore: 🤖 make isConfigValid return type a boolean * refactor: 💡 move getDisplayName to factory, remove deprecated * style: 💄 remove any * feat: 🎸 improve ActionFactoryDefinition * refactor: 💡 change visualize_embeddable params * feat: 🎸 add dashboard dependency to dashboard_enhanced * style: 💄 rename drilldown plugin life-cycle contracts * refactor: 💡 do naming adjustments for dashboard drilldown * fix: 🐛 fix Type error * fix: 🐛 fix TypeScript type errors * test: 💍 fix test after refactor * refactor: 💡 rename context -> placeContext in React component * chore: 🤖 remove setting from kibana.yml * refactor: 💡 change return type of getAction as per review * remove custom css per review * refactor: 💡 rename drilldownCount to eventCount * style: 💄 remove any * refactor: 💡 change how uiActions are passed to vis embeddable * style: 💄 remove unused import Co-authored-by: Anton Dosov Co-authored-by: Matt Kime Co-authored-by: Elastic Machine Co-authored-by: Andrea Del Rio --- .github/CODEOWNERS | 1 + examples/ui_action_examples/public/plugin.ts | 2 +- examples/ui_actions_explorer/public/app.tsx | 3 +- .../ui_actions_explorer/public/plugin.tsx | 16 +- .../public/overlays/flyout/flyout_service.tsx | 1 + src/dev/storybook/aliases.ts | 4 +- .../public/embeddable/visualize_embeddable.ts | 8 +- .../visualize_embeddable_factory.tsx | 10 +- .../public/np_ready/public/mocks.ts | 5 +- .../public/np_ready/public/plugin.ts | 6 +- .../public/actions/replace_panel_action.tsx | 2 +- src/plugins/dashboard/public/plugin.tsx | 4 +- .../public/tests/dashboard_container.test.tsx | 2 +- src/plugins/data/public/plugin.ts | 9 +- .../public/lib/actions/edit_panel_action.ts | 2 +- .../public/lib/embeddables/embeddable.tsx | 73 +- .../embeddable_action_storage.test.ts | 128 ++-- .../embeddables/embeddable_action_storage.ts | 53 +- .../public/lib/embeddables/i_embeddable.ts | 16 +- .../lib/panel/embeddable_panel.test.tsx | 14 +- .../public/lib/panel/embeddable_panel.tsx | 38 +- .../customize_title/customize_panel_action.ts | 8 +- .../panel_actions/inspect_panel_action.ts | 2 +- .../panel_actions/remove_panel_action.ts | 2 +- .../lib/panel/panel_header/panel_header.tsx | 9 +- .../create_state_container_react_helpers.ts | 69 +- .../common/state_containers/types.ts | 2 +- src/plugins/kibana_utils/index.ts | 20 + .../ui_actions/public/actions/action.ts | 26 +- .../public/actions/action_factory.ts | 71 ++ .../actions/action_factory_definition.ts | 46 ++ .../public/actions/action_internal.test.ts | 33 + .../public/actions/action_internal.ts | 58 ++ .../public/actions/create_action.ts | 14 +- .../actions/dynamic_action_manager.test.ts | 646 ++++++++++++++++++ .../public/actions/dynamic_action_manager.ts | 284 ++++++++ .../actions/dynamic_action_manager_state.ts | 111 +++ .../public/actions/dynamic_action_storage.ts | 102 +++ .../ui_actions/public/actions/index.ts | 6 + .../ui_actions/public/actions/types.ts | 24 + .../build_eui_context_menu_panels.tsx | 61 +- src/plugins/ui_actions/public/index.ts | 22 +- src/plugins/ui_actions/public/mocks.ts | 18 +- src/plugins/ui_actions/public/plugin.ts | 8 +- .../public/service/ui_actions_service.test.ts | 114 +++- .../public/service/ui_actions_service.ts | 122 +++- .../tests/execute_trigger_actions.test.ts | 10 +- .../public/tests/get_trigger_actions.test.ts | 9 +- .../get_trigger_compatible_actions.test.ts | 6 +- .../public/tests/test_samples/index.ts | 1 + .../public/triggers/select_range_trigger.ts | 2 +- .../public/triggers/trigger_internal.ts | 1 + .../public/triggers/value_click_trigger.ts | 2 +- src/plugins/ui_actions/public/types.ts | 6 +- .../ui_actions/public/util/configurable.ts | 60 ++ src/plugins/ui_actions/public/util/index.ts | 21 + .../presentable.ts} | 47 +- src/plugins/ui_actions/scripts/storybook.js | 26 + .../public/np_ready/public/plugin.tsx | 3 +- .../public/sample_panel_action.tsx | 3 +- .../public/sample_panel_link.ts | 3 +- x-pack/.i18nrc.json | 1 + .../action_wizard/action_wizard.scss | 5 - .../action_wizard/action_wizard.story.tsx | 24 +- .../action_wizard/action_wizard.test.tsx | 13 +- .../action_wizard/action_wizard.tsx | 95 ++- .../public/components/action_wizard/i18n.ts | 2 +- .../public/components/action_wizard/index.ts | 2 +- .../components/action_wizard/test_data.tsx | 218 +++--- .../public/components/index.ts} | 2 +- .../public/custom_time_range_action.tsx | 2 +- .../advanced_ui_actions/public/index.ts | 14 + .../advanced_ui_actions/public/plugin.ts | 28 +- .../action_factory_service/action_factory.ts | 11 + .../action_factory_definition.ts | 11 + .../services/action_factory_service/index.ts | 8 + .../public/services}/index.ts | 2 +- .../advanced_ui_actions/public/util/index.ts | 10 + x-pack/plugins/dashboard_enhanced/README.md | 1 + x-pack/plugins/dashboard_enhanced/kibana.json | 8 + .../public/components/README.md | 5 + .../dashboard_drilldown_config.story.tsx | 54 ++ .../dashboard_drilldown_config.test.tsx | 11 + .../dashboard_drilldown_config.tsx | 69 ++ .../dashboard_drilldown_config/i18n.ts | 14 + .../dashboard_drilldown_config/index.ts | 7 + .../public/components/index.ts | 7 + .../dashboard_enhanced/public/index.ts | 19 + .../dashboard_enhanced/public/mocks.ts | 27 + .../dashboard_enhanced/public/plugin.ts | 50 ++ .../flyout_create_drilldown.test.tsx | 124 ++++ .../flyout_create_drilldown.tsx | 74 ++ .../actions/flyout_create_drilldown/index.ts | 11 + .../flyout_edit_drilldown.test.tsx | 102 +++ .../flyout_edit_drilldown.tsx | 71 ++ .../actions/flyout_edit_drilldown}/i18n.ts | 6 +- .../actions/flyout_edit_drilldown/index.tsx | 11 + .../flyout_edit_drilldown/menu_item.test.tsx | 37 + .../flyout_edit_drilldown/menu_item.tsx | 30 + .../services/drilldowns}/actions/index.ts | 0 .../drilldowns/actions/test_helpers.ts | 28 + .../dashboard_drilldowns_services.ts | 60 ++ .../collect_config.test.tsx | 9 + .../collect_config.tsx | 55 ++ .../constants.ts | 7 + .../drilldown.test.tsx | 20 + .../drilldown.tsx | 52 ++ .../dashboard_to_dashboard_drilldown/i18n.ts | 11 + .../dashboard_to_dashboard_drilldown/index.ts | 16 + .../dashboard_to_dashboard_drilldown/types.ts | 22 + .../public/services/drilldowns/index.ts | 7 + .../public/services/index.ts | 7 + .../scripts/storybook.js} | 10 +- .../dashboard_enhanced/server/config.ts | 23 + .../dashboard_enhanced/server/index.ts | 12 + x-pack/plugins/drilldowns/kibana.json | 5 +- .../actions/flyout_create_drilldown/index.tsx | 52 -- .../actions/flyout_edit_drilldown/index.tsx | 72 -- ...nnected_flyout_manage_drilldowns.story.tsx | 43 ++ ...onnected_flyout_manage_drilldowns.test.tsx | 221 ++++++ .../connected_flyout_manage_drilldowns.tsx | 332 +++++++++ .../i18n.ts | 88 +++ .../index.ts | 7 + .../test_data.ts | 89 +++ .../drilldown_hello_bar.story.tsx | 16 +- .../drilldown_hello_bar.tsx | 58 +- .../components/drilldown_hello_bar/i18n.ts | 29 + .../drilldown_picker/drilldown_picker.tsx | 21 - .../flyout_create_drilldown.story.tsx | 24 - .../flyout_create_drilldown.tsx | 34 - .../flyout_drilldown_wizard.story.tsx | 70 ++ .../flyout_drilldown_wizard.tsx | 139 ++++ .../flyout_drilldown_wizard/i18n.ts | 42 ++ .../flyout_drilldown_wizard/index.ts | 7 + .../flyout_frame/flyout_frame.story.tsx | 7 + .../flyout_frame/flyout_frame.test.tsx | 4 +- .../components/flyout_frame/flyout_frame.tsx | 31 +- .../public/components/flyout_frame/i18n.ts | 6 +- .../flyout_list_manage_drilldowns.story.tsx | 22 + .../flyout_list_manage_drilldowns.tsx | 46 ++ .../flyout_list_manage_drilldowns/i18n.ts | 14 + .../flyout_list_manage_drilldowns/index.ts | 7 + .../form_create_drilldown.story.tsx | 34 - .../form_create_drilldown.tsx | 52 -- .../form_drilldown_wizard.story.tsx | 29 + .../form_drilldown_wizard.test.tsx} | 20 +- .../form_drilldown_wizard.tsx | 79 +++ .../i18n.ts | 4 +- .../index.tsx | 2 +- .../components/list_manage_drilldowns/i18n.ts | 36 + .../list_manage_drilldowns/index.tsx | 7 + .../list_manage_drilldowns.story.tsx | 19 + .../list_manage_drilldowns.test.tsx | 70 ++ .../list_manage_drilldowns.tsx | 116 ++++ x-pack/plugins/drilldowns/public/index.ts | 10 +- x-pack/plugins/drilldowns/public/mocks.ts | 12 +- x-pack/plugins/drilldowns/public/plugin.ts | 54 +- .../public/service/drilldown_service.ts | 32 - .../public/services/drilldown_service.ts | 79 +++ .../public/{service => services}/index.ts | 0 x-pack/plugins/drilldowns/public/types.ts | 120 ++++ x-pack/plugins/reporting/public/plugin.tsx | 3 +- .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 164 files changed, 5368 insertions(+), 898 deletions(-) create mode 100644 src/plugins/kibana_utils/index.ts create mode 100644 src/plugins/ui_actions/public/actions/action_factory.ts create mode 100644 src/plugins/ui_actions/public/actions/action_factory_definition.ts create mode 100644 src/plugins/ui_actions/public/actions/action_internal.test.ts create mode 100644 src/plugins/ui_actions/public/actions/action_internal.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts create mode 100644 src/plugins/ui_actions/public/actions/dynamic_action_storage.ts create mode 100644 src/plugins/ui_actions/public/actions/types.ts create mode 100644 src/plugins/ui_actions/public/util/configurable.ts create mode 100644 src/plugins/ui_actions/public/util/index.ts rename src/plugins/ui_actions/public/{actions/action_definition.ts => util/presentable.ts} (50%) create mode 100644 src/plugins/ui_actions/scripts/storybook.js rename x-pack/plugins/{drilldowns/public/components/drilldown_picker/index.tsx => advanced_ui_actions/public/components/index.ts} (87%) create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts create mode 100644 x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts rename x-pack/plugins/{drilldowns/public/components/flyout_create_drilldown => advanced_ui_actions/public/services}/index.ts (84%) create mode 100644 x-pack/plugins/advanced_ui_actions/public/util/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/README.md create mode 100644 x-pack/plugins/dashboard_enhanced/kibana.json create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/README.md create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/components/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/mocks.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/plugin.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx rename x-pack/plugins/{drilldowns/public/components/flyout_create_drilldown => dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown}/i18n.ts (64%) create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx rename x-pack/plugins/{drilldowns/public => dashboard_enhanced/public/services/drilldowns}/actions/index.ts (100%) create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts create mode 100644 x-pack/plugins/dashboard_enhanced/public/services/index.ts rename x-pack/plugins/{drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx => dashboard_enhanced/scripts/storybook.js} (53%) create mode 100644 x-pack/plugins/dashboard_enhanced/server/config.ts create mode 100644 x-pack/plugins/dashboard_enhanced/server/index.ts delete mode 100644 x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx delete mode 100644 x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts create mode 100644 x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts create mode 100644 x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts delete mode 100644 x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx delete mode 100644 x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown/form_create_drilldown.test.tsx => form_drilldown_wizard/form_drilldown_wizard.test.tsx} (70%) create mode 100644 x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown => form_drilldown_wizard}/i18n.ts (89%) rename x-pack/plugins/drilldowns/public/components/{form_create_drilldown => form_drilldown_wizard}/index.tsx (85%) create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx create mode 100644 x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx delete mode 100644 x-pack/plugins/drilldowns/public/service/drilldown_service.ts create mode 100644 x-pack/plugins/drilldowns/public/services/drilldown_service.ts rename x-pack/plugins/drilldowns/public/{service => services}/index.ts (100%) create mode 100644 x-pack/plugins/drilldowns/public/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 2db898fab68bf..d48b29c89ece6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,6 +3,7 @@ # For more info, see https://help.github.com/articles/about-codeowners/ # App +/x-pack/legacy/plugins/dashboard_enhanced/ @elastic/kibana-app /x-pack/legacy/plugins/lens/ @elastic/kibana-app /x-pack/legacy/plugins/graph/ @elastic/kibana-app /src/legacy/server/url_shortening/ @elastic/kibana-app diff --git a/examples/ui_action_examples/public/plugin.ts b/examples/ui_action_examples/public/plugin.ts index c47746d4b3fd6..d053f7e82862c 100644 --- a/examples/ui_action_examples/public/plugin.ts +++ b/examples/ui_action_examples/public/plugin.ts @@ -46,7 +46,7 @@ export class UiActionExamplesPlugin })); uiActions.registerAction(helloWorldAction); - uiActions.attachAction(helloWorldTrigger.id, helloWorldAction); + uiActions.addTriggerAction(helloWorldTrigger.id, helloWorldAction); } public start() {} diff --git a/examples/ui_actions_explorer/public/app.tsx b/examples/ui_actions_explorer/public/app.tsx index 462f5c3bf88ba..f08b8bb29bdd3 100644 --- a/examples/ui_actions_explorer/public/app.tsx +++ b/examples/ui_actions_explorer/public/app.tsx @@ -95,8 +95,7 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => { ); }, }); - uiActionsApi.registerAction(dynamicAction); - uiActionsApi.attachAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); + uiActionsApi.addTriggerAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); setConfirmationText( `You've successfully added a new action: ${dynamicAction.getDisplayName( {} diff --git a/examples/ui_actions_explorer/public/plugin.tsx b/examples/ui_actions_explorer/public/plugin.tsx index f1895905a45e1..de86b51aee3a8 100644 --- a/examples/ui_actions_explorer/public/plugin.tsx +++ b/examples/ui_actions_explorer/public/plugin.tsx @@ -79,21 +79,21 @@ export class UiActionsExplorerPlugin implements Plugin (await startServices)[1].uiActions) ); - deps.uiActions.attachAction( + deps.uiActions.addTriggerAction( USER_TRIGGER, createEditUserAction(async () => (await startServices)[0].overlays.openModal) ); - deps.uiActions.attachAction(COUNTRY_TRIGGER, viewInMapsAction); - deps.uiActions.attachAction(COUNTRY_TRIGGER, lookUpWeatherAction); - deps.uiActions.attachAction(COUNTRY_TRIGGER, showcasePluggability); - deps.uiActions.attachAction(PHONE_TRIGGER, makePhoneCallAction); - deps.uiActions.attachAction(PHONE_TRIGGER, showcasePluggability); - deps.uiActions.attachAction(USER_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, viewInMapsAction); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, lookUpWeatherAction); + deps.uiActions.addTriggerAction(COUNTRY_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(PHONE_TRIGGER, makePhoneCallAction); + deps.uiActions.addTriggerAction(PHONE_TRIGGER, showcasePluggability); + deps.uiActions.addTriggerAction(USER_TRIGGER, showcasePluggability); core.application.register({ id: 'uiActionsExplorer', diff --git a/src/core/public/overlays/flyout/flyout_service.tsx b/src/core/public/overlays/flyout/flyout_service.tsx index b609b2ce1d741..444430175d4f2 100644 --- a/src/core/public/overlays/flyout/flyout_service.tsx +++ b/src/core/public/overlays/flyout/flyout_service.tsx @@ -91,6 +91,7 @@ export interface OverlayFlyoutStart { export interface OverlayFlyoutOpenOptions { className?: string; closeButtonAriaLabel?: string; + ownFocus?: boolean; 'data-test-subj'?: string; } diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 8ed64f004c9be..370abc120d475 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -18,12 +18,14 @@ */ export const storybookAliases = { + advanced_ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', apm: 'x-pack/legacy/plugins/apm/scripts/storybook.js', canvas: 'x-pack/legacy/plugins/canvas/scripts/storybook_new.js', codeeditor: 'src/plugins/kibana_react/public/code_editor/scripts/storybook.ts', + dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/scripts/storybook.js', drilldowns: 'x-pack/plugins/drilldowns/scripts/storybook.js', embeddable: 'src/plugins/embeddable/scripts/storybook.js', infra: 'x-pack/legacy/plugins/infra/scripts/storybook.js', siem: 'x-pack/legacy/plugins/siem/scripts/storybook.js', - ui_actions: 'x-pack/plugins/advanced_ui_actions/scripts/storybook.js', + ui_actions: 'src/plugins/ui_actions/scripts/storybook.js', }; diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts index 342824bade3dd..4b21be83f1722 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/embeddable/visualize_embeddable.ts @@ -45,6 +45,7 @@ import { PersistedState } from '../../../../../../../plugins/visualizations/publ import { buildPipeline } from '../legacy/build_pipeline'; import { Vis } from '../vis'; import { getExpressions, getUiActions } from '../services'; +import { VisualizationsStartDeps } from '../plugin'; import { VIS_EVENT_TO_TRIGGER } from './events'; const getKeys = (o: T): Array => Object.keys(o) as Array; @@ -56,6 +57,7 @@ export interface VisualizeEmbeddableConfiguration { editable: boolean; appState?: { save(): void }; uiState?: PersistedState; + uiActions?: VisualizationsStartDeps['uiActions']; } export interface VisualizeInput extends EmbeddableInput { @@ -94,7 +96,7 @@ export class VisualizeEmbeddable extends Embeddable { public readonly type = VISUALIZE_EMBEDDABLE_TYPE; - constructor() { + constructor( + private readonly getUiActions: () => Promise< + Pick['uiActions'] + > + ) { super({ savedObjectMetaData: { name: i18n.translate('visualizations.savedObjectName', { defaultMessage: 'Visualization' }), @@ -114,6 +119,8 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< const indexPattern = vis.data.indexPattern; const indexPatterns = indexPattern ? [indexPattern] : []; + const uiActions = await this.getUiActions(); + const editable = await this.isEditable(); return new VisualizeEmbeddable( getTimeFilter(), @@ -124,6 +131,7 @@ export class VisualizeEmbeddableFactory extends EmbeddableFactory< editable, appState: input.appState, uiState: input.uiState, + uiActions, }, input, parent diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts index 17f777e4e80e1..dcd11c920f17c 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/mocks.ts @@ -17,7 +17,7 @@ * under the License. */ -import { PluginInitializerContext } from '../../../../../../core/public'; +import { CoreSetup, PluginInitializerContext } from '../../../../../../core/public'; import { VisualizationsSetup, VisualizationsStart } from './'; import { VisualizationsPlugin } from './plugin'; import { coreMock } from '../../../../../../core/public/mocks'; @@ -26,6 +26,7 @@ import { expressionsPluginMock } from '../../../../../../plugins/expressions/pub import { dataPluginMock } from '../../../../../../plugins/data/public/mocks'; import { usageCollectionPluginMock } from '../../../../../../plugins/usage_collection/public/mocks'; import { uiActionsPluginMock } from '../../../../../../plugins/ui_actions/public/mocks'; +import { VisualizationsStartDeps } from './plugin'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), @@ -48,7 +49,7 @@ const createStartContract = (): VisualizationsStart => ({ const createInstance = async () => { const plugin = new VisualizationsPlugin({} as PluginInitializerContext); - const setup = plugin.setup(coreMock.createSetup(), { + const setup = plugin.setup(coreMock.createSetup() as CoreSetup, { data: dataPluginMock.createSetupContract(), expressions: expressionsPluginMock.createSetupContract(), embeddable: embeddablePluginMock.createSetupContract(), diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts index 3ade6cee0d4d2..c826841e2bcf3 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/plugin.ts @@ -111,7 +111,7 @@ export class VisualizationsPlugin constructor(initializerContext: PluginInitializerContext) {} public setup( - core: CoreSetup, + core: CoreSetup, { expressions, embeddable, usageCollection, data }: VisualizationsSetupDeps ): VisualizationsSetup { setUISettings(core.uiSettings); @@ -120,7 +120,9 @@ export class VisualizationsPlugin expressions.registerFunction(visualizationFunction); expressions.registerRenderer(visualizationRenderer); - const embeddableFactory = new VisualizeEmbeddableFactory(); + const embeddableFactory = new VisualizeEmbeddableFactory( + async () => (await core.getStartServices())[1].uiActions + ); embeddable.registerEmbeddableFactory(VISUALIZE_EMBEDDABLE_TYPE, embeddableFactory); return { diff --git a/src/plugins/dashboard/public/actions/replace_panel_action.tsx b/src/plugins/dashboard/public/actions/replace_panel_action.tsx index 21ec961917d17..4e20aa3c35088 100644 --- a/src/plugins/dashboard/public/actions/replace_panel_action.tsx +++ b/src/plugins/dashboard/public/actions/replace_panel_action.tsx @@ -37,7 +37,7 @@ export interface ReplacePanelActionContext { export class ReplacePanelAction implements ActionByType { public readonly type = ACTION_REPLACE_PANEL; public readonly id = ACTION_REPLACE_PANEL; - public order = 11; + public order = 3; constructor( private core: CoreStart, diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 8a6e747aac170..d663c736e5aed 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -78,7 +78,7 @@ export class DashboardEmbeddableContainerPublicPlugin ): Setup { const expandPanelAction = new ExpandPanelAction(); uiActions.registerAction(expandPanelAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction); + uiActions.attachAction(CONTEXT_MENU_TRIGGER, expandPanelAction.id); const startServices = core.getStartServices(); if (share) { @@ -134,7 +134,7 @@ export class DashboardEmbeddableContainerPublicPlugin plugins.embeddable.getEmbeddableFactories ); uiActions.registerAction(changeViewAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, changeViewAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, changeViewAction); } public stop() {} diff --git a/src/plugins/dashboard/public/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/tests/dashboard_container.test.tsx index a81d80b440e04..4aede3f3442fb 100644 --- a/src/plugins/dashboard/public/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/tests/dashboard_container.test.tsx @@ -49,7 +49,7 @@ test('DashboardContainer in edit mode shows edit mode actions', async () => { const editModeAction = createEditModeAction(); uiActionsSetup.registerAction(editModeAction); - uiActionsSetup.attachAction(CONTEXT_MENU_TRIGGER, editModeAction); + uiActionsSetup.addTriggerAction(CONTEXT_MENU_TRIGGER, editModeAction); setup.registerEmbeddableFactory( CONTACT_CARD_EMBEDDABLE, new ContactCardEmbeddableFactory({} as any, (() => null) as any, {} as any) diff --git a/src/plugins/data/public/plugin.ts b/src/plugins/data/public/plugin.ts index fc5dde94fa851..ea2e85947aa12 100644 --- a/src/plugins/data/public/plugin.ts +++ b/src/plugins/data/public/plugin.ts @@ -109,12 +109,12 @@ export class DataPublicPlugin implements Plugin { public readonly type = ACTION_EDIT_PANEL; public readonly id = ACTION_EDIT_PANEL; - public order = 15; + public order = 50; constructor(private readonly getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory']) {} diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx index eb10c16806640..35973cc16cf9b 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable.tsx @@ -16,23 +16,35 @@ * specific language governing permissions and limitations * under the License. */ -import { isEqual, cloneDeep } from 'lodash'; +import { cloneDeep, isEqual } from 'lodash'; import * as Rx from 'rxjs'; -import { Adapters } from '../types'; +import { Adapters, ViewMode } from '../types'; import { IContainer } from '../containers'; -import { IEmbeddable, EmbeddableInput, EmbeddableOutput } from './i_embeddable'; -import { ViewMode } from '../types'; +import { EmbeddableInput, EmbeddableOutput, IEmbeddable } from './i_embeddable'; import { TriggerContextMapping } from '../ui_actions'; import { EmbeddableActionStorage } from './embeddable_action_storage'; +import { + UiActionsDynamicActionManager, + UiActionsStart, +} from '../../../../../plugins/ui_actions/public'; +import { EmbeddableContext } from '../triggers'; function getPanelTitle(input: EmbeddableInput, output: EmbeddableOutput) { return input.hidePanelTitles ? '' : input.title === undefined ? output.defaultTitle : input.title; } +export interface EmbeddableParams { + uiActions?: UiActionsStart; +} + export abstract class Embeddable< TEmbeddableInput extends EmbeddableInput = EmbeddableInput, TEmbeddableOutput extends EmbeddableOutput = EmbeddableOutput > implements IEmbeddable { + static runtimeId: number = 0; + + public readonly runtimeId = Embeddable.runtimeId++; + public readonly parent?: IContainer; public readonly isContainer: boolean = false; public abstract readonly type: string; @@ -48,15 +60,34 @@ export abstract class Embeddable< // to update input when the parent changes. private parentSubscription?: Rx.Subscription; + private storageSubscription?: Rx.Subscription; + // TODO: Rename to destroyed. private destoyed: boolean = false; - private __actionStorage?: EmbeddableActionStorage; - public get actionStorage(): EmbeddableActionStorage { - return this.__actionStorage || (this.__actionStorage = new EmbeddableActionStorage(this)); + private storage = new EmbeddableActionStorage((this as unknown) as Embeddable); + + private cachedDynamicActions?: UiActionsDynamicActionManager; + public get dynamicActions(): UiActionsDynamicActionManager | undefined { + if (!this.params.uiActions) return undefined; + if (!this.cachedDynamicActions) { + this.cachedDynamicActions = new UiActionsDynamicActionManager({ + isCompatible: async (context: unknown) => + (context as EmbeddableContext).embeddable.runtimeId === this.runtimeId, + storage: this.storage, + uiActions: this.params.uiActions, + }); + } + + return this.cachedDynamicActions; } - constructor(input: TEmbeddableInput, output: TEmbeddableOutput, parent?: IContainer) { + constructor( + input: TEmbeddableInput, + output: TEmbeddableOutput, + parent?: IContainer, + public readonly params: EmbeddableParams = {} + ) { this.id = input.id; this.output = { title: getPanelTitle(input, output), @@ -80,6 +111,18 @@ export abstract class Embeddable< this.onResetInput(newInput); }); } + + if (this.dynamicActions) { + this.dynamicActions.start().catch(error => { + /* eslint-disable */ + console.log('Failed to start embeddable dynamic actions', this); + console.error(error); + /* eslint-enable */ + }); + this.storageSubscription = this.input$.subscribe(() => { + this.storage.reload$.next(); + }); + } } public getIsContainer(): this is IContainer { @@ -158,6 +201,20 @@ export abstract class Embeddable< */ public destroy(): void { this.destoyed = true; + + if (this.dynamicActions) { + this.dynamicActions.stop().catch(error => { + /* eslint-disable */ + console.log('Failed to stop embeddable dynamic actions', this); + console.error(error); + /* eslint-enable */ + }); + } + + if (this.storageSubscription) { + this.storageSubscription.unsubscribe(); + } + if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts index 56facc37fc666..83fd3f184e098 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.test.ts @@ -20,7 +20,8 @@ import { Embeddable } from './embeddable'; import { EmbeddableInput } from './i_embeddable'; import { ViewMode } from '../types'; -import { EmbeddableActionStorage, SerializedEvent } from './embeddable_action_storage'; +import { EmbeddableActionStorage } from './embeddable_action_storage'; +import { UiActionsSerializedEvent } from '../../../../ui_actions/public'; import { of } from '../../../../kibana_utils/common'; class TestEmbeddable extends Embeddable { @@ -42,9 +43,9 @@ describe('EmbeddableActionStorage', () => { test('can add event to embeddable', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -57,23 +58,40 @@ describe('EmbeddableActionStorage', () => { expect(events2).toEqual([event]); }); + test('does not merge .getInput() into .updateInput()', async () => { + const embeddable = new TestEmbeddable(); + const storage = new EmbeddableActionStorage(embeddable); + const event: UiActionsSerializedEvent = { + eventId: 'EVENT_ID', + triggers: ['TRIGGER-ID'], + action: {} as any, + }; + + const spy = jest.spyOn(embeddable, 'updateInput'); + + await storage.create(event); + + expect(spy.mock.calls[0][0].id).toBe(undefined); + expect(spy.mock.calls[0][0].viewMode).toBe(undefined); + }); + test('can create multiple events', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -95,9 +113,9 @@ describe('EmbeddableActionStorage', () => { test('throws when creating an event with the same ID', async () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -122,16 +140,16 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, @@ -148,30 +166,30 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, }; - const event22: SerializedEvent = { + const event22: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'baz', } as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'qux', } as any, @@ -199,9 +217,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -217,14 +235,14 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -249,9 +267,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -266,23 +284,23 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'foo', } as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'bar', } as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: { name: 'qux', } as any, @@ -327,9 +345,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -355,9 +373,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -383,9 +401,9 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event: SerializedEvent = { + const event: UiActionsSerializedEvent = { eventId: 'EVENT_ID', - triggerId: 'TRIGGER-ID', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -402,19 +420,19 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID2', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event3: SerializedEvent = { + const event3: UiActionsSerializedEvent = { eventId: 'EVENT_ID3', - triggerId: 'TRIGGER-ID3', + triggers: ['TRIGGER-ID'], action: {} as any, }; @@ -458,7 +476,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }); @@ -466,7 +484,7 @@ describe('EmbeddableActionStorage', () => { await storage.create({ eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }); @@ -502,15 +520,15 @@ describe('EmbeddableActionStorage', () => { const embeddable = new TestEmbeddable(); const storage = new EmbeddableActionStorage(embeddable); - const event1: SerializedEvent = { + const event1: UiActionsSerializedEvent = { eventId: 'EVENT_ID1', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; - const event2: SerializedEvent = { + const event2: UiActionsSerializedEvent = { eventId: 'EVENT_ID2', - triggerId: 'TRIGGER-ID1', + triggers: ['TRIGGER-ID'], action: {} as any, }; diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts index 520f92840c5f9..fad5b4d535d6c 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_action_storage.ts @@ -17,32 +17,20 @@ * under the License. */ +import { + UiActionsAbstractActionStorage, + UiActionsSerializedEvent, +} from '../../../../ui_actions/public'; import { Embeddable } from '..'; -/** - * Below two interfaces are here temporarily, they will move to `ui_actions` - * plugin once #58216 is merged. - */ -export interface SerializedEvent { - eventId: string; - triggerId: string; - action: unknown; -} -export interface ActionStorage { - create(event: SerializedEvent): Promise; - update(event: SerializedEvent): Promise; - remove(eventId: string): Promise; - read(eventId: string): Promise; - count(): Promise; - list(): Promise; -} - -export class EmbeddableActionStorage implements ActionStorage { - constructor(private readonly embbeddable: Embeddable) {} +export class EmbeddableActionStorage extends UiActionsAbstractActionStorage { + constructor(private readonly embbeddable: Embeddable) { + super(); + } - async create(event: SerializedEvent) { + async create(event: UiActionsSerializedEvent) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const exists = !!events.find(({ eventId }) => eventId === event.eventId); if (exists) { @@ -53,14 +41,13 @@ export class EmbeddableActionStorage implements ActionStorage { } this.embbeddable.updateInput({ - ...input, events: [...events, event], }); } - async update(event: SerializedEvent) { + async update(event: UiActionsSerializedEvent) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const index = events.findIndex(({ eventId }) => eventId === event.eventId); if (index === -1) { @@ -72,14 +59,13 @@ export class EmbeddableActionStorage implements ActionStorage { } this.embbeddable.updateInput({ - ...input, events: [...events.slice(0, index), event, ...events.slice(index + 1)], }); } async remove(eventId: string) { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const index = events.findIndex(event => eventId === event.eventId); if (index === -1) { @@ -91,14 +77,13 @@ export class EmbeddableActionStorage implements ActionStorage { } this.embbeddable.updateInput({ - ...input, events: [...events.slice(0, index), ...events.slice(index + 1)], }); } - async read(eventId: string): Promise { + async read(eventId: string): Promise { const input = this.embbeddable.getInput(); - const events = (input.events || []) as SerializedEvent[]; + const events = (input.events || []) as UiActionsSerializedEvent[]; const event = events.find(ev => eventId === ev.eventId); if (!event) { @@ -113,14 +98,10 @@ export class EmbeddableActionStorage implements ActionStorage { private __list() { const input = this.embbeddable.getInput(); - return (input.events || []) as SerializedEvent[]; - } - - async count(): Promise { - return this.__list().length; + return (input.events || []) as UiActionsSerializedEvent[]; } - async list(): Promise { + async list(): Promise { return this.__list(); } } diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 6345c34b0dda2..9a4452aceba00 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -18,6 +18,7 @@ */ import { Observable } from 'rxjs'; +import { UiActionsDynamicActionManager } from '../../../../../plugins/ui_actions/public'; import { Adapters } from '../types'; import { IContainer } from '../containers/i_container'; import { ViewMode } from '../types'; @@ -33,7 +34,7 @@ export interface EmbeddableInput { /** * Reserved key for `ui_actions` events. */ - events?: unknown; + events?: Array<{ eventId: string }>; /** * List of action IDs that this embeddable should not render. @@ -82,6 +83,19 @@ export interface IEmbeddable< **/ readonly id: string; + /** + * Unique ID an embeddable is assigned each time it is initialized. This ID + * is different for different instances of the same embeddable. For example, + * if the same dashboard is rendered twice on the screen, all embeddable + * instances will have a unique `runtimeId`. + */ + readonly runtimeId?: number; + + /** + * Default implementation of dynamic action API for embeddables. + */ + dynamicActions?: UiActionsDynamicActionManager; + /** * A functional representation of the isContainer variable, but helpful for typescript to * know the shape if this returns true diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 757d4e6bfddef..83d3d5e10761b 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -44,7 +44,7 @@ import { import { inspectorPluginMock } from 'src/plugins/inspector/public/mocks'; import { EuiBadge } from '@elastic/eui'; -const actionRegistry = new Map>(); +const actionRegistry = new Map(); const triggerRegistry = new Map(); const embeddableFactories = new Map(); const getEmbeddableFactory = (id: string) => embeddableFactories.get(id); @@ -213,13 +213,17 @@ const renderInEditModeAndOpenContextMenu = async ( }; test('HelloWorldContainer in edit mode hides disabledActions', async () => { - const action: Action = { + const action = { id: 'FOO', type: 'FOO' as ActionType, getIconType: () => undefined, getDisplayName: () => 'foo', isCompatible: async () => true, execute: async () => {}, + order: 10, + getHref: () => { + return undefined; + }, }; const getActions = () => Promise.resolve([action]); @@ -245,13 +249,17 @@ test('HelloWorldContainer in edit mode hides disabledActions', async () => { }); test('HelloWorldContainer hides disabled badges', async () => { - const action: Action = { + const action = { id: 'BAR', type: 'BAR' as ActionType, getIconType: () => undefined, getDisplayName: () => 'bar', isCompatible: async () => true, execute: async () => {}, + order: 10, + getHref: () => { + return undefined; + }, }; const getActions = () => Promise.resolve([action]); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index b95060a73252f..c6537f2d94994 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -38,6 +38,14 @@ import { EditPanelAction } from '../actions'; import { CustomizePanelModal } from './panel_header/panel_actions/customize_title/customize_panel_modal'; import { EmbeddableStart } from '../../plugin'; +const sortByOrderField = ( + { order: orderA }: { order?: number }, + { order: orderB }: { order?: number } +) => (orderB || 0) - (orderA || 0); + +const removeById = (disabledActions: string[]) => ({ id }: { id: string }) => + disabledActions.indexOf(id) === -1; + interface Props { embeddable: IEmbeddable; getActions: UiActionsService['getTriggerCompatibleActions']; @@ -57,12 +65,14 @@ interface State { hidePanelTitles: boolean; closeContextMenu: boolean; badges: Array>; + eventCount?: number; } export class EmbeddablePanel extends React.Component { private embeddableRoot: React.RefObject; private parentSubscription?: Subscription; private subscription?: Subscription; + private eventCountSubscription?: Subscription; private mounted: boolean = false; private generateId = htmlIdGenerator(); @@ -136,6 +146,9 @@ export class EmbeddablePanel extends React.Component { if (this.subscription) { this.subscription.unsubscribe(); } + if (this.eventCountSubscription) { + this.eventCountSubscription.unsubscribe(); + } if (this.parentSubscription) { this.parentSubscription.unsubscribe(); } @@ -177,6 +190,7 @@ export class EmbeddablePanel extends React.Component { badges={this.state.badges} embeddable={this.props.embeddable} headerId={headerId} + eventCount={this.state.eventCount} /> )}
@@ -188,6 +202,15 @@ export class EmbeddablePanel extends React.Component { if (this.embeddableRoot.current) { this.props.embeddable.render(this.embeddableRoot.current); } + + const dynamicActions = this.props.embeddable.dynamicActions; + if (dynamicActions) { + this.setState({ eventCount: dynamicActions.state.get().events.length }); + this.eventCountSubscription = dynamicActions.state.state$.subscribe(({ events }) => { + if (!this.mounted) return; + this.setState({ eventCount: events.length }); + }); + } } closeMyContextMenuPanel = () => { @@ -201,13 +224,14 @@ export class EmbeddablePanel extends React.Component { }; private getActionContextMenuPanel = async () => { - let actions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { + let regularActions = await this.props.getActions(CONTEXT_MENU_TRIGGER, { embeddable: this.props.embeddable, }); const { disabledActions } = this.props.embeddable.getInput(); if (disabledActions) { - actions = actions.filter(action => disabledActions.indexOf(action.id) === -1); + const removeDisabledActions = removeById(disabledActions); + regularActions = regularActions.filter(removeDisabledActions); } const createGetUserData = (overlays: OverlayStart) => @@ -246,16 +270,10 @@ export class EmbeddablePanel extends React.Component { new EditPanelAction(this.props.getEmbeddableFactory), ]; - const sorted = actions - .concat(extraActions) - .sort((a: Action, b: Action) => { - const bOrder = b.order || 0; - const aOrder = a.order || 0; - return bOrder - aOrder; - }); + const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); return await buildContextMenuForActions({ - actions: sorted, + actions: sortedActions, actionContext: { embeddable: this.props.embeddable }, closeMenu: this.closeMyContextMenuPanel, }); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts index c0e43c0538833..36957c3b79491 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.ts @@ -33,15 +33,13 @@ interface ActionContext { export class CustomizePanelTitleAction implements Action { public readonly type = ACTION_CUSTOMIZE_PANEL; public id = ACTION_CUSTOMIZE_PANEL; - public order = 10; + public order = 40; - constructor(private readonly getDataFromUser: GetUserData) { - this.order = 10; - } + constructor(private readonly getDataFromUser: GetUserData) {} public getDisplayName() { return i18n.translate('embeddableApi.customizePanel.action.displayName', { - defaultMessage: 'Customize panel', + defaultMessage: 'Edit panel title', }); } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts index d04f35715537c..ae9645767b267 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/inspect_panel_action.ts @@ -31,7 +31,7 @@ interface ActionContext { export class InspectPanelAction implements Action { public readonly type = ACTION_INSPECT_PANEL; public readonly id = ACTION_INSPECT_PANEL; - public order = 10; + public order = 20; constructor(private readonly inspector: InspectorStartContract) {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts index ee7948f3d6a4a..a6d4128f3f106 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/remove_panel_action.ts @@ -41,7 +41,7 @@ function hasExpandedPanelInput( export class RemovePanelAction implements Action { public readonly type = REMOVE_PANEL_ACTION; public readonly id = REMOVE_PANEL_ACTION; - public order = 5; + public order = 1; constructor() {} diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 99516a1d21d6f..2a856af7ae916 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -23,6 +23,7 @@ import { EuiIcon, EuiToolTip, EuiScreenReaderOnly, + EuiNotificationBadge, } from '@elastic/eui'; import classNames from 'classnames'; import React from 'react'; @@ -40,6 +41,7 @@ export interface PanelHeaderProps { badges: Array>; embeddable: IEmbeddable; headerId?: string; + eventCount?: number; } function renderBadges(badges: Array>, embeddable: IEmbeddable) { @@ -90,6 +92,7 @@ export function PanelHeader({ badges, embeddable, headerId, + eventCount, }: PanelHeaderProps) { const viewDescription = getViewDescription(embeddable); const showTitle = !isViewMode || (title && !hidePanelTitles) || viewDescription !== ''; @@ -147,7 +150,11 @@ export function PanelHeader({ )} {renderBadges(badges, embeddable)} - + {!isViewMode && !!eventCount && ( + + {eventCount} + + )} >( + container: Container +): UnboxState => useObservable(container.state$, container.get()); + +/** + * Apply selector to state container to extract only needed information. Will + * re-render your component only when the section changes. + * + * @param container State container which state to track. + * @param selector Function used to pick parts of state. + * @param comparator Comparator function used to memoize previous result, to not + * re-render React component if state did not change. By default uses + * `fast-deep-equal` package. + */ +export const useContainerSelector = , Result>( + container: Container, + selector: (state: UnboxState) => Result, + comparator: Comparator = defaultComparator +): Result => { + const { state$, get } = container; + const lastValueRef = useRef(get()); + const [value, setValue] = React.useState(() => { + const newValue = selector(get()); + lastValueRef.current = newValue; + return newValue; + }); + useLayoutEffect(() => { + const subscription = state$.subscribe((currentState: UnboxState) => { + const newValue = selector(currentState); + if (!comparator(lastValueRef.current, newValue)) { + lastValueRef.current = newValue; + setValue(newValue); + } + }); + return () => subscription.unsubscribe(); + }, [state$, comparator]); + return value; +}; + export const createStateContainerReactHelpers = >() => { const context = React.createContext(null as any); const useContainer = (): Container => useContext(context); const useState = (): UnboxState => { - const { state$, get } = useContainer(); - const value = useObservable(state$, get()); - return value; + const container = useContainer(); + return useContainerState(container); }; const useTransitions: () => Container['transitions'] = () => useContainer().transitions; @@ -41,24 +84,8 @@ export const createStateContainerReactHelpers = ) => Result, comparator: Comparator = defaultComparator ): Result => { - const { state$, get } = useContainer(); - const lastValueRef = useRef(get()); - const [value, setValue] = React.useState(() => { - const newValue = selector(get()); - lastValueRef.current = newValue; - return newValue; - }); - useLayoutEffect(() => { - const subscription = state$.subscribe((currentState: UnboxState) => { - const newValue = selector(currentState); - if (!comparator(lastValueRef.current, newValue)) { - lastValueRef.current = newValue; - setValue(newValue); - } - }); - return () => subscription.unsubscribe(); - }, [state$, comparator]); - return value; + const container = useContainer(); + return useContainerSelector(container, selector, comparator); }; const connect: Connect> = mapStateToProp => component => props => diff --git a/src/plugins/kibana_utils/common/state_containers/types.ts b/src/plugins/kibana_utils/common/state_containers/types.ts index 26a29bc470e8a..29ffa4cd486b5 100644 --- a/src/plugins/kibana_utils/common/state_containers/types.ts +++ b/src/plugins/kibana_utils/common/state_containers/types.ts @@ -43,7 +43,7 @@ export interface BaseStateContainer { export interface StateContainer< State extends BaseState, - PureTransitions extends object, + PureTransitions extends object = object, PureSelectors extends object = {} > extends BaseStateContainer { transitions: Readonly>; diff --git a/src/plugins/kibana_utils/index.ts b/src/plugins/kibana_utils/index.ts new file mode 100644 index 0000000000000..14d6e52dc0465 --- /dev/null +++ b/src/plugins/kibana_utils/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { createStateContainer, StateContainer, of } from './common'; diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index 2b2fc004a84c6..15f1d6dd79289 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -19,10 +19,12 @@ import { UiComponent } from 'src/plugins/kibana_utils/common'; import { ActionType, ActionContextMapping } from '../types'; +import { Presentable } from '../util/presentable'; export type ActionByType = Action; -export interface Action { +export interface Action + extends Partial> { /** * Determined the order when there is more than one action matched to a trigger. * Higher numbers are displayed first. @@ -63,12 +65,30 @@ export interface Action { isCompatible(context: Context): Promise; /** - * If this returns something truthy, this is used in addition to the `execute` method when clicked. + * Executes the action. */ - getHref?(context: Context): string | undefined; + execute(context: Context): Promise; +} + +/** + * A convenience interface used to register an action. + */ +export interface ActionDefinition + extends Partial> { + /** + * ID of the action that uniquely identifies this action in the actions registry. + */ + readonly id: string; + + /** + * ID of the factory for this action. Used to construct dynamic actions. + */ + readonly type?: ActionType; /** * Executes the action. */ execute(context: Context): Promise; } + +export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/actions/action_factory.ts b/src/plugins/ui_actions/public/actions/action_factory.ts new file mode 100644 index 0000000000000..bc0ec844d00f5 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_factory.ts @@ -0,0 +1,71 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { uiToReactComponent } from '../../../kibana_react/public'; +import { Presentable } from '../util/presentable'; +import { ActionDefinition } from './action'; +import { ActionFactoryDefinition } from './action_factory_definition'; +import { Configurable } from '../util'; +import { SerializedAction } from './types'; + +export class ActionFactory< + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object +> implements Presentable, Configurable { + constructor( + protected readonly def: ActionFactoryDefinition + ) {} + + public readonly id = this.def.id; + public readonly order = this.def.order || 0; + public readonly MenuItem? = this.def.MenuItem; + public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; + + public readonly CollectConfig = this.def.CollectConfig; + public readonly ReactCollectConfig = uiToReactComponent(this.CollectConfig); + public readonly createConfig = this.def.createConfig; + public readonly isConfigValid = this.def.isConfigValid; + + public getIconType(context: FactoryContext): string | undefined { + if (!this.def.getIconType) return undefined; + return this.def.getIconType(context); + } + + public getDisplayName(context: FactoryContext): string { + if (!this.def.getDisplayName) return ''; + return this.def.getDisplayName(context); + } + + public async isCompatible(context: FactoryContext): Promise { + if (!this.def.isCompatible) return true; + return await this.def.isCompatible(context); + } + + public getHref(context: FactoryContext): string | undefined { + if (!this.def.getHref) return undefined; + return this.def.getHref(context); + } + + public create( + serializedAction: Omit, 'factoryId'> + ): ActionDefinition { + return this.def.create(serializedAction); + } +} diff --git a/src/plugins/ui_actions/public/actions/action_factory_definition.ts b/src/plugins/ui_actions/public/actions/action_factory_definition.ts new file mode 100644 index 0000000000000..7ac94a41e7076 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_factory_definition.ts @@ -0,0 +1,46 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ActionDefinition } from './action'; +import { Presentable, Configurable } from '../util'; +import { SerializedAction } from './types'; + +/** + * This is a convenience interface for registering new action factories. + */ +export interface ActionFactoryDefinition< + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object +> extends Partial>, Configurable { + /** + * Unique ID of the action factory. This ID is used to identify this action + * factory in the registry as well as to construct actions of this type and + * identify this action factory when presenting it to the user in UI. + */ + id: string; + + /** + * This method should return a definition of a new action, normally used to + * register it in `ui_actions` registry. + */ + create( + serializedAction: Omit, 'factoryId'> + ): ActionDefinition; +} diff --git a/src/plugins/ui_actions/public/actions/action_internal.test.ts b/src/plugins/ui_actions/public/actions/action_internal.test.ts new file mode 100644 index 0000000000000..b14346180c274 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_internal.test.ts @@ -0,0 +1,33 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ActionDefinition } from './action'; +import { ActionInternal } from './action_internal'; + +const defaultActionDef: ActionDefinition = { + id: 'test-action', + execute: jest.fn(), +}; + +describe('ActionInternal', () => { + test('can instantiate from action definition', () => { + const action = new ActionInternal(defaultActionDef); + expect(action.id).toBe('test-action'); + }); +}); diff --git a/src/plugins/ui_actions/public/actions/action_internal.ts b/src/plugins/ui_actions/public/actions/action_internal.ts new file mode 100644 index 0000000000000..245ded991c032 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/action_internal.ts @@ -0,0 +1,58 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Action, ActionContext as Context, ActionDefinition } from './action'; +import { Presentable } from '../util/presentable'; +import { uiToReactComponent } from '../../../kibana_react/public'; +import { ActionType } from '../types'; + +export class ActionInternal + implements Action>, Presentable> { + constructor(public readonly definition: A) {} + + public readonly id: string = this.definition.id; + public readonly type: ActionType = this.definition.type || ''; + public readonly order: number = this.definition.order || 0; + public readonly MenuItem? = this.definition.MenuItem; + public readonly ReactMenuItem? = this.MenuItem ? uiToReactComponent(this.MenuItem) : undefined; + + public execute(context: Context) { + return this.definition.execute(context); + } + + public getIconType(context: Context): string | undefined { + if (!this.definition.getIconType) return undefined; + return this.definition.getIconType(context); + } + + public getDisplayName(context: Context): string { + if (!this.definition.getDisplayName) return `Action: ${this.id}`; + return this.definition.getDisplayName(context); + } + + public async isCompatible(context: Context): Promise { + if (!this.definition.isCompatible) return true; + return await this.definition.isCompatible(context); + } + + public getHref(context: Context): string | undefined { + if (!this.definition.getHref) return undefined; + return this.definition.getHref(context); + } +} diff --git a/src/plugins/ui_actions/public/actions/create_action.ts b/src/plugins/ui_actions/public/actions/create_action.ts index 90a9415c0b497..8f1cd23715d3f 100644 --- a/src/plugins/ui_actions/public/actions/create_action.ts +++ b/src/plugins/ui_actions/public/actions/create_action.ts @@ -17,11 +17,19 @@ * under the License. */ +import { ActionContextMapping } from '../types'; import { ActionByType } from './action'; import { ActionType } from '../types'; -import { ActionDefinition } from './action_definition'; +import { ActionDefinition } from './action'; -export function createAction(action: ActionDefinition): ActionByType { +interface ActionDefinitionByType + extends Omit, 'id'> { + id?: string; +} + +export function createAction( + action: ActionDefinitionByType +): ActionByType { return { getIconType: () => undefined, order: 0, @@ -30,5 +38,5 @@ export function createAction(action: ActionDefinition): getDisplayName: () => '', getHref: () => undefined, ...action, - }; + } as ActionByType; } diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts new file mode 100644 index 0000000000000..2574a9e529ebf --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_manager.test.ts @@ -0,0 +1,646 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DynamicActionManager } from './dynamic_action_manager'; +import { ActionStorage, MemoryActionStorage, SerializedEvent } from './dynamic_action_storage'; +import { UiActionsService } from '../service'; +import { ActionFactoryDefinition } from './action_factory_definition'; +import { ActionRegistry } from '../types'; +import { SerializedAction } from './types'; +import { of } from '../../../kibana_utils'; + +const actionFactoryDefinition1: ActionFactoryDefinition = { + id: 'ACTION_FACTORY_1', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: (() => true) as any, + create: ({ name }) => ({ + id: '', + execute: async () => {}, + getDisplayName: () => name, + }), +}; + +const actionFactoryDefinition2: ActionFactoryDefinition = { + id: 'ACTION_FACTORY_2', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: (() => true) as any, + create: ({ name }) => ({ + id: '', + execute: async () => {}, + getDisplayName: () => name, + }), +}; + +const event1: SerializedEvent = { + eventId: 'EVENT_ID_1', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'Action 1', + config: {}, + }, +}; + +const event2: SerializedEvent = { + eventId: 'EVENT_ID_2', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'Action 2', + config: {}, + }, +}; + +const event3: SerializedEvent = { + eventId: 'EVENT_ID_3', + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition2.id, + name: 'Action 3', + config: {}, + }, +}; + +const setup = (events: readonly SerializedEvent[] = []) => { + const isCompatible = async () => true; + const storage: ActionStorage = new MemoryActionStorage(events); + const actions: ActionRegistry = new Map(); + const uiActions = new UiActionsService({ + actions, + }); + const manager = new DynamicActionManager({ + isCompatible, + storage, + uiActions, + }); + + uiActions.registerTrigger({ + id: 'VALUE_CLICK_TRIGGER', + }); + + return { + isCompatible, + actions, + storage, + uiActions, + manager, + }; +}; + +describe('DynamicActionManager', () => { + test('can instantiate', () => { + const { manager } = setup([event1]); + expect(manager).toBeInstanceOf(DynamicActionManager); + }); + + describe('.start()', () => { + test('instantiates stored events', async () => { + const { manager, actions, uiActions } = setup([event1]); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + + await manager.start(); + + expect(create1).toHaveBeenCalledTimes(1); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(1); + }); + + test('does nothing when no events stored', async () => { + const { manager, actions, uiActions } = setup(); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + + await manager.start(); + + expect(create1).toHaveBeenCalledTimes(0); + expect(create2).toHaveBeenCalledTimes(0); + expect(actions.size).toBe(0); + }); + + test('UI state is empty before manager starts', async () => { + const { manager } = setup([event1]); + + expect(manager.state.get()).toMatchObject({ + events: [], + isFetchingEvents: false, + fetchCount: 0, + }); + }); + + test('loads events into UI state', async () => { + const { manager, uiActions } = setup([event1, event2, event3]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + uiActions.registerActionFactory(actionFactoryDefinition2); + + await manager.start(); + + expect(manager.state.get()).toMatchObject({ + events: [event1, event2, event3], + isFetchingEvents: false, + fetchCount: 1, + }); + }); + + test('sets isFetchingEvents to true while fetching events', async () => { + const { manager, uiActions } = setup([event1, event2, event3]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + uiActions.registerActionFactory(actionFactoryDefinition2); + + const promise = manager.start().catch(() => {}); + + expect(manager.state.get().isFetchingEvents).toBe(true); + + await promise; + + expect(manager.state.get().isFetchingEvents).toBe(false); + }); + + test('throws if storage threw', async () => { + const { manager, storage } = setup([event1]); + + storage.list = async () => { + throw new Error('baz'); + }; + + const [, error] = await of(manager.start()); + + expect(error).toEqual(new Error('baz')); + }); + + test('sets UI state error if error happened during initial fetch', async () => { + const { manager, storage } = setup([event1]); + + storage.list = async () => { + throw new Error('baz'); + }; + + await of(manager.start()); + + expect(manager.state.get().fetchError!.message).toBe('baz'); + }); + }); + + describe('.stop()', () => { + test('removes events from UI actions registry', async () => { + const { manager, actions, uiActions } = setup([event1, event2]); + const create1 = jest.fn(); + const create2 = jest.fn(); + + uiActions.registerActionFactory({ ...actionFactoryDefinition1, create: create1 }); + uiActions.registerActionFactory({ ...actionFactoryDefinition2, create: create2 }); + + expect(actions.size).toBe(0); + + await manager.start(); + + expect(actions.size).toBe(2); + + await manager.stop(); + + expect(actions.size).toBe(0); + }); + }); + + describe('.createEvent()', () => { + describe('when storage succeeds', () => { + test('stores new event in storage', async () => { + const { manager, storage, uiActions } = setup([]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + expect(await storage.count()).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(await storage.count()).toBe(1); + + const [event] = await storage.list(); + + expect(event).toMatchObject({ + eventId: expect.any(String), + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }, + }); + }); + + test('adds event to UI state', async () => { + const { manager, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(manager.state.get().events.length).toBe(1); + }); + + test('optimistically adds event to UI state', async () => { + const { manager, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); + + expect(manager.state.get().events.length).toBe(1); + + await promise; + + expect(manager.state.get().events.length).toBe(1); + }); + + test('instantiates event in actions service', async () => { + const { manager, uiActions, actions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(0); + + await manager.createEvent(action, ['VALUE_CLICK_TRIGGER']); + + expect(actions.size).toBe(1); + }); + }); + + describe('when storage fails', () => { + test('throws an error', async () => { + const { manager, storage, uiActions } = setup([]); + + storage.create = async () => { + throw new Error('foo'); + }; + + uiActions.registerActionFactory(actionFactoryDefinition1); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + const [, error] = await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(error).toEqual(new Error('foo')); + }); + + test('does not add even to UI state', async () => { + const { manager, storage, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(manager.state.get().events.length).toBe(0); + }); + + test('optimistically adds event to UI state and then removes it', async () => { + const { manager, storage, uiActions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events.length).toBe(0); + + const promise = manager.createEvent(action, ['VALUE_CLICK_TRIGGER']).catch(e => e); + + expect(manager.state.get().events.length).toBe(1); + + await promise; + + expect(manager.state.get().events.length).toBe(0); + }); + + test('does not instantiate event in actions service', async () => { + const { manager, storage, uiActions, actions } = setup([]); + const action: SerializedAction = { + factoryId: actionFactoryDefinition1.id, + name: 'foo', + config: {}, + }; + + storage.create = async () => { + throw new Error('foo'); + }; + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(0); + + await of(manager.createEvent(action, ['VALUE_CLICK_TRIGGER'])); + + expect(actions.size).toBe(0); + }); + }); + }); + + describe('.updateEvent()', () => { + describe('when storage succeeds', () => { + test('un-registers old event from ui actions service and registers the new one', async () => { + const { manager, actions, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + expect(actions.size).toBe(1); + + const registeredAction1 = actions.values().next().value; + + expect(registeredAction1.getDisplayName()).toBe('Action 3'); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(actions.size).toBe(1); + + const registeredAction2 = actions.values().next().value; + + expect(registeredAction2.getDisplayName()).toBe('foo'); + }); + + test('updates event in storage', async () => { + const { manager, storage, uiActions } = setup([event3]); + const storageUpdateSpy = jest.spyOn(storage, 'update'); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(storageUpdateSpy).toHaveBeenCalledTimes(0); + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(storageUpdateSpy).toHaveBeenCalledTimes(1); + expect(storageUpdateSpy.mock.calls[0][0]).toMatchObject({ + eventId: expect.any(String), + triggers: ['VALUE_CLICK_TRIGGER'], + action: { + factoryId: actionFactoryDefinition2.id, + }, + }); + }); + + test('updates event in UI state', async () => { + const { manager, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + await manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']); + + expect(manager.state.get().events[0].action.name).toBe('foo'); + }); + + test('optimistically updates event in UI state', async () => { + const { manager, uiActions } = setup([event3]); + + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + const promise = manager + .updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) + .catch(e => e); + + expect(manager.state.get().events[0].action.name).toBe('foo'); + + await promise; + }); + }); + + describe('when storage fails', () => { + test('throws error', async () => { + const { manager, storage, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + const [, error] = await of( + manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER']) + ); + + expect(error).toEqual(new Error('bar')); + }); + + test('keeps the old action in actions registry', async () => { + const { manager, storage, actions, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + expect(actions.size).toBe(1); + + const registeredAction1 = actions.values().next().value; + + expect(registeredAction1.getDisplayName()).toBe('Action 3'); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); + + expect(actions.size).toBe(1); + + const registeredAction2 = actions.values().next().value; + + expect(registeredAction2.getDisplayName()).toBe('Action 3'); + }); + + test('keeps old event in UI state', async () => { + const { manager, storage, uiActions } = setup([event3]); + + storage.update = () => { + throw new Error('bar'); + }; + uiActions.registerActionFactory(actionFactoryDefinition2); + await manager.start(); + + const action: SerializedAction = { + factoryId: actionFactoryDefinition2.id, + name: 'foo', + config: {}, + }; + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + + await of(manager.updateEvent(event3.eventId, action, ['VALUE_CLICK_TRIGGER'])); + + expect(manager.state.get().events[0].action.name).toBe('Action 3'); + }); + }); + }); + + describe('.deleteEvents()', () => { + describe('when storage succeeds', () => { + test('removes all actions from uiActions service', async () => { + const { manager, actions, uiActions } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(actions.size).toBe(2); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(actions.size).toBe(0); + }); + + test('removes all events from storage', async () => { + const { manager, uiActions, storage } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(await storage.list()).toEqual([event2, event1]); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(await storage.list()).toEqual([]); + }); + + test('removes all events from UI state', async () => { + const { manager, uiActions } = setup([event2, event1]); + + uiActions.registerActionFactory(actionFactoryDefinition1); + + await manager.start(); + + expect(manager.state.get().events).toEqual([event2, event1]); + + await manager.deleteEvents([event1.eventId, event2.eventId]); + + expect(manager.state.get().events).toEqual([]); + }); + }); + }); +}); diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts new file mode 100644 index 0000000000000..97eb5b05fbbc2 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_manager.ts @@ -0,0 +1,284 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { v4 as uuidv4 } from 'uuid'; +import { Subscription } from 'rxjs'; +import { ActionStorage, SerializedEvent } from './dynamic_action_storage'; +import { UiActionsService } from '../service'; +import { SerializedAction } from './types'; +import { TriggerContextMapping } from '../types'; +import { ActionDefinition } from './action'; +import { defaultState, transitions, selectors, State } from './dynamic_action_manager_state'; +import { StateContainer, createStateContainer } from '../../../kibana_utils'; + +const compareEvents = ( + a: ReadonlyArray<{ eventId: string }>, + b: ReadonlyArray<{ eventId: string }> +) => { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i].eventId !== b[i].eventId) return false; + return true; +}; + +export type DynamicActionManagerState = State; + +export interface DynamicActionManagerParams { + storage: ActionStorage; + uiActions: Pick< + UiActionsService, + 'registerAction' | 'attachAction' | 'unregisterAction' | 'detachAction' | 'getActionFactory' + >; + isCompatible: (context: C) => Promise; +} + +export class DynamicActionManager { + static idPrefixCounter = 0; + + private readonly idPrefix = `D_ACTION_${DynamicActionManager.idPrefixCounter++}_`; + private stopped: boolean = false; + private reloadSubscription?: Subscription; + + /** + * UI State of the dynamic action manager. + */ + protected readonly ui = createStateContainer(defaultState, transitions, selectors); + + constructor(protected readonly params: DynamicActionManagerParams) {} + + protected getEvent(eventId: string): SerializedEvent { + const oldEvent = this.ui.selectors.getEvent(eventId); + if (!oldEvent) throw new Error(`Could not find event [eventId = ${eventId}].`); + return oldEvent; + } + + /** + * We prefix action IDs with a unique `.idPrefix`, so we can render the + * same dashboard twice on the screen. + */ + protected generateActionId(eventId: string): string { + return this.idPrefix + eventId; + } + + protected reviveAction(event: SerializedEvent) { + const { eventId, triggers, action } = event; + const { uiActions, isCompatible } = this.params; + + const actionId = this.generateActionId(eventId); + const factory = uiActions.getActionFactory(event.action.factoryId); + const actionDefinition: ActionDefinition = { + ...factory.create(action as SerializedAction), + id: actionId, + isCompatible, + }; + + uiActions.registerAction(actionDefinition); + for (const trigger of triggers) uiActions.attachAction(trigger as any, actionId); + } + + protected killAction({ eventId, triggers }: SerializedEvent) { + const { uiActions } = this.params; + const actionId = this.generateActionId(eventId); + + for (const trigger of triggers) uiActions.detachAction(trigger as any, actionId); + uiActions.unregisterAction(actionId); + } + + private syncId = 0; + + /** + * This function is called every time stored events might have changed not by + * us. For example, when in edit mode on dashboard user presses "back" button + * in the browser, then contents of storage changes. + */ + private onSync = () => { + if (this.stopped) return; + + (async () => { + const syncId = ++this.syncId; + const events = await this.params.storage.list(); + + if (this.stopped) return; + if (syncId !== this.syncId) return; + if (compareEvents(events, this.ui.get().events)) return; + + for (const event of this.ui.get().events) this.killAction(event); + for (const event of events) this.reviveAction(event); + this.ui.transitions.finishFetching(events); + })().catch(error => { + /* eslint-disable */ + console.log('Dynamic action manager storage reload failed.'); + console.error(error); + /* eslint-enable */ + }); + }; + + // Public API: --------------------------------------------------------------- + + /** + * Read-only state container of dynamic action manager. Use it to perform all + * *read* operations. + */ + public readonly state: StateContainer = this.ui; + + /** + * 1. Loads all events from @type {DynamicActionStorage} storage. + * 2. Creates actions for each event in `ui_actions` registry. + * 3. Adds events to UI state. + * 4. Does nothing if dynamic action manager was stopped or if event fetching + * is already taking place. + */ + public async start() { + if (this.stopped) return; + if (this.ui.get().isFetchingEvents) return; + + this.ui.transitions.startFetching(); + try { + const events = await this.params.storage.list(); + for (const event of events) this.reviveAction(event); + this.ui.transitions.finishFetching(events); + } catch (error) { + this.ui.transitions.failFetching(error instanceof Error ? error : { message: String(error) }); + throw error; + } + + if (this.params.storage.reload$) { + this.reloadSubscription = this.params.storage.reload$.subscribe(this.onSync); + } + } + + /** + * 1. Removes all events from `ui_actions` registry. + * 2. Puts dynamic action manager is stopped state. + */ + public async stop() { + this.stopped = true; + const events = await this.params.storage.list(); + + for (const event of events) { + this.killAction(event); + } + + if (this.reloadSubscription) { + this.reloadSubscription.unsubscribe(); + } + } + + /** + * Creates a new event. + * + * 1. Stores event in @type {DynamicActionStorage} storage. + * 2. Optimistically adds it to UI state, and rolls back on failure. + * 3. Adds action to `ui_actions` registry. + * + * @param action Dynamic action for which to create an event. + * @param triggers List of triggers to which action should react. + */ + public async createEvent( + action: SerializedAction, + triggers: Array + ) { + const event: SerializedEvent = { + eventId: uuidv4(), + triggers, + action, + }; + + this.ui.transitions.addEvent(event); + try { + await this.params.storage.create(event); + this.reviveAction(event); + } catch (error) { + this.ui.transitions.removeEvent(event.eventId); + throw error; + } + } + + /** + * Updates an existing event. Fails if event with given `eventId` does not + * exit. + * + * 1. Updates the event in @type {DynamicActionStorage} storage. + * 2. Optimistically replaces the old event by the new one in UI state, and + * rolls back on failure. + * 3. Replaces action in `ui_actions` registry with the new event. + * + * + * @param eventId ID of the event to replace. + * @param action New action for which to create the event. + * @param triggers List of triggers to which action should react. + */ + public async updateEvent( + eventId: string, + action: SerializedAction, + triggers: Array + ) { + const event: SerializedEvent = { + eventId, + triggers, + action, + }; + const oldEvent = this.getEvent(eventId); + this.killAction(oldEvent); + + this.reviveAction(event); + this.ui.transitions.replaceEvent(event); + + try { + await this.params.storage.update(event); + } catch (error) { + this.killAction(event); + this.reviveAction(oldEvent); + this.ui.transitions.replaceEvent(oldEvent); + throw error; + } + } + + /** + * Removes existing event. Throws if event does not exist. + * + * 1. Removes the event from @type {DynamicActionStorage} storage. + * 2. Optimistically removes event from UI state, and puts it back on failure. + * 3. Removes associated action from `ui_actions` registry. + * + * @param eventId ID of the event to remove. + */ + public async deleteEvent(eventId: string) { + const event = this.getEvent(eventId); + + this.killAction(event); + this.ui.transitions.removeEvent(eventId); + + try { + await this.params.storage.remove(eventId); + } catch (error) { + this.reviveAction(event); + this.ui.transitions.addEvent(event); + throw error; + } + } + + /** + * Deletes multiple events at once. + * + * @param eventIds List of event IDs. + */ + public async deleteEvents(eventIds: string[]) { + await Promise.all(eventIds.map(this.deleteEvent.bind(this))); + } +} diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts b/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts new file mode 100644 index 0000000000000..636af076ea39f --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_manager_state.ts @@ -0,0 +1,111 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SerializedEvent } from './dynamic_action_storage'; + +/** + * This interface represents the state of @type {DynamicActionManager} at any + * point in time. + */ +export interface State { + /** + * Whether dynamic action manager is currently in process of fetching events + * from storage. + */ + readonly isFetchingEvents: boolean; + + /** + * Number of times event fetching has been completed. + */ + readonly fetchCount: number; + + /** + * Error received last time when fetching events. + */ + readonly fetchError?: { + message: string; + }; + + /** + * List of all fetched events. + */ + readonly events: readonly SerializedEvent[]; +} + +export interface Transitions { + startFetching: (state: State) => () => State; + finishFetching: (state: State) => (events: SerializedEvent[]) => State; + failFetching: (state: State) => (error: { message: string }) => State; + addEvent: (state: State) => (event: SerializedEvent) => State; + removeEvent: (state: State) => (eventId: string) => State; + replaceEvent: (state: State) => (event: SerializedEvent) => State; +} + +export interface Selectors { + getEvent: (state: State) => (eventId: string) => SerializedEvent | null; +} + +export const defaultState: State = { + isFetchingEvents: false, + fetchCount: 0, + events: [], +}; + +export const transitions: Transitions = { + startFetching: state => () => ({ ...state, isFetchingEvents: true }), + + finishFetching: state => events => ({ + ...state, + isFetchingEvents: false, + fetchCount: state.fetchCount + 1, + fetchError: undefined, + events, + }), + + failFetching: state => ({ message }) => ({ + ...state, + isFetchingEvents: false, + fetchCount: state.fetchCount + 1, + fetchError: { message }, + }), + + addEvent: state => (event: SerializedEvent) => ({ + ...state, + events: [...state.events, event], + }), + + removeEvent: state => (eventId: string) => ({ + ...state, + events: state.events ? state.events.filter(event => event.eventId !== eventId) : state.events, + }), + + replaceEvent: state => event => { + const index = state.events.findIndex(({ eventId }) => eventId === event.eventId); + if (index === -1) return state; + + return { + ...state, + events: [...state.events.slice(0, index), event, ...state.events.slice(index + 1)], + }; + }, +}; + +export const selectors: Selectors = { + getEvent: state => eventId => state.events.find(event => event.eventId === eventId) || null, +}; diff --git a/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts b/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts new file mode 100644 index 0000000000000..28550a671782e --- /dev/null +++ b/src/plugins/ui_actions/public/actions/dynamic_action_storage.ts @@ -0,0 +1,102 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* eslint-disable max-classes-per-file */ + +import { Observable, Subject } from 'rxjs'; +import { SerializedAction } from './types'; + +/** + * Serialized representation of event-action pair, used to persist in storage. + */ +export interface SerializedEvent { + eventId: string; + triggers: string[]; + action: SerializedAction; +} + +/** + * This CRUD interface needs to be implemented by dynamic action users if they + * want to persist the dynamic actions. It has a default implementation in + * Embeddables, however one can use the dynamic actions without Embeddables, + * in that case they have to implement this interface. + */ +export interface ActionStorage { + create(event: SerializedEvent): Promise; + update(event: SerializedEvent): Promise; + remove(eventId: string): Promise; + read(eventId: string): Promise; + count(): Promise; + list(): Promise; + + /** + * Triggered every time events changed in storage and should be re-loaded. + */ + readonly reload$?: Observable; +} + +export abstract class AbstractActionStorage implements ActionStorage { + public readonly reload$: Observable & Pick, 'next'> = new Subject(); + + public async count(): Promise { + return (await this.list()).length; + } + + public async read(eventId: string): Promise { + const events = await this.list(); + const event = events.find(ev => ev.eventId === eventId); + if (!event) throw new Error(`Event [eventId = ${eventId}] not found.`); + return event; + } + + abstract create(event: SerializedEvent): Promise; + abstract update(event: SerializedEvent): Promise; + abstract remove(eventId: string): Promise; + abstract list(): Promise; +} + +/** + * This is an in-memory implementation of ActionStorage. It is used in testing, + * but can also be used production code to store events in memory. + */ +export class MemoryActionStorage extends AbstractActionStorage { + constructor(public events: readonly SerializedEvent[] = []) { + super(); + } + + public async list() { + return this.events.map(event => ({ ...event })); + } + + public async create(event: SerializedEvent) { + this.events = [...this.events, { ...event }]; + } + + public async update(event: SerializedEvent) { + const index = this.events.findIndex(({ eventId }) => eventId === event.eventId); + if (index < 0) throw new Error(`Event [eventId = ${event.eventId}] not found`); + this.events = [...this.events.slice(0, index), { ...event }, ...this.events.slice(index + 1)]; + } + + public async remove(eventId: string) { + const index = this.events.findIndex(ev => eventId === ev.eventId); + if (index < 0) throw new Error(`Event [eventId = ${eventId}] not found`); + this.events = [...this.events.slice(0, index), ...this.events.slice(index + 1)]; + } +} diff --git a/src/plugins/ui_actions/public/actions/index.ts b/src/plugins/ui_actions/public/actions/index.ts index 64bfd368e3dfa..0ddba197aced6 100644 --- a/src/plugins/ui_actions/public/actions/index.ts +++ b/src/plugins/ui_actions/public/actions/index.ts @@ -18,5 +18,11 @@ */ export * from './action'; +export * from './action_internal'; +export * from './action_factory_definition'; +export * from './action_factory'; export * from './create_action'; export * from './incompatible_action_error'; +export * from './dynamic_action_storage'; +export * from './dynamic_action_manager'; +export * from './types'; diff --git a/src/plugins/ui_actions/public/actions/types.ts b/src/plugins/ui_actions/public/actions/types.ts new file mode 100644 index 0000000000000..465f091e45ef1 --- /dev/null +++ b/src/plugins/ui_actions/public/actions/types.ts @@ -0,0 +1,24 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface SerializedAction { + readonly factoryId: string; + readonly name: string; + readonly config: Config; +} diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 3dce2c1f4c257..ec58261d9e4f7 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -24,19 +24,25 @@ import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; import { Action } from '../actions'; +export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { + defaultMessage: 'Options', +}); + /** * Transforms an array of Actions to the shape EuiContextMenuPanel expects. */ -export async function buildContextMenuForActions({ +export async function buildContextMenuForActions({ actions, actionContext, + title = defaultTitle, closeMenu, }: { - actions: Array>; - actionContext: A; + actions: Array>; + actionContext: Context; + title?: string; closeMenu: () => void; }): Promise { - const menuItems = await buildEuiContextMenuPanelItems({ + const menuItems = await buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, @@ -44,9 +50,7 @@ export async function buildContextMenuForActions({ return { id: 'mainMenu', - title: i18n.translate('uiActions.actionPanel.title', { - defaultMessage: 'Options', - }), + title, items: menuItems, }; } @@ -54,49 +58,41 @@ export async function buildContextMenuForActions({ /** * Transform an array of Actions into the shape needed to build an EUIContextMenu */ -async function buildEuiContextMenuPanelItems({ +async function buildEuiContextMenuPanelItems({ actions, actionContext, closeMenu, }: { - actions: Array>; - actionContext: A; + actions: Array>; + actionContext: Context; closeMenu: () => void; }) { - const items: EuiContextMenuPanelItemDescriptor[] = []; - const promises = actions.map(async action => { + const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); + const promises = actions.map(async (action, index) => { const isCompatible = await action.isCompatible(actionContext); if (!isCompatible) { return; } - items.push( - convertPanelActionToContextMenuItem({ - action, - actionContext, - closeMenu, - }) - ); + items[index] = convertPanelActionToContextMenuItem({ + action, + actionContext, + closeMenu, + }); }); await Promise.all(promises); - return items; + return items.filter(Boolean); } -/** - * - * @param {ContextMenuAction} action - * @param {Embeddable} embeddable - * @return {EuiContextMenuPanelItemDescriptor} - */ -function convertPanelActionToContextMenuItem({ +function convertPanelActionToContextMenuItem({ action, actionContext, closeMenu, }: { - action: Action; - actionContext: A; + action: Action; + actionContext: Context; closeMenu: () => void; }): EuiContextMenuPanelItemDescriptor { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { @@ -115,8 +111,11 @@ function convertPanelActionToContextMenuItem({ closeMenu(); }; - if (action.getHref && action.getHref(actionContext)) { - menuPanelItem.href = action.getHref(actionContext); + if (action.getHref) { + const href = action.getHref(actionContext); + if (href) { + menuPanelItem.href = action.getHref(actionContext); + } } return menuPanelItem; diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index 49b6bd5e17699..9265d35bad9a9 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -26,8 +26,26 @@ export function plugin(initializerContext: PluginInitializerContext) { export { UiActionsSetup, UiActionsStart } from './plugin'; export { UiActionsServiceParams, UiActionsService } from './service'; -export { Action, createAction, IncompatibleActionError } from './actions'; +export { + Action, + ActionDefinition as UiActionsActionDefinition, + ActionFactoryDefinition as UiActionsActionFactoryDefinition, + ActionInternal as UiActionsActionInternal, + ActionStorage as UiActionsActionStorage, + AbstractActionStorage as UiActionsAbstractActionStorage, + createAction, + DynamicActionManager, + DynamicActionManagerState, + IncompatibleActionError, + SerializedAction as UiActionsSerializedAction, + SerializedEvent as UiActionsSerializedEvent, +} from './actions'; export { buildContextMenuForActions } from './context_menu'; +export { + Presentable as UiActionsPresentable, + Configurable as UiActionsConfigurable, + CollectConfigProps as UiActionsCollectConfigProps, +} from './util'; export { Trigger, TriggerContext, @@ -39,4 +57,4 @@ export { applyFilterTrigger, } from './triggers'; export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; -export { ActionByType } from './actions'; +export { ActionByType, DynamicActionManager as UiActionsDynamicActionManager } from './actions'; diff --git a/src/plugins/ui_actions/public/mocks.ts b/src/plugins/ui_actions/public/mocks.ts index c1be6b2626525..4de38eb5421e9 100644 --- a/src/plugins/ui_actions/public/mocks.ts +++ b/src/plugins/ui_actions/public/mocks.ts @@ -28,10 +28,13 @@ export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { + addTriggerAction: jest.fn(), attachAction: jest.fn(), detachAction: jest.fn(), registerAction: jest.fn(), + registerActionFactory: jest.fn(), registerTrigger: jest.fn(), + unregisterAction: jest.fn(), }; return setupContract; }; @@ -39,16 +42,21 @@ const createSetupContract = (): Setup => { const createStartContract = (): Start => { const startContract: Start = { attachAction: jest.fn(), - registerAction: jest.fn(), - registerTrigger: jest.fn(), - getAction: jest.fn(), + unregisterAction: jest.fn(), + addTriggerAction: jest.fn(), + clear: jest.fn(), detachAction: jest.fn(), executeTriggerActions: jest.fn(), + fork: jest.fn(), + getAction: jest.fn(), + getActionFactories: jest.fn(), + getActionFactory: jest.fn(), getTrigger: jest.fn(), getTriggerActions: jest.fn((id: TriggerId) => []), getTriggerCompatibleActions: jest.fn(), - clear: jest.fn(), - fork: jest.fn(), + registerAction: jest.fn(), + registerActionFactory: jest.fn(), + registerTrigger: jest.fn(), }; return startContract; diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 928e57937a9b5..88a5cb04eac6f 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -23,7 +23,13 @@ import { selectRangeTrigger, valueClickTrigger, applyFilterTrigger } from './tri export type UiActionsSetup = Pick< UiActionsService, - 'attachAction' | 'detachAction' | 'registerAction' | 'registerTrigger' + | 'addTriggerAction' + | 'attachAction' + | 'detachAction' + | 'registerAction' + | 'registerActionFactory' + | 'registerTrigger' + | 'unregisterAction' >; export type UiActionsStart = PublicMethodsOf; diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts index bdf71a25e6dbc..41e2b57d53dd8 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.test.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.test.ts @@ -18,7 +18,13 @@ */ import { UiActionsService } from './ui_actions_service'; -import { Action, createAction } from '../actions'; +import { + Action, + ActionInternal, + createAction, + ActionFactoryDefinition, + ActionFactory, +} from '../actions'; import { createHelloWorldAction } from '../tests/test_samples'; import { ActionRegistry, TriggerRegistry, TriggerId, ActionType } from '../types'; import { Trigger } from '../triggers'; @@ -102,6 +108,21 @@ describe('UiActionsService', () => { type: 'test' as ActionType, }); }); + + test('return action instance', () => { + const service = new UiActionsService(); + const action = service.registerAction({ + id: 'test', + execute: async () => {}, + getDisplayName: () => 'test', + getIconType: () => '', + isCompatible: async () => true, + type: 'test' as ActionType, + }); + + expect(action).toBeInstanceOf(ActionInternal); + expect(action.id).toBe('test'); + }); }); describe('.getTriggerActions()', () => { @@ -139,13 +160,14 @@ describe('UiActionsService', () => { expect(list0).toHaveLength(0); - service.attachAction(FOO_TRIGGER, action1); + service.addTriggerAction(FOO_TRIGGER, action1); const list1 = service.getTriggerActions(FOO_TRIGGER); expect(list1).toHaveLength(1); - expect(list1).toEqual([action1]); + expect(list1[0]).toBeInstanceOf(ActionInternal); + expect(list1[0].id).toBe(action1.id); - service.attachAction(FOO_TRIGGER, action2); + service.addTriggerAction(FOO_TRIGGER, action2); const list2 = service.getTriggerActions(FOO_TRIGGER); expect(list2).toHaveLength(2); @@ -164,7 +186,7 @@ describe('UiActionsService', () => { service.registerAction(helloWorldAction); expect(actions.size - length).toBe(1); - expect(actions.get(helloWorldAction.id)).toBe(helloWorldAction); + expect(actions.get(helloWorldAction.id)!.id).toBe(helloWorldAction.id); }); test('getTriggerCompatibleActions returns attached actions', async () => { @@ -178,7 +200,7 @@ describe('UiActionsService', () => { title: 'My trigger', }; service.registerTrigger(testTrigger); - service.attachAction(MY_TRIGGER, helloWorldAction); + service.addTriggerAction(MY_TRIGGER, helloWorldAction); const compatibleActions = await service.getTriggerCompatibleActions(MY_TRIGGER, { hi: 'there', @@ -204,7 +226,7 @@ describe('UiActionsService', () => { }; service.registerTrigger(testTrigger); - service.attachAction(testTrigger.id, action); + service.addTriggerAction(testTrigger.id, action); const compatibleActions1 = await service.getTriggerCompatibleActions(testTrigger.id, { accept: true, @@ -288,7 +310,7 @@ describe('UiActionsService', () => { id: FOO_TRIGGER, }); service1.registerAction(testAction1); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); @@ -309,14 +331,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service2.attachAction(FOO_TRIGGER, testAction2); + service2.addTriggerAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); @@ -330,14 +352,14 @@ describe('UiActionsService', () => { }); service1.registerAction(testAction1); service1.registerAction(testAction2); - service1.attachAction(FOO_TRIGGER, testAction1); + service1.addTriggerAction(FOO_TRIGGER, testAction1); const service2 = service1.fork(); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); - service1.attachAction(FOO_TRIGGER, testAction2); + service1.addTriggerAction(FOO_TRIGGER, testAction2); expect(service1.getTriggerActions(FOO_TRIGGER)).toHaveLength(2); expect(service2.getTriggerActions(FOO_TRIGGER)).toHaveLength(1); @@ -392,7 +414,7 @@ describe('UiActionsService', () => { } as any; service.registerTrigger(trigger); - service.attachAction(MY_TRIGGER, action); + service.addTriggerAction(MY_TRIGGER, action); const actions = service.getTriggerActions(trigger.id); @@ -400,7 +422,7 @@ describe('UiActionsService', () => { expect(actions[0].id).toBe(ACTION_HELLO_WORLD); }); - test('can detach an action to a trigger', () => { + test('can detach an action from a trigger', () => { const service = new UiActionsService(); const trigger: Trigger = { @@ -413,7 +435,7 @@ describe('UiActionsService', () => { service.registerTrigger(trigger); service.registerAction(action); - service.attachAction(trigger.id, action); + service.addTriggerAction(trigger.id, action); service.detachAction(trigger.id, action.id); const actions2 = service.getTriggerActions(trigger.id); @@ -445,7 +467,7 @@ describe('UiActionsService', () => { } as any; service.registerAction(action); - expect(() => service.attachAction('i do not exist' as TriggerId, action)).toThrowError( + expect(() => service.addTriggerAction('i do not exist' as TriggerId, action)).toThrowError( 'No trigger [triggerId = i do not exist] exists, for attaching action [actionId = ACTION_HELLO_WORLD].' ); }); @@ -475,4 +497,64 @@ describe('UiActionsService', () => { ); }); }); + + describe('action factories', () => { + const factoryDefinition1: ActionFactoryDefinition = { + id: 'test-factory-1', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + create: () => ({} as any), + }; + const factoryDefinition2: ActionFactoryDefinition = { + id: 'test-factory-2', + CollectConfig: {} as any, + createConfig: () => ({}), + isConfigValid: () => true, + create: () => ({} as any), + }; + + test('.getActionFactories() returns empty array if no action factories registered', () => { + const service = new UiActionsService(); + + const factories = service.getActionFactories(); + + expect(factories).toEqual([]); + }); + + test('can register and retrieve an action factory', () => { + const service = new UiActionsService(); + + service.registerActionFactory(factoryDefinition1); + + const factory = service.getActionFactory(factoryDefinition1.id); + + expect(factory).toBeInstanceOf(ActionFactory); + expect(factory.id).toBe(factoryDefinition1.id); + }); + + test('can retrieve all action factories', () => { + const service = new UiActionsService(); + + service.registerActionFactory(factoryDefinition1); + service.registerActionFactory(factoryDefinition2); + + const factories = service.getActionFactories(); + const factoriesSorted = [...factories].sort((f1, f2) => (f1.id > f2.id ? 1 : -1)); + + expect(factoriesSorted.length).toBe(2); + expect(factoriesSorted[0].id).toBe(factoryDefinition1.id); + expect(factoriesSorted[1].id).toBe(factoryDefinition2.id); + }); + + test('throws when retrieving action factory that does not exist', () => { + const service = new UiActionsService(); + + service.registerActionFactory(factoryDefinition1); + + expect(() => service.getActionFactory('UNKNOWN_ID')).toThrowError( + 'Action factory [actionFactoryId = UNKNOWN_ID] does not exist.' + ); + }); + }); }); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index f7718e63773f5..8bd3bb34fbbd8 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -24,8 +24,17 @@ import { TriggerId, TriggerContextMapping, ActionType, + ActionFactoryRegistry, } from '../types'; -import { Action, ActionByType } from '../actions'; +import { + ActionInternal, + Action, + ActionByType, + ActionFactory, + ActionDefinition, + ActionFactoryDefinition, + ActionContext, +} from '../actions'; import { Trigger, TriggerContext } from '../triggers/trigger'; import { TriggerInternal } from '../triggers/trigger_internal'; import { TriggerContract } from '../triggers/trigger_contract'; @@ -38,21 +47,25 @@ export interface UiActionsServiceParams { * A 1-to-N mapping from `Trigger` to zero or more `Action`. */ readonly triggerToActions?: TriggerToActionsRegistry; + readonly actionFactories?: ActionFactoryRegistry; } export class UiActionsService { protected readonly triggers: TriggerRegistry; protected readonly actions: ActionRegistry; protected readonly triggerToActions: TriggerToActionsRegistry; + protected readonly actionFactories: ActionFactoryRegistry; constructor({ triggers = new Map(), actions = new Map(), triggerToActions = new Map(), + actionFactories = new Map(), }: UiActionsServiceParams = {}) { this.triggers = triggers; this.actions = actions; this.triggerToActions = triggerToActions; + this.actionFactories = actionFactories; } public readonly registerTrigger = (trigger: Trigger) => { @@ -76,49 +89,44 @@ export class UiActionsService { return trigger.contract; }; - public readonly registerAction = (action: ActionByType) => { - if (this.actions.has(action.id)) { - throw new Error(`Action [action.id = ${action.id}] already registered.`); + public readonly registerAction = ( + definition: A + ): ActionInternal => { + if (this.actions.has(definition.id)) { + throw new Error(`Action [action.id = ${definition.id}] already registered.`); } + const action = new ActionInternal(definition); + this.actions.set(action.id, action); + + return action; }; - public readonly getAction = (id: string): ActionByType => { - if (!this.actions.has(id)) { - throw new Error(`Action [action.id = ${id}] not registered.`); + public readonly unregisterAction = (actionId: string): void => { + if (!this.actions.has(actionId)) { + throw new Error(`Action [action.id = ${actionId}] is not registered.`); } - return this.actions.get(id) as ActionByType; + this.actions.delete(actionId); }; - public readonly attachAction = ( - triggerId: TType, - // The action can accept partial or no context, but if it needs context not provided - // by this type of trigger, typescript will complain. yay! - action: ActionByType & Action + public readonly attachAction = ( + triggerId: TriggerId, + actionId: string ): void => { - if (!this.actions.has(action.id)) { - this.registerAction(action); - } else { - const registeredAction = this.actions.get(action.id); - if (registeredAction !== action) { - throw new Error(`A different action instance with this id is already registered.`); - } - } - const trigger = this.triggers.get(triggerId); if (!trigger) { throw new Error( - `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${action.id}].` + `No trigger [triggerId = ${triggerId}] exists, for attaching action [actionId = ${actionId}].` ); } const actionIds = this.triggerToActions.get(triggerId); - if (!actionIds!.find(id => id === action.id)) { - this.triggerToActions.set(triggerId, [...actionIds!, action.id]); + if (!actionIds!.find(id => id === actionId)) { + this.triggerToActions.set(triggerId, [...actionIds!, actionId]); } }; @@ -139,6 +147,26 @@ export class UiActionsService { ); }; + public readonly addTriggerAction = ( + triggerId: TType, + // The action can accept partial or no context, but if it needs context not provided + // by this type of trigger, typescript will complain. yay! + action: ActionByType & Action + ): void => { + if (!this.actions.has(action.id)) this.registerAction(action); + this.attachAction(triggerId, action.id); + }; + + public readonly getAction = ( + id: string + ): Action> => { + if (!this.actions.has(id)) { + throw new Error(`Action [action.id = ${id}] not registered.`); + } + + return this.actions.get(id) as ActionInternal; + }; + public readonly getTriggerActions = ( triggerId: T ): Array> => { @@ -147,9 +175,9 @@ export class UiActionsService { const actionIds = this.triggerToActions.get(triggerId); - const actions = actionIds!.map(actionId => this.actions.get(actionId)).filter(Boolean) as Array< - Action - >; + const actions = actionIds! + .map(actionId => this.actions.get(actionId) as ActionInternal) + .filter(Boolean); return actions as Array>>; }; @@ -187,6 +215,7 @@ export class UiActionsService { this.actions.clear(); this.triggers.clear(); this.triggerToActions.clear(); + this.actionFactories.clear(); }; /** @@ -206,4 +235,41 @@ export class UiActionsService { return new UiActionsService({ triggers, actions, triggerToActions }); }; + + /** + * Register an action factory. Action factories are used to configure and + * serialize/deserialize dynamic actions. + */ + public readonly registerActionFactory = < + Config extends object = object, + FactoryContext extends object = object, + ActionContext extends object = object + >( + definition: ActionFactoryDefinition + ) => { + if (this.actionFactories.has(definition.id)) { + throw new Error(`ActionFactory [actionFactory.id = ${definition.id}] already registered.`); + } + + const actionFactory = new ActionFactory(definition); + + this.actionFactories.set(actionFactory.id, actionFactory as ActionFactory); + }; + + public readonly getActionFactory = (actionFactoryId: string): ActionFactory => { + const actionFactory = this.actionFactories.get(actionFactoryId); + + if (!actionFactory) { + throw new Error(`Action factory [actionFactoryId = ${actionFactoryId}] does not exist.`); + } + + return actionFactory; + }; + + /** + * Returns an array of all action factories. + */ + public readonly getActionFactories = (): ActionFactory[] => { + return [...this.actionFactories.values()]; + }; } diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 5b427f918c173..ade21ee4b7d91 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -69,7 +69,7 @@ test('executes a single action mapped to a trigger', async () => { const action = createTestAction('test1', () => true); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const context = {}; const start = doStart(); @@ -109,7 +109,7 @@ test('does not execute an incompatible action', async () => { ); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const start = doStart(); const context = { @@ -130,8 +130,8 @@ test('shows a context menu when more than one action is mapped to a trigger', as const action2 = createTestAction('test2', () => true); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action1); - setup.attachAction(trigger.id, action2); + setup.addTriggerAction(trigger.id, action1); + setup.addTriggerAction(trigger.id, action2); expect(openContextMenu).toHaveBeenCalledTimes(0); @@ -155,7 +155,7 @@ test('passes whole action context to isCompatible()', async () => { }); setup.registerTrigger(trigger); - setup.attachAction(trigger.id, action); + setup.addTriggerAction(trigger.id, action); const start = doStart(); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts index f5a6a96fb41a4..55ccac42ff255 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_actions.test.ts @@ -17,7 +17,7 @@ * under the License. */ -import { Action } from '../actions'; +import { ActionInternal, Action } from '../actions'; import { uiActionsPluginMock } from '../mocks'; import { TriggerId, ActionType } from '../types'; @@ -47,13 +47,14 @@ test('returns actions set on trigger', () => { expect(list0).toHaveLength(0); - setup.attachAction('trigger' as TriggerId, action1); + setup.addTriggerAction('trigger' as TriggerId, action1); const list1 = start.getTriggerActions('trigger' as TriggerId); expect(list1).toHaveLength(1); - expect(list1).toEqual([action1]); + expect(list1[0]).toBeInstanceOf(ActionInternal); + expect(list1[0].id).toBe(action1.id); - setup.attachAction('trigger' as TriggerId, action2); + setup.addTriggerAction('trigger' as TriggerId, action2); const list2 = start.getTriggerActions('trigger' as TriggerId); expect(list2).toHaveLength(2); diff --git a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts index c5e68e5d5ca5a..21dd17ed82e3f 100644 --- a/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/get_trigger_compatible_actions.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { id: 'trigger' as TriggerId, title: 'trigger', }); - uiActions.setup.attachAction('trigger' as TriggerId, action); + uiActions.setup.addTriggerAction('trigger' as TriggerId, action); }); test('can register action', async () => { @@ -58,7 +58,7 @@ test('getTriggerCompatibleActions returns attached actions', async () => { title: 'My trigger', }; setup.registerTrigger(testTrigger); - setup.attachAction('MY-TRIGGER' as TriggerId, helloWorldAction); + setup.addTriggerAction('MY-TRIGGER' as TriggerId, helloWorldAction); const start = doStart(); const actions = await start.getTriggerCompatibleActions('MY-TRIGGER' as TriggerId, {}); @@ -84,7 +84,7 @@ test('filters out actions not applicable based on the context', async () => { setup.registerTrigger(testTrigger); setup.registerAction(action1); - setup.attachAction(testTrigger.id, action1); + setup.addTriggerAction(testTrigger.id, action1); const start = doStart(); let actions = await start.getTriggerCompatibleActions(testTrigger.id, { accept: true }); diff --git a/src/plugins/ui_actions/public/tests/test_samples/index.ts b/src/plugins/ui_actions/public/tests/test_samples/index.ts index 7d63b1b6d5669..dfa71cec89595 100644 --- a/src/plugins/ui_actions/public/tests/test_samples/index.ts +++ b/src/plugins/ui_actions/public/tests/test_samples/index.ts @@ -16,4 +16,5 @@ * specific language governing permissions and limitations * under the License. */ + export { createHelloWorldAction } from './hello_world_action'; diff --git a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts index c638db0ce9dab..9758508dc3dac 100644 --- a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts @@ -22,6 +22,6 @@ import { Trigger } from '.'; export const SELECT_RANGE_TRIGGER = 'SELECT_RANGE_TRIGGER'; export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = { id: SELECT_RANGE_TRIGGER, - title: 'Select range', + title: '', description: 'Applies a range filter', }; diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index 5b670df354f78..9885ed3abe93b 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -72,6 +72,7 @@ export class TriggerInternal { const panel = await buildContextMenuForActions({ actions, actionContext: context, + title: this.trigger.title, closeMenu: () => session.close(), }); const session = openContextMenu([panel]); diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index ad32bdc1b564e..2671584d105c8 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -22,6 +22,6 @@ import { Trigger } from '.'; export const VALUE_CLICK_TRIGGER = 'VALUE_CLICK_TRIGGER'; export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { id: VALUE_CLICK_TRIGGER, - title: 'Value clicked', + title: '', description: 'Value was clicked', }; diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index c7e6d61e15f31..2cb4a8f26a879 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -17,15 +17,17 @@ * under the License. */ -import { ActionByType } from './actions/action'; +import { ActionInternal } from './actions/action_internal'; import { TriggerInternal } from './triggers/trigger_internal'; +import { ActionFactory } from './actions'; import { EmbeddableVisTriggerContext, IEmbeddable } from '../../embeddable/public'; import { Filter } from '../../data/public'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; export type TriggerRegistry = Map>; -export type ActionRegistry = Map>; +export type ActionRegistry = Map; export type TriggerToActionsRegistry = Map; +export type ActionFactoryRegistry = Map; const DEFAULT_TRIGGER = ''; diff --git a/src/plugins/ui_actions/public/util/configurable.ts b/src/plugins/ui_actions/public/util/configurable.ts new file mode 100644 index 0000000000000..d3a527a2183b1 --- /dev/null +++ b/src/plugins/ui_actions/public/util/configurable.ts @@ -0,0 +1,60 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { UiComponent } from 'src/plugins/kibana_utils/common'; + +/** + * Represents something that can be configured by user using UI. + */ +export interface Configurable { + /** + * Create default config for this item, used when item is created for the first time. + */ + readonly createConfig: () => Config; + + /** + * Is this config valid. Used to validate user's input before saving. + */ + readonly isConfigValid: (config: Config) => boolean; + + /** + * `UiComponent` to be rendered when collecting configuration for this item. + */ + readonly CollectConfig: UiComponent>; +} + +/** + * Props provided to `CollectConfig` component on every re-render. + */ +export interface CollectConfigProps { + /** + * Current (latest) config of the item. + */ + config: Config; + + /** + * Callback called when user updates the config in UI. + */ + onConfig: (config: Config) => void; + + /** + * Context information about where component is being rendered. + */ + context: Context; +} diff --git a/src/plugins/ui_actions/public/util/index.ts b/src/plugins/ui_actions/public/util/index.ts new file mode 100644 index 0000000000000..53c6109cac4ca --- /dev/null +++ b/src/plugins/ui_actions/public/util/index.ts @@ -0,0 +1,21 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './presentable'; +export * from './configurable'; diff --git a/src/plugins/ui_actions/public/actions/action_definition.ts b/src/plugins/ui_actions/public/util/presentable.ts similarity index 50% rename from src/plugins/ui_actions/public/actions/action_definition.ts rename to src/plugins/ui_actions/public/util/presentable.ts index c590cf8f34ee0..945fd2065ce78 100644 --- a/src/plugins/ui_actions/public/actions/action_definition.ts +++ b/src/plugins/ui_actions/public/util/presentable.ts @@ -18,55 +18,46 @@ */ import { UiComponent } from 'src/plugins/kibana_utils/common'; -import { ActionType, ActionContextMapping } from '../types'; -export interface ActionDefinition { +/** + * Represents something that can be displayed to user in UI. + */ +export interface Presentable { /** - * Determined the order when there is more than one action matched to a trigger. - * Higher numbers are displayed first. + * ID that uniquely identifies this object. */ - order?: number; + readonly id: string; /** - * A unique identifier for this action instance. + * Determines the display order in relation to other items. Higher numbers are + * displayed first. */ - id?: string; + readonly order: number; /** - * The action type is what determines the context shape. + * `UiComponent` to render when displaying this entity as a context menu item. + * If not provided, `getDisplayName` will be used instead. */ - readonly type: T; + readonly MenuItem?: UiComponent<{ context: Context }>; /** * Optional EUI icon type that can be displayed along with the title. */ - getIconType?(context: ActionContextMapping[T]): string; + getIconType(context: Context): string | undefined; /** * Returns a title to be displayed to the user. - * @param context - */ - getDisplayName?(context: ActionContextMapping[T]): string; - - /** - * `UiComponent` to render when displaying this action as a context menu item. - * If not provided, `getDisplayName` will be used instead. - */ - MenuItem?: UiComponent<{ context: ActionContextMapping[T] }>; - - /** - * Returns a promise that resolves to true if this action is compatible given the context, - * otherwise resolves to false. */ - isCompatible?(context: ActionContextMapping[T]): Promise; + getDisplayName(context: Context): string; /** - * If this returns something truthy, this is used in addition to the `execute` method when clicked. + * This method should return a link if this item can be clicked on. */ - getHref?(context: ActionContextMapping[T]): string | undefined; + getHref?(context: Context): string | undefined; /** - * Executes the action. + * Returns a promise that resolves to true if this item is compatible given + * the context and should be displayed to user, otherwise resolves to false. */ - execute(context: ActionContextMapping[T]): Promise; + isCompatible(context: Context): Promise; } diff --git a/src/plugins/ui_actions/scripts/storybook.js b/src/plugins/ui_actions/scripts/storybook.js new file mode 100644 index 0000000000000..cb2eda610170d --- /dev/null +++ b/src/plugins/ui_actions/scripts/storybook.js @@ -0,0 +1,26 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'path'; + +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'ui_actions', + storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], +}); diff --git a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx index 18ceec652392d..8ddb2e1a4803b 100644 --- a/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx +++ b/test/plugin_functional/plugins/kbn_tp_embeddable_explorer/public/np_ready/public/plugin.tsx @@ -70,11 +70,10 @@ export class EmbeddableExplorerPublicPlugin const sayHelloAction = new SayHelloAction(alert); const sendMessageAction = createSendMessageAction(core.overlays); - plugins.uiActions.registerAction(helloWorldAction); plugins.uiActions.registerAction(sayHelloAction); plugins.uiActions.registerAction(sendMessageAction); - plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, helloWorldAction); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, helloWorldAction); plugins.__LEGACY.onRenderComplete(() => { const root = document.getElementById(REACT_ROOT_ID); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx index 8395fddece2a4..7c7cc689d05e5 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_action.tsx @@ -62,5 +62,4 @@ function createSamplePanelAction() { } const action = createSamplePanelAction(); -npSetup.plugins.uiActions.registerAction(action); -npSetup.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); +npSetup.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); diff --git a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts index 4b09be4db8a60..e034fbe320608 100644 --- a/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts +++ b/test/plugin_functional/plugins/kbn_tp_sample_panel_action/public/sample_panel_link.ts @@ -33,5 +33,4 @@ export const createSamplePanelLink = (): Action => }); const action = createSamplePanelLink(); -npStart.plugins.uiActions.registerAction(action); -npStart.plugins.uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); +npStart.plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 2a28e349ace99..784b5a5a42ace 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -9,6 +9,7 @@ "xpack.beatsManagement": "legacy/plugins/beats_management", "xpack.canvas": "legacy/plugins/canvas", "xpack.crossClusterReplication": "legacy/plugins/cross_cluster_replication", + "xpack.dashboard": "plugins/dashboard_enhanced", "xpack.dashboardMode": "legacy/plugins/dashboard_mode", "xpack.data": "plugins/data_enhanced", "xpack.drilldowns": "plugins/drilldowns", diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss index 2ba6f9baca90d..87ec3f8fc7ec1 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.scss @@ -1,8 +1,3 @@ -.auaActionWizard__selectedActionFactoryContainer { - background-color: $euiColorLightestShade; - padding: $euiSize; -} - .auaActionWizard__actionFactoryItem { .euiKeyPadMenuItem__label { height: #{$euiSizeXL}; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx index 62f16890cade2..9c73f07289dc9 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.story.tsx @@ -6,28 +6,26 @@ import React from 'react'; import { storiesOf } from '@storybook/react'; -import { dashboardDrilldownActionFactory, Demo, urlDrilldownActionFactory } from './test_data'; +import { Demo, dashboardFactory, urlFactory } from './test_data'; storiesOf('components/ActionWizard', module) - .add('default', () => ( - - )) + .add('default', () => ) .add('Only one factory is available', () => ( // to make sure layout doesn't break - + )) .add('Long list of action factories', () => ( // to make sure layout doesn't break )); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx index aea47be693b8f..cc56714fcb2f8 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.test.tsx @@ -8,21 +8,14 @@ import React from 'react'; import { cleanup, fireEvent, render } from '@testing-library/react/pure'; import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global import { TEST_SUBJ_ACTION_FACTORY_ITEM, TEST_SUBJ_SELECTED_ACTION_FACTORY } from './action_wizard'; -import { - dashboardDrilldownActionFactory, - dashboards, - Demo, - urlDrilldownActionFactory, -} from './test_data'; +import { dashboardFactory, dashboards, Demo, urlFactory } from './test_data'; // TODO: afterEach is not available for it globally during setup // https://github.com/elastic/kibana/issues/59469 afterEach(cleanup); test('Pick and configure action', () => { - const screen = render( - - ); + const screen = render(); // check that all factories are displayed to pick expect(screen.getAllByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).toHaveLength(2); @@ -47,7 +40,7 @@ test('Pick and configure action', () => { }); test('If only one actions factory is available then actionFactory selection is emitted without user input', () => { - const screen = render(); + const screen = render(); // check that no factories are displayed to pick from expect(screen.queryByTestId(TEST_SUBJ_ACTION_FACTORY_ITEM)).not.toBeInTheDocument(); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx index 41ef863c00e44..846f6d41eb30d 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/action_wizard.tsx @@ -16,40 +16,23 @@ import { } from '@elastic/eui'; import { txtChangeButton } from './i18n'; import './action_wizard.scss'; +import { ActionFactory } from '../../services'; -// TODO: this interface is temporary for just moving forward with the component -// and it will be imported from the ../ui_actions when implemented properly -// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -export type ActionBaseConfig = {}; -export interface ActionFactory { - type: string; // TODO: type should be tied to Action and ActionByType - displayName: string; - iconType?: string; - wizard: React.FC>; - createConfig: () => Config; - isValid: (config: Config) => boolean; -} - -export interface ActionFactoryWizardProps { - config?: Config; - - /** - * Callback called when user updates the config in UI. - */ - onConfig: (config: Config) => void; -} +type ActionBaseConfig = object; +type ActionFactoryBaseContext = object; export interface ActionWizardProps { /** * List of available action factories */ - actionFactories: Array>; // any here to be able to pass array of ActionFactory with different configs + actionFactories: ActionFactory[]; /** * Currently selected action factory * undefined - is allowed and means that non is selected */ currentActionFactory?: ActionFactory; + /** * Action factory selected changed * null - means user click "change" and removed action factory selection @@ -65,6 +48,11 @@ export interface ActionWizardProps { * config changed */ onConfigChange: (config: ActionBaseConfig) => void; + + /** + * Context will be passed into ActionFactory's methods + */ + context: ActionFactoryBaseContext; } export const ActionWizard: React.FC = ({ @@ -73,6 +61,7 @@ export const ActionWizard: React.FC = ({ onActionFactoryChange, onConfigChange, config, + context, }) => { // auto pick action factory if there is only 1 available if (!currentActionFactory && actionFactories.length === 1) { @@ -87,6 +76,7 @@ export const ActionWizard: React.FC = ({ onDeselect={() => { onActionFactoryChange(null); }} + context={context} config={config} onConfigChange={newConfig => { onConfigChange(newConfig); @@ -97,6 +87,7 @@ export const ActionWizard: React.FC = ({ return ( { onActionFactoryChange(actionFactory); @@ -105,10 +96,11 @@ export const ActionWizard: React.FC = ({ ); }; -interface SelectedActionFactoryProps { - actionFactory: ActionFactory; - config: Config; - onConfigChange: (config: Config) => void; +interface SelectedActionFactoryProps { + actionFactory: ActionFactory; + config: ActionBaseConfig; + context: ActionFactoryBaseContext; + onConfigChange: (config: ActionBaseConfig) => void; showDeselect: boolean; onDeselect: () => void; } @@ -121,28 +113,28 @@ const SelectedActionFactory: React.FC = ({ showDeselect, onConfigChange, config, + context, }) => { return (
- {actionFactory.iconType && ( + {actionFactory.getIconType(context) && ( - + )} -

{actionFactory.displayName}

+

{actionFactory.getDisplayName(context)}

{showDeselect && ( - onDeselect()}> + onDeselect()}> {txtChangeButton} @@ -151,10 +143,11 @@ const SelectedActionFactory: React.FC = ({
- {actionFactory.wizard({ - config, - onConfig: onConfigChange, - })} +
); @@ -162,6 +155,7 @@ const SelectedActionFactory: React.FC = ({ interface ActionFactorySelectorProps { actionFactories: ActionFactory[]; + context: ActionFactoryBaseContext; onActionFactorySelected: (actionFactory: ActionFactory) => void; } @@ -170,6 +164,7 @@ export const TEST_SUBJ_ACTION_FACTORY_ITEM = 'action-factory-item'; const ActionFactorySelector: React.FC = ({ actionFactories, onActionFactorySelected, + context, }) => { if (actionFactories.length === 0) { // this is not user facing, as it would be impossible to get into this state @@ -178,19 +173,23 @@ const ActionFactorySelector: React.FC = ({ } return ( - - {actionFactories.map(actionFactory => ( - onActionFactorySelected(actionFactory)} - > - {actionFactory.iconType && } - - ))} + + {[...actionFactories] + .sort((f1, f2) => f1.order - f2.order) + .map(actionFactory => ( + + onActionFactorySelected(actionFactory)} + > + {actionFactory.getIconType(context) && ( + + )} + + + ))} ); }; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts index 641f25176264a..a315184bf68ef 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/i18n.ts @@ -9,6 +9,6 @@ import { i18n } from '@kbn/i18n'; export const txtChangeButton = i18n.translate( 'xpack.advancedUiActions.components.actionWizard.changeButton', { - defaultMessage: 'change', + defaultMessage: 'Change', } ); diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts index ed224248ec4cd..a189afbf956ee 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { ActionFactory, ActionWizard } from './action_wizard'; +export { ActionWizard } from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx index 8ecdde681069e..167cb130fdb4a 100644 --- a/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/action_wizard/test_data.tsx @@ -6,124 +6,161 @@ import React, { useState } from 'react'; import { EuiFieldText, EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; -import { ActionFactory, ActionBaseConfig, ActionWizard } from './action_wizard'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { ActionWizard } from './action_wizard'; +import { ActionFactoryDefinition, ActionFactory } from '../../services'; +import { CollectConfigProps } from '../../util'; + +type ActionBaseConfig = object; export const dashboards = [ { id: 'dashboard1', title: 'Dashboard 1' }, { id: 'dashboard2', title: 'Dashboard 2' }, ]; -export const dashboardDrilldownActionFactory: ActionFactory<{ +interface DashboardDrilldownConfig { dashboardId?: string; - useCurrentDashboardFilters: boolean; - useCurrentDashboardDataRange: boolean; -}> = { - type: 'Dashboard', - displayName: 'Go to Dashboard', - iconType: 'dashboardApp', + useCurrentFilters: boolean; + useCurrentDateRange: boolean; +} + +function DashboardDrilldownCollectConfig(props: CollectConfigProps) { + const config = props.config ?? { + dashboardId: undefined, + useCurrentFilters: true, + useCurrentDateRange: true, + }; + return ( + <> + + ({ value: id, text: title }))} + value={config.dashboardId} + onChange={e => { + props.onConfig({ ...config, dashboardId: e.target.value }); + }} + /> + + + + props.onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + /> + + + + props.onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + + + ); +} + +export const dashboardDrilldownActionFactory: ActionFactoryDefinition< + DashboardDrilldownConfig, + any, + any +> = { + id: 'Dashboard', + getDisplayName: () => 'Go to Dashboard', + getIconType: () => 'dashboardApp', createConfig: () => { return { dashboardId: undefined, - useCurrentDashboardDataRange: true, - useCurrentDashboardFilters: true, + useCurrentFilters: true, + useCurrentDateRange: true, }; }, - isValid: config => { + isConfigValid: (config: DashboardDrilldownConfig): config is DashboardDrilldownConfig => { if (!config.dashboardId) return false; return true; }, - wizard: props => { - const config = props.config ?? { - dashboardId: undefined, - useCurrentDashboardDataRange: true, - useCurrentDashboardFilters: true, - }; - return ( - <> - - ({ value: id, text: title }))} - value={config.dashboardId} - onChange={e => { - props.onConfig({ ...config, dashboardId: e.target.value }); - }} - /> - - - - props.onConfig({ - ...config, - useCurrentDashboardFilters: !config.useCurrentDashboardFilters, - }) - } - /> - - - - props.onConfig({ - ...config, - useCurrentDashboardDataRange: !config.useCurrentDashboardDataRange, - }) - } - /> - - - ); + CollectConfig: reactToUiComponent(DashboardDrilldownCollectConfig), + + isCompatible(context?: object): Promise { + return Promise.resolve(true); }, + order: 0, + create: () => ({ + id: 'test', + execute: async () => alert('Navigate to dashboard!'), + }), }; -export const urlDrilldownActionFactory: ActionFactory<{ url: string; openInNewTab: boolean }> = { - type: 'Url', - displayName: 'Go to URL', - iconType: 'link', +export const dashboardFactory = new ActionFactory(dashboardDrilldownActionFactory); + +interface UrlDrilldownConfig { + url: string; + openInNewTab: boolean; +} +function UrlDrilldownCollectConfig(props: CollectConfigProps) { + const config = props.config ?? { + url: '', + openInNewTab: false, + }; + return ( + <> + + props.onConfig({ ...config, url: event.target.value })} + /> + + + props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + + + ); +} +export const urlDrilldownActionFactory: ActionFactoryDefinition = { + id: 'Url', + getDisplayName: () => 'Go to URL', + getIconType: () => 'link', createConfig: () => { return { url: '', openInNewTab: false, }; }, - isValid: config => { + isConfigValid: (config: UrlDrilldownConfig): config is UrlDrilldownConfig => { if (!config.url) return false; return true; }, - wizard: props => { - const config = props.config ?? { - url: '', - openInNewTab: false, - }; - return ( - <> - - props.onConfig({ ...config, url: event.target.value })} - /> - - - props.onConfig({ ...config, openInNewTab: !config.openInNewTab })} - /> - - - ); + CollectConfig: reactToUiComponent(UrlDrilldownCollectConfig), + + order: 10, + isCompatible(context?: object): Promise { + return Promise.resolve(true); }, + create: () => null as any, }; +export const urlFactory = new ActionFactory(urlDrilldownActionFactory); + export function Demo({ actionFactories }: { actionFactories: Array> }) { const [state, setState] = useState<{ currentActionFactory?: ActionFactory; @@ -157,14 +194,15 @@ export function Demo({ actionFactories }: { actionFactories: Array

-
Action Factory Type: {state.currentActionFactory?.type}
+
Action Factory Id: {state.currentActionFactory?.id}
Action Factory Config: {JSON.stringify(state.config)}
Is config valid:{' '} - {JSON.stringify(state.currentActionFactory?.isValid(state.config!) ?? false)} + {JSON.stringify(state.currentActionFactory?.isConfigValid(state.config!) ?? false)}
); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx b/x-pack/plugins/advanced_ui_actions/public/components/index.ts similarity index 87% rename from x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx rename to x-pack/plugins/advanced_ui_actions/public/components/index.ts index 3be289fe6d46e..236b1a6ec4611 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/index.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/components/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './drilldown_picker'; +export * from './action_wizard'; diff --git a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx index 325a5ddc10179..c0cd8d5540db2 100644 --- a/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/custom_time_range_action.tsx @@ -44,7 +44,7 @@ export class CustomTimeRangeAction implements ActionByType { + implements Plugin { constructor(initializerContext: PluginInitializerContext) {} - public setup(core: CoreSetup, { uiActions }: SetupDependencies): Setup {} + public setup(core: CoreSetup, { uiActions }: SetupDependencies): SetupContract { + return { + ...uiActions, + }; + } - public start(core: CoreStart, { uiActions }: StartDependencies): Start { + public start(core: CoreStart, { uiActions }: StartDependencies): StartContract { const dateFormat = core.uiSettings.get('dateFormat') as string; const commonlyUsedRanges = core.uiSettings.get('timepicker:quickRanges') as CommonlyUsedRange[]; const { openModal } = createReactOverlays(core); @@ -66,16 +72,18 @@ export class AdvancedUiActionsPublicPlugin dateFormat, commonlyUsedRanges, }); - uiActions.registerAction(timeRangeAction); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, timeRangeAction); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, timeRangeAction); const timeRangeBadge = new CustomTimeRangeBadge({ openModal, dateFormat, commonlyUsedRanges, }); - uiActions.registerAction(timeRangeBadge); - uiActions.attachAction(PANEL_BADGE_TRIGGER, timeRangeBadge); + uiActions.addTriggerAction(PANEL_BADGE_TRIGGER, timeRangeBadge); + + return { + ...uiActions, + }; } public stop() {} diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts new file mode 100644 index 0000000000000..66e2a4eafa880 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable */ + +export { + ActionFactory +} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory'; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts new file mode 100644 index 0000000000000..f8669a4bf813f --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/action_factory_definition.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable */ + +export { + ActionFactoryDefinition +} from '../../../../../../src/plugins/ui_actions/public/actions/action_factory_definition'; diff --git a/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts new file mode 100644 index 0000000000000..db5bb3aa62a16 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/services/action_factory_service/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './action_factory_definition'; +export * from './action_factory'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts b/x-pack/plugins/advanced_ui_actions/public/services/index.ts similarity index 84% rename from x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts rename to x-pack/plugins/advanced_ui_actions/public/services/index.ts index ce235043b4ef6..0f8b4c8d8f409 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/index.ts +++ b/x-pack/plugins/advanced_ui_actions/public/services/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './flyout_create_drilldown'; +export * from './action_factory_service'; diff --git a/x-pack/plugins/advanced_ui_actions/public/util/index.ts b/x-pack/plugins/advanced_ui_actions/public/util/index.ts new file mode 100644 index 0000000000000..fd3ab89973348 --- /dev/null +++ b/x-pack/plugins/advanced_ui_actions/public/util/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + UiActionsConfigurable as Configurable, + UiActionsCollectConfigProps as CollectConfigProps, +} from '../../../../../src/plugins/ui_actions/public'; diff --git a/x-pack/plugins/dashboard_enhanced/README.md b/x-pack/plugins/dashboard_enhanced/README.md new file mode 100644 index 0000000000000..d9296ae158621 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/README.md @@ -0,0 +1 @@ +# X-Pack part of Dashboard app diff --git a/x-pack/plugins/dashboard_enhanced/kibana.json b/x-pack/plugins/dashboard_enhanced/kibana.json new file mode 100644 index 0000000000000..acbca5c33295c --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "dashboardEnhanced", + "version": "kibana", + "server": true, + "ui": true, + "requiredPlugins": ["uiActions", "embeddable", "dashboard", "drilldowns"], + "configPath": ["xpack", "dashboardEnhanced"] +} diff --git a/x-pack/plugins/dashboard_enhanced/public/components/README.md b/x-pack/plugins/dashboard_enhanced/public/components/README.md new file mode 100644 index 0000000000000..8081f8a2451cf --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/README.md @@ -0,0 +1,5 @@ +# Presentation React components + +Here we keep reusable *presentation* (aka *dumb*) React components—these +components should not be connected to state and ideally should not know anything +about Kibana. diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx new file mode 100644 index 0000000000000..8e204b044a136 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.story.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { DashboardDrilldownConfig } from '.'; + +export const dashboards = [ + { id: 'dashboard1', title: 'Dashboard 1' }, + { id: 'dashboard2', title: 'Dashboard 2' }, + { id: 'dashboard3', title: 'Dashboard 3' }, +]; + +const InteractiveDemo: React.FC = () => { + const [activeDashboardId, setActiveDashboardId] = React.useState('dashboard1'); + const [currentFilters, setCurrentFilters] = React.useState(false); + const [keepRange, setKeepRange] = React.useState(false); + + return ( + setActiveDashboardId(id)} + onCurrentFiltersToggle={() => setCurrentFilters(old => !old)} + onKeepRangeToggle={() => setKeepRange(old => !old)} + /> + ); +}; + +storiesOf('components/DashboardDrilldownConfig', module) + .add('default', () => ( + console.log('onDashboardSelect', e)} + /> + )) + .add('with switches', () => ( + console.log('onDashboardSelect', e)} + onCurrentFiltersToggle={() => console.log('onCurrentFiltersToggle')} + onKeepRangeToggle={() => console.log('onKeepRangeToggle')} + /> + )) + .add('interactive demo', () => ); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx new file mode 100644 index 0000000000000..911ff6f632635 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.test.tsx @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +test.todo('renders list of dashboards'); +test.todo('renders correct selected dashboard'); +test.todo('can change dashboard'); +test.todo('can toggle "use current filters" switch'); +test.todo('can toggle "date range" switch'); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx new file mode 100644 index 0000000000000..b45ba602b9bb1 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/dashboard_drilldown_config.tsx @@ -0,0 +1,69 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFormRow, EuiSelect, EuiSwitch } from '@elastic/eui'; +import { txtChooseDestinationDashboard } from './i18n'; + +export interface DashboardItem { + id: string; + title: string; +} + +export interface DashboardDrilldownConfigProps { + activeDashboardId?: string; + dashboards: DashboardItem[]; + currentFilters?: boolean; + keepRange?: boolean; + onDashboardSelect: (dashboardId: string) => void; + onCurrentFiltersToggle?: () => void; + onKeepRangeToggle?: () => void; +} + +export const DashboardDrilldownConfig: React.FC = ({ + activeDashboardId, + dashboards, + currentFilters, + keepRange, + onDashboardSelect, + onCurrentFiltersToggle, + onKeepRangeToggle, +}) => { + // TODO: use i18n below. + return ( + <> + + ({ value: id, text: title }))} + value={activeDashboardId} + onChange={e => onDashboardSelect(e.target.value)} + /> + + {!!onCurrentFiltersToggle && ( + + + + )} + {!!onKeepRangeToggle && ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts new file mode 100644 index 0000000000000..38fe6dd150853 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/i18n.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtChooseDestinationDashboard = i18n.translate( + 'xpack.dashboard.components.DashboardDrilldownConfig.chooseDestinationDashboard', + { + defaultMessage: 'Choose destination dashboard', + } +); diff --git a/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts new file mode 100644 index 0000000000000..b9a64a3cc17e6 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/dashboard_drilldown_config/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dashboard_drilldown_config'; diff --git a/x-pack/plugins/dashboard_enhanced/public/components/index.ts b/x-pack/plugins/dashboard_enhanced/public/components/index.ts new file mode 100644 index 0000000000000..b9a64a3cc17e6 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dashboard_drilldown_config'; diff --git a/x-pack/plugins/dashboard_enhanced/public/index.ts b/x-pack/plugins/dashboard_enhanced/public/index.ts new file mode 100644 index 0000000000000..53540a4a1ad2e --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/index.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { DashboardEnhancedPlugin } from './plugin'; + +export { + SetupContract as DashboardEnhancedSetupContract, + SetupDependencies as DashboardEnhancedSetupDependencies, + StartContract as DashboardEnhancedStartContract, + StartDependencies as DashboardEnhancedStartDependencies, +} from './plugin'; + +export function plugin(context: PluginInitializerContext) { + return new DashboardEnhancedPlugin(context); +} diff --git a/x-pack/plugins/dashboard_enhanced/public/mocks.ts b/x-pack/plugins/dashboard_enhanced/public/mocks.ts new file mode 100644 index 0000000000000..67dc1fd97d521 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/mocks.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DashboardEnhancedSetupContract, DashboardEnhancedStartContract } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = {}; + + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = {}; + + return startContract; +}; + +export const dashboardEnhancedPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/plugin.ts b/x-pack/plugins/dashboard_enhanced/public/plugin.ts new file mode 100644 index 0000000000000..30b3f3c080f49 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/plugin.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; +import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; +import { DashboardDrilldownsService } from './services'; +import { DrilldownsSetup, DrilldownsStart } from '../../drilldowns/public'; + +export interface SetupDependencies { + uiActions: UiActionsSetup; + drilldowns: DrilldownsSetup; +} + +export interface StartDependencies { + uiActions: UiActionsStart; + drilldowns: DrilldownsStart; +} + +// eslint-disable-next-line +export interface SetupContract {} + +// eslint-disable-next-line +export interface StartContract {} + +export class DashboardEnhancedPlugin + implements Plugin { + public readonly drilldowns = new DashboardDrilldownsService(); + public readonly config: { drilldowns: { enabled: boolean } }; + + constructor(protected readonly context: PluginInitializerContext) { + this.config = context.config.get(); + } + + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + this.drilldowns.bootstrap(core, plugins, { + enableDrilldowns: this.config.drilldowns.enabled, + }); + + return {}; + } + + public start(core: CoreStart, plugins: StartDependencies): StartContract { + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx new file mode 100644 index 0000000000000..31ee9e29938cb --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.test.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + FlyoutCreateDrilldownAction, + OpenFlyoutAddDrilldownParams, +} from './flyout_create_drilldown'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks'; +import { TriggerContextMapping } from '../../../../../../../../src/plugins/ui_actions/public'; +import { MockEmbeddable } from '../test_helpers'; + +const overlays = coreMock.createStart().overlays; +const drilldowns = drilldownsPluginMock.createStartContract(); +const uiActions = uiActionsPluginMock.createStartContract(); + +const actionParams: OpenFlyoutAddDrilldownParams = { + drilldowns: () => Promise.resolve(drilldowns), + overlays: () => Promise.resolve(overlays), +}; + +test('should create', () => { + expect(() => new FlyoutCreateDrilldownAction(actionParams)).not.toThrow(); +}); + +test('title is a string', () => { + expect(typeof new FlyoutCreateDrilldownAction(actionParams).getDisplayName() === 'string').toBe( + true + ); +}); + +test('icon exists', () => { + expect(typeof new FlyoutCreateDrilldownAction(actionParams).getIconType() === 'string').toBe( + true + ); +}); + +describe('isCompatible', () => { + const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); + + function checkCompatibility(params: { + isEdit: boolean; + withUiActions: boolean; + isValueClickTriggerSupported: boolean; + }): Promise { + return drilldownAction.isCompatible({ + embeddable: new MockEmbeddable( + { id: '', viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW }, + { + supportedTriggers: (params.isValueClickTriggerSupported + ? ['VALUE_CLICK_TRIGGER'] + : []) as Array, + uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support + } + ), + }); + } + + test("compatible if dynamicUiActions enabled, 'VALUE_CLICK_TRIGGER' is supported, in edit mode", async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: true, + isValueClickTriggerSupported: true, + }) + ).toBe(true); + }); + + test('not compatible if dynamicUiActions disabled', async () => { + expect( + await checkCompatibility({ + withUiActions: false, + isEdit: true, + isValueClickTriggerSupported: true, + }) + ).toBe(false); + }); + + test("not compatible if 'VALUE_CLICK_TRIGGER' is not supported", async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: true, + isValueClickTriggerSupported: false, + }) + ).toBe(false); + }); + + test('not compatible if in view mode', async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: false, + isValueClickTriggerSupported: true, + }) + ).toBe(false); + }); +}); + +describe('execute', () => { + const drilldownAction = new FlyoutCreateDrilldownAction(actionParams); + test('throws error if no dynamicUiActions', async () => { + await expect( + drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, {}), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager"` + ); + }); + + test('should open flyout', async () => { + const spy = jest.spyOn(overlays, 'openFlyout'); + await drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, { uiActions }), + }); + expect(spy).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx new file mode 100644 index 0000000000000..00e74ea570a11 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { i18n } from '@kbn/i18n'; +import { CoreStart } from 'src/core/public'; +import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { DrilldownsStart } from '../../../../../../drilldowns/public'; +import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; + +export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; + +export interface OpenFlyoutAddDrilldownParams { + overlays: () => Promise; + drilldowns: () => Promise; +} + +export class FlyoutCreateDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; + public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; + public order = 12; + + constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} + + public getDisplayName() { + return i18n.translate('xpack.dashboard.FlyoutCreateDrilldownAction.displayName', { + defaultMessage: 'Create drilldown', + }); + } + + public getIconType() { + return 'plusInCircle'; + } + + private isEmbeddableCompatible(context: EmbeddableContext) { + if (!context.embeddable.dynamicActions) return false; + const supportedTriggers = context.embeddable.supportedTriggers(); + if (!supportedTriggers || !supportedTriggers.length) return false; + return supportedTriggers.indexOf('VALUE_CLICK_TRIGGER') > -1; + } + + public async isCompatible(context: EmbeddableContext) { + const isEditMode = context.embeddable.getInput().viewMode === 'edit'; + return isEditMode && this.isEmbeddableCompatible(context); + } + + public async execute(context: EmbeddableContext) { + const overlays = await this.params.overlays(); + const drilldowns = await this.params.drilldowns(); + const dynamicActionManager = context.embeddable.dynamicActions; + + if (!dynamicActionManager) { + throw new Error(`Can't execute FlyoutCreateDrilldownAction without dynamicActionsManager`); + } + + const handle = overlays.openFlyout( + toMountPoint( + handle.close()} + placeContext={context} + viewMode={'create'} + dynamicActionManager={dynamicActionManager} + /> + ), + { + ownFocus: true, + } + ); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts new file mode 100644 index 0000000000000..4d2db209fc961 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + FlyoutCreateDrilldownAction, + OpenFlyoutAddDrilldownParams, + OPEN_FLYOUT_ADD_DRILLDOWN, +} from './flyout_create_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx new file mode 100644 index 0000000000000..a3f11eb976f90 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.test.tsx @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FlyoutEditDrilldownAction, FlyoutEditDrilldownParams } from './flyout_edit_drilldown'; +import { coreMock } from '../../../../../../../../src/core/public/mocks'; +import { drilldownsPluginMock } from '../../../../../../drilldowns/public/mocks'; +import { ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { uiActionsPluginMock } from '../../../../../../../../src/plugins/ui_actions/public/mocks'; +import { MockEmbeddable } from '../test_helpers'; + +const overlays = coreMock.createStart().overlays; +const drilldowns = drilldownsPluginMock.createStartContract(); +const uiActions = uiActionsPluginMock.createStartContract(); + +const actionParams: FlyoutEditDrilldownParams = { + drilldowns: () => Promise.resolve(drilldowns), + overlays: () => Promise.resolve(overlays), +}; + +test('should create', () => { + expect(() => new FlyoutEditDrilldownAction(actionParams)).not.toThrow(); +}); + +test('title is a string', () => { + expect(typeof new FlyoutEditDrilldownAction(actionParams).getDisplayName() === 'string').toBe( + true + ); +}); + +test('icon exists', () => { + expect(typeof new FlyoutEditDrilldownAction(actionParams).getIconType() === 'string').toBe(true); +}); + +test('MenuItem exists', () => { + expect(new FlyoutEditDrilldownAction(actionParams).MenuItem).toBeDefined(); +}); + +describe('isCompatible', () => { + const drilldownAction = new FlyoutEditDrilldownAction(actionParams); + + function checkCompatibility(params: { + isEdit: boolean; + withUiActions: boolean; + }): Promise { + return drilldownAction.isCompatible({ + embeddable: new MockEmbeddable( + { + id: '', + viewMode: params.isEdit ? ViewMode.EDIT : ViewMode.VIEW, + }, + { + uiActions: params.withUiActions ? uiActions : undefined, // dynamic actions support + } + ), + }); + } + + // TODO: need proper DynamicActionsMock and ActionFactory mock + test.todo('compatible if dynamicUiActions enabled, in edit view, and have at least 1 drilldown'); + + test('not compatible if dynamicUiActions disabled', async () => { + expect( + await checkCompatibility({ + withUiActions: false, + isEdit: true, + }) + ).toBe(false); + }); + + test('not compatible if no drilldowns', async () => { + expect( + await checkCompatibility({ + withUiActions: true, + isEdit: true, + }) + ).toBe(false); + }); +}); + +describe('execute', () => { + const drilldownAction = new FlyoutEditDrilldownAction(actionParams); + test('throws error if no dynamicUiActions', async () => { + await expect( + drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, {}), + }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Can't execute FlyoutEditDrilldownAction without dynamicActionsManager"` + ); + }); + + test('should open flyout', async () => { + const spy = jest.spyOn(overlays, 'openFlyout'); + await drilldownAction.execute({ + embeddable: new MockEmbeddable({ id: '' }, { uiActions }), + }); + expect(spy).toBeCalled(); + }); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx new file mode 100644 index 0000000000000..816b757592a72 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { CoreStart } from 'src/core/public'; +import { ActionByType } from '../../../../../../../../src/plugins/ui_actions/public'; +import { + reactToUiComponent, + toMountPoint, +} from '../../../../../../../../src/plugins/kibana_react/public'; +import { EmbeddableContext, ViewMode } from '../../../../../../../../src/plugins/embeddable/public'; +import { DrilldownsStart } from '../../../../../../drilldowns/public'; +import { txtDisplayName } from './i18n'; +import { MenuItem } from './menu_item'; + +export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; + +export interface FlyoutEditDrilldownParams { + overlays: () => Promise; + drilldowns: () => Promise; +} + +export class FlyoutEditDrilldownAction implements ActionByType { + public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; + public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; + public order = 10; + + constructor(protected readonly params: FlyoutEditDrilldownParams) {} + + public getDisplayName() { + return txtDisplayName; + } + + public getIconType() { + return 'list'; + } + + MenuItem = reactToUiComponent(MenuItem); + + public async isCompatible({ embeddable }: EmbeddableContext) { + if (embeddable.getInput().viewMode !== ViewMode.EDIT) return false; + if (!embeddable.dynamicActions) return false; + return embeddable.dynamicActions.state.get().events.length > 0; + } + + public async execute(context: EmbeddableContext) { + const overlays = await this.params.overlays(); + const drilldowns = await this.params.drilldowns(); + const dynamicActionManager = context.embeddable.dynamicActions; + if (!dynamicActionManager) { + throw new Error(`Can't execute FlyoutEditDrilldownAction without dynamicActionsManager`); + } + + const handle = overlays.openFlyout( + toMountPoint( + handle.close()} + placeContext={context} + viewMode={'manage'} + dynamicActionManager={dynamicActionManager} + /> + ), + { + ownFocus: true, + } + ); + } +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts similarity index 64% rename from x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts index ceabc6d3a9aa5..4e2e5eb7092e4 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/i18n.ts +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/i18n.ts @@ -6,9 +6,9 @@ import { i18n } from '@kbn/i18n'; -export const txtCreateDrilldown = i18n.translate( - 'xpack.drilldowns.components.FlyoutCreateDrilldown.CreateDrilldown', +export const txtDisplayName = i18n.translate( + 'xpack.dashboard.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Create drilldown', + defaultMessage: 'Manage drilldowns', } ); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx new file mode 100644 index 0000000000000..3e1b37f270708 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/index.tsx @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { + FlyoutEditDrilldownAction, + FlyoutEditDrilldownParams, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './flyout_edit_drilldown'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx new file mode 100644 index 0000000000000..be693fadf9282 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.test.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, cleanup, act } from '@testing-library/react/pure'; +import { MenuItem } from './menu_item'; +import { createStateContainer } from '../../../../../../../../src/plugins/kibana_utils/common'; +import { DynamicActionManager } from '../../../../../../../../src/plugins/ui_actions/public'; +import { IEmbeddable } from '../../../../../../../../src/plugins/embeddable/public/lib/embeddables'; +import '@testing-library/jest-dom'; + +afterEach(cleanup); + +test('', () => { + const state = createStateContainer<{ events: object[] }>({ events: [] }); + const { getByText, queryByText } = render( + + ); + + expect(getByText(/manage drilldowns/i)).toBeInTheDocument(); + expect(queryByText('0')).not.toBeInTheDocument(); + + act(() => { + state.set({ events: [{}] }); + }); + + expect(queryByText('1')).toBeInTheDocument(); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx new file mode 100644 index 0000000000000..4f99fca511b07 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/menu_item.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiNotificationBadge, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { EmbeddableContext } from '../../../../../../../../src/plugins/embeddable/public'; +import { txtDisplayName } from './i18n'; +import { useContainerState } from '../../../../../../../../src/plugins/kibana_utils/common'; + +export const MenuItem: React.FC<{ context: EmbeddableContext }> = ({ context }) => { + if (!context.embeddable.dynamicActions) + throw new Error('Flyout edit drillldown context menu item requires `dynamicActions`'); + + const { events } = useContainerState(context.embeddable.dynamicActions.state); + const count = events.length; + + return ( + + {txtDisplayName} + {count > 0 && ( + + {count} + + )} + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/actions/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/actions/index.ts rename to x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/index.ts diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts new file mode 100644 index 0000000000000..9b156b0ba85b4 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/test_helpers.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Embeddable, EmbeddableInput } from '../../../../../../../src/plugins/embeddable/public/'; +import { + TriggerContextMapping, + UiActionsStart, +} from '../../../../../../../src/plugins/ui_actions/public'; + +export class MockEmbeddable extends Embeddable { + public readonly type = 'mock'; + private readonly triggers: Array = []; + constructor( + initialInput: EmbeddableInput, + params: { uiActions?: UiActionsStart; supportedTriggers?: Array } + ) { + super(initialInput, {}, undefined, params); + this.triggers = params.supportedTriggers ?? []; + } + public render(node: HTMLElement) {} + public reload() {} + public supportedTriggers(): Array { + return this.triggers; + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts new file mode 100644 index 0000000000000..4bdf03dff3531 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_drilldowns_services.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { SetupDependencies } from '../../plugin'; +import { + CONTEXT_MENU_TRIGGER, + EmbeddableContext, +} from '../../../../../../src/plugins/embeddable/public'; +import { + FlyoutCreateDrilldownAction, + FlyoutEditDrilldownAction, + OPEN_FLYOUT_ADD_DRILLDOWN, + OPEN_FLYOUT_EDIT_DRILLDOWN, +} from './actions'; +import { DrilldownsStart } from '../../../../drilldowns/public'; +import { DashboardToDashboardDrilldown } from './dashboard_to_dashboard_drilldown'; + +declare module '../../../../../../src/plugins/ui_actions/public' { + export interface ActionContextMapping { + [OPEN_FLYOUT_ADD_DRILLDOWN]: EmbeddableContext; + [OPEN_FLYOUT_EDIT_DRILLDOWN]: EmbeddableContext; + } +} + +interface BootstrapParams { + enableDrilldowns: boolean; +} + +export class DashboardDrilldownsService { + bootstrap( + core: CoreSetup<{ drilldowns: DrilldownsStart }>, + plugins: SetupDependencies, + { enableDrilldowns }: BootstrapParams + ) { + if (enableDrilldowns) { + this.setupDrilldowns(core, plugins); + } + } + + setupDrilldowns(core: CoreSetup<{ drilldowns: DrilldownsStart }>, plugins: SetupDependencies) { + const overlays = async () => (await core.getStartServices())[0].overlays; + const drilldowns = async () => (await core.getStartServices())[1].drilldowns; + const savedObjects = async () => (await core.getStartServices())[0].savedObjects.client; + + const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays, drilldowns }); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); + + const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays, drilldowns }); + plugins.uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); + + const dashboardToDashboardDrilldown = new DashboardToDashboardDrilldown({ + savedObjects, + }); + plugins.drilldowns.registerDrilldown(dashboardToDashboardDrilldown); + } +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx new file mode 100644 index 0000000000000..95101605ce468 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.test.tsx @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +test.todo('displays all dashboard in a list'); +test.todo('does not display dashboard on which drilldown is being created'); +test.todo('updates config object correctly'); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx new file mode 100644 index 0000000000000..e463cc38b6fbf --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/collect_config.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, useEffect } from 'react'; +import { CollectConfigProps } from './types'; +import { DashboardDrilldownConfig } from '../../../components/dashboard_drilldown_config'; +import { Params } from './drilldown'; + +export interface CollectConfigContainerProps extends CollectConfigProps { + params: Params; +} + +export const CollectConfigContainer: React.FC = ({ + config, + onConfig, + params: { savedObjects }, +}) => { + const [dashboards] = useState([ + { id: 'dashboard1', title: 'Dashboard 1' }, + { id: 'dashboard2', title: 'Dashboard 2' }, + { id: 'dashboard3', title: 'Dashboard 3' }, + { id: 'dashboard4', title: 'Dashboard 4' }, + ]); + + useEffect(() => { + // TODO: Load dashboards... + }, [savedObjects]); + + return ( + { + onConfig({ ...config, dashboardId }); + }} + onCurrentFiltersToggle={() => + onConfig({ + ...config, + useCurrentFilters: !config.useCurrentFilters, + }) + } + onKeepRangeToggle={() => + onConfig({ + ...config, + useCurrentDateRange: !config.useCurrentDateRange, + }) + } + /> + ); +}; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts new file mode 100644 index 0000000000000..e2a530b156da5 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const DASHBOARD_TO_DASHBOARD_DRILLDOWN = 'DASHBOARD_TO_DASHBOARD_DRILLDOWN'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx new file mode 100644 index 0000000000000..0fb60bb1064a1 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.test.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +describe('.isConfigValid()', () => { + test.todo('returns false for incorrect config'); + test.todo('returns true for incorrect config'); +}); + +describe('.execute()', () => { + test.todo('navigates to correct dashboard'); + test.todo( + 'when user chooses to keep current filters, current fileters are set on destination dashboard' + ); + test.todo( + 'when user chooses to keep current time range, current time range is set on destination dashboard' + ); +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx new file mode 100644 index 0000000000000..9d2a378f08acd --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/drilldown.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { CoreStart } from 'src/core/public'; +import { reactToUiComponent } from '../../../../../../../src/plugins/kibana_react/public'; +import { PlaceContext, ActionContext, Config, CollectConfigProps } from './types'; +import { CollectConfigContainer } from './collect_config'; +import { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; +import { DrilldownDefinition as Drilldown } from '../../../../../drilldowns/public'; +import { txtGoToDashboard } from './i18n'; + +export interface Params { + savedObjects: () => Promise; +} + +export class DashboardToDashboardDrilldown + implements Drilldown { + constructor(protected readonly params: Params) {} + + public readonly id = DASHBOARD_TO_DASHBOARD_DRILLDOWN; + + public readonly order = 100; + + public readonly getDisplayName = () => txtGoToDashboard; + + public readonly euiIcon = 'dashboardApp'; + + private readonly ReactCollectConfig: React.FC = props => ( + + ); + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + dashboardId: '123', + useCurrentFilters: true, + useCurrentDateRange: true, + }); + + public readonly isConfigValid = (config: Config): config is Config => { + if (!config.dashboardId) return false; + return true; + }; + + public readonly execute = () => { + alert('Go to another dashboard!'); + }; +} diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts new file mode 100644 index 0000000000000..98b746bafd24a --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/i18n.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtGoToDashboard = i18n.translate('xpack.dashboard.drilldown.goToDashboard', { + defaultMessage: 'Go to Dashboard', +}); diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts new file mode 100644 index 0000000000000..9daa485bb6e6c --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DASHBOARD_TO_DASHBOARD_DRILLDOWN } from './constants'; +export { + DashboardToDashboardDrilldown, + Params as DashboardToDashboardDrilldownParams, +} from './drilldown'; +export { + PlaceContext as DashboardToDashboardPlaceContext, + ActionContext as DashboardToDashboardActionContext, + Config as DashboardToDashboardConfig, +} from './types'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts new file mode 100644 index 0000000000000..398a259491e3e --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/dashboard_to_dashboard_drilldown/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EmbeddableVisTriggerContext, + EmbeddableContext, +} from '../../../../../../../src/plugins/embeddable/public'; +import { UiActionsCollectConfigProps } from '../../../../../../../src/plugins/ui_actions/public'; + +export type PlaceContext = EmbeddableContext; +export type ActionContext = EmbeddableVisTriggerContext; + +export interface Config { + dashboardId?: string; + useCurrentFilters: boolean; + useCurrentDateRange: boolean; +} + +export type CollectConfigProps = UiActionsCollectConfigProps; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts new file mode 100644 index 0000000000000..7be8f1c65da12 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './dashboard_drilldowns_services'; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/index.ts b/x-pack/plugins/dashboard_enhanced/public/services/index.ts new file mode 100644 index 0000000000000..8cc3e12906531 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/public/services/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx b/x-pack/plugins/dashboard_enhanced/scripts/storybook.js similarity index 53% rename from x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx rename to x-pack/plugins/dashboard_enhanced/scripts/storybook.js index 5627a5d6f4522..f2cbe4135f4cb 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.story.tsx +++ b/x-pack/plugins/dashboard_enhanced/scripts/storybook.js @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as React from 'react'; -import { storiesOf } from '@storybook/react'; -import { DrilldownPicker } from '.'; +import { join } from 'path'; -storiesOf('components/DrilldownPicker', module).add('default', () => { - return ; +// eslint-disable-next-line +require('@kbn/storybook').runStorybookCli({ + name: 'dashboard_enhanced', + storyGlobs: [join(__dirname, '..', 'public', 'components', '**', '*.story.tsx')], }); diff --git a/x-pack/plugins/dashboard_enhanced/server/config.ts b/x-pack/plugins/dashboard_enhanced/server/config.ts new file mode 100644 index 0000000000000..b75c95d5f8832 --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/server/config.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from '../../../../src/core/server'; + +export const configSchema = schema.object({ + drilldowns: schema.object({ + enabled: schema.boolean({ defaultValue: false }), + }), +}); + +export type ConfigSchema = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + drilldowns: true, + }, +}; diff --git a/x-pack/plugins/dashboard_enhanced/server/index.ts b/x-pack/plugins/dashboard_enhanced/server/index.ts new file mode 100644 index 0000000000000..e361b9fb075ed --- /dev/null +++ b/x-pack/plugins/dashboard_enhanced/server/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { config } from './config'; + +export const plugin = () => ({ + setup() {}, + start() {}, +}); diff --git a/x-pack/plugins/drilldowns/kibana.json b/x-pack/plugins/drilldowns/kibana.json index b951c7dc1fc87..8372d87166364 100644 --- a/x-pack/plugins/drilldowns/kibana.json +++ b/x-pack/plugins/drilldowns/kibana.json @@ -3,8 +3,5 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": [ - "uiActions", - "embeddable" - ] + "requiredPlugins": ["uiActions", "embeddable", "advancedUiActions"] } diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx deleted file mode 100644 index 4834cc8081374..0000000000000 --- a/x-pack/plugins/drilldowns/public/actions/flyout_create_drilldown/index.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; -import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; -import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldown } from '../../components/flyout_create_drilldown'; - -export const OPEN_FLYOUT_ADD_DRILLDOWN = 'OPEN_FLYOUT_ADD_DRILLDOWN'; - -export interface FlyoutCreateDrilldownActionContext { - embeddable: IEmbeddable; -} - -export interface OpenFlyoutAddDrilldownParams { - overlays: () => Promise; -} - -export class FlyoutCreateDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_ADD_DRILLDOWN; - public readonly id = OPEN_FLYOUT_ADD_DRILLDOWN; - public order = 100; - - constructor(protected readonly params: OpenFlyoutAddDrilldownParams) {} - - public getDisplayName() { - return i18n.translate('xpack.drilldowns.FlyoutCreateDrilldownAction.displayName', { - defaultMessage: 'Create drilldown', - }); - } - - public getIconType() { - return 'plusInCircle'; - } - - public async isCompatible({ embeddable }: FlyoutCreateDrilldownActionContext) { - return embeddable.getInput().viewMode === 'edit'; - } - - public async execute(context: FlyoutCreateDrilldownActionContext) { - const overlays = await this.params.overlays(); - const handle = overlays.openFlyout( - toMountPoint( handle.close()} />) - ); - } -} diff --git a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx deleted file mode 100644 index f109da94fcaca..0000000000000 --- a/x-pack/plugins/drilldowns/public/actions/flyout_edit_drilldown/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { CoreStart } from 'src/core/public'; -import { EuiNotificationBadge } from '@elastic/eui'; -import { ActionByType } from '../../../../../../src/plugins/ui_actions/public'; -import { - toMountPoint, - reactToUiComponent, -} from '../../../../../../src/plugins/kibana_react/public'; -import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; -import { FormCreateDrilldown } from '../../components/form_create_drilldown'; - -export const OPEN_FLYOUT_EDIT_DRILLDOWN = 'OPEN_FLYOUT_EDIT_DRILLDOWN'; - -export interface FlyoutEditDrilldownActionContext { - embeddable: IEmbeddable; -} - -export interface FlyoutEditDrilldownParams { - overlays: () => Promise; -} - -const displayName = i18n.translate('xpack.drilldowns.panel.openFlyoutEditDrilldown.displayName', { - defaultMessage: 'Manage drilldowns', -}); - -// mocked data -const drilldrownCount = 2; - -export class FlyoutEditDrilldownAction implements ActionByType { - public readonly type = OPEN_FLYOUT_EDIT_DRILLDOWN; - public readonly id = OPEN_FLYOUT_EDIT_DRILLDOWN; - public order = 100; - - constructor(protected readonly params: FlyoutEditDrilldownParams) {} - - public getDisplayName() { - return displayName; - } - - public getIconType() { - return 'list'; - } - - private ReactComp: React.FC<{ context: FlyoutEditDrilldownActionContext }> = () => { - return ( - <> - {displayName}{' '} - - {drilldrownCount} - - - ); - }; - - MenuItem = reactToUiComponent(this.ReactComp); - - public async isCompatible({ embeddable }: FlyoutEditDrilldownActionContext) { - return embeddable.getInput().viewMode === 'edit' && drilldrownCount > 0; - } - - public async execute({ embeddable }: FlyoutEditDrilldownActionContext) { - const overlays = await this.params.overlays(); - overlays.openFlyout(toMountPoint()); - } -} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..16b4d3a25d9e5 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.story.tsx @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; +import { + dashboardFactory, + urlFactory, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +import { mockDynamicActionManager } from './test_data'; + +const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ + advancedUiActions: { + getActionFactories() { + return [dashboardFactory, urlFactory]; + }, + } as any, + storage: new Storage(new StubBrowserStorage()), + notifications: { + toasts: { + addError: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + addSuccess: (...args: any[]) => { + alert(JSON.stringify(args)); + }, + } as any, + }, +}); + +storiesOf('components/FlyoutManageDrilldowns', module).add('default', () => ( + {}}> + + +)); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx new file mode 100644 index 0000000000000..6749b41e81fc7 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.test.tsx @@ -0,0 +1,221 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { cleanup, fireEvent, render, wait } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; +import { createFlyoutManageDrilldowns } from './connected_flyout_manage_drilldowns'; +import { + dashboardFactory, + urlFactory, +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; +import { StubBrowserStorage } from '../../../../../../src/test_utils/public/stub_browser_storage'; +import { Storage } from '../../../../../../src/plugins/kibana_utils/public'; +import { mockDynamicActionManager } from './test_data'; +import { TEST_SUBJ_DRILLDOWN_ITEM } from '../list_manage_drilldowns'; +import { WELCOME_MESSAGE_TEST_SUBJ } from '../drilldown_hello_bar'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { NotificationsStart } from 'kibana/public'; +import { toastDrilldownsCRUDError } from './i18n'; + +const storage = new Storage(new StubBrowserStorage()); +const notifications = coreMock.createStart().notifications; +const FlyoutManageDrilldowns = createFlyoutManageDrilldowns({ + advancedUiActions: { + getActionFactories() { + return [dashboardFactory, urlFactory]; + }, + } as any, + storage, + notifications, +}); + +// https://github.com/elastic/kibana/issues/59469 +afterEach(cleanup); + +beforeEach(() => { + storage.clear(); + (notifications.toasts as jest.Mocked).addSuccess.mockClear(); + (notifications.toasts as jest.Mocked).addError.mockClear(); +}); + +test('Allows to manage drilldowns', async () => { + const screen = render( + + ); + + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + // no drilldowns in the list + expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0); + + fireEvent.click(screen.getByText(/Create new/i)); + + let [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); + expect(createHeading).toBeVisible(); + expect(screen.getByLabelText(/Back/i)).toBeVisible(); + + expect(createButton).toBeDisabled(); + + // input drilldown name + const name = 'Test name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: name }, + }); + + // select URL one + fireEvent.click(screen.getByText(/Go to URL/i)); + + // Input url + const URL = 'https://elastic.co'; + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: URL }, + }); + + [createHeading, createButton] = screen.getAllByText(/Create Drilldown/i); + + expect(createButton).toBeEnabled(); + fireEvent.click(createButton); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(1)); + expect(screen.getByText(name)).toBeVisible(); + const editButton = screen.getByText(/edit/i); + fireEvent.click(editButton); + + expect(screen.getByText(/Edit Drilldown/i)).toBeVisible(); + // check that wizard is prefilled with current drilldown values + expect(screen.getByLabelText(/name/i)).toHaveValue(name); + expect(screen.getByLabelText(/url/i)).toHaveValue(URL); + + // input new drilldown name + const newName = 'New drilldown name'; + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: newName }, + }); + fireEvent.click(screen.getByText(/save/i)); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + await wait(() => screen.getByText(newName)); + + // delete drilldown from edit view + fireEvent.click(screen.getByText(/edit/i)); + fireEvent.click(screen.getByText(/delete/i)); + + expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible(); + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); +}); + +test('Can delete multiple drilldowns', async () => { + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + const createDrilldown = async () => { + const oldCount = screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM).length; + fireEvent.click(screen.getByText(/Create new/i)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => + expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(oldCount + 1) + ); + }; + + await createDrilldown(); + await createDrilldown(); + await createDrilldown(); + + const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); + expect(checkboxes).toHaveLength(3); + checkboxes.forEach(checkbox => fireEvent.click(checkbox)); + expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByText(/Delete \(3\)/i)); + + await wait(() => expect(screen.queryAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(0)); +}); + +test('Create only mode', async () => { + const onClose = jest.fn(); + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getAllByText(/Create/i).length).toBeGreaterThan(0)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + + await wait(() => expect(notifications.toasts.addSuccess).toBeCalled()); + expect(onClose).toBeCalled(); + expect(await mockDynamicActionManager.state.get().events.length).toBe(1); +}); + +test.todo("Error when can't fetch drilldown list"); + +test("Error when can't save drilldown changes", async () => { + const error = new Error('Oops'); + jest.spyOn(mockDynamicActionManager, 'createEvent').mockImplementationOnce(async () => { + throw error; + }); + const screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + fireEvent.click(screen.getByText(/Create new/i)); + fireEvent.change(screen.getByLabelText(/name/i), { + target: { value: 'test' }, + }); + fireEvent.click(screen.getByText(/Go to URL/i)); + fireEvent.change(screen.getByLabelText(/url/i), { + target: { value: 'https://elastic.co' }, + }); + fireEvent.click(screen.getAllByText(/Create Drilldown/i)[1]); + await wait(() => + expect(notifications.toasts.addError).toBeCalledWith(error, { title: toastDrilldownsCRUDError }) + ); +}); + +test('Should show drilldown welcome message. Should be able to dismiss it', async () => { + let screen = render( + + ); + + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + + expect(screen.getByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeVisible(); + fireEvent.click(screen.getByText(/hide/i)); + expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); + cleanup(); + + screen = render( + + ); + // wait for initial render. It is async because resolving compatible action factories is async + await wait(() => expect(screen.getByText(/Manage Drilldowns/i)).toBeVisible()); + expect(screen.queryByTestId(WELCOME_MESSAGE_TEST_SUBJ)).toBeNull(); +}); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx new file mode 100644 index 0000000000000..f22ccc2f26f02 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -0,0 +1,332 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import useMountedState from 'react-use/lib/useMountedState'; +import { + AdvancedUiActionsActionFactory as ActionFactory, + AdvancedUiActionsStart, +} from '../../../../advanced_ui_actions/public'; +import { NotificationsStart } from '../../../../../../src/core/public'; +import { DrilldownWizardConfig, FlyoutDrilldownWizard } from '../flyout_drilldown_wizard'; +import { FlyoutListManageDrilldowns } from '../flyout_list_manage_drilldowns'; +import { IStorageWrapper } from '../../../../../../src/plugins/kibana_utils/public'; +import { + DynamicActionManager, + UiActionsSerializedEvent, + UiActionsSerializedAction, + VALUE_CLICK_TRIGGER, + SELECT_RANGE_TRIGGER, + TriggerContextMapping, +} from '../../../../../../src/plugins/ui_actions/public'; +import { useContainerState } from '../../../../../../src/plugins/kibana_utils/common'; +import { DrilldownListItem } from '../list_manage_drilldowns'; +import { + toastDrilldownCreated, + toastDrilldownDeleted, + toastDrilldownEdited, + toastDrilldownsCRUDError, + toastDrilldownsDeleted, +} from './i18n'; +import { DrilldownFactoryContext } from '../../types'; + +interface ConnectedFlyoutManageDrilldownsProps { + placeContext: Context; + dynamicActionManager: DynamicActionManager; + viewMode?: 'create' | 'manage'; + onClose?: () => void; +} + +/** + * Represent current state (route) of FlyoutManageDrilldowns + */ +enum Routes { + Manage = 'manage', + Create = 'create', + Edit = 'edit', +} + +export function createFlyoutManageDrilldowns({ + advancedUiActions, + storage, + notifications, +}: { + advancedUiActions: AdvancedUiActionsStart; + storage: IStorageWrapper; + notifications: NotificationsStart; +}) { + // fine to assume this is static, + // because all action factories should be registered in setup phase + const allActionFactories = advancedUiActions.getActionFactories(); + const allActionFactoriesById = allActionFactories.reduce((acc, next) => { + acc[next.id] = next; + return acc; + }, {} as Record); + + return (props: ConnectedFlyoutManageDrilldownsProps) => { + const isCreateOnly = props.viewMode === 'create'; + + const selectedTriggers: Array = React.useMemo( + () => [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER], + [] + ); + + const factoryContext: DrilldownFactoryContext = React.useMemo( + () => ({ + placeContext: props.placeContext, + triggers: selectedTriggers, + }), + [props.placeContext, selectedTriggers] + ); + + const actionFactories = useCompatibleActionFactoriesForCurrentContext( + allActionFactories, + factoryContext + ); + + const [route, setRoute] = useState( + () => (isCreateOnly ? Routes.Create : Routes.Manage) // initial state is different depending on `viewMode` + ); + const [currentEditId, setCurrentEditId] = useState(null); + + const [shouldShowWelcomeMessage, onHideWelcomeMessage] = useWelcomeMessage(storage); + + const { + drilldowns, + createDrilldown, + editDrilldown, + deleteDrilldown, + } = useDrilldownsStateManager(props.dynamicActionManager, notifications); + + /** + * isCompatible promise is not yet resolved. + * Skip rendering until it is resolved + */ + if (!actionFactories) return null; + /** + * Drilldowns are not fetched yet or error happened during fetching + * In case of error user is notified with toast + */ + if (!drilldowns) return null; + + /** + * Needed for edit mode to prefill wizard fields with data from current edited drilldown + */ + function resolveInitialDrilldownWizardConfig(): DrilldownWizardConfig | undefined { + if (route !== Routes.Edit) return undefined; + if (!currentEditId) return undefined; + const drilldownToEdit = drilldowns?.find(d => d.eventId === currentEditId); + if (!drilldownToEdit) return undefined; + + return { + actionFactory: allActionFactoriesById[drilldownToEdit.action.factoryId], + actionConfig: drilldownToEdit.action.config as object, // TODO: config is unknown, but we know it always extends object + name: drilldownToEdit.action.name, + }; + } + + /** + * Maps drilldown to list item view model + */ + function mapToDrilldownToDrilldownListItem( + drilldown: UiActionsSerializedEvent + ): DrilldownListItem { + const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; + return { + id: drilldown.eventId, + drilldownName: drilldown.action.name, + actionName: actionFactory?.getDisplayName(factoryContext) ?? drilldown.action.factoryId, + icon: actionFactory?.getIconType(factoryContext), + }; + } + + switch (route) { + case Routes.Create: + case Routes.Edit: + return ( + setRoute(Routes.Manage)} + onSubmit={({ actionConfig, actionFactory, name }) => { + if (route === Routes.Create) { + createDrilldown( + { + name, + config: actionConfig, + factoryId: actionFactory.id, + }, + selectedTriggers + ); + } else { + editDrilldown( + currentEditId!, + { + name, + config: actionConfig, + factoryId: actionFactory.id, + }, + selectedTriggers + ); + } + + if (isCreateOnly) { + if (props.onClose) { + props.onClose(); + } + } else { + setRoute(Routes.Manage); + } + + setCurrentEditId(null); + }} + onDelete={() => { + deleteDrilldown(currentEditId!); + setRoute(Routes.Manage); + setCurrentEditId(null); + }} + actionFactoryContext={factoryContext} + initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} + /> + ); + + case Routes.Manage: + default: + return ( + { + setCurrentEditId(null); + deleteDrilldown(ids); + }} + onEdit={id => { + setCurrentEditId(id); + setRoute(Routes.Edit); + }} + onCreate={() => { + setCurrentEditId(null); + setRoute(Routes.Create); + }} + onClose={props.onClose} + /> + ); + } + }; +} + +function useCompatibleActionFactoriesForCurrentContext( + actionFactories: Array>, + context: Context +) { + const [compatibleActionFactories, setCompatibleActionFactories] = useState< + Array> + >(); + useEffect(() => { + let canceled = false; + async function updateCompatibleFactoriesForContext() { + const compatibility = await Promise.all( + actionFactories.map(factory => factory.isCompatible(context)) + ); + if (canceled) return; + setCompatibleActionFactories(actionFactories.filter((_, i) => compatibility[i])); + } + updateCompatibleFactoriesForContext(); + return () => { + canceled = true; + }; + }, [context, actionFactories]); + + return compatibleActionFactories; +} + +function useWelcomeMessage(storage: IStorageWrapper): [boolean, () => void] { + const key = `drilldowns:hidWelcomeMessage`; + const [hidWelcomeMessage, setHidWelcomeMessage] = useState(storage.get(key) ?? false); + + return [ + !hidWelcomeMessage, + () => { + if (hidWelcomeMessage) return; + setHidWelcomeMessage(true); + storage.set(key, true); + }, + ]; +} + +function useDrilldownsStateManager( + actionManager: DynamicActionManager, + notifications: NotificationsStart +) { + const { events: drilldowns } = useContainerState(actionManager.state); + const [isLoading, setIsLoading] = useState(false); + const isMounted = useMountedState(); + + async function run(op: () => Promise) { + setIsLoading(true); + try { + await op(); + } catch (e) { + notifications.toasts.addError(e, { + title: toastDrilldownsCRUDError, + }); + if (!isMounted) return; + setIsLoading(false); + return; + } + } + + async function createDrilldown( + action: UiActionsSerializedAction, + selectedTriggers: Array + ) { + await run(async () => { + await actionManager.createEvent(action, selectedTriggers); + notifications.toasts.addSuccess({ + title: toastDrilldownCreated.title, + text: toastDrilldownCreated.text(action.name), + }); + }); + } + + async function editDrilldown( + drilldownId: string, + action: UiActionsSerializedAction, + selectedTriggers: Array + ) { + await run(async () => { + await actionManager.updateEvent(drilldownId, action, selectedTriggers); + notifications.toasts.addSuccess({ + title: toastDrilldownEdited.title, + text: toastDrilldownEdited.text(action.name), + }); + }); + } + + async function deleteDrilldown(drilldownIds: string | string[]) { + await run(async () => { + drilldownIds = Array.isArray(drilldownIds) ? drilldownIds : [drilldownIds]; + await actionManager.deleteEvents(drilldownIds); + notifications.toasts.addSuccess( + drilldownIds.length === 1 + ? { + title: toastDrilldownDeleted.title, + text: toastDrilldownDeleted.text, + } + : { + title: toastDrilldownsDeleted.title, + text: toastDrilldownsDeleted.text(drilldownIds.length), + } + ); + }); + } + + return { drilldowns, isLoading, createDrilldown, editDrilldown, deleteDrilldown }; +} diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..70f4d735e2a74 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/i18n.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const toastDrilldownCreated = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedTitle', + { + defaultMessage: 'Drilldown created', + } + ), + text: (drilldownName: string) => + i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownCreatedText', { + defaultMessage: 'You created "{drilldownName}"', + values: { + drilldownName, + }, + }), +}; + +export const toastDrilldownEdited = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedTitle', + { + defaultMessage: 'Drilldown edited', + } + ), + text: (drilldownName: string) => + i18n.translate('xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownEditedText', { + defaultMessage: 'You edited "{drilldownName}"', + values: { + drilldownName, + }, + }), +}; + +export const toastDrilldownDeleted = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedTitle', + { + defaultMessage: 'Drilldown deleted', + } + ), + text: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownDeletedText', + { + defaultMessage: 'You deleted a drilldown', + } + ), +}; + +export const toastDrilldownsDeleted = { + title: i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedTitle', + { + defaultMessage: 'Drilldowns deleted', + } + ), + text: (n: number) => + i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsDeletedText', + { + defaultMessage: 'You deleted {n} drilldowns', + values: { + n, + }, + } + ), +}; + +export const toastDrilldownsCRUDError = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsCRUDErrorTitle', + { + defaultMessage: 'Error saving drilldown', + description: 'Title for generic error toast when persisting drilldown updates failed', + } +); + +export const toastDrilldownsFetchError = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.toast.drilldownsFetchErrorTitle', + { + defaultMessage: 'Error fetching drilldowns', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts new file mode 100644 index 0000000000000..f084a3e563c23 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './connected_flyout_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts new file mode 100644 index 0000000000000..b8deaa8b842bc --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/connected_flyout_manage_drilldowns/test_data.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { + DynamicActionManager, + DynamicActionManagerState, + UiActionsSerializedAction, + TriggerContextMapping, +} from '../../../../../../src/plugins/ui_actions/public'; +import { createStateContainer } from '../../../../../../src/plugins/kibana_utils/common'; + +class MockDynamicActionManager implements PublicMethodsOf { + public readonly state = createStateContainer({ + isFetchingEvents: false, + fetchCount: 0, + events: [], + }); + + async count() { + return this.state.get().events.length; + } + + async list() { + return this.state.get().events; + } + + async createEvent( + action: UiActionsSerializedAction, + triggers: Array + ) { + const event = { + action, + triggers, + eventId: uuid(), + }; + const state = this.state.get(); + this.state.set({ + ...state, + events: [...state.events, event], + }); + } + + async deleteEvents(eventIds: string[]) { + const state = this.state.get(); + let events = state.events; + + eventIds.forEach(id => { + events = events.filter(e => e.eventId !== id); + }); + + this.state.set({ + ...state, + events, + }); + } + + async updateEvent( + eventId: string, + action: UiActionsSerializedAction, + triggers: Array + ) { + const state = this.state.get(); + const events = state.events; + const idx = events.findIndex(e => e.eventId === eventId); + const event = { + eventId, + action, + triggers, + }; + + this.state.set({ + ...state, + events: [...events.slice(0, idx), event, ...events.slice(idx + 1)], + }); + } + + async deleteEvent() { + throw new Error('not implemented'); + } + + async start() {} + async stop() {} +} + +export const mockDynamicActionManager = (new MockDynamicActionManager() as unknown) as DynamicActionManager; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx index 7a9e19342f27c..c4a4630397f1c 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.story.tsx @@ -8,6 +8,16 @@ import * as React from 'react'; import { storiesOf } from '@storybook/react'; import { DrilldownHelloBar } from '.'; -storiesOf('components/DrilldownHelloBar', module).add('default', () => { - return ; -}); +const Demo = () => { + const [show, setShow] = React.useState(true); + return show ? ( + { + setShow(false); + }} + /> + ) : null; +}; + +storiesOf('components/DrilldownHelloBar', module).add('default', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx index 1ef714f7b86e2..8c6739a8ad6c8 100644 --- a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/drilldown_hello_bar.tsx @@ -5,22 +5,58 @@ */ import React from 'react'; +import { + EuiCallOut, + EuiFlexGroup, + EuiFlexItem, + EuiTextColor, + EuiText, + EuiLink, + EuiSpacer, + EuiButtonEmpty, + EuiIcon, +} from '@elastic/eui'; +import { txtHideHelpButtonLabel, txtHelpText, txtViewDocsLinkLabel } from './i18n'; export interface DrilldownHelloBarProps { docsLink?: string; + onHideClick?: () => void; } -/** - * @todo https://github.com/elastic/kibana/issues/55311 - */ -export const DrilldownHelloBar: React.FC = ({ docsLink }) => { +export const WELCOME_MESSAGE_TEST_SUBJ = 'drilldowns-welcome-message-test-subj'; + +export const DrilldownHelloBar: React.FC = ({ + docsLink, + onHideClick = () => {}, +}) => { return ( -
+ + +
+ +
+
+ + + {txtHelpText} + + {docsLink && ( + <> + + {txtViewDocsLinkLabel} + + )} + + + + {txtHideHelpButtonLabel} + + + + } + /> ); }; diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts new file mode 100644 index 0000000000000..63dc95dabc0fb --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/drilldown_hello_bar/i18n.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtHelpText = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.helpText', + { + defaultMessage: + 'Drilldowns provide the ability to define a new behavior when interacting with a panel. You can add multiple options or simply override the default filtering behavior.', + } +); + +export const txtViewDocsLinkLabel = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.viewDocsLinkLabel', + { + defaultMessage: 'View docs', + } +); + +export const txtHideHelpButtonLabel = i18n.translate( + 'xpack.drilldowns.components.DrilldownHelloBar.hideHelpButtonLabel', + { + defaultMessage: 'Hide', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx b/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx deleted file mode 100644 index 3748fc666c81c..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/drilldown_picker/drilldown_picker.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; - -// eslint-disable-next-line -export interface DrilldownPickerProps {} - -export const DrilldownPicker: React.FC = () => { - return ( - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx deleted file mode 100644 index 4f024b7d9cd6a..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.story.tsx +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FlyoutCreateDrilldown } from '.'; - -storiesOf('components/FlyoutCreateDrilldown', module) - .add('default', () => { - return ; - }) - .add('open in flyout', () => { - return ( - - - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx deleted file mode 100644 index b45ac9197c7e0..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/flyout_create_drilldown/flyout_create_drilldown.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiButton } from '@elastic/eui'; -import { FormCreateDrilldown } from '../form_create_drilldown'; -import { FlyoutFrame } from '../flyout_frame'; -import { txtCreateDrilldown } from './i18n'; -import { FlyoutCreateDrilldownActionContext } from '../../actions'; - -export interface FlyoutCreateDrilldownProps { - context: FlyoutCreateDrilldownActionContext; - onClose?: () => void; -} - -export const FlyoutCreateDrilldown: React.FC = ({ - context, - onClose, -}) => { - const footer = ( - {}} fill> - {txtCreateDrilldown} - - ); - - return ( - - - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx new file mode 100644 index 0000000000000..152cd393b9d3e --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.story.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/* eslint-disable no-console */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutDrilldownWizard } from '.'; +import { + dashboardFactory, + urlFactory, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../advanced_ui_actions/public/components/action_wizard/test_data'; + +storiesOf('components/FlyoutDrilldownWizard', module) + .add('default', () => { + return ; + }) + .add('open in flyout - create', () => { + return ( + {}}> + {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + /> + + ); + }) + .add('open in flyout - edit', () => { + return ( + {}}> + {}} + drilldownActionFactories={[urlFactory, dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + + ); + }) + .add('open in flyout - edit, just 1 action type', () => { + return ( + {}}> + {}} + drilldownActionFactories={[dashboardFactory]} + initialDrilldownWizardConfig={{ + name: 'My fancy drilldown', + actionFactory: urlFactory as any, + actionConfig: { + url: 'https://elastic.co', + openInNewTab: true, + }, + }} + mode={'edit'} + /> + + ); + }); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx new file mode 100644 index 0000000000000..faa965a98a4bb --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormDrilldownWizard } from '../form_drilldown_wizard'; +import { FlyoutFrame } from '../flyout_frame'; +import { + txtCreateDrilldownButtonLabel, + txtCreateDrilldownTitle, + txtDeleteDrilldownButtonLabel, + txtEditDrilldownButtonLabel, + txtEditDrilldownTitle, +} from './i18n'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; +import { AdvancedUiActionsActionFactory as ActionFactory } from '../../../../advanced_ui_actions/public'; + +export interface DrilldownWizardConfig { + name: string; + actionFactory?: ActionFactory; + actionConfig?: ActionConfig; +} + +export interface FlyoutDrilldownWizardProps { + drilldownActionFactories: Array>; + + onSubmit?: (drilldownWizardConfig: Required) => void; + onDelete?: () => void; + onClose?: () => void; + onBack?: () => void; + + mode?: 'create' | 'edit'; + initialDrilldownWizardConfig?: DrilldownWizardConfig; + + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; + + actionFactoryContext?: object; +} + +export function FlyoutDrilldownWizard({ + onClose, + onBack, + onSubmit = () => {}, + initialDrilldownWizardConfig, + mode = 'create', + onDelete = () => {}, + showWelcomeMessage = true, + onWelcomeHideClick, + drilldownActionFactories, + actionFactoryContext, +}: FlyoutDrilldownWizardProps) { + const [wizardConfig, setWizardConfig] = useState( + () => + initialDrilldownWizardConfig ?? { + name: '', + } + ); + + const isActionValid = ( + config: DrilldownWizardConfig + ): config is Required => { + if (!wizardConfig.name) return false; + if (!wizardConfig.actionFactory) return false; + if (!wizardConfig.actionConfig) return false; + + return wizardConfig.actionFactory.isConfigValid(wizardConfig.actionConfig); + }; + + const footer = ( + { + if (isActionValid(wizardConfig)) { + onSubmit(wizardConfig); + } + }} + fill + isDisabled={!isActionValid(wizardConfig)} + > + {mode === 'edit' ? txtEditDrilldownButtonLabel : txtCreateDrilldownButtonLabel} + + ); + + return ( + } + > + { + setWizardConfig({ + ...wizardConfig, + name: newName, + }); + }} + actionConfig={wizardConfig.actionConfig} + onActionConfigChange={newActionConfig => { + setWizardConfig({ + ...wizardConfig, + actionConfig: newActionConfig, + }); + }} + currentActionFactory={wizardConfig.actionFactory} + onActionFactoryChange={actionFactory => { + if (!actionFactory) { + setWizardConfig({ + ...wizardConfig, + actionFactory: undefined, + actionConfig: undefined, + }); + } else { + setWizardConfig({ + ...wizardConfig, + actionFactory, + actionConfig: actionFactory.createConfig(), + }); + } + }} + actionFactories={drilldownActionFactories} + actionFactoryContext={actionFactoryContext!} + /> + {mode === 'edit' && ( + <> + + + {txtDeleteDrilldownButtonLabel} + + + )} + + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts new file mode 100644 index 0000000000000..a4a2754a444ab --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/i18n.ts @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtCreateDrilldownTitle = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownTitle', + { + defaultMessage: 'Create Drilldown', + } +); + +export const txtEditDrilldownTitle = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownTitle', + { + defaultMessage: 'Edit Drilldown', + } +); + +export const txtCreateDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.createDrilldownButtonLabel', + { + defaultMessage: 'Create drilldown', + } +); + +export const txtEditDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.editDrilldownButtonLabel', + { + defaultMessage: 'Save', + } +); + +export const txtDeleteDrilldownButtonLabel = i18n.translate( + 'xpack.drilldowns.components.flyoutDrilldownWizard.deleteDrilldownButtonLabel', + { + defaultMessage: 'Delete drilldown', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts new file mode 100644 index 0000000000000..96ed23bf112c9 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_drilldown_wizard/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './flyout_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx index 2715637f6392f..cb223db556f56 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.story.tsx @@ -21,6 +21,13 @@ storiesOf('components/FlyoutFrame', module) .add('with onClose', () => { return console.log('onClose')}>test; }) + .add('with onBack', () => { + return ( + console.log('onClose')} title={'Title'}> + test + + ); + }) .add('custom footer', () => { return click me!}>test; }) diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx index b5fb52fcf5c18..0a3989487745f 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.test.tsx @@ -6,9 +6,11 @@ import React from 'react'; import { render } from 'react-dom'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { FlyoutFrame } from '.'; +afterEach(cleanup); + describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx index 2945cfd739482..b55cbd88d0dc0 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/flyout_frame.tsx @@ -13,13 +13,16 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, + EuiButtonIcon, } from '@elastic/eui'; -import { txtClose } from './i18n'; +import { txtClose, txtBack } from './i18n'; export interface FlyoutFrameProps { title?: React.ReactNode; footer?: React.ReactNode; + banner?: React.ReactNode; onClose?: () => void; + onBack?: () => void; } /** @@ -30,11 +33,31 @@ export const FlyoutFrame: React.FC = ({ footer, onClose, children, + onBack, + banner, }) => { - const headerFragment = title && ( + const headerFragment = (title || onBack) && ( -

{title}

+ + {onBack && ( + +
+ +
+
+ )} + {title && ( + +

{title}

+
+ )} +
); @@ -64,7 +87,7 @@ export const FlyoutFrame: React.FC = ({ return ( <> {headerFragment} - {children} + {children} {footerFragment} ); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts index 257d7d36dbee1..23af89ebf9bc7 100644 --- a/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/flyout_frame/i18n.ts @@ -6,6 +6,10 @@ import { i18n } from '@kbn/i18n'; -export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.Close', { +export const txtClose = i18n.translate('xpack.drilldowns.components.FlyoutFrame.CloseButtonLabel', { defaultMessage: 'Close', }); + +export const txtBack = i18n.translate('xpack.drilldowns.components.FlyoutFrame.BackButtonLabel', { + defaultMessage: 'Back', +}); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..0529f0451b16a --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.story.tsx @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { EuiFlyout } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import { FlyoutListManageDrilldowns } from './flyout_list_manage_drilldowns'; + +storiesOf('components/FlyoutListManageDrilldowns', module).add('default', () => ( + {}}> + + +)); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx new file mode 100644 index 0000000000000..a44a7ccccb4dc --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/flyout_list_manage_drilldowns.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FlyoutFrame } from '../flyout_frame'; +import { DrilldownListItem, ListManageDrilldowns } from '../list_manage_drilldowns'; +import { txtManageDrilldowns } from './i18n'; +import { DrilldownHelloBar } from '../drilldown_hello_bar'; + +export interface FlyoutListManageDrilldownsProps { + drilldowns: DrilldownListItem[]; + onClose?: () => void; + onCreate?: () => void; + onEdit?: (drilldownId: string) => void; + onDelete?: (drilldownIds: string[]) => void; + showWelcomeMessage?: boolean; + onWelcomeHideClick?: () => void; +} + +export function FlyoutListManageDrilldowns({ + drilldowns, + onClose = () => {}, + onCreate, + onDelete, + onEdit, + showWelcomeMessage = true, + onWelcomeHideClick, +}: FlyoutListManageDrilldownsProps) { + return ( + } + > + + + ); +} diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..0dd4e37d4dddd --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/i18n.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtManageDrilldowns = i18n.translate( + 'xpack.drilldowns.components.FlyoutListManageDrilldowns.manageDrilldownsTitle', + { + defaultMessage: 'Manage Drilldowns', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts new file mode 100644 index 0000000000000..f8c9d224fb292 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/flyout_list_manage_drilldowns/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './flyout_list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx deleted file mode 100644 index e7e1d67473e8c..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.story.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -/* eslint-disable no-console */ - -import * as React from 'react'; -import { EuiFlyout } from '@elastic/eui'; -import { storiesOf } from '@storybook/react'; -import { FormCreateDrilldown } from '.'; - -const DemoEditName: React.FC = () => { - const [name, setName] = React.useState(''); - - return ; -}; - -storiesOf('components/FormCreateDrilldown', module) - .add('default', () => { - return ; - }) - .add('[name=foobar]', () => { - return ; - }) - .add('can edit name', () => ) - .add('open in flyout', () => { - return ( - - - - ); - }); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx b/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx deleted file mode 100644 index 4422de604092b..0000000000000 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiForm, EuiFormRow, EuiFieldText } from '@elastic/eui'; -import { DrilldownHelloBar } from '../drilldown_hello_bar'; -import { txtNameOfDrilldown, txtUntitledDrilldown, txtDrilldownAction } from './i18n'; -import { DrilldownPicker } from '../drilldown_picker'; - -const noop = () => {}; - -export interface FormCreateDrilldownProps { - name?: string; - onNameChange?: (name: string) => void; -} - -export const FormCreateDrilldown: React.FC = ({ - name = '', - onNameChange = noop, -}) => { - const nameFragment = ( - - onNameChange(event.target.value)} - data-test-subj="dynamicActionNameInput" - /> - - ); - - const triggerPicker =
Trigger Picker will be here
; - const actionPicker = ( - - - - ); - - return ( - <> - - {nameFragment} - {triggerPicker} - {actionPicker} - - ); -}; diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx new file mode 100644 index 0000000000000..2fc35eb6b5298 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.story.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { FormDrilldownWizard } from '.'; + +const DemoEditName: React.FC = () => { + const [name, setName] = React.useState(''); + + return ( + <> + {' '} +
name: {name}
+ + ); +}; + +storiesOf('components/FormDrilldownWizard', module) + .add('default', () => { + return ; + }) + .add('[name=foobar]', () => { + return ; + }) + .add('can edit name', () => ); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx similarity index 70% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx index 6691966e47e64..4560773cc8a6d 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/form_create_drilldown.test.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.test.tsx @@ -6,21 +6,23 @@ import React from 'react'; import { render } from 'react-dom'; -import { FormCreateDrilldown } from '.'; -import { render as renderTestingLibrary, fireEvent } from '@testing-library/react'; +import { FormDrilldownWizard } from './form_drilldown_wizard'; +import { render as renderTestingLibrary, fireEvent, cleanup } from '@testing-library/react/pure'; import { txtNameOfDrilldown } from './i18n'; -describe('', () => { +afterEach(cleanup); + +describe('', () => { test('renders without crashing', () => { const div = document.createElement('div'); - render( {}} />, div); + render( {}} actionFactoryContext={{}} />, div); }); describe('[name=]', () => { test('if name not provided, uses to empty string', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector( '[data-test-subj="dynamicActionNameInput"]' @@ -29,10 +31,10 @@ describe('', () => { expect(input?.value).toBe(''); }); - test('can set name input field value', () => { + test('can set initial name input field value', () => { const div = document.createElement('div'); - render(, div); + render(, div); const input = div.querySelector( '[data-test-subj="dynamicActionNameInput"]' @@ -40,7 +42,7 @@ describe('', () => { expect(input?.value).toBe('foo'); - render(, div); + render(, div); expect(input?.value).toBe('bar'); }); @@ -48,7 +50,7 @@ describe('', () => { test('fires onNameChange callback on name change', () => { const onNameChange = jest.fn(); const utils = renderTestingLibrary( - + ); const input = utils.getByLabelText(txtNameOfDrilldown); diff --git a/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx new file mode 100644 index 0000000000000..bdafaaf07873c --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/form_drilldown_wizard.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiFieldText, EuiForm, EuiFormRow, EuiSpacer } from '@elastic/eui'; +import { txtDrilldownAction, txtNameOfDrilldown, txtUntitledDrilldown } from './i18n'; +import { + AdvancedUiActionsActionFactory as ActionFactory, + ActionWizard, +} from '../../../../advanced_ui_actions/public'; + +const noopFn = () => {}; + +export interface FormDrilldownWizardProps { + name?: string; + onNameChange?: (name: string) => void; + + currentActionFactory?: ActionFactory; + onActionFactoryChange?: (actionFactory: ActionFactory | null) => void; + actionFactoryContext: object; + + actionConfig?: object; + onActionConfigChange?: (config: object) => void; + + actionFactories?: ActionFactory[]; +} + +export const FormDrilldownWizard: React.FC = ({ + name = '', + actionConfig, + currentActionFactory, + onNameChange = noopFn, + onActionConfigChange = noopFn, + onActionFactoryChange = noopFn, + actionFactories = [], + actionFactoryContext, +}) => { + const nameFragment = ( + + onNameChange(event.target.value)} + data-test-subj="dynamicActionNameInput" + /> + + ); + + const actionWizard = ( + 1 ? txtDrilldownAction : undefined} + fullWidth={true} + > + onActionFactoryChange(actionFactory)} + onConfigChange={config => onActionConfigChange(config)} + context={actionFactoryContext} + /> + + ); + + return ( + <> + + {nameFragment} + + {actionWizard} + + + ); +}; diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts similarity index 89% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts index 4c0e287935edd..e9b19ab0afa97 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/i18n.ts +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/i18n.ts @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; export const txtNameOfDrilldown = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.nameOfDrilldown', { - defaultMessage: 'Name of drilldown', + defaultMessage: 'Name', } ); @@ -23,6 +23,6 @@ export const txtUntitledDrilldown = i18n.translate( export const txtDrilldownAction = i18n.translate( 'xpack.drilldowns.components.FormCreateDrilldown.drilldownAction', { - defaultMessage: 'Drilldown action', + defaultMessage: 'Action', } ); diff --git a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx similarity index 85% rename from x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx rename to x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx index c2c5a7e435b39..4aea824de00d7 100644 --- a/x-pack/plugins/drilldowns/public/components/form_create_drilldown/index.tsx +++ b/x-pack/plugins/drilldowns/public/components/form_drilldown_wizard/index.tsx @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './form_create_drilldown'; +export * from './form_drilldown_wizard'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts new file mode 100644 index 0000000000000..fbc7c9dcfb4a1 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/i18n.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtCreateDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.createDrilldownButtonLabel', + { + defaultMessage: 'Create new', + } +); + +export const txtEditDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.editDrilldownButtonLabel', + { + defaultMessage: 'Edit', + } +); + +export const txtDeleteDrilldowns = (count: number) => + i18n.translate('xpack.drilldowns.components.ListManageDrilldowns.deleteDrilldownsButtonLabel', { + defaultMessage: 'Delete ({count})', + values: { + count, + }, + }); + +export const txtSelectDrilldown = i18n.translate( + 'xpack.drilldowns.components.ListManageDrilldowns.selectThisDrilldownCheckboxLabel', + { + defaultMessage: 'Select this drilldown', + } +); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx new file mode 100644 index 0000000000000..82b6ce27af6d4 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './list_manage_drilldowns'; diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.tsx new file mode 100644 index 0000000000000..eafe50bab2016 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.story.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; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as React from 'react'; +import { storiesOf } from '@storybook/react'; +import { ListManageDrilldowns } from './list_manage_drilldowns'; + +storiesOf('components/ListManageDrilldowns', module).add('default', () => ( + +)); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx new file mode 100644 index 0000000000000..4a4d67b08b1d3 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.test.tsx @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { cleanup, fireEvent, render } from '@testing-library/react/pure'; +import '@testing-library/jest-dom/extend-expect'; // TODO: this should be global +import { + DrilldownListItem, + ListManageDrilldowns, + TEST_SUBJ_DRILLDOWN_ITEM, +} from './list_manage_drilldowns'; + +// TODO: for some reason global cleanup from RTL doesn't work +// afterEach is not available for it globally during setup +afterEach(cleanup); + +const drilldowns: DrilldownListItem[] = [ + { id: '1', actionName: 'Dashboard', drilldownName: 'Drilldown 1' }, + { id: '2', actionName: 'Dashboard', drilldownName: 'Drilldown 2' }, + { id: '3', actionName: 'Dashboard', drilldownName: 'Drilldown 3' }, +]; + +test('Render list of drilldowns', () => { + const screen = render(); + expect(screen.getAllByTestId(TEST_SUBJ_DRILLDOWN_ITEM)).toHaveLength(drilldowns.length); +}); + +test('Emit onEdit() when clicking on edit drilldown', () => { + const fn = jest.fn(); + const screen = render(); + + const editButtons = screen.getAllByText('Edit'); + expect(editButtons).toHaveLength(drilldowns.length); + fireEvent.click(editButtons[1]); + expect(fn).toBeCalledWith(drilldowns[1].id); +}); + +test('Emit onCreate() when clicking on create drilldown', () => { + const fn = jest.fn(); + const screen = render(); + fireEvent.click(screen.getByText('Create new')); + expect(fn).toBeCalled(); +}); + +test('Delete button is not visible when non is selected', () => { + const fn = jest.fn(); + const screen = render(); + expect(screen.queryByText(/Delete/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Create/i)).toBeInTheDocument(); +}); + +test('Can delete drilldowns', () => { + const fn = jest.fn(); + const screen = render(); + + const checkboxes = screen.getAllByLabelText(/Select this drilldown/i); + expect(checkboxes).toHaveLength(3); + + fireEvent.click(checkboxes[1]); + fireEvent.click(checkboxes[2]); + + expect(screen.queryByText(/Create/i)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText(/Delete \(2\)/i)); + + expect(fn).toBeCalledWith([drilldowns[1].id, drilldowns[2].id]); +}); diff --git a/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx new file mode 100644 index 0000000000000..5a15781a1faf2 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/components/list_manage_drilldowns/list_manage_drilldowns.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiBasicTable, + EuiBasicTableColumn, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiTextColor, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { + txtCreateDrilldown, + txtDeleteDrilldowns, + txtEditDrilldown, + txtSelectDrilldown, +} from './i18n'; + +export interface DrilldownListItem { + id: string; + actionName: string; + drilldownName: string; + icon?: string; +} + +export interface ListManageDrilldownsProps { + drilldowns: DrilldownListItem[]; + + onEdit?: (id: string) => void; + onCreate?: () => void; + onDelete?: (ids: string[]) => void; +} + +const noop = () => {}; + +export const TEST_SUBJ_DRILLDOWN_ITEM = 'list-manage-drilldowns-item'; + +export function ListManageDrilldowns({ + drilldowns, + onEdit = noop, + onCreate = noop, + onDelete = noop, +}: ListManageDrilldownsProps) { + const [selectedDrilldowns, setSelectedDrilldowns] = useState([]); + + const columns: Array> = [ + { + field: 'drilldownName', + name: 'Name', + truncateText: true, + width: '50%', + }, + { + name: 'Action', + render: (drilldown: DrilldownListItem) => ( + + {drilldown.icon && ( + + + + )} + + {drilldown.actionName} + + + ), + }, + { + align: 'right', + render: (drilldown: DrilldownListItem) => ( + onEdit(drilldown.id)}> + {txtEditDrilldown} + + ), + }, + ]; + + return ( + <> + { + setSelectedDrilldowns(selection.map(drilldown => drilldown.id)); + }, + selectableMessage: () => txtSelectDrilldown, + }} + rowProps={{ + 'data-test-subj': TEST_SUBJ_DRILLDOWN_ITEM, + }} + hasActions={true} + /> + + {selectedDrilldowns.length === 0 ? ( + onCreate()}> + {txtCreateDrilldown} + + ) : ( + onDelete(selectedDrilldowns)}> + {txtDeleteDrilldowns(selectedDrilldowns.length)} + + )} + + ); +} diff --git a/x-pack/plugins/drilldowns/public/index.ts b/x-pack/plugins/drilldowns/public/index.ts index 63e7a12235462..044e29c671de4 100644 --- a/x-pack/plugins/drilldowns/public/index.ts +++ b/x-pack/plugins/drilldowns/public/index.ts @@ -7,12 +7,14 @@ import { DrilldownsPlugin } from './plugin'; export { - DrilldownsSetupContract, - DrilldownsSetupDependencies, - DrilldownsStartContract, - DrilldownsStartDependencies, + SetupContract as DrilldownsSetup, + SetupDependencies as DrilldownsSetupDependencies, + StartContract as DrilldownsStart, + StartDependencies as DrilldownsStartDependencies, } from './plugin'; export function plugin() { return new DrilldownsPlugin(); } + +export { DrilldownDefinition } from './types'; diff --git a/x-pack/plugins/drilldowns/public/mocks.ts b/x-pack/plugins/drilldowns/public/mocks.ts index bfade1674072a..18816243a3572 100644 --- a/x-pack/plugins/drilldowns/public/mocks.ts +++ b/x-pack/plugins/drilldowns/public/mocks.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DrilldownsSetupContract, DrilldownsStartContract } from '.'; +import { DrilldownsSetup, DrilldownsStart } from '.'; -export type Setup = jest.Mocked; -export type Start = jest.Mocked; +export type Setup = jest.Mocked; +export type Start = jest.Mocked; const createSetupContract = (): Setup => { const setupContract: Setup = { @@ -17,12 +17,14 @@ const createSetupContract = (): Setup => { }; const createStartContract = (): Start => { - const startContract: Start = {}; + const startContract: Start = { + FlyoutManageDrilldowns: jest.fn(), + }; return startContract; }; -export const bfetchPluginMock = { +export const drilldownsPluginMock = { createSetupContract, createStartContract, }; diff --git a/x-pack/plugins/drilldowns/public/plugin.ts b/x-pack/plugins/drilldowns/public/plugin.ts index b89172541b91e..bbc06847d5842 100644 --- a/x-pack/plugins/drilldowns/public/plugin.ts +++ b/x-pack/plugins/drilldowns/public/plugin.ts @@ -6,52 +6,46 @@ import { CoreStart, CoreSetup, Plugin } from 'src/core/public'; import { UiActionsSetup, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; -import { DrilldownService } from './service'; -import { - FlyoutCreateDrilldownActionContext, - FlyoutEditDrilldownActionContext, - OPEN_FLYOUT_ADD_DRILLDOWN, - OPEN_FLYOUT_EDIT_DRILLDOWN, -} from './actions'; - -export interface DrilldownsSetupDependencies { +import { AdvancedUiActionsSetup, AdvancedUiActionsStart } from '../../advanced_ui_actions/public'; +import { DrilldownService, DrilldownServiceSetupContract } from './services'; +import { createFlyoutManageDrilldowns } from './components/connected_flyout_manage_drilldowns'; +import { Storage } from '../../../../src/plugins/kibana_utils/public'; + +export interface SetupDependencies { uiActions: UiActionsSetup; + advancedUiActions: AdvancedUiActionsSetup; } -export interface DrilldownsStartDependencies { +export interface StartDependencies { uiActions: UiActionsStart; + advancedUiActions: AdvancedUiActionsStart; } -export type DrilldownsSetupContract = Pick; +export type SetupContract = DrilldownServiceSetupContract; // eslint-disable-next-line -export interface DrilldownsStartContract {} - -declare module '../../../../src/plugins/ui_actions/public' { - export interface ActionContextMapping { - [OPEN_FLYOUT_ADD_DRILLDOWN]: FlyoutCreateDrilldownActionContext; - [OPEN_FLYOUT_EDIT_DRILLDOWN]: FlyoutEditDrilldownActionContext; - } +export interface StartContract { + FlyoutManageDrilldowns: ReturnType; } export class DrilldownsPlugin - implements - Plugin< - DrilldownsSetupContract, - DrilldownsStartContract, - DrilldownsSetupDependencies, - DrilldownsStartDependencies - > { + implements Plugin { private readonly service = new DrilldownService(); - public setup(core: CoreSetup, plugins: DrilldownsSetupDependencies): DrilldownsSetupContract { - this.service.bootstrap(core, plugins); + public setup(core: CoreSetup, plugins: SetupDependencies): SetupContract { + const setup = this.service.setup(core, plugins); - return this.service; + return setup; } - public start(core: CoreStart, plugins: DrilldownsStartDependencies): DrilldownsStartContract { - return {}; + public start(core: CoreStart, plugins: StartDependencies): StartContract { + return { + FlyoutManageDrilldowns: createFlyoutManageDrilldowns({ + advancedUiActions: plugins.advancedUiActions, + storage: new Storage(localStorage), + notifications: core.notifications, + }), + }; } public stop() {} diff --git a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts b/x-pack/plugins/drilldowns/public/service/drilldown_service.ts deleted file mode 100644 index 7745c30b4e335..0000000000000 --- a/x-pack/plugins/drilldowns/public/service/drilldown_service.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { CoreSetup } from 'src/core/public'; -// import { CONTEXT_MENU_TRIGGER } from '../../../../../src/plugins/embeddable/public'; -import { FlyoutCreateDrilldownAction, FlyoutEditDrilldownAction } from '../actions'; -import { DrilldownsSetupDependencies } from '../plugin'; - -export class DrilldownService { - bootstrap(core: CoreSetup, { uiActions }: DrilldownsSetupDependencies) { - const overlays = async () => (await core.getStartServices())[0].overlays; - - const actionFlyoutCreateDrilldown = new FlyoutCreateDrilldownAction({ overlays }); - uiActions.registerAction(actionFlyoutCreateDrilldown); - // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutCreateDrilldown); - - const actionFlyoutEditDrilldown = new FlyoutEditDrilldownAction({ overlays }); - uiActions.registerAction(actionFlyoutEditDrilldown); - // uiActions.attachAction(CONTEXT_MENU_TRIGGER, actionFlyoutEditDrilldown); - } - - /** - * Convenience method to register a drilldown. (It should set-up all the - * necessary triggers and actions.) - */ - registerDrilldown = (): void => { - throw new Error('not implemented'); - }; -} diff --git a/x-pack/plugins/drilldowns/public/services/drilldown_service.ts b/x-pack/plugins/drilldowns/public/services/drilldown_service.ts new file mode 100644 index 0000000000000..bfbe514d46095 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/services/drilldown_service.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'src/core/public'; +import { AdvancedUiActionsSetup } from '../../../advanced_ui_actions/public'; +import { DrilldownDefinition, DrilldownFactoryContext } from '../types'; +import { UiActionsActionFactoryDefinition as ActionFactoryDefinition } from '../../../../../src/plugins/ui_actions/public'; + +export interface DrilldownServiceSetupDeps { + advancedUiActions: AdvancedUiActionsSetup; +} + +export interface DrilldownServiceSetupContract { + /** + * Convenience method to register a drilldown. + */ + registerDrilldown: < + Config extends object = object, + CreationContext extends object = object, + ExecutionContext extends object = object + >( + drilldown: DrilldownDefinition + ) => void; +} + +export class DrilldownService { + setup( + core: CoreSetup, + { advancedUiActions }: DrilldownServiceSetupDeps + ): DrilldownServiceSetupContract { + const registerDrilldown = < + Config extends object = object, + CreationContext extends object = object, + ExecutionContext extends object = object + >({ + id: factoryId, + CollectConfig, + createConfig, + isConfigValid, + getDisplayName, + euiIcon, + execute, + }: DrilldownDefinition) => { + const actionFactory: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + > = { + id: factoryId, + CollectConfig, + createConfig, + isConfigValid, + getDisplayName, + getIconType: () => euiIcon, + isCompatible: async () => true, + create: serializedAction => ({ + id: '', + type: factoryId, + getIconType: () => euiIcon, + getDisplayName: () => serializedAction.name, + execute: async context => await execute(serializedAction.config, context), + }), + } as ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >; + + advancedUiActions.registerActionFactory(actionFactory); + }; + + return { + registerDrilldown, + }; + } +} diff --git a/x-pack/plugins/drilldowns/public/service/index.ts b/x-pack/plugins/drilldowns/public/services/index.ts similarity index 100% rename from x-pack/plugins/drilldowns/public/service/index.ts rename to x-pack/plugins/drilldowns/public/services/index.ts diff --git a/x-pack/plugins/drilldowns/public/types.ts b/x-pack/plugins/drilldowns/public/types.ts new file mode 100644 index 0000000000000..a8232887f9ca6 --- /dev/null +++ b/x-pack/plugins/drilldowns/public/types.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AdvancedUiActionsActionFactoryDefinition as ActionFactoryDefinition } from '../../advanced_ui_actions/public'; + +/** + * This is a convenience interface to register a drilldown. Drilldown has + * ability to collect configuration from user. Once drilldown is executed it + * receives the collected information together with the context of the + * user's interaction. + * + * `Config` is a serializable object containing the configuration that the + * drilldown is able to collect using UI. + * + * `PlaceContext` is an object that the app that opens drilldown management + * flyout provides to the React component, specifying the contextual information + * about that app. For example, on Dashboard app this context contains + * information about the current embeddable and dashboard. + * + * `ExecutionContext` is an object created in response to user's interaction + * and provided to the `execute` function of the drilldown. This object contains + * information about the action user performed. + */ +export interface DrilldownDefinition< + Config extends object = object, + PlaceContext extends object = object, + ExecutionContext extends object = object +> { + /** + * Globally unique identifier for this drilldown. + */ + id: string; + + /** + * Function that returns default config for this drilldown. + */ + createConfig: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >['createConfig']; + + /** + * `UiComponent` that collections config for this drilldown. You can create + * a React component and transform it `UiComponent` using `uiToReactComponent` + * helper from `kibana_utils` plugin. + * + * ```tsx + * import React from 'react'; + * import { uiToReactComponent } from 'src/plugins/kibana_utils'; + * import { UiActionsCollectConfigProps as CollectConfigProps } from 'src/plugins/ui_actions/public'; + * + * type Props = CollectConfigProps; + * + * const ReactCollectConfig: React.FC = () => { + * return
Collecting config...'
; + * }; + * + * export const CollectConfig = uiToReactComponent(ReactCollectConfig); + * ``` + */ + CollectConfig: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >['CollectConfig']; + + /** + * A validator function for the config object. Should always return a boolean + * given any input. + */ + isConfigValid: ActionFactoryDefinition< + Config, + DrilldownFactoryContext, + ExecutionContext + >['isConfigValid']; + + /** + * Name of EUI icon to display when showing this drilldown to user. + */ + euiIcon?: string; + + /** + * Should return an internationalized name of the drilldown, which will be + * displayed to the user. + */ + getDisplayName: () => string; + + /** + * Implements the "navigation" action of the drilldown. This happens when + * user clicks something in the UI that executes a trigger to which this + * drilldown was attached. + * + * @param config Config object that user configured this drilldown with. + * @param context Object that represents context in which the underlying + * `UIAction` of this drilldown is being executed in. + */ + execute(config: Config, context: ExecutionContext): void; +} + +/** + * Context object used when creating a drilldown. + */ +export interface DrilldownFactoryContext { + /** + * Context provided to the drilldown factory by the place where the UI is + * rendered. For example, for the "dashboard" place, this context contains + * the ID of the current dashboard, which could be used for filtering it out + * of the list. + */ + placeContext: T; + + /** + * List of triggers that user selected in the UI. + */ + triggers: string[]; +} diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index 08ba10ff69207..ac46d84469513 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -143,8 +143,7 @@ export class ReportingPublicPlugin implements Plugin { }, }); - uiActions.registerAction(action); - uiActions.attachAction(CONTEXT_MENU_TRIGGER, action); + uiActions.addTriggerAction(CONTEXT_MENU_TRIGGER, action); share.register(csvReportingProvider({ apiClient, toasts, license$ })); share.register( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index fe7ad863945c5..6290366e4b93f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -636,7 +636,6 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "一致するオブジェクトが見つかりませんでした。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} が追加されました", "embeddableApi.addPanel.Title": "パネルの追加", - "embeddableApi.customizePanel.action.displayName": "パネルをカスタマイズ", "embeddableApi.customizePanel.modal.cancel": "キャンセル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "パネルタイトル", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "パネルのカスタムタイトルを入力してください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index e1cfa5e4ef358..37527023d7208 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -636,7 +636,6 @@ "embeddableApi.addPanel.noMatchingObjectsMessage": "未找到任何匹配对象。", "embeddableApi.addPanel.savedObjectAddedToContainerSuccessMessageTitle": "{savedObjectName} 已添加", "embeddableApi.addPanel.Title": "添加面板", - "embeddableApi.customizePanel.action.displayName": "定制面板", "embeddableApi.customizePanel.modal.cancel": "取消", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleFormRowLabel": "面板标题", "embeddableApi.customizePanel.modal.optionsMenuForm.panelTitleInputAriaLabel": "为面板输入定制标题", From 66b5efd0841a3881ae73ca04882a6c17e43ad830 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Tue, 24 Mar 2020 08:46:11 +0100 Subject: [PATCH 31/34] [ML] Module setup with dynamic model memory estimation (#60656) * [ML] add estimateModelMemory to the setup endpoint * [ML] wip caching cardinality checks * [ML] refactor * [ML] fix a fallback time range * [ML] fix typing issue * [ML] fields_aggs_cache.ts as part of fields_service * [ML] fix types, add comments * [ML] check for MML overrides * [ML] disable estimateModelMemory * [ML] fix typing * [ML] check for empty max mml * [ML] refactor, update types, fix jobsForModelMemoryEstimation * [ML] fix override lookup * [ML] resolve nit comments * [ML] init jobsForModelMemoryEstimation --- x-pack/plugins/ml/common/types/modules.ts | 10 +- .../jobs/new_job/recognize/page.tsx | 1 + .../services/ml_api_service/index.ts | 3 + .../calculate_model_memory_limit.ts | 160 ++++++------- .../models/data_recognizer/data_recognizer.ts | 215 +++++++++++++----- .../fields_service/fields_aggs_cache.ts | 66 ++++++ .../models/fields_service/fields_service.ts | 128 +++++++---- x-pack/plugins/ml/server/routes/modules.ts | 40 ++-- .../ml/server/routes/schemas/modules.ts | 5 + .../shared_services/providers/modules.ts | 9 +- 10 files changed, 443 insertions(+), 194 deletions(-) create mode 100644 x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts diff --git a/x-pack/plugins/ml/common/types/modules.ts b/x-pack/plugins/ml/common/types/modules.ts index e61ff9972d601..b476762f6efca 100644 --- a/x-pack/plugins/ml/common/types/modules.ts +++ b/x-pack/plugins/ml/common/types/modules.ts @@ -90,8 +90,14 @@ export interface DataRecognizerConfigResponse { }; } -export type GeneralOverride = any; - export type JobOverride = Partial; +export type GeneralJobsOverride = Omit; +export type JobSpecificOverride = JobOverride & { job_id: Job['job_id'] }; + +export function isGeneralJobOverride(override: JobOverride): override is GeneralJobsOverride { + return override.job_id === undefined; +} + +export type GeneralDatafeedsOverride = Partial>; export type DatafeedOverride = Partial; diff --git a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx index 50c35ec426acb..9b76b9be9bf45 100644 --- a/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx +++ b/x-pack/plugins/ml/public/application/jobs/new_job/recognize/page.tsx @@ -172,6 +172,7 @@ export const Page: FC = ({ moduleId, existingGroupIds }) => { startDatafeed: startDatafeedAfterSave, ...(jobOverridesPayload !== null ? { jobOverrides: jobOverridesPayload } : {}), ...resultTimeRange, + estimateModelMemory: false, }); const { datafeeds: datafeedsResponse, jobs: jobsResponse, kibana: kibanaResponse } = response; diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index cd4a97bd10ed4..df59678452e2f 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -367,6 +367,7 @@ export const ml = { start, end, jobOverrides, + estimateModelMemory, }: { moduleId: string; prefix?: string; @@ -378,6 +379,7 @@ export const ml = { start?: number; end?: number; jobOverrides?: Array>; + estimateModelMemory?: boolean; }) { const body = JSON.stringify({ prefix, @@ -389,6 +391,7 @@ export const ml = { start, end, jobOverrides, + estimateModelMemory, }); return http({ diff --git a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts index c97bbe07fffda..cd61dd9eddcdd 100644 --- a/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts +++ b/x-pack/plugins/ml/server/models/calculate_model_memory_limit/calculate_model_memory_limit.ts @@ -6,6 +6,7 @@ import numeral from '@elastic/numeral'; import { APICaller } from 'kibana/server'; +import { MLCATEGORY } from '../../../common/constants/field_types'; import { AnalysisConfig } from '../../../common/types/anomaly_detection_jobs'; import { fieldsServiceProvider } from '../fields_service'; @@ -34,92 +35,96 @@ export interface ModelMemoryEstimate { /** * Retrieves overall and max bucket cardinalities. */ -async function getCardinalities( - callAsCurrentUser: APICaller, - analysisConfig: AnalysisConfig, - indexPattern: string, - query: any, - timeFieldName: string, - earliestMs: number, - latestMs: number -): Promise<{ - overallCardinality: { [key: string]: number }; - maxBucketCardinality: { [key: string]: number }; -}> { - /** - * Fields not involved in cardinality check - */ - const excludedKeywords = new Set( - /** - * The keyword which is used to mean the output of categorization, - * so it will have cardinality zero in the actual input data. - */ - 'mlcategory' - ); - +const cardinalityCheckProvider = (callAsCurrentUser: APICaller) => { const fieldsService = fieldsServiceProvider(callAsCurrentUser); - const { detectors, influencers, bucket_span: bucketSpan } = analysisConfig; - - let overallCardinality = {}; - let maxBucketCardinality = {}; - const overallCardinalityFields: Set = detectors.reduce( - ( - acc, - { - by_field_name: byFieldName, - partition_field_name: partitionFieldName, - over_field_name: overFieldName, - } - ) => { - [byFieldName, partitionFieldName, overFieldName] - .filter(field => field !== undefined && field !== '' && !excludedKeywords.has(field)) - .forEach(key => { - acc.add(key as string); - }); - return acc; - }, - new Set() - ); - - const maxBucketFieldCardinalities: string[] = influencers.filter( - influencerField => - typeof influencerField === 'string' && - !excludedKeywords.has(influencerField) && - !!influencerField && - !overallCardinalityFields.has(influencerField) - ) as string[]; - - if (overallCardinalityFields.size > 0) { - overallCardinality = await fieldsService.getCardinalityOfFields( - indexPattern, - [...overallCardinalityFields], - query, - timeFieldName, - earliestMs, - latestMs + return async ( + analysisConfig: AnalysisConfig, + indexPattern: string, + query: any, + timeFieldName: string, + earliestMs: number, + latestMs: number + ): Promise<{ + overallCardinality: { [key: string]: number }; + maxBucketCardinality: { [key: string]: number }; + }> => { + /** + * Fields not involved in cardinality check + */ + const excludedKeywords = new Set( + /** + * The keyword which is used to mean the output of categorization, + * so it will have cardinality zero in the actual input data. + */ + MLCATEGORY ); - } - if (maxBucketFieldCardinalities.length > 0) { - maxBucketCardinality = await fieldsService.getMaxBucketCardinalities( - indexPattern, - maxBucketFieldCardinalities, - query, - timeFieldName, - earliestMs, - latestMs, - bucketSpan + const { detectors, influencers, bucket_span: bucketSpan } = analysisConfig; + + let overallCardinality = {}; + let maxBucketCardinality = {}; + + // Get fields required for the model memory estimation + const overallCardinalityFields: Set = detectors.reduce( + ( + acc, + { + by_field_name: byFieldName, + partition_field_name: partitionFieldName, + over_field_name: overFieldName, + } + ) => { + [byFieldName, partitionFieldName, overFieldName] + .filter(field => field !== undefined && field !== '' && !excludedKeywords.has(field)) + .forEach(key => { + acc.add(key as string); + }); + return acc; + }, + new Set() ); - } - return { - overallCardinality, - maxBucketCardinality, + const maxBucketFieldCardinalities: string[] = influencers.filter( + influencerField => + !!influencerField && + !excludedKeywords.has(influencerField) && + !overallCardinalityFields.has(influencerField) + ) as string[]; + + if (overallCardinalityFields.size > 0) { + overallCardinality = await fieldsService.getCardinalityOfFields( + indexPattern, + [...overallCardinalityFields], + query, + timeFieldName, + earliestMs, + latestMs + ); + } + + if (maxBucketFieldCardinalities.length > 0) { + maxBucketCardinality = await fieldsService.getMaxBucketCardinalities( + indexPattern, + maxBucketFieldCardinalities, + query, + timeFieldName, + earliestMs, + latestMs, + bucketSpan + ); + } + + return { + overallCardinality, + maxBucketCardinality, + }; }; -} +}; export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) { + const getCardinalities = cardinalityCheckProvider(callAsCurrentUser); + /** * Retrieves an estimated size of the model memory limit used in the job config * based on the cardinality of the fields being used to split the data @@ -145,7 +150,6 @@ export function calculateModelMemoryLimitProvider(callAsCurrentUser: APICaller) } const { overallCardinality, maxBucketCardinality } = await getCardinalities( - callAsCurrentUser, analysisConfig, indexPattern, query, diff --git a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts index a54c2f22a7951..824f9cc57982c 100644 --- a/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts +++ b/x-pack/plugins/ml/server/models/data_recognizer/data_recognizer.ts @@ -7,10 +7,12 @@ import fs from 'fs'; import Boom from 'boom'; import numeral from '@elastic/numeral'; -import { CallAPIOptions, APICaller, SavedObjectsClientContract } from 'kibana/server'; +import { APICaller, SavedObjectsClientContract } from 'kibana/server'; +import moment from 'moment'; import { IndexPatternAttributes } from 'src/plugins/data/server'; import { merge } from 'lodash'; -import { CombinedJobWithStats } from '../../../common/types/anomaly_detection_jobs'; +import { AnalysisLimits, CombinedJobWithStats } from '../../../common/types/anomaly_detection_jobs'; +import { MlInfoResponse } from '../../../common/types/ml_server_info'; import { KibanaObjects, ModuleDataFeed, @@ -18,14 +20,19 @@ import { Module, JobOverride, DatafeedOverride, - GeneralOverride, + GeneralJobsOverride, DatafeedResponse, JobResponse, KibanaObjectResponse, DataRecognizerConfigResponse, + GeneralDatafeedsOverride, + JobSpecificOverride, + isGeneralJobOverride, } from '../../../common/types/modules'; import { getLatestDataOrBucketTimestamp, prefixDatafeedId } from '../../../common/util/job_utils'; import { mlLog } from '../../client/log'; +import { calculateModelMemoryLimitProvider } from '../calculate_model_memory_limit'; +import { fieldsServiceProvider } from '../fields_service'; import { jobServiceProvider } from '../job_service'; import { resultsServiceProvider } from '../results_service'; @@ -107,18 +114,15 @@ export class DataRecognizer { modulesDir = `${__dirname}/modules`; indexPatternName: string = ''; indexPatternId: string | undefined = undefined; - savedObjectsClient: SavedObjectsClientContract; + /** + * List of the module jobs that require model memory estimation + */ + jobsForModelMemoryEstimation: ModuleJob[] = []; - callAsCurrentUser: ( - endpoint: string, - clientParams?: Record, - options?: CallAPIOptions - ) => Promise; - - constructor(callAsCurrentUser: APICaller, savedObjectsClient: SavedObjectsClientContract) { - this.callAsCurrentUser = callAsCurrentUser; - this.savedObjectsClient = savedObjectsClient; - } + constructor( + private callAsCurrentUser: APICaller, + private savedObjectsClient: SavedObjectsClientContract + ) {} // list all directories under the given directory async listDirs(dirName: string): Promise { @@ -367,16 +371,17 @@ export class DataRecognizer { // if any of the savedObjects already exist, they will not be overwritten. async setupModuleItems( moduleId: string, - jobPrefix: string, - groups: string[], - indexPatternName: string, - query: any, - useDedicatedIndex: boolean, - startDatafeed: boolean, - start: number, - end: number, - jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + jobPrefix?: string, + groups?: string[], + indexPatternName?: string, + query?: any, + useDedicatedIndex?: boolean, + startDatafeed?: boolean, + start?: number, + end?: number, + jobOverrides?: JobOverride | JobOverride[], + datafeedOverrides?: DatafeedOverride | DatafeedOverride[], + estimateModelMemory?: boolean ) { // load the config from disk const moduleConfig = await this.getModule(moduleId, jobPrefix); @@ -418,11 +423,13 @@ export class DataRecognizer { savedObjects: [] as KibanaObjectResponse[], }; + this.jobsForModelMemoryEstimation = moduleConfig.jobs; + this.applyJobConfigOverrides(moduleConfig, jobOverrides, jobPrefix); this.applyDatafeedConfigOverrides(moduleConfig, datafeedOverrides, jobPrefix); this.updateDatafeedIndices(moduleConfig); this.updateJobUrlIndexPatterns(moduleConfig); - await this.updateModelMemoryLimits(moduleConfig); + await this.updateModelMemoryLimits(moduleConfig, estimateModelMemory, start, end); // create the jobs if (moduleConfig.jobs && moduleConfig.jobs.length) { @@ -689,8 +696,8 @@ export class DataRecognizer { async startDatafeeds( datafeeds: ModuleDataFeed[], - start: number, - end: number + start?: number, + end?: number ): Promise<{ [key: string]: DatafeedResponse }> { const results = {} as { [key: string]: DatafeedResponse }; for (const datafeed of datafeeds) { @@ -933,28 +940,117 @@ export class DataRecognizer { } } - // ensure the model memory limit for each job is not greater than - // the max model memory setting for the cluster - async updateModelMemoryLimits(moduleConfig: Module) { - const { limits } = await this.callAsCurrentUser('ml.info'); + /** + * Provides a time range of the last 3 months of data + */ + async getFallbackTimeRange( + timeField: string, + query?: any + ): Promise<{ start: number; end: number }> { + const fieldsService = fieldsServiceProvider(this.callAsCurrentUser); + + const timeFieldRange = await fieldsService.getTimeFieldRange( + this.indexPatternName, + timeField, + query + ); + + return { + start: timeFieldRange.end.epoch - moment.duration(3, 'months').asMilliseconds(), + end: timeFieldRange.end.epoch, + }; + } + + /** + * Ensure the model memory limit for each job is not greater than + * the max model memory setting for the cluster + */ + async updateModelMemoryLimits( + moduleConfig: Module, + estimateMML: boolean = false, + start?: number, + end?: number + ) { + if (!Array.isArray(moduleConfig.jobs)) { + return; + } + + if (estimateMML && this.jobsForModelMemoryEstimation.length > 0) { + const calculateModelMemoryLimit = calculateModelMemoryLimitProvider(this.callAsCurrentUser); + const query = moduleConfig.query ?? null; + + // Checks if all jobs in the module have the same time field configured + const isSameTimeFields = this.jobsForModelMemoryEstimation.every( + job => + job.config.data_description.time_field === + this.jobsForModelMemoryEstimation[0].config.data_description.time_field + ); + + if (isSameTimeFields && (start === undefined || end === undefined)) { + // In case of time range is not provided and the time field is the same + // set the fallback range for all jobs + const { start: fallbackStart, end: fallbackEnd } = await this.getFallbackTimeRange( + this.jobsForModelMemoryEstimation[0].config.data_description.time_field, + query + ); + start = fallbackStart; + end = fallbackEnd; + } + + for (const job of this.jobsForModelMemoryEstimation) { + let earliestMs = start; + let latestMs = end; + if (earliestMs === undefined || latestMs === undefined) { + const timeFieldRange = await this.getFallbackTimeRange( + job.config.data_description.time_field, + query + ); + earliestMs = timeFieldRange.start; + latestMs = timeFieldRange.end; + } + + const { modelMemoryLimit } = await calculateModelMemoryLimit( + job.config.analysis_config, + this.indexPatternName, + query, + job.config.data_description.time_field, + earliestMs, + latestMs + ); + + if (!job.config.analysis_limits) { + job.config.analysis_limits = {} as AnalysisLimits; + } + + job.config.analysis_limits.model_memory_limit = modelMemoryLimit; + } + } + + const { limits } = await this.callAsCurrentUser('ml.info'); const maxMml = limits.max_model_memory_limit; - if (maxMml !== undefined) { - // @ts-ignore - const maxBytes: number = numeral(maxMml.toUpperCase()).value(); - - if (Array.isArray(moduleConfig.jobs)) { - moduleConfig.jobs.forEach(job => { - const mml = job.config?.analysis_limits?.model_memory_limit; - if (mml !== undefined) { - // @ts-ignore - const mmlBytes: number = numeral(mml.toUpperCase()).value(); - if (mmlBytes > maxBytes) { - // if the job's mml is over the max, - // so set the jobs mml to be the max - job.config.analysis_limits!.model_memory_limit = maxMml; - } + + if (!maxMml) { + return; + } + + // @ts-ignore + const maxBytes: number = numeral(maxMml.toUpperCase()).value(); + + for (const job of moduleConfig.jobs) { + const mml = job.config?.analysis_limits?.model_memory_limit; + if (mml !== undefined) { + // @ts-ignore + const mmlBytes: number = numeral(mml.toUpperCase()).value(); + if (mmlBytes > maxBytes) { + // if the job's mml is over the max, + // so set the jobs mml to be the max + + if (!job.config.analysis_limits) { + job.config.analysis_limits = {} as AnalysisLimits; } - }); + + job.config.analysis_limits.model_memory_limit = maxMml; + } } } } @@ -975,7 +1071,11 @@ export class DataRecognizer { return false; } - applyJobConfigOverrides(moduleConfig: Module, jobOverrides: JobOverride[], jobPrefix = '') { + applyJobConfigOverrides( + moduleConfig: Module, + jobOverrides?: JobOverride | JobOverride[], + jobPrefix = '' + ) { if (jobOverrides === undefined || jobOverrides === null) { return; } @@ -993,17 +1093,26 @@ export class DataRecognizer { // separate all the overrides. // the overrides which don't contain a job id will be applied to all jobs in the module - const generalOverrides: GeneralOverride[] = []; - const jobSpecificOverrides: JobOverride[] = []; + const generalOverrides: GeneralJobsOverride[] = []; + const jobSpecificOverrides: JobSpecificOverride[] = []; overrides.forEach(override => { - if (override.job_id === undefined) { + if (isGeneralJobOverride(override)) { generalOverrides.push(override); } else { jobSpecificOverrides.push(override); } }); + if (generalOverrides.some(override => !!override.analysis_limits?.model_memory_limit)) { + this.jobsForModelMemoryEstimation = []; + } else { + this.jobsForModelMemoryEstimation = moduleConfig.jobs.filter(job => { + const override = jobSpecificOverrides.find(o => `${jobPrefix}${o.job_id}` === job.id); + return override?.analysis_limits?.model_memory_limit === undefined; + }); + } + function processArrayValues(source: any, update: any) { if (typeof source !== 'object' || typeof update !== 'object') { return; @@ -1052,7 +1161,7 @@ export class DataRecognizer { applyDatafeedConfigOverrides( moduleConfig: Module, - datafeedOverrides: DatafeedOverride | DatafeedOverride[], + datafeedOverrides?: DatafeedOverride | DatafeedOverride[], jobPrefix = '' ) { if (datafeedOverrides !== undefined && datafeedOverrides !== null) { @@ -1069,7 +1178,7 @@ export class DataRecognizer { // separate all the overrides. // the overrides which don't contain a datafeed id or a job id will be applied to all jobs in the module - const generalOverrides: GeneralOverride[] = []; + const generalOverrides: GeneralDatafeedsOverride[] = []; const datafeedSpecificOverrides: DatafeedOverride[] = []; overrides.forEach(o => { if (o.datafeed_id === undefined && o.job_id === undefined) { diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts b/x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts new file mode 100644 index 0000000000000..cdaefe6fdeed7 --- /dev/null +++ b/x-pack/plugins/ml/server/models/fields_service/fields_aggs_cache.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pick } from 'lodash'; + +/** + * Cached aggregation types + */ +type AggType = 'overallCardinality' | 'maxBucketCardinality'; + +type CacheStorage = { [key in AggType]: { [field: string]: number } }; + +/** + * Caches cardinality fields values to avoid + * unnecessary aggregations on elasticsearch + */ +export const initCardinalityFieldsCache = () => { + const cardinalityCache = new Map(); + + return { + /** + * Gets requested values from cache + */ + getValues( + indexPatternName: string | string[], + timeField: string, + earliestMs: number, + latestMs: number, + aggType: AggType, + fieldNames: string[] + ): CacheStorage[AggType] | null { + const cacheKey = indexPatternName + timeField + earliestMs + latestMs; + const cached = cardinalityCache.get(cacheKey); + if (!cached) { + return null; + } + return pick(cached[aggType], fieldNames); + }, + /** + * Extends cache with provided values + */ + updateValues( + indexPatternName: string | string[], + timeField: string, + earliestMs: number, + latestMs: number, + update: Partial + ): void { + const cacheKey = indexPatternName + timeField + earliestMs + latestMs; + const cachedValues = cardinalityCache.get(cacheKey); + if (cachedValues === undefined) { + cardinalityCache.set(cacheKey, { + overallCardinality: update.overallCardinality ?? {}, + maxBucketCardinality: update.maxBucketCardinality ?? {}, + }); + return; + } + + Object.assign(cachedValues.overallCardinality, update.overallCardinality); + Object.assign(cachedValues.maxBucketCardinality, update.maxBucketCardinality); + }, + }; +}; diff --git a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts index d16984abc5d2a..567c5d2afb7de 100644 --- a/x-pack/plugins/ml/server/models/fields_service/fields_service.ts +++ b/x-pack/plugins/ml/server/models/fields_service/fields_service.ts @@ -7,12 +7,15 @@ import Boom from 'boom'; import { APICaller } from 'kibana/server'; import { parseInterval } from '../../../common/util/parse_interval'; +import { initCardinalityFieldsCache } from './fields_aggs_cache'; /** * Service for carrying out queries to obtain data * specific to fields in Elasticsearch indices. */ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { + const fieldsAggsCache = initCardinalityFieldsCache(); + /** * Gets aggregatable fields. */ @@ -58,6 +61,23 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return {}; } + const cachedValues = + fieldsAggsCache.getValues( + index, + timeFieldName, + earliestMs, + latestMs, + 'overallCardinality', + fieldNames + ) ?? {}; + + // No need to perform aggregation over the cached fields + const fieldsToAgg = aggregatableFields.filter(field => !cachedValues.hasOwnProperty(field)); + + if (fieldsToAgg.length === 0) { + return cachedValues; + } + // Build the criteria to use in the bool filter part of the request. // Add criteria for the time range and the datafeed config query. const mustCriteria = [ @@ -76,7 +96,7 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { mustCriteria.push(query); } - const aggs = aggregatableFields.reduce((obj, field) => { + const aggs = fieldsToAgg.reduce((obj, field) => { obj[field] = { cardinality: { field } }; return obj; }, {} as { [field: string]: { cardinality: { field: string } } }); @@ -105,53 +125,63 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return {}; } - return aggregatableFields.reduce((obj, field) => { + const aggResult = fieldsToAgg.reduce((obj, field) => { obj[field] = (aggregations[field] || { value: 0 }).value; return obj; }, {} as { [field: string]: number }); + + fieldsAggsCache.updateValues(index, timeFieldName, earliestMs, latestMs, { + overallCardinality: aggResult, + }); + + return { + ...cachedValues, + ...aggResult, + }; } - function getTimeFieldRange( + /** + * Gets time boundaries of the index data based on the provided time field. + */ + async function getTimeFieldRange( index: string[] | string, timeFieldName: string, query: any - ): Promise { - return new Promise((resolve, reject) => { - const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; - - callAsCurrentUser('search', { - index, - size: 0, - body: { - query, - aggs: { - earliest: { - min: { - field: timeFieldName, - }, + ): Promise<{ + success: boolean; + start: { epoch: number; string: string }; + end: { epoch: number; string: string }; + }> { + const obj = { success: true, start: { epoch: 0, string: '' }, end: { epoch: 0, string: '' } }; + + const resp = await callAsCurrentUser('search', { + index, + size: 0, + body: { + ...(query ? { query } : {}), + aggs: { + earliest: { + min: { + field: timeFieldName, }, - latest: { - max: { - field: timeFieldName, - }, + }, + latest: { + max: { + field: timeFieldName, }, }, }, - }) - .then(resp => { - if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { - obj.start.epoch = resp.aggregations.earliest.value; - obj.start.string = resp.aggregations.earliest.value_as_string; - - obj.end.epoch = resp.aggregations.latest.value; - obj.end.string = resp.aggregations.latest.value_as_string; - } - resolve(obj); - }) - .catch(resp => { - reject(resp); - }); + }, }); + + if (resp.aggregations && resp.aggregations.earliest && resp.aggregations.latest) { + obj.start.epoch = resp.aggregations.earliest.value; + obj.start.string = resp.aggregations.earliest.value_as_string; + + obj.end.epoch = resp.aggregations.latest.value; + obj.end.string = resp.aggregations.latest.value_as_string; + } + return obj; } /** @@ -213,6 +243,23 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { return {}; } + const cachedValues = + fieldsAggsCache.getValues( + index, + timeFieldName, + earliestMs, + latestMs, + 'maxBucketCardinality', + fieldNames + ) ?? {}; + + // No need to perform aggregation over the cached fields + const fieldsToAgg = aggregatableFields.filter(field => !cachedValues.hasOwnProperty(field)); + + if (fieldsToAgg.length === 0) { + return cachedValues; + } + const { start, end } = getSafeTimeRange(earliestMs, latestMs, interval); const mustCriteria = [ @@ -238,7 +285,7 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { const getSafeAggName = (field: string) => field.replace(/\W/g, ''); const getMaxBucketAggKey = (field: string) => `max_bucket_${field}`; - const fieldsCardinalityAggs = aggregatableFields.reduce((obj, field) => { + const fieldsCardinalityAggs = fieldsToAgg.reduce((obj, field) => { obj[getSafeAggName(field)] = { cardinality: { field } }; return obj; }, {} as { [field: string]: { cardinality: { field: string } } }); @@ -279,13 +326,18 @@ export function fieldsServiceProvider(callAsCurrentUser: APICaller) { )?.aggregations; if (!aggregations) { - return {}; + return cachedValues; } - return aggregatableFields.reduce((obj, field) => { + const aggResult = fieldsToAgg.reduce((obj, field) => { obj[field] = (aggregations[getMaxBucketAggKey(field)] || { value: 0 }).value ?? 0; return obj; }, {} as { [field: string]: number }); + + return { + ...cachedValues, + ...aggResult, + }; } return { diff --git a/x-pack/plugins/ml/server/routes/modules.ts b/x-pack/plugins/ml/server/routes/modules.ts index 685119672a983..358cd0ac2871c 100644 --- a/x-pack/plugins/ml/server/routes/modules.ts +++ b/x-pack/plugins/ml/server/routes/modules.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema } from '@kbn/config-schema'; +import { schema, TypeOf } from '@kbn/config-schema'; import { RequestHandlerContext } from 'kibana/server'; import { DatafeedOverride, JobOverride } from '../../common/types/modules'; @@ -36,16 +36,17 @@ function getModule(context: RequestHandlerContext, moduleId: string) { function saveModuleItems( context: RequestHandlerContext, moduleId: string, - prefix: string, - groups: string[], - indexPatternName: string, - query: any, - useDedicatedIndex: boolean, - startDatafeed: boolean, - start: number, - end: number, - jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + prefix?: string, + groups?: string[], + indexPatternName?: string, + query?: any, + useDedicatedIndex?: boolean, + startDatafeed?: boolean, + start?: number, + end?: number, + jobOverrides?: JobOverride | JobOverride[], + datafeedOverrides?: DatafeedOverride | DatafeedOverride[], + estimateModelMemory?: boolean ) { const dr = new DataRecognizer( context.ml!.mlClient.callAsCurrentUser, @@ -62,7 +63,8 @@ function saveModuleItems( start, end, jobOverrides, - datafeedOverrides + datafeedOverrides, + estimateModelMemory ); } @@ -156,9 +158,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/modules/setup/{moduleId}', validate: { - params: schema.object({ - ...getModuleIdParamSchema(), - }), + params: schema.object(getModuleIdParamSchema()), body: setupModuleBodySchema, }, }, @@ -177,7 +177,8 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { end, jobOverrides, datafeedOverrides, - } = request.body; + estimateModelMemory, + } = request.body as TypeOf; const result = await saveModuleItems( context, @@ -191,7 +192,8 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { start, end, jobOverrides, - datafeedOverrides + datafeedOverrides, + estimateModelMemory ); return response.ok({ body: result }); @@ -214,9 +216,7 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { { path: '/api/ml/modules/jobs_exist/{moduleId}', validate: { - params: schema.object({ - ...getModuleIdParamSchema(), - }), + params: schema.object(getModuleIdParamSchema()), }, }, mlLicense.fullLicenseAPIGuard(async (context, request, response) => { diff --git a/x-pack/plugins/ml/server/routes/schemas/modules.ts b/x-pack/plugins/ml/server/routes/schemas/modules.ts index 46b7e53c22a05..98e3d80f0ff84 100644 --- a/x-pack/plugins/ml/server/routes/schemas/modules.ts +++ b/x-pack/plugins/ml/server/routes/schemas/modules.ts @@ -17,6 +17,11 @@ export const setupModuleBodySchema = schema.object({ end: schema.maybe(schema.number()), jobOverrides: schema.maybe(schema.any()), datafeedOverrides: schema.maybe(schema.any()), + /** + * Indicates whether an estimate of the model memory limit + * should be made by checking the cardinality of fields in the job configurations. + */ + estimateModelMemory: schema.maybe(schema.boolean()), }); export const getModuleIdParamSchema = (optional = false) => { diff --git a/x-pack/plugins/ml/server/shared_services/providers/modules.ts b/x-pack/plugins/ml/server/shared_services/providers/modules.ts index ffc977917ae46..ec876273c2c33 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/modules.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/modules.ts @@ -32,7 +32,8 @@ export interface ModulesProvider { start: number, end: number, jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + datafeedOverrides: DatafeedOverride[], + estimateModelMemory?: boolean ): Promise; }; } @@ -65,7 +66,8 @@ export function getModulesProvider(isFullLicense: LicenseCheck): ModulesProvider start: number, end: number, jobOverrides: JobOverride[], - datafeedOverrides: DatafeedOverride[] + datafeedOverrides: DatafeedOverride[], + estimateModelMemory?: boolean ) { const dr = dataRecognizerFactory(callAsCurrentUser, savedObjectsClient); return dr.setupModuleItems( @@ -79,7 +81,8 @@ export function getModulesProvider(isFullLicense: LicenseCheck): ModulesProvider start, end, jobOverrides, - datafeedOverrides + datafeedOverrides, + estimateModelMemory ); }, }; From 8f55a8edc79873bae3a0eb8b61d43f32adde94b3 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Tue, 24 Mar 2020 11:19:22 +0300 Subject: [PATCH 32/34] Migrated styles for "share" plugin to new platform (#59981) Co-authored-by: Elastic Machine --- src/legacy/ui/public/_index.scss | 1 - .../share => plugins/share/public/components}/_index.scss | 0 .../share/public/components}/_share_context_menu.scss | 0 src/plugins/share/public/index.scss | 1 + src/plugins/share/public/plugin.ts | 2 ++ 5 files changed, 3 insertions(+), 1 deletion(-) rename src/{legacy/ui/public/share => plugins/share/public/components}/_index.scss (100%) rename src/{legacy/ui/public/share => plugins/share/public/components}/_share_context_menu.scss (100%) create mode 100644 src/plugins/share/public/index.scss diff --git a/src/legacy/ui/public/_index.scss b/src/legacy/ui/public/_index.scss index 3c3067776a161..87006d9347de4 100644 --- a/src/legacy/ui/public/_index.scss +++ b/src/legacy/ui/public/_index.scss @@ -15,7 +15,6 @@ @import './error_url_overflow/index'; @import './exit_full_screen/index'; @import './field_editor/index'; -@import './share/index'; @import './style_compile/index'; @import '../../../plugins/management/public/components/index'; diff --git a/src/legacy/ui/public/share/_index.scss b/src/plugins/share/public/components/_index.scss similarity index 100% rename from src/legacy/ui/public/share/_index.scss rename to src/plugins/share/public/components/_index.scss diff --git a/src/legacy/ui/public/share/_share_context_menu.scss b/src/plugins/share/public/components/_share_context_menu.scss similarity index 100% rename from src/legacy/ui/public/share/_share_context_menu.scss rename to src/plugins/share/public/components/_share_context_menu.scss diff --git a/src/plugins/share/public/index.scss b/src/plugins/share/public/index.scss new file mode 100644 index 0000000000000..0271fbb8e9026 --- /dev/null +++ b/src/plugins/share/public/index.scss @@ -0,0 +1 @@ +@import './components/index' \ No newline at end of file diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index 5b638174b4dfb..d02f51af42905 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -17,6 +17,8 @@ * under the License. */ +import './index.scss'; + import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { ShareMenuManager, ShareMenuManagerStart } from './services'; import { ShareMenuRegistry, ShareMenuRegistrySetup } from './services'; From c26493d56c0b93c9baa7a30a0e1cb65b86f63636 Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Tue, 24 Mar 2020 11:19:42 +0300 Subject: [PATCH 33/34] [App Arch] migrate legacy CSS to new platform (core_plugins/kibana_react) (#59882) * Migrate markdown styles to the new platform * Removed unused import * Update index.ts * Removed not need layer * Fixed paths Co-authored-by: Elastic Machine --- .../np_ready/angular/context/query/actions.js | 2 +- src/legacy/core_plugins/kibana_react/index.ts | 41 ------------------- .../core_plugins/kibana_react/package.json | 4 -- .../kibana_react/public/index.scss | 3 -- .../core_plugins/kibana_react/public/index.ts | 20 --------- .../public/markdown_vis_controller.tsx | 2 +- .../components/vis_types/markdown/vis.js | 2 +- .../components/vis_types/timeseries/vis.js | 2 +- .../public/markdown/_markdown.scss | 0 .../kibana_react/public/markdown/index.scss} | 0 .../public/markdown/{index.ts => index.tsx} | 0 .../kibana_react/public/markdown/markdown.tsx | 1 + .../renderers/markdown/index.js | 2 +- .../license/logstash_license_service.js | 2 +- 14 files changed, 7 insertions(+), 74 deletions(-) delete mode 100644 src/legacy/core_plugins/kibana_react/index.ts delete mode 100644 src/legacy/core_plugins/kibana_react/package.json delete mode 100644 src/legacy/core_plugins/kibana_react/public/index.scss delete mode 100644 src/legacy/core_plugins/kibana_react/public/index.ts rename src/{legacy/core_plugins => plugins}/kibana_react/public/markdown/_markdown.scss (100%) rename src/{legacy/core_plugins/kibana_react/public/markdown/_index.scss => plugins/kibana_react/public/markdown/index.scss} (100%) rename src/plugins/kibana_react/public/markdown/{index.ts => index.tsx} (100%) diff --git a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js index 674f40d0186e5..9efddc5275069 100644 --- a/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js +++ b/src/legacy/core_plugins/kibana/public/discover/np_ready/angular/context/query/actions.js @@ -26,7 +26,7 @@ import { fetchAnchorProvider } from '../api/anchor'; import { fetchContextProvider } from '../api/context'; import { getQueryParameterActions } from '../query_parameters'; import { FAILURE_REASONS, LOADING_STATUS } from './constants'; -import { MarkdownSimple } from '../../../../../../../kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../../../plugins/kibana_react/public'; export function QueryActionsProvider(Promise) { const { filterManager, indexPatterns } = getServices(); diff --git a/src/legacy/core_plugins/kibana_react/index.ts b/src/legacy/core_plugins/kibana_react/index.ts deleted file mode 100644 index f4083f3d50c34..0000000000000 --- a/src/legacy/core_plugins/kibana_react/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { resolve } from 'path'; -import { Legacy } from '../../../../kibana'; - -// eslint-disable-next-line import/no-default-export -export default function DataPlugin(kibana: any) { - const config: Legacy.PluginSpecOptions = { - id: 'kibana_react', - require: [], - config: (Joi: any) => { - return Joi.object({ - enabled: Joi.boolean().default(true), - }).default(); - }, - init: (server: Legacy.Server) => ({}), - uiExports: { - injectDefaultVars: () => ({}), - styleSheetPaths: resolve(__dirname, 'public/index.scss'), - }, - }; - - return new kibana.Plugin(config); -} diff --git a/src/legacy/core_plugins/kibana_react/package.json b/src/legacy/core_plugins/kibana_react/package.json deleted file mode 100644 index 3f7cf717a1963..0000000000000 --- a/src/legacy/core_plugins/kibana_react/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "kibana_react", - "version": "kibana" -} diff --git a/src/legacy/core_plugins/kibana_react/public/index.scss b/src/legacy/core_plugins/kibana_react/public/index.scss deleted file mode 100644 index 14b4687c459e1..0000000000000 --- a/src/legacy/core_plugins/kibana_react/public/index.scss +++ /dev/null @@ -1,3 +0,0 @@ -@import 'src/legacy/ui/public/styles/styling_constants'; - -@import './markdown/index'; diff --git a/src/legacy/core_plugins/kibana_react/public/index.ts b/src/legacy/core_plugins/kibana_react/public/index.ts deleted file mode 100644 index a6a7cb72a8dee..0000000000000 --- a/src/legacy/core_plugins/kibana_react/public/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -export { Markdown, MarkdownSimple } from '../../../../plugins/kibana_react/public'; diff --git a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx index 4e77bb196b713..3260e9f7d8091 100644 --- a/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx +++ b/src/legacy/core_plugins/vis_type_markdown/public/markdown_vis_controller.tsx @@ -18,7 +18,7 @@ */ import React from 'react'; -import { Markdown } from '../../kibana_react/public'; +import { Markdown } from '../../../../plugins/kibana_react/public'; import { MarkdownVisParams } from './types'; interface MarkdownVisComponentProps extends MarkdownVisParams { diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js index a806339085450..d8bcf56b48cb9 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/markdown/vis.js @@ -21,7 +21,7 @@ import React from 'react'; import classNames from 'classnames'; import uuid from 'uuid'; import { get } from 'lodash'; -import { Markdown } from '../../../../../kibana_react/public'; +import { Markdown } from '../../../../../../../plugins/kibana_react/public'; import { ErrorComponent } from '../../error'; import { replaceVars } from '../../lib/replace_vars'; diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js index 356ba08ac2427..f559bc38b6c58 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/public/components/vis_types/timeseries/vis.js @@ -27,7 +27,7 @@ import { ScaleType } from '@elastic/charts'; import { createTickFormatter } from '../../lib/tick_formatter'; import { TimeSeries } from '../../../visualizations/views/timeseries'; -import { MarkdownSimple } from '../../../../../kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../plugins/kibana_react/public'; import { replaceVars } from '../../lib/replace_vars'; import { getAxisLabelString } from '../../lib/get_axis_label_string'; import { getInterval } from '../../lib/get_interval'; diff --git a/src/legacy/core_plugins/kibana_react/public/markdown/_markdown.scss b/src/plugins/kibana_react/public/markdown/_markdown.scss similarity index 100% rename from src/legacy/core_plugins/kibana_react/public/markdown/_markdown.scss rename to src/plugins/kibana_react/public/markdown/_markdown.scss diff --git a/src/legacy/core_plugins/kibana_react/public/markdown/_index.scss b/src/plugins/kibana_react/public/markdown/index.scss similarity index 100% rename from src/legacy/core_plugins/kibana_react/public/markdown/_index.scss rename to src/plugins/kibana_react/public/markdown/index.scss diff --git a/src/plugins/kibana_react/public/markdown/index.ts b/src/plugins/kibana_react/public/markdown/index.tsx similarity index 100% rename from src/plugins/kibana_react/public/markdown/index.ts rename to src/plugins/kibana_react/public/markdown/index.tsx diff --git a/src/plugins/kibana_react/public/markdown/markdown.tsx b/src/plugins/kibana_react/public/markdown/markdown.tsx index ba81b5e111cbd..a0c2cdad78c66 100644 --- a/src/plugins/kibana_react/public/markdown/markdown.tsx +++ b/src/plugins/kibana_react/public/markdown/markdown.tsx @@ -23,6 +23,7 @@ import MarkdownIt from 'markdown-it'; import { memoize } from 'lodash'; import { getSecureRelForTarget } from '@elastic/eui'; +import './index.scss'; /** * Return a memoized markdown rendering function that use the specified * whiteListedRules and openLinksInNewTab configurations. diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js index c1bfd7c99ac41..126699534caad 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/markdown/index.js @@ -7,7 +7,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { RendererStrings } from '../../../i18n'; -import { Markdown } from '../../../../../../../src/legacy/core_plugins/kibana_react/public'; +import { Markdown } from '../../../../../../../src/plugins/kibana_react/public'; const { markdown: strings } = RendererStrings; diff --git a/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js b/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js index 795899ff32f97..97b336ec0728b 100755 --- a/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js +++ b/x-pack/legacy/plugins/logstash/public/services/license/logstash_license_service.js @@ -6,7 +6,7 @@ import React from 'react'; import { toastNotifications } from 'ui/notify'; -import { MarkdownSimple } from '../../../../../../../src/legacy/core_plugins/kibana_react/public'; +import { MarkdownSimple } from '../../../../../../../src/plugins/kibana_react/public'; import { PLUGIN } from '../../../common/constants'; export class LogstashLicenseService { From 8ef35c8f8726278adbd8a354dc992f4bfd559262 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 24 Mar 2020 09:28:29 +0100 Subject: [PATCH 34/34] [APM] add service map config options to legacy plugin (#61002) --- x-pack/legacy/plugins/apm/index.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index 0107997f233fe..6f238b48d9465 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -71,7 +71,12 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(true) + serviceMapEnabled: Joi.boolean().default(true), + serviceMapFingerprintBucketSize: Joi.number().default(100), + serviceMapTraceIdBucketSize: Joi.number().default(65), + serviceMapFingerprintGlobalBucketSize: Joi.number().default(1000), + serviceMapTraceIdGlobalBucketSize: Joi.number().default(6), + serviceMapMaxTracesPerRequest: Joi.number().default(50) }).default(); },