From 6df1b28a82fa2f3bfd1bcc1a653a6234af3c7717 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Thu, 12 May 2022 13:36:53 +0200 Subject: [PATCH 01/46] [ML] Explain log rate spikes: Plugin setup (#131317) Sets up the boilerplate code for the aiops plugin and adds a demo page within the ML app to demonstrate single API request data streaming from Kibana server to UI client. --- .github/CODEOWNERS | 5 +- docs/developer/plugin-list.asciidoc | 4 + packages/kbn-optimizer/limits.yml | 1 + tsconfig.base.json | 2 + x-pack/.i18nrc.json | 1 + x-pack/plugins/aiops/README.md | 9 + .../aiops/common/api/example_stream.ts | 68 +++++++ x-pack/plugins/aiops/common/api/index.ts | 19 ++ x-pack/plugins/aiops/common/index.ts | 22 +++ x-pack/plugins/aiops/jest.config.js | 15 ++ x-pack/plugins/aiops/kibana.json | 16 ++ x-pack/plugins/aiops/public/api/index.ts | 15 ++ .../plugins/aiops/public/components/app.tsx | 167 ++++++++++++++++++ .../components/explain_log_rate_spikes.tsx | 34 ++++ .../public/components/get_status_message.tsx | 22 +++ .../aiops/public/components/stream_fetch.ts | 80 +++++++++ .../aiops/public/components/stream_reducer.ts | 86 +++++++++ .../components/use_stream_fetch_reducer.ts | 67 +++++++ x-pack/plugins/aiops/public/index.ts | 18 ++ .../plugins/aiops/public/kibana_services.ts | 19 ++ .../aiops/public/lazy_load_bundle/index.ts | 30 ++++ .../public/lazy_load_bundle/lazy/index.ts | 9 + x-pack/plugins/aiops/public/plugin.ts | 25 +++ x-pack/plugins/aiops/public/types.ts | 21 +++ x-pack/plugins/aiops/server/index.ts | 15 ++ x-pack/plugins/aiops/server/plugin.ts | 36 ++++ x-pack/plugins/aiops/server/routes/index.ts | 129 ++++++++++++++ x-pack/plugins/aiops/server/types.ts | 18 ++ x-pack/plugins/aiops/tsconfig.json | 28 +++ x-pack/plugins/ml/common/constants/locator.ts | 2 + x-pack/plugins/ml/common/types/locator.ts | 4 +- x-pack/plugins/ml/kibana.json | 1 + .../aiops/explain_log_rate_spikes.tsx | 49 +++++ .../ml/public/application/aiops/index.ts | 8 + x-pack/plugins/ml/public/application/app.tsx | 2 + .../components/ml_page/ml_page.tsx | 5 +- .../components/ml_page/side_nav.tsx | 25 ++- .../contexts/kibana/kibana_context.ts | 2 + .../public/application/routing/breadcrumbs.ts | 8 + .../ml/public/application/routing/router.tsx | 1 + .../routes/aiops/explain_log_rate_spikes.tsx | 63 +++++++ .../application/routing/routes/aiops/index.ts | 8 + .../application/routing/routes/index.ts | 1 + .../application/util/dependency_cache.ts | 4 + .../plugins/ml/public/locator/ml_locator.ts | 2 + x-pack/plugins/ml/public/plugin.ts | 3 + x-pack/plugins/ml/tsconfig.json | 1 + .../apis/aiops/example_stream.ts | 104 +++++++++++ .../test/api_integration/apis/aiops/index.ts | 16 ++ x-pack/test/api_integration/apis/index.ts | 1 + 50 files changed, 1286 insertions(+), 5 deletions(-) create mode 100755 x-pack/plugins/aiops/README.md create mode 100644 x-pack/plugins/aiops/common/api/example_stream.ts create mode 100644 x-pack/plugins/aiops/common/api/index.ts create mode 100755 x-pack/plugins/aiops/common/index.ts create mode 100644 x-pack/plugins/aiops/jest.config.js create mode 100755 x-pack/plugins/aiops/kibana.json create mode 100644 x-pack/plugins/aiops/public/api/index.ts create mode 100755 x-pack/plugins/aiops/public/components/app.tsx create mode 100644 x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx create mode 100644 x-pack/plugins/aiops/public/components/get_status_message.tsx create mode 100644 x-pack/plugins/aiops/public/components/stream_fetch.ts create mode 100644 x-pack/plugins/aiops/public/components/stream_reducer.ts create mode 100644 x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts create mode 100755 x-pack/plugins/aiops/public/index.ts create mode 100644 x-pack/plugins/aiops/public/kibana_services.ts create mode 100644 x-pack/plugins/aiops/public/lazy_load_bundle/index.ts create mode 100644 x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts create mode 100755 x-pack/plugins/aiops/public/plugin.ts create mode 100755 x-pack/plugins/aiops/public/types.ts create mode 100755 x-pack/plugins/aiops/server/index.ts create mode 100755 x-pack/plugins/aiops/server/plugin.ts create mode 100755 x-pack/plugins/aiops/server/routes/index.ts create mode 100755 x-pack/plugins/aiops/server/types.ts create mode 100644 x-pack/plugins/aiops/tsconfig.json create mode 100644 x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx create mode 100644 x-pack/plugins/ml/public/application/aiops/index.ts create mode 100644 x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx create mode 100644 x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts create mode 100644 x-pack/test/api_integration/apis/aiops/example_stream.ts create mode 100644 x-pack/test/api_integration/apis/aiops/index.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 450daeebd24d1..abd63289e0480 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -187,10 +187,11 @@ /x-pack/test/screenshot_creation/apps/ml_docs @elastic/ml-ui /x-pack/test/screenshot_creation/services/ml_screenshots.ts @elastic/ml-ui -# ML team owns and maintains the transform plugin despite it living in the Data management section. -/x-pack/plugins/transform/ @elastic/ml-ui +# Additional plugins maintained by the ML team. +/x-pack/plugins/aiops/ @elastic/ml-ui /x-pack/plugins/data_visualizer/ @elastic/ml-ui /x-pack/plugins/file_upload/ @elastic/ml-ui +/x-pack/plugins/transform/ @elastic/ml-ui /x-pack/test/accessibility/apps/transform.ts @elastic/ml-ui /x-pack/test/api_integration/apis/transform/ @elastic/ml-ui /x-pack/test/api_integration_basic/apis/transform/ @elastic/ml-ui diff --git a/docs/developer/plugin-list.asciidoc b/docs/developer/plugin-list.asciidoc index a91776fde65ba..0d2d69123b5f3 100644 --- a/docs/developer/plugin-list.asciidoc +++ b/docs/developer/plugin-list.asciidoc @@ -376,6 +376,10 @@ The plugin exposes the static DefaultEditorController class to consume. |The Kibana actions plugin provides a framework to create executable actions. You can: +|{kib-repo}blob/{branch}/x-pack/plugins/aiops/README.md[aiops] +|The plugin provides APIs and components for AIOps features, including the “Explain log rate spikes” UI, maintained by the ML team. + + |{kib-repo}blob/{branch}/x-pack/plugins/alerting/README.md[alerting] |The Kibana Alerting plugin provides a common place to set up rules. You can: diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 7fbe272e01bb0..c9460f7bab4ea 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -1,6 +1,7 @@ pageLoadAssetSize: advancedSettings: 27596 actions: 20000 + aiops: 10000 alerting: 106936 apm: 64385 canvas: 1066647 diff --git a/tsconfig.base.json b/tsconfig.base.json index 78023a603276a..daf7bf78903c1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -277,6 +277,8 @@ "@kbn/ui-actions-enhanced-examples-plugin/*": ["x-pack/examples/ui_actions_enhanced_examples/*"], "@kbn/actions-plugin": ["x-pack/plugins/actions"], "@kbn/actions-plugin/*": ["x-pack/plugins/actions/*"], + "@kbn/aiops-plugin": ["x-pack/plugins/aiops"], + "@kbn/aiops-plugin/*": ["x-pack/plugins/aiops/*"], "@kbn/alerting-plugin": ["x-pack/plugins/alerting"], "@kbn/alerting-plugin/*": ["x-pack/plugins/alerting/*"], "@kbn/apm-plugin": ["x-pack/plugins/apm"], diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index b1464f5cfbe2e..738c5242813be 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -37,6 +37,7 @@ "xpack.logstash": ["plugins/logstash"], "xpack.main": "legacy/plugins/xpack_main", "xpack.maps": ["plugins/maps"], + "xpack.aiops": ["plugins/aiops"], "xpack.ml": ["plugins/ml"], "xpack.monitoring": ["plugins/monitoring"], "xpack.osquery": ["plugins/osquery"], diff --git a/x-pack/plugins/aiops/README.md b/x-pack/plugins/aiops/README.md new file mode 100755 index 0000000000000..9bfd64f9bf3a3 --- /dev/null +++ b/x-pack/plugins/aiops/README.md @@ -0,0 +1,9 @@ +# aiops + +The plugin provides APIs and components for AIOps features, including the “Explain log rate spikes” UI, maintained by the ML team. + +--- + +## Development + +See the [kibana contributing guide](https://github.com/elastic/kibana/blob/main/CONTRIBUTING.md) for instructions setting up your development environment. diff --git a/x-pack/plugins/aiops/common/api/example_stream.ts b/x-pack/plugins/aiops/common/api/example_stream.ts new file mode 100644 index 0000000000000..1210cccf55487 --- /dev/null +++ b/x-pack/plugins/aiops/common/api/example_stream.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const aiopsExampleStreamSchema = schema.object({ + /** Boolean flag to enable/disabling simulation of response errors. */ + simulateErrors: schema.maybe(schema.boolean()), + /** Maximum timeout between streaming messages. */ + timeout: schema.maybe(schema.number()), +}); + +export type AiopsExampleStreamSchema = TypeOf; + +export const API_ACTION_NAME = { + UPDATE_PROGRESS: 'update_progress', + ADD_TO_ENTITY: 'add_to_entity', + DELETE_ENTITY: 'delete_entity', +} as const; +export type ApiActionName = typeof API_ACTION_NAME[keyof typeof API_ACTION_NAME]; + +interface ApiActionUpdateProgress { + type: typeof API_ACTION_NAME.UPDATE_PROGRESS; + payload: number; +} + +export function updateProgressAction(payload: number): ApiActionUpdateProgress { + return { + type: API_ACTION_NAME.UPDATE_PROGRESS, + payload, + }; +} + +interface ApiActionAddToEntity { + type: typeof API_ACTION_NAME.ADD_TO_ENTITY; + payload: { + entity: string; + value: number; + }; +} + +export function addToEntityAction(entity: string, value: number): ApiActionAddToEntity { + return { + type: API_ACTION_NAME.ADD_TO_ENTITY, + payload: { + entity, + value, + }, + }; +} + +interface ApiActionDeleteEntity { + type: typeof API_ACTION_NAME.DELETE_ENTITY; + payload: string; +} + +export function deleteEntityAction(payload: string): ApiActionDeleteEntity { + return { + type: API_ACTION_NAME.DELETE_ENTITY, + payload, + }; +} + +export type ApiAction = ApiActionUpdateProgress | ApiActionAddToEntity | ApiActionDeleteEntity; diff --git a/x-pack/plugins/aiops/common/api/index.ts b/x-pack/plugins/aiops/common/api/index.ts new file mode 100644 index 0000000000000..da1e091d3fb54 --- /dev/null +++ b/x-pack/plugins/aiops/common/api/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AiopsExampleStreamSchema } from './example_stream'; + +export const API_ENDPOINT = { + EXAMPLE_STREAM: '/internal/aiops/example_stream', + ANOTHER: '/internal/aiops/another', +} as const; +export type ApiEndpoint = typeof API_ENDPOINT[keyof typeof API_ENDPOINT]; + +export interface ApiEndpointOptions { + [API_ENDPOINT.EXAMPLE_STREAM]: AiopsExampleStreamSchema; + [API_ENDPOINT.ANOTHER]: { anotherOption: string }; +} diff --git a/x-pack/plugins/aiops/common/index.ts b/x-pack/plugins/aiops/common/index.ts new file mode 100755 index 0000000000000..0f4835d67ecc7 --- /dev/null +++ b/x-pack/plugins/aiops/common/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * PLUGIN_ID is used as a unique identifier for the aiops plugin + */ +export const PLUGIN_ID = 'aiops'; + +/** + * PLUGIN_NAME is used as the display name for the aiops plugin + */ +export const PLUGIN_NAME = 'AIOps'; + +/** + * This is an internal hard coded feature flag so we can easily turn on/off the + * "Explain log rate spikes UI" during development until the first release. + */ +export const AIOPS_ENABLED = true; diff --git a/x-pack/plugins/aiops/jest.config.js b/x-pack/plugins/aiops/jest.config.js new file mode 100644 index 0000000000000..4b92cb8dc86cb --- /dev/null +++ b/x-pack/plugins/aiops/jest.config.js @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +module.exports = { + preset: '@kbn/test', + rootDir: '../../..', + roots: ['/x-pack/plugins/aiops'], + coverageDirectory: '/target/kibana-coverage/jest/x-pack/plugins/aiops', + coverageReporters: ['text', 'html'], + collectCoverageFrom: ['/x-pack/plugins/aiops/{common,public,server}/**/*.{js,ts,tsx}'], +}; diff --git a/x-pack/plugins/aiops/kibana.json b/x-pack/plugins/aiops/kibana.json new file mode 100755 index 0000000000000..b74a23bf2bc9e --- /dev/null +++ b/x-pack/plugins/aiops/kibana.json @@ -0,0 +1,16 @@ +{ + "id": "aiops", + "version": "1.0.0", + "kibanaVersion": "kibana", + "owner": { + "name": "Machine Learning UI", + "githubTeam": "ml-ui" + }, + "description": "AIOps plugin maintained by ML team.", + "server": true, + "ui": true, + "requiredPlugins": [], + "optionalPlugins": [], + "requiredBundles": ["kibanaReact"], + "extraPublicDirs": ["common"] +} diff --git a/x-pack/plugins/aiops/public/api/index.ts b/x-pack/plugins/aiops/public/api/index.ts new file mode 100644 index 0000000000000..6aa171df5286c --- /dev/null +++ b/x-pack/plugins/aiops/public/api/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { lazyLoadModules } from '../lazy_load_bundle'; + +import type { ExplainLogRateSpikesSpec } from '../components/explain_log_rate_spikes'; + +export async function getExplainLogRateSpikesComponent(): Promise<() => ExplainLogRateSpikesSpec> { + const modules = await lazyLoadModules(); + return () => modules.ExplainLogRateSpikes; +} diff --git a/x-pack/plugins/aiops/public/components/app.tsx b/x-pack/plugins/aiops/public/components/app.tsx new file mode 100755 index 0000000000000..963253b154e27 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/app.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; + +import { Chart, Settings, Axis, BarSeries, Position, ScaleType } from '@elastic/charts'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import { + EuiBadge, + EuiButton, + EuiCheckbox, + EuiFlexGroup, + EuiFlexItem, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiPageContentHeader, + EuiProgress, + EuiSpacer, + EuiTitle, + EuiText, +} from '@elastic/eui'; + +import { getStatusMessage } from './get_status_message'; +import { initialState, resetStream, streamReducer } from './stream_reducer'; +import { useStreamFetchReducer } from './use_stream_fetch_reducer'; + +export const AiopsApp = () => { + const { notifications } = useKibana(); + + const [simulateErrors, setSimulateErrors] = useState(false); + + const { dispatch, start, cancel, data, isCancelled, isRunning } = useStreamFetchReducer( + '/internal/aiops/example_stream', + streamReducer, + initialState, + { simulateErrors } + ); + + const { errors, progress, entities } = data; + + const onClickHandler = async () => { + if (isRunning) { + cancel(); + } else { + dispatch(resetStream()); + start(); + } + }; + + useEffect(() => { + if (errors.length > 0) { + notifications.toasts.danger({ body: errors[errors.length - 1] }); + } + }, [errors, notifications.toasts]); + + const buttonLabel = isRunning + ? i18n.translate('xpack.aiops.stopbuttonText', { + defaultMessage: 'Stop development', + }) + : i18n.translate('xpack.aiops.startbuttonText', { + defaultMessage: 'Start development', + }); + + return ( + + + + + +

+ +

+
+
+ + + + + + {buttonLabel} + + + + + {progress}% + + + + + + + +
+ + + + + + { + return { + x, + y, + }; + }) + .sort((a, b) => b.y - a.y)} + /> + +
+

{getStatusMessage(isRunning, isCancelled, data.progress)}

+ setSimulateErrors(!simulateErrors)} + compressed + /> +
+
+
+
+
+ ); +}; diff --git a/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.tsx new file mode 100644 index 0000000000000..21d7b39a2a148 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/explain_log_rate_spikes.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; + +import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public'; +import { I18nProvider } from '@kbn/i18n-react'; + +import { getCoreStart } from '../kibana_services'; + +import { AiopsApp } from './app'; + +/** + * Spec used for lazy loading in the ML plugin + */ +export type ExplainLogRateSpikesSpec = typeof ExplainLogRateSpikes; + +export const ExplainLogRateSpikes: FC = () => { + const coreStart = getCoreStart(); + + return ( + + + + + + + + ); +}; diff --git a/x-pack/plugins/aiops/public/components/get_status_message.tsx b/x-pack/plugins/aiops/public/components/get_status_message.tsx new file mode 100644 index 0000000000000..e63748d03600a --- /dev/null +++ b/x-pack/plugins/aiops/public/components/get_status_message.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export function getStatusMessage(isRunning: boolean, isCancelled: boolean, progress: number) { + if (!isRunning && !isCancelled && progress === 0) { + return 'Development did not start yet.'; + } else if (isRunning && !isCancelled) { + return 'Development is ongoing, the hype is real!'; + } else if (!isRunning && isCancelled) { + return 'Oh no, development got cancelled!'; + } else if (!isRunning && progress === 100) { + return 'Development clompeted, the release got out the door!'; + } + + // When the process stops but wasn't cancelled by the user and progress is not yet at 100%, + // this indicates there must have been a problem with the stream. + return 'Oh no, looks like there was a bug?!'; +} diff --git a/x-pack/plugins/aiops/public/components/stream_fetch.ts b/x-pack/plugins/aiops/public/components/stream_fetch.ts new file mode 100644 index 0000000000000..37d7c13dd3b55 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/stream_fetch.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type React from 'react'; + +import type { ApiEndpoint, ApiEndpointOptions } from '../../common/api'; + +export async function* streamFetch( + endpoint: E, + abortCtrl: React.MutableRefObject, + options: ApiEndpointOptions[ApiEndpoint], + basePath = '' +) { + const stream = await fetch(`${basePath}${endpoint}`, { + signal: abortCtrl.current.signal, + method: 'POST', + headers: { + // This refers to the format of the request body, + // not the response, which will be a uint8array Buffer. + 'Content-Type': 'application/json', + 'kbn-xsrf': 'stream', + }, + body: JSON.stringify(options), + }); + + if (stream.body !== null) { + // Note that Firefox 99 doesn't support `TextDecoderStream` yet. + // That's why we skip it here and use `TextDecoder` later to decode each chunk. + // Once Firefox supports it, we can use the following alternative: + // const reader = stream.body.pipeThrough(new TextDecoderStream()).getReader(); + const reader = stream.body.getReader(); + + const bufferBounce = 100; + let partial = ''; + let actionBuffer: A[] = []; + let lastCall = 0; + + while (true) { + try { + const { value: uint8array, done } = await reader.read(); + if (done) break; + + const value = new TextDecoder().decode(uint8array); + + const full = `${partial}${value}`; + const parts = full.split('\n'); + const last = parts.pop(); + + partial = last ?? ''; + + const actions = parts.map((p) => JSON.parse(p)); + actionBuffer.push(...actions); + + const now = Date.now(); + + if (now - lastCall >= bufferBounce && actionBuffer.length > 0) { + yield actionBuffer; + actionBuffer = []; + lastCall = now; + } + } catch (error) { + if (error.name !== 'AbortError') { + yield { type: 'error', payload: error.toString() }; + } + break; + } + } + + // The reader might finish with a partially filled actionBuffer so + // we need to clear it once more after the request is done. + if (actionBuffer.length > 0) { + yield actionBuffer; + actionBuffer.length = 0; + } + } +} diff --git a/x-pack/plugins/aiops/public/components/stream_reducer.ts b/x-pack/plugins/aiops/public/components/stream_reducer.ts new file mode 100644 index 0000000000000..3e68e139ceeca --- /dev/null +++ b/x-pack/plugins/aiops/public/components/stream_reducer.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ApiAction, API_ACTION_NAME } from '../../common/api/example_stream'; + +export const UI_ACTION_NAME = { + ERROR: 'error', + RESET: 'reset', +} as const; +export type UiActionName = typeof UI_ACTION_NAME[keyof typeof UI_ACTION_NAME]; + +export interface StreamState { + errors: string[]; + progress: number; + entities: Record; +} +export const initialState: StreamState = { + errors: [], + progress: 0, + entities: {}, +}; + +interface UiActionError { + type: typeof UI_ACTION_NAME.ERROR; + payload: string; +} +interface UiActionResetStream { + type: typeof UI_ACTION_NAME.RESET; +} + +export function resetStream(): UiActionResetStream { + return { type: UI_ACTION_NAME.RESET }; +} + +type UiAction = UiActionResetStream | UiActionError; +export type ReducerAction = ApiAction | UiAction; +export function streamReducer( + state: StreamState, + action: ReducerAction | ReducerAction[] +): StreamState { + if (Array.isArray(action)) { + return action.reduce(streamReducer, state); + } + + switch (action.type) { + case API_ACTION_NAME.UPDATE_PROGRESS: + return { + ...state, + progress: action.payload, + }; + case API_ACTION_NAME.DELETE_ENTITY: + const deleteFromEntities = { ...state.entities }; + delete deleteFromEntities[action.payload]; + return { + ...state, + entities: deleteFromEntities, + }; + case API_ACTION_NAME.ADD_TO_ENTITY: + const addToEntities = { ...state.entities }; + if (addToEntities[action.payload.entity] === undefined) { + addToEntities[action.payload.entity] = action.payload.value; + } else { + addToEntities[action.payload.entity] += action.payload.value; + } + return { + ...state, + entities: addToEntities, + }; + case UI_ACTION_NAME.RESET: + return initialState; + case UI_ACTION_NAME.ERROR: + return { + ...state, + errors: [...state.errors, action.payload], + }; + default: + return { + ...state, + errors: [...state.errors, 'UNKNOWN_ACTION_ERROR'], + }; + } +} diff --git a/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts b/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts new file mode 100644 index 0000000000000..77ac09e0ff429 --- /dev/null +++ b/x-pack/plugins/aiops/public/components/use_stream_fetch_reducer.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useReducer, useRef, useState, Reducer, ReducerAction, ReducerState } from 'react'; + +import { useKibana } from '@kbn/kibana-react-plugin/public'; + +import type { ApiEndpoint, ApiEndpointOptions } from '../../common/api'; + +import { streamFetch } from './stream_fetch'; + +export const useStreamFetchReducer = , E = ApiEndpoint>( + endpoint: E, + reducer: R, + initialState: ReducerState, + options: ApiEndpointOptions[ApiEndpoint] +) => { + const kibana = useKibana(); + + const [isCancelled, setIsCancelled] = useState(false); + const [isRunning, setIsRunning] = useState(false); + + const [data, dispatch] = useReducer(reducer, initialState); + + const abortCtrl = useRef(new AbortController()); + + const start = async () => { + if (isRunning) { + throw new Error('Restart not supported yet'); + } + + setIsRunning(true); + setIsCancelled(false); + + abortCtrl.current = new AbortController(); + + for await (const actions of streamFetch( + endpoint, + abortCtrl, + options, + kibana.services.http?.basePath.get() + )) { + dispatch(actions as ReducerAction); + } + + setIsRunning(false); + }; + + const cancel = () => { + abortCtrl.current.abort(); + setIsCancelled(true); + setIsRunning(false); + }; + + return { + cancel, + data, + dispatch, + isCancelled, + isRunning, + start, + }; +}; diff --git a/x-pack/plugins/aiops/public/index.ts b/x-pack/plugins/aiops/public/index.ts new file mode 100755 index 0000000000000..30bcaf5afabdc --- /dev/null +++ b/x-pack/plugins/aiops/public/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { AiopsPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, Kibana Platform `plugin()` initializer. +export function plugin() { + return new AiopsPlugin(); +} + +export type { AiopsPluginSetup, AiopsPluginStart } from './types'; + +export type { ExplainLogRateSpikesSpec } from './components/explain_log_rate_spikes'; diff --git a/x-pack/plugins/aiops/public/kibana_services.ts b/x-pack/plugins/aiops/public/kibana_services.ts new file mode 100644 index 0000000000000..9a43d2de5e5a1 --- /dev/null +++ b/x-pack/plugins/aiops/public/kibana_services.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreStart } from '@kbn/core/public'; +import { AppPluginStartDependencies } from './types'; + +let coreStart: CoreStart; +let pluginsStart: AppPluginStartDependencies; +export function setStartServices(core: CoreStart, plugins: AppPluginStartDependencies) { + coreStart = core; + pluginsStart = plugins; +} + +export const getCoreStart = () => coreStart; +export const getPluginsStart = () => pluginsStart; diff --git a/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts b/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts new file mode 100644 index 0000000000000..0072336080175 --- /dev/null +++ b/x-pack/plugins/aiops/public/lazy_load_bundle/index.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ExplainLogRateSpikesSpec } from '../components/explain_log_rate_spikes'; + +let loadModulesPromise: Promise; + +interface LazyLoadedModules { + ExplainLogRateSpikes: ExplainLogRateSpikesSpec; +} + +export async function lazyLoadModules(): Promise { + if (typeof loadModulesPromise !== 'undefined') { + return loadModulesPromise; + } + + loadModulesPromise = new Promise(async (resolve, reject) => { + try { + const lazyImports = await import('./lazy'); + resolve({ ...lazyImports }); + } catch (error) { + reject(error); + } + }); + return loadModulesPromise; +} diff --git a/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.ts new file mode 100644 index 0000000000000..967525de9bd6e --- /dev/null +++ b/x-pack/plugins/aiops/public/lazy_load_bundle/lazy/index.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export type { ExplainLogRateSpikesSpec } from '../../components/explain_log_rate_spikes'; +export { ExplainLogRateSpikes } from '../../components/explain_log_rate_spikes'; diff --git a/x-pack/plugins/aiops/public/plugin.ts b/x-pack/plugins/aiops/public/plugin.ts new file mode 100755 index 0000000000000..3c3cff39abb80 --- /dev/null +++ b/x-pack/plugins/aiops/public/plugin.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CoreSetup, CoreStart, Plugin } from '@kbn/core/public'; + +import { getExplainLogRateSpikesComponent } from './api'; +import { setStartServices } from './kibana_services'; +import { AiopsPluginSetup, AiopsPluginStart } from './types'; + +export class AiopsPlugin implements Plugin { + public setup(core: CoreSetup) {} + + public start(core: CoreStart) { + setStartServices(core, {}); + return { + getExplainLogRateSpikesComponent, + }; + } + + public stop() {} +} diff --git a/x-pack/plugins/aiops/public/types.ts b/x-pack/plugins/aiops/public/types.ts new file mode 100755 index 0000000000000..fae18dc1d3106 --- /dev/null +++ b/x-pack/plugins/aiops/public/types.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AiopsPlugin } from './plugin'; + +/** + * aiops plugin public setup contract + */ +export type AiopsPluginSetup = ReturnType; + +/** + * aiops plugin public start contract + */ +export type AiopsPluginStart = ReturnType; + +// eslint-disable-next-line +export type AppPluginStartDependencies = {}; diff --git a/x-pack/plugins/aiops/server/index.ts b/x-pack/plugins/aiops/server/index.ts new file mode 100755 index 0000000000000..8dca6eb397d5e --- /dev/null +++ b/x-pack/plugins/aiops/server/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext } from '@kbn/core/server'; +import { AiopsPlugin } from './plugin'; + +export function plugin(initializerContext: PluginInitializerContext) { + return new AiopsPlugin(initializerContext); +} + +export type { AiopsPluginSetup, AiopsPluginStart } from './types'; diff --git a/x-pack/plugins/aiops/server/plugin.ts b/x-pack/plugins/aiops/server/plugin.ts new file mode 100755 index 0000000000000..c6b1b8b22a187 --- /dev/null +++ b/x-pack/plugins/aiops/server/plugin.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server'; + +import { AiopsPluginSetup, AiopsPluginStart } from './types'; +import { defineRoutes } from './routes'; + +export class AiopsPlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + this.logger.debug('aiops: Setup'); + const router = core.http.createRouter(); + + // Register server side APIs + defineRoutes(router, this.logger); + + return {}; + } + + public start(core: CoreStart) { + this.logger.debug('aiops: Started'); + return {}; + } + + public stop() {} +} diff --git a/x-pack/plugins/aiops/server/routes/index.ts b/x-pack/plugins/aiops/server/routes/index.ts new file mode 100755 index 0000000000000..e87c27e2af81e --- /dev/null +++ b/x-pack/plugins/aiops/server/routes/index.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Readable } from 'stream'; + +import type { IRouter, Logger } from '@kbn/core/server'; + +import { AIOPS_ENABLED } from '../../common'; +import type { ApiAction } from '../../common/api/example_stream'; +import { + aiopsExampleStreamSchema, + updateProgressAction, + addToEntityAction, + deleteEntityAction, +} from '../../common/api/example_stream'; + +// We need this otherwise Kibana server will crash with a 'ERR_METHOD_NOT_IMPLEMENTED' error. +class ResponseStream extends Readable { + _read(): void {} +} + +const delimiter = '\n'; + +export function defineRoutes(router: IRouter, logger: Logger) { + if (AIOPS_ENABLED) { + router.post( + { + path: '/internal/aiops/example_stream', + validate: { + body: aiopsExampleStreamSchema, + }, + }, + async (context, request, response) => { + const maxTimeoutMs = request.body.timeout ?? 250; + const simulateError = request.body.simulateErrors ?? false; + + let shouldStop = false; + request.events.aborted$.subscribe(() => { + shouldStop = true; + }); + request.events.completed$.subscribe(() => { + shouldStop = true; + }); + + const stream = new ResponseStream(); + + function streamPush(d: ApiAction) { + try { + const line = JSON.stringify(d); + stream.push(`${line}${delimiter}`); + } catch (error) { + logger.error('Could not serialize or stream a message.'); + logger.error(error); + } + } + + const entities = [ + 'kimchy', + 's1monw', + 'martijnvg', + 'jasontedor', + 'nik9000', + 'javanna', + 'rjernst', + 'jrodewig', + ]; + + const actions = [...Array(19).fill('add'), 'delete']; + + if (simulateError) { + actions.push('server-only-error'); + actions.push('server-to-client-error'); + actions.push('client-error'); + } + + let progress = 0; + + async function pushStreamUpdate() { + setTimeout(() => { + try { + progress++; + + if (progress > 100 || shouldStop) { + stream.push(null); + return; + } + + streamPush(updateProgressAction(progress)); + + const randomEntity = entities[Math.floor(Math.random() * entities.length)]; + const randomAction = actions[Math.floor(Math.random() * actions.length)]; + + if (randomAction === 'add') { + const randomCommits = Math.floor(Math.random() * 100); + streamPush(addToEntityAction(randomEntity, randomCommits)); + } else if (randomAction === 'delete') { + streamPush(deleteEntityAction(randomEntity)); + } else if (randomAction === 'server-to-client-error') { + // Throw an error. It should not crash Kibana! + throw new Error('There was a (simulated) server side error!'); + } else if (randomAction === 'client-error') { + // Return not properly encoded JSON to the client. + stream.push(`{body:'Not valid JSON${delimiter}`); + } + + pushStreamUpdate(); + } catch (error) { + stream.push( + `${JSON.stringify({ type: 'error', payload: error.toString() })}${delimiter}` + ); + stream.push(null); + } + }, Math.floor(Math.random() * maxTimeoutMs)); + } + + // do not call this using `await` so it will run asynchronously while we return the stream already. + pushStreamUpdate(); + + return response.ok({ + body: stream, + }); + } + ); + } +} diff --git a/x-pack/plugins/aiops/server/types.ts b/x-pack/plugins/aiops/server/types.ts new file mode 100755 index 0000000000000..526e7280e9495 --- /dev/null +++ b/x-pack/plugins/aiops/server/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * aiops plugin server setup contract + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AiopsPluginSetup {} + +/** + * aiops plugin server start contract + */ +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface AiopsPluginStart {} diff --git a/x-pack/plugins/aiops/tsconfig.json b/x-pack/plugins/aiops/tsconfig.json new file mode 100644 index 0000000000000..2545c0e21ed03 --- /dev/null +++ b/x-pack/plugins/aiops/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types", + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true + }, + "include": [ + "../../../typings/**/*", + "common/**/*", + "public/**/*", + "scripts/**/*", + "server/**/*", + "types/**/*" + ], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_utils/tsconfig.json" }, + { "path": "../../../src/plugins/kibana_react/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/usage_collection/tsconfig.json" }, + { "path": "../../../src/plugins/custom_integrations/tsconfig.json" }, + { "path": "../../../src/plugins/navigation/tsconfig.json" }, + { "path": "../../../src/plugins/unified_search/tsconfig.json" }, + { "path": "../security/tsconfig.json" }, + ] +} diff --git a/x-pack/plugins/ml/common/constants/locator.ts b/x-pack/plugins/ml/common/constants/locator.ts index 0a1c2638e684a..0c19c5b59766c 100644 --- a/x-pack/plugins/ml/common/constants/locator.ts +++ b/x-pack/plugins/ml/common/constants/locator.ts @@ -51,6 +51,8 @@ export const ML_PAGES = { FILTER_LISTS_EDIT: 'settings/filter_lists/edit_filter_list', ACCESS_DENIED: 'access-denied', OVERVIEW: 'overview', + AIOPS: 'aiops', + AIOPS_EXPLAIN_LOG_RATE_SPIKES: 'aiops/explain_log_rate_spikes', } as const; export type MlPages = typeof ML_PAGES[keyof typeof ML_PAGES]; diff --git a/x-pack/plugins/ml/common/types/locator.ts b/x-pack/plugins/ml/common/types/locator.ts index 33ec94b825303..a440aaa349bcc 100644 --- a/x-pack/plugins/ml/common/types/locator.ts +++ b/x-pack/plugins/ml/common/types/locator.ts @@ -60,7 +60,9 @@ export type MlGenericUrlState = MLPageState< | typeof ML_PAGES.ACCESS_DENIED | typeof ML_PAGES.DATA_VISUALIZER | typeof ML_PAGES.DATA_VISUALIZER_FILE - | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT, + | typeof ML_PAGES.DATA_VISUALIZER_INDEX_SELECT + | typeof ML_PAGES.AIOPS + | typeof ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES, MlGenericUrlPageState | undefined >; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index eb00ca117f01a..f62cec0ec0fca 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -7,6 +7,7 @@ "ml" ], "requiredPlugins": [ + "aiops", "cloud", "data", "dataViews", diff --git a/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx new file mode 100644 index 0000000000000..473525d40ca9a --- /dev/null +++ b/x-pack/plugins/ml/public/application/aiops/explain_log_rate_spikes.tsx @@ -0,0 +1,49 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import type { ExplainLogRateSpikesSpec } from '@kbn/aiops-plugin/public'; +import { useMlKibana, useTimefilter } from '../contexts/kibana'; +import { HelpMenu } from '../components/help_menu'; + +import { MlPageHeader } from '../components/page_header'; + +export const ExplainLogRateSpikesPage: FC = () => { + useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false }); + const { + services: { docLinks, aiops }, + } = useMlKibana(); + + const [ExplainLogRateSpikes, setExplainLogRateSpikes] = useState( + null + ); + + useEffect(() => { + if (aiops !== undefined) { + const { getExplainLogRateSpikesComponent } = aiops; + getExplainLogRateSpikesComponent().then(setExplainLogRateSpikes); + } + }, []); + + return ( + <> + {ExplainLogRateSpikes !== null ? ( + <> + + + + + + ) : null} + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/aiops/index.ts b/x-pack/plugins/ml/public/application/aiops/index.ts new file mode 100644 index 0000000000000..fa47ae09822e2 --- /dev/null +++ b/x-pack/plugins/ml/public/application/aiops/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ExplainLogRateSpikesPage } from './explain_log_rate_spikes'; diff --git a/x-pack/plugins/ml/public/application/app.tsx b/x-pack/plugins/ml/public/application/app.tsx index 833a4fade128b..50417aafab9b6 100644 --- a/x-pack/plugins/ml/public/application/app.tsx +++ b/x-pack/plugins/ml/public/application/app.tsx @@ -82,6 +82,7 @@ const App: FC = ({ coreStart, deps, appMountParams }) => { maps: deps.maps, triggersActionsUi: deps.triggersActionsUi, dataVisualizer: deps.dataVisualizer, + aiops: deps.aiops, usageCollection: deps.usageCollection, fieldFormats: deps.fieldFormats, dashboard: deps.dashboard, @@ -135,6 +136,7 @@ export const renderApp = ( dashboard: deps.dashboard, maps: deps.maps, dataVisualizer: deps.dataVisualizer, + aiops: deps.aiops, dataViews: deps.data.dataViews, }); diff --git a/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx b/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx index 301939fb6fdbc..d41ca59255467 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/ml_page.tsx @@ -71,7 +71,10 @@ export const MlPage: FC<{ pageDeps: PageDependencies }> = React.memo(({ pageDeps ); const routeList = useMemo( - () => Object.values(routes).map((routeFactory) => routeFactory(navigateToPath, basePath.get())), + () => + Object.values(routes) + .map((routeFactory) => routeFactory(navigateToPath, basePath.get())) + .filter((d) => !d.disabled), [] ); diff --git a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx index e5c67de96f494..84474e85330d6 100644 --- a/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx +++ b/x-pack/plugins/ml/public/application/components/ml_page/side_nav.tsx @@ -8,6 +8,7 @@ import { i18n } from '@kbn/i18n'; import type { EuiSideNavItemType } from '@elastic/eui'; import { useCallback, useMemo } from 'react'; +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; import type { MlLocatorParams } from '../../../../common/types/locator'; import { useUrlState } from '../../util/url_state'; import { useMlLocator, useNavigateToPath } from '../../contexts/kibana'; @@ -64,7 +65,7 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { const tabsDefinition: Tab[] = useMemo((): Tab[] => { const disableLinks = mlFeaturesDisabled; - return [ + const mlTabs: Tab[] = [ { id: 'main_section', name: '', @@ -218,6 +219,28 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) { ], }, ]; + + if (AIOPS_ENABLED) { + mlTabs.push({ + id: 'aiops_section', + name: i18n.translate('xpack.ml.navMenu.aiopsTabLinkText', { + defaultMessage: 'AIOps', + }), + items: [ + { + id: 'explainlogratespikes', + pathId: ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES, + name: i18n.translate('xpack.ml.navMenu.explainLogRateSpikesLinkText', { + defaultMessage: 'Explain log rate spikes', + }), + disabled: disableLinks, + testSubj: 'mlMainTab explainLogRateSpikes', + }, + ], + }); + } + + return mlTabs; }, [mlFeaturesDisabled, canViewMlNodes]); const getTabItem: (tab: Tab) => EuiSideNavItemType = useCallback( diff --git a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts index bfb27e6d4dbbc..fdfcd9106e8e0 100644 --- a/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts +++ b/x-pack/plugins/ml/public/application/contexts/kibana/kibana_context.ts @@ -16,6 +16,7 @@ import type { IStorageWrapper } from '@kbn/kibana-utils-plugin/public'; import type { EmbeddableStart } from '@kbn/embeddable-plugin/public'; import type { MapsStartApi } from '@kbn/maps-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; +import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; import type { TriggersAndActionsUIPublicPluginStart } from '@kbn/triggers-actions-ui-plugin/public'; import type { FieldFormatsRegistry } from '@kbn/field-formats-plugin/common'; import type { DashboardSetup } from '@kbn/dashboard-plugin/public'; @@ -32,6 +33,7 @@ interface StartPlugins { maps?: MapsStartApi; triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; dataVisualizer?: DataVisualizerPluginStart; + aiops?: AiopsPluginStart; usageCollection?: UsageCollectionSetup; fieldFormats: FieldFormatsRegistry; dashboard: DashboardSetup; diff --git a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts index e563831d16376..54aedb4a71857 100644 --- a/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts +++ b/x-pack/plugins/ml/public/application/routing/breadcrumbs.ts @@ -55,6 +55,13 @@ export const DATA_VISUALIZER_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ href: '/datavisualizer', }); +export const AIOPS_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ + text: i18n.translate('xpack.ml.aiopsBreadcrumbLabel', { + defaultMessage: 'AIOps', + }), + href: '/aiops', +}); + export const CREATE_JOB_BREADCRUMB: ChromeBreadcrumb = Object.freeze({ text: i18n.translate('xpack.ml.createJobsBreadcrumbLabel', { defaultMessage: 'Create job', @@ -83,6 +90,7 @@ const breadcrumbs = { DATA_FRAME_ANALYTICS_BREADCRUMB, TRAINED_MODELS, DATA_VISUALIZER_BREADCRUMB, + AIOPS_BREADCRUMB, CREATE_JOB_BREADCRUMB, CALENDAR_MANAGEMENT_BREADCRUMB, FILTER_LISTS_BREADCRUMB, diff --git a/x-pack/plugins/ml/public/application/routing/router.tsx b/x-pack/plugins/ml/public/application/routing/router.tsx index e4e7daa9ee0e1..a761bce2ce38a 100644 --- a/x-pack/plugins/ml/public/application/routing/router.tsx +++ b/x-pack/plugins/ml/public/application/routing/router.tsx @@ -48,6 +48,7 @@ export interface MlRoute { enableDatePicker?: boolean; 'data-test-subj'?: string; actionMenu?: React.ReactNode; + disabled?: boolean; } export interface PageProps { diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.tsx new file mode 100644 index 0000000000000..ca670df258a6a --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/explain_log_rate_spikes.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FC } from 'react'; +import { parse } from 'query-string'; + +import { i18n } from '@kbn/i18n'; + +import { AIOPS_ENABLED } from '@kbn/aiops-plugin/common'; + +import { NavigateToPath } from '../../../contexts/kibana'; + +import { MlRoute, PageLoader, PageProps } from '../../router'; +import { useResolver } from '../../use_resolver'; +import { ExplainLogRateSpikesPage as Page } from '../../../aiops/explain_log_rate_spikes'; + +import { checkBasicLicense } from '../../../license'; +import { checkGetJobsCapabilitiesResolver } from '../../../capabilities/check_capabilities'; +import { cacheDataViewsContract } from '../../../util/index_utils'; +import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs'; + +export const explainLogRateSpikesRouteFactory = ( + navigateToPath: NavigateToPath, + basePath: string +): MlRoute => ({ + id: 'explain_log_rate_spikes', + path: '/aiops/explain_log_rate_spikes', + title: i18n.translate('xpack.ml.aiops.explainLogRateSpikes.docTitle', { + defaultMessage: 'Explain log rate spikes', + }), + render: (props, deps) => , + breadcrumbs: [ + getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath), + getBreadcrumbWithUrlForApp('AIOPS_BREADCRUMB', navigateToPath, basePath), + { + text: i18n.translate('xpack.ml.AiopsBreadcrumbs.explainLogRateSpikesLabel', { + defaultMessage: 'Explain log rate spikes', + }), + }, + ], + disabled: !AIOPS_ENABLED, +}); + +const PageWrapper: FC = ({ location, deps }) => { + const { redirectToMlAccessDeniedPage } = deps; + + const { index, savedSearchId }: Record = parse(location.search, { sort: false }); + const { context } = useResolver(index, savedSearchId, deps.config, deps.dataViewsContract, { + checkBasicLicense, + cacheDataViewsContract: () => cacheDataViewsContract(deps.dataViewsContract), + checkGetJobsCapabilities: () => checkGetJobsCapabilitiesResolver(redirectToMlAccessDeniedPage), + }); + + return ( + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts b/x-pack/plugins/ml/public/application/routing/routes/aiops/index.ts new file mode 100644 index 0000000000000..f2b192a4cd097 --- /dev/null +++ b/x-pack/plugins/ml/public/application/routing/routes/aiops/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export * from './explain_log_rate_spikes'; diff --git a/x-pack/plugins/ml/public/application/routing/routes/index.ts b/x-pack/plugins/ml/public/application/routing/routes/index.ts index 31a8d863e3086..12ddc39e0e23e 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/index.ts +++ b/x-pack/plugins/ml/public/application/routing/routes/index.ts @@ -11,6 +11,7 @@ export * from './new_job'; export * from './datavisualizer'; export * from './settings'; export * from './data_frame_analytics'; +export * from './aiops'; export { timeSeriesExplorerRouteFactory } from './timeseriesexplorer'; export * from './explorer'; export * from './access_denied'; diff --git a/x-pack/plugins/ml/public/application/util/dependency_cache.ts b/x-pack/plugins/ml/public/application/util/dependency_cache.ts index 3680f8b63b0c9..00895cdb3990e 100644 --- a/x-pack/plugins/ml/public/application/util/dependency_cache.ts +++ b/x-pack/plugins/ml/public/application/util/dependency_cache.ts @@ -27,6 +27,7 @@ import type { DataViewsContract } from '@kbn/data-views-plugin/public'; import type { SecurityPluginSetup } from '@kbn/security-plugin/public'; import type { MapsStartApi } from '@kbn/maps-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; +import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; export interface DependencyCache { timefilter: DataPublicPluginSetup['query']['timefilter'] | null; @@ -48,6 +49,7 @@ export interface DependencyCache { dashboard: DashboardStart | null; maps: MapsStartApi | null; dataVisualizer: DataVisualizerPluginStart | null; + aiops: AiopsPluginStart | null; dataViews: DataViewsContract | null; } @@ -71,6 +73,7 @@ const cache: DependencyCache = { dashboard: null, maps: null, dataVisualizer: null, + aiops: null, dataViews: null, }; @@ -93,6 +96,7 @@ export function setDependencyCache(deps: Partial) { cache.i18n = deps.i18n || null; cache.dashboard = deps.dashboard || null; cache.dataVisualizer = deps.dataVisualizer || null; + cache.aiops = deps.aiops || null; cache.dataViews = deps.dataViews || null; } diff --git a/x-pack/plugins/ml/public/locator/ml_locator.ts b/x-pack/plugins/ml/public/locator/ml_locator.ts index b1ea2549c3347..01d63aa0ebf3f 100644 --- a/x-pack/plugins/ml/public/locator/ml_locator.ts +++ b/x-pack/plugins/ml/public/locator/ml_locator.ts @@ -83,6 +83,8 @@ export class MlLocatorDefinition implements LocatorDefinition { case ML_PAGES.DATA_VISUALIZER_FILE: case ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER: case ML_PAGES.DATA_VISUALIZER_INDEX_SELECT: + case ML_PAGES.AIOPS: + case ML_PAGES.AIOPS_EXPLAIN_LOG_RATE_SPIKES: case ML_PAGES.OVERVIEW: case ML_PAGES.SETTINGS: case ML_PAGES.FILTER_LISTS_MANAGE: diff --git a/x-pack/plugins/ml/public/plugin.ts b/x-pack/plugins/ml/public/plugin.ts index 1ef7c73d2189a..79f386d521da1 100644 --- a/x-pack/plugins/ml/public/plugin.ts +++ b/x-pack/plugins/ml/public/plugin.ts @@ -37,6 +37,7 @@ import { TriggersAndActionsUIPublicPluginStart, } from '@kbn/triggers-actions-ui-plugin/public'; import type { DataVisualizerPluginStart } from '@kbn/data-visualizer-plugin/public'; +import type { AiopsPluginStart } from '@kbn/aiops-plugin/public'; import type { PluginSetupContract as AlertingSetup } from '@kbn/alerting-plugin/public'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import type { FieldFormatsSetup, FieldFormatsStart } from '@kbn/field-formats-plugin/public'; @@ -59,6 +60,7 @@ export interface MlStartDependencies { maps?: MapsStartApi; triggersActionsUi?: TriggersAndActionsUIPublicPluginStart; dataVisualizer: DataVisualizerPluginStart; + aiops: AiopsPluginStart; fieldFormats: FieldFormatsStart; dashboard: DashboardStart; charts: ChartsPluginStart; @@ -125,6 +127,7 @@ export class MlPlugin implements Plugin { kibanaVersion, triggersActionsUi: pluginsStart.triggersActionsUi, dataVisualizer: pluginsStart.dataVisualizer, + aiops: pluginsStart.aiops, usageCollection: pluginsSetup.usageCollection, fieldFormats: pluginsStart.fieldFormats, }, diff --git a/x-pack/plugins/ml/tsconfig.json b/x-pack/plugins/ml/tsconfig.json index a937586369ef4..bd89d383adcef 100644 --- a/x-pack/plugins/ml/tsconfig.json +++ b/x-pack/plugins/ml/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../cloud/tsconfig.json" }, { "path": "../features/tsconfig.json" }, { "path": "../data_visualizer/tsconfig.json"}, + { "path": "../aiops/tsconfig.json"}, { "path": "../license_management/tsconfig.json" }, { "path": "../licensing/tsconfig.json" }, { "path": "../maps/tsconfig.json" }, diff --git a/x-pack/test/api_integration/apis/aiops/example_stream.ts b/x-pack/test/api_integration/apis/aiops/example_stream.ts new file mode 100644 index 0000000000000..693a6de2c6716 --- /dev/null +++ b/x-pack/test/api_integration/apis/aiops/example_stream.ts @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import fetch from 'node-fetch'; +import { format as formatUrl } from 'url'; + +import expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getService }: FtrProviderContext) => { + const supertest = getService('supertest'); + const config = getService('config'); + const kibanaServerUrl = formatUrl(config.get('servers.kibana')); + + describe('POST /internal/aiops/example_stream', () => { + it('should return full data without streaming', async () => { + const resp = await supertest + .post(`/internal/aiops/example_stream`) + .set('kbn-xsrf', 'kibana') + .send({ + timeout: 1, + }) + .expect(200); + + expect(Buffer.isBuffer(resp.body)).to.be(true); + + const chunks: string[] = resp.body.toString().split('\n'); + + expect(chunks.length).to.be(201); + + const lastChunk = chunks.pop(); + expect(lastChunk).to.be(''); + + let data: any[] = []; + + expect(() => { + data = chunks.map((c) => JSON.parse(c)); + }).not.to.throwError(); + + data.forEach((d) => { + expect(typeof d.type).to.be('string'); + }); + + const progressData = data.filter((d) => d.type === 'update_progress'); + expect(progressData.length).to.be(100); + expect(progressData[0].payload).to.be(1); + expect(progressData[progressData.length - 1].payload).to.be(100); + }); + + it('should return data in chunks with streaming', async () => { + const response = await fetch(`${kibanaServerUrl}/internal/aiops/example_stream`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'kbn-xsrf': 'stream', + }, + body: JSON.stringify({ timeout: 1 }), + }); + + const stream = response.body; + + expect(stream).not.to.be(null); + + if (stream !== null) { + let partial = ''; + let threw = false; + const progressData: any[] = []; + + try { + for await (const value of stream) { + const full = `${partial}${value}`; + const parts = full.split('\n'); + const last = parts.pop(); + + partial = last ?? ''; + + const actions = parts.map((p) => JSON.parse(p)); + + actions.forEach((action) => { + expect(typeof action.type).to.be('string'); + + if (action.type === 'update_progress') { + progressData.push(action); + } + }); + } + } catch (e) { + threw = true; + } + + expect(threw).to.be(false); + + expect(progressData.length).to.be(100); + expect(progressData[0].payload).to.be(1); + expect(progressData[progressData.length - 1].payload).to.be(100); + } + }); + }); +}; diff --git a/x-pack/test/api_integration/apis/aiops/index.ts b/x-pack/test/api_integration/apis/aiops/index.ts new file mode 100644 index 0000000000000..04b4181906dbf --- /dev/null +++ b/x-pack/test/api_integration/apis/aiops/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('AIOps', function () { + this.tags(['ml']); + + loadTestFile(require.resolve('./example_stream')); + }); +} diff --git a/x-pack/test/api_integration/apis/index.ts b/x-pack/test/api_integration/apis/index.ts index 645cc81560682..b3566ff30aea2 100644 --- a/x-pack/test/api_integration/apis/index.ts +++ b/x-pack/test/api_integration/apis/index.ts @@ -31,6 +31,7 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./searchprofiler')); loadTestFile(require.resolve('./painless_lab')); loadTestFile(require.resolve('./file_upload')); + loadTestFile(require.resolve('./aiops')); loadTestFile(require.resolve('./ml')); loadTestFile(require.resolve('./watcher')); loadTestFile(require.resolve('./logs_ui')); From b70b89994fff3dc76cb49c62332eccc2093cccac Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 12 May 2022 14:52:32 +0300 Subject: [PATCH 02/46] [Visualize] Fixes metric label font size (#132100) --- .../public/__snapshots__/to_ast.test.ts.snap | 38 +++++++++++++++++++ src/plugins/vis_types/metric/public/to_ast.ts | 2 + 2 files changed, 40 insertions(+) diff --git a/src/plugins/vis_types/metric/public/__snapshots__/to_ast.test.ts.snap b/src/plugins/vis_types/metric/public/__snapshots__/to_ast.test.ts.snap index 233e38874e6da..ef6102571f324 100644 --- a/src/plugins/vis_types/metric/public/__snapshots__/to_ast.test.ts.snap +++ b/src/plugins/vis_types/metric/public/__snapshots__/to_ast.test.ts.snap @@ -59,6 +59,25 @@ Object { "type": "expression", }, ], + "labelFont": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "align": Array [ + "center", + ], + "size": Array [ + "14", + ], + }, + "function": "font", + "type": "function", + }, + ], + "type": "expression", + }, + ], "percentageMode": Array [ true, ], @@ -133,6 +152,25 @@ Object { "type": "expression", }, ], + "labelFont": Array [ + Object { + "chain": Array [ + Object { + "arguments": Object { + "align": Array [ + "center", + ], + "size": Array [ + "14", + ], + }, + "function": "font", + "type": "function", + }, + ], + "type": "expression", + }, + ], "showLabels": Array [ false, ], diff --git a/src/plugins/vis_types/metric/public/to_ast.ts b/src/plugins/vis_types/metric/public/to_ast.ts index 322ea561abeb4..d206d046cde6a 100644 --- a/src/plugins/vis_types/metric/public/to_ast.ts +++ b/src/plugins/vis_types/metric/public/to_ast.ts @@ -83,6 +83,8 @@ export const toExpressionAst: VisToExpressionAst = (vis, params) => { ) ); + metricVis.addArgument('labelFont', buildExpression(`font size="14" align="center"`)); + if (colorsRange && colorsRange.length > 1) { const stopsWithColors = getStopsWithColorsFromRanges(colorsRange, colorSchema, invertColors); const palette = buildExpressionFunction('palette', { From 94b894a63f68a1f86165ad643f9f45515f0d8527 Mon Sep 17 00:00:00 2001 From: Dominique Clarke Date: Thu, 12 May 2022 08:08:42 -0400 Subject: [PATCH 03/46] [Synthetics] fix script_type for monitor management telemetry (#131855) * synthetics - fix script_type for monitor management telemetry * adjust tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../synthetics_service/add_monitor.ts | 8 +- .../synthetics_service/delete_monitor.ts | 10 ++- .../synthetics_service/edit_monitor.ts | 23 +++-- .../telemetry/monitor_upgrade_sender.test.ts | 90 +++++++++++-------- .../telemetry/monitor_upgrade_sender.ts | 18 ++-- 5 files changed, 93 insertions(+), 56 deletions(-) diff --git a/x-pack/plugins/synthetics/server/rest_api/synthetics_service/add_monitor.ts b/x-pack/plugins/synthetics/server/rest_api/synthetics_service/add_monitor.ts index 598328349227d..0dfc01df069ff 100644 --- a/x-pack/plugins/synthetics/server/rest_api/synthetics_service/add_monitor.ts +++ b/x-pack/plugins/synthetics/server/rest_api/synthetics_service/add_monitor.ts @@ -7,6 +7,7 @@ import { schema } from '@kbn/config-schema'; import { SavedObject } from '@kbn/core/server'; import { + ConfigKey, MonitorFields, SyntheticsMonitor, EncryptedSyntheticsMonitor, @@ -57,7 +58,12 @@ export const addSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({ sendTelemetryEvents( server.logger, server.telemetry, - formatTelemetryEvent({ monitor: newMonitor, errors, kibanaVersion: server.kibanaVersion }) + formatTelemetryEvent({ + monitor: newMonitor, + errors, + isInlineScript: Boolean((monitor as MonitorFields)[ConfigKey.SOURCE_INLINE]), + kibanaVersion: server.kibanaVersion, + }) ); if (errors && errors.length > 0) { diff --git a/x-pack/plugins/synthetics/server/rest_api/synthetics_service/delete_monitor.ts b/x-pack/plugins/synthetics/server/rest_api/synthetics_service/delete_monitor.ts index dc6fc759e81a4..980d26d2f39ad 100644 --- a/x-pack/plugins/synthetics/server/rest_api/synthetics_service/delete_monitor.ts +++ b/x-pack/plugins/synthetics/server/rest_api/synthetics_service/delete_monitor.ts @@ -7,6 +7,8 @@ import { schema } from '@kbn/config-schema'; import { SavedObjectsErrorHelpers } from '@kbn/core/server'; import { + ConfigKey, + MonitorFields, EncryptedSyntheticsMonitor, SyntheticsMonitorWithSecrets, } from '../../../common/runtime_types'; @@ -66,7 +68,13 @@ export const deleteSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({ sendTelemetryEvents( logger, telemetry, - formatTelemetryDeleteEvent(monitor, kibanaVersion, new Date().toISOString(), errors) + formatTelemetryDeleteEvent( + monitor, + kibanaVersion, + new Date().toISOString(), + Boolean((normalizedMonitor.attributes as MonitorFields)[ConfigKey.SOURCE_INLINE]), + errors + ) ); if (errors && errors.length > 0) { diff --git a/x-pack/plugins/synthetics/server/rest_api/synthetics_service/edit_monitor.ts b/x-pack/plugins/synthetics/server/rest_api/synthetics_service/edit_monitor.ts index 55be289f7162a..99f9d542dc895 100644 --- a/x-pack/plugins/synthetics/server/rest_api/synthetics_service/edit_monitor.ts +++ b/x-pack/plugins/synthetics/server/rest_api/synthetics_service/edit_monitor.ts @@ -79,24 +79,25 @@ export const editSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({ return response.badRequest({ body: { message, attributes: { details, ...payload } } }); } - const monitorWithRevision = formatSecrets({ + const monitorWithRevision = { ...editedMonitor, revision: (previousMonitor.attributes[ConfigKey.REVISION] || 0) + 1, - }); + }; + const formattedMonitor = formatSecrets(monitorWithRevision); - const editMonitor: SavedObjectsUpdateResponse = + const updatedMonitor: SavedObjectsUpdateResponse = await savedObjectsClient.update( syntheticsMonitorType, monitorId, - monitor.type === 'browser' ? { ...monitorWithRevision, urls: '' } : monitorWithRevision + monitor.type === 'browser' ? { ...formattedMonitor, urls: '' } : formattedMonitor ); const errors = await syntheticsService.pushConfigs([ { ...editedMonitor, - id: editMonitor.id, + id: updatedMonitor.id, fields: { - config_id: editMonitor.id, + config_id: updatedMonitor.id, }, fields_under_root: true, }, @@ -105,7 +106,13 @@ export const editSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({ sendTelemetryEvents( logger, telemetry, - formatTelemetryUpdateEvent(editMonitor, previousMonitor, kibanaVersion, errors) + formatTelemetryUpdateEvent( + updatedMonitor, + previousMonitor, + kibanaVersion, + Boolean((monitor as MonitorFields)[ConfigKey.SOURCE_INLINE]), + errors + ) ); // Return service sync errors in OK response @@ -115,7 +122,7 @@ export const editSyntheticsMonitorRoute: UMRestApiRouteFactory = () => ({ }); } - return editMonitor; + return updatedMonitor; } catch (updateErr) { if (SavedObjectsErrorHelpers.isNotFoundError(updateErr)) { return getMonitorNotFoundResponse(response, monitorId); diff --git a/x-pack/plugins/synthetics/server/rest_api/synthetics_service/telemetry/monitor_upgrade_sender.test.ts b/x-pack/plugins/synthetics/server/rest_api/synthetics_service/telemetry/monitor_upgrade_sender.test.ts index 9d8161b02b634..95db598fa0e65 100644 --- a/x-pack/plugins/synthetics/server/rest_api/synthetics_service/telemetry/monitor_upgrade_sender.test.ts +++ b/x-pack/plugins/synthetics/server/rest_api/synthetics_service/telemetry/monitor_upgrade_sender.test.ts @@ -83,40 +83,10 @@ const createTestConfig = (extraConfigs: Record, updatedAt?: string) describe('monitor upgrade telemetry helpers', () => { it('formats telemetry events', () => { - const actual = formatTelemetryEvent({ monitor: testConfig, kibanaVersion, errors }); - expect(actual).toEqual({ - stackVersion: kibanaVersion, - configId: sha256.create().update(testConfig.id).hex(), - locations: ['us_central', 'other'], - locationsCount: 2, - monitorNameLength: testConfig.attributes[ConfigKey.NAME].length, - updatedAt: testConfig.updated_at, - type: testConfig.attributes[ConfigKey.MONITOR_TYPE], - scriptType: undefined, - monitorInterval: 180000, - lastUpdatedAt: undefined, - deletedAt: undefined, - errors, - durationSinceLastUpdated: undefined, - revision: 1, - }); - }); - - it.each([ - [ConfigKey.SOURCE_INLINE, 'recorder', true], - [ConfigKey.SOURCE_INLINE, 'inline', false], - [ConfigKey.SOURCE_ZIP_URL, 'zip', false], - ])('handles formatting scriptType for browser monitors', (config, scriptType, isRecorder) => { const actual = formatTelemetryEvent({ - monitor: createTestConfig({ - [config]: 'test', - [ConfigKey.METADATA]: { - script_source: { - is_generated_script: isRecorder, - }, - }, - }), + monitor: testConfig, kibanaVersion, + isInlineScript: false, errors, }); expect(actual).toEqual({ @@ -127,7 +97,7 @@ describe('monitor upgrade telemetry helpers', () => { monitorNameLength: testConfig.attributes[ConfigKey.NAME].length, updatedAt: testConfig.updated_at, type: testConfig.attributes[ConfigKey.MONITOR_TYPE], - scriptType, + scriptType: undefined, monitorInterval: 180000, lastUpdatedAt: undefined, deletedAt: undefined, @@ -137,11 +107,51 @@ describe('monitor upgrade telemetry helpers', () => { }); }); + it.each([ + [ConfigKey.SOURCE_INLINE, 'recorder', true, true], + [ConfigKey.SOURCE_INLINE, 'inline', false, true], + [ConfigKey.SOURCE_ZIP_URL, 'zip', false, false], + ])( + 'handles formatting scriptType for browser monitors', + (config, scriptType, isRecorder, isInlineScript) => { + const actual = formatTelemetryEvent({ + monitor: createTestConfig({ + [config]: 'test', + [ConfigKey.METADATA]: { + script_source: { + is_generated_script: isRecorder, + }, + }, + }), + isInlineScript, + kibanaVersion, + errors, + }); + expect(actual).toEqual({ + stackVersion: kibanaVersion, + configId: sha256.create().update(testConfig.id).hex(), + locations: ['us_central', 'other'], + locationsCount: 2, + monitorNameLength: testConfig.attributes[ConfigKey.NAME].length, + updatedAt: testConfig.updated_at, + type: testConfig.attributes[ConfigKey.MONITOR_TYPE], + scriptType, + monitorInterval: 180000, + lastUpdatedAt: undefined, + deletedAt: undefined, + errors, + durationSinceLastUpdated: undefined, + revision: 1, + }); + } + ); + it('handles formatting update events', () => { const actual = formatTelemetryUpdateEvent( createTestConfig({}, '2011-10-05T16:48:00.000Z'), testConfig, kibanaVersion, + false, errors ); expect(actual).toEqual({ @@ -167,6 +177,7 @@ describe('monitor upgrade telemetry helpers', () => { testConfig, kibanaVersion, '2011-10-05T16:48:00.000Z', + false, errors ); expect(actual).toEqual({ @@ -198,12 +209,13 @@ describe('sendTelemetryEvents', () => { }); it('should queue telemetry events with generic error', () => { - const event = formatTelemetryEvent({ monitor: testConfig, kibanaVersion, errors }); - sendTelemetryEvents( - loggerMock, - eventsTelemetryMock, - formatTelemetryEvent({ monitor: testConfig, kibanaVersion, errors }) - ); + const event = formatTelemetryEvent({ + monitor: testConfig, + kibanaVersion, + isInlineScript: true, + errors, + }); + sendTelemetryEvents(loggerMock, eventsTelemetryMock, event); expect(eventsTelemetryMock.queueTelemetryEvents).toHaveBeenCalledWith(MONITOR_UPDATE_CHANNEL, [ event, diff --git a/x-pack/plugins/synthetics/server/rest_api/synthetics_service/telemetry/monitor_upgrade_sender.ts b/x-pack/plugins/synthetics/server/rest_api/synthetics_service/telemetry/monitor_upgrade_sender.ts index 526e40ef2f230..55c97fd3c1006 100644 --- a/x-pack/plugins/synthetics/server/rest_api/synthetics_service/telemetry/monitor_upgrade_sender.ts +++ b/x-pack/plugins/synthetics/server/rest_api/synthetics_service/telemetry/monitor_upgrade_sender.ts @@ -44,6 +44,7 @@ export function sendTelemetryEvents( export function formatTelemetryEvent({ monitor, kibanaVersion, + isInlineScript, lastUpdatedAt, durationSinceLastUpdated, deletedAt, @@ -51,6 +52,7 @@ export function formatTelemetryEvent({ }: { monitor: SavedObject; kibanaVersion: string; + isInlineScript: boolean; lastUpdatedAt?: string; durationSinceLastUpdated?: number; deletedAt?: string; @@ -71,7 +73,7 @@ export function formatTelemetryEvent({ monitorNameLength: attributes[ConfigKey.NAME].length, monitorInterval: scheduleToMilli(attributes[ConfigKey.SCHEDULE]), stackVersion: kibanaVersion, - scriptType: getScriptType(attributes as Partial), + scriptType: getScriptType(attributes as Partial, isInlineScript), errors: errors && errors?.length ? errors.map((e) => ({ @@ -92,6 +94,7 @@ export function formatTelemetryUpdateEvent( currentMonitor: SavedObjectsUpdateResponse, previousMonitor: SavedObject, kibanaVersion: string, + isInlineScript: boolean, errors?: ServiceLocationErrors | null ) { let durationSinceLastUpdated: number = 0; @@ -106,6 +109,7 @@ export function formatTelemetryUpdateEvent( kibanaVersion, durationSinceLastUpdated, lastUpdatedAt: previousMonitor.updated_at, + isInlineScript, errors, }); } @@ -114,6 +118,7 @@ export function formatTelemetryDeleteEvent( previousMonitor: SavedObject, kibanaVersion: string, deletedAt: string, + isInlineScript: boolean, errors?: ServiceLocationErrors | null ) { let durationSinceLastUpdated: number = 0; @@ -128,21 +133,20 @@ export function formatTelemetryDeleteEvent( durationSinceLastUpdated, lastUpdatedAt: previousMonitor.updated_at, deletedAt, + isInlineScript, errors, }); } function getScriptType( - attributes: Partial + attributes: Partial, + isInlineScript: boolean ): 'inline' | 'recorder' | 'zip' | undefined { if (attributes[ConfigKey.SOURCE_ZIP_URL]) { return 'zip'; - } else if ( - attributes[ConfigKey.SOURCE_INLINE] && - attributes[ConfigKey.METADATA]?.script_source?.is_generated_script - ) { + } else if (isInlineScript && attributes[ConfigKey.METADATA]?.script_source?.is_generated_script) { return 'recorder'; - } else if (attributes[ConfigKey.SOURCE_INLINE]) { + } else if (isInlineScript) { return 'inline'; } From c9372dc3a30e90d68410b30fd4a4120e8fea7a00 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 12 May 2022 06:42:38 -0600 Subject: [PATCH 04/46] [maps] fix marker size scale issue for counts (#132057) * [maps] fix marker size scale issue for counts * fix test names Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../pluck_style_meta_from_features.test.ts | 67 +++++++++++- .../pluck_style_meta_from_features.ts | 10 +- .../mvt_vector_layer/pluck_style_meta.test.ts | 102 ++++++++++++++++++ ...uck_style_meta.tsx => pluck_style_meta.ts} | 13 ++- 4 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.test.ts rename x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/{pluck_style_meta.tsx => pluck_style_meta.ts} (92%) diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.test.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.test.ts index 5c609f66e3f53..31aea302b70b1 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.test.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.test.ts @@ -188,7 +188,7 @@ describe('pluckStyleMetaFromFeatures', () => { }); }); - test('Should extract scaled field range', async () => { + test('Should extract range', async () => { const features = [ { type: 'Feature', @@ -197,7 +197,7 @@ describe('pluckStyleMetaFromFeatures', () => { coordinates: [0, 0], }, properties: { - myDynamicField: 1, + myDynamicField: 3, }, }, { @@ -242,9 +242,9 @@ describe('pluckStyleMetaFromFeatures', () => { myDynamicField: { categories: [], range: { - delta: 9, + delta: 7, max: 10, - min: 1, + min: 3, }, }, }, @@ -255,6 +255,65 @@ describe('pluckStyleMetaFromFeatures', () => { }, }); }); + + test('Should extract range with "min = 1" for count field', async () => { + const features = [ + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0], + }, + properties: { + count: 3, + }, + }, + { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [0, 0], + }, + properties: { + count: 10, + }, + }, + ] as Feature[]; + const dynamicColorOptions = { + type: COLOR_MAP_TYPE.ORDINAL, + field: { + origin: FIELD_ORIGIN.SOURCE, + name: 'count', + }, + } as ColorDynamicOptions; + const field = new InlineField({ + fieldName: dynamicColorOptions.field!.name, + source: {} as unknown as IVectorSource, + origin: dynamicColorOptions.field!.origin, + dataType: 'number', + }); + field.isCount = () => { + return true; + }; + const dynamicColorProperty = new DynamicColorProperty( + dynamicColorOptions, + VECTOR_STYLES.FILL_COLOR, + field, + {} as unknown as IVectorLayer, + () => { + return null; + } // getFieldFormatter + ); + + const styleMeta = await pluckStyleMetaFromFeatures(features, Object.values(VECTOR_SHAPE_TYPE), [ + dynamicColorProperty, + ]); + expect(styleMeta.fieldMeta.count.range).toEqual({ + delta: 9, + max: 10, + min: 1, + }); + }); }); describe('pluckCategoricalStyleMetaFromFeatures', () => { diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.ts index 2ea0fef1bf648..7867161a14e21 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.ts +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/geojson_vector_layer/pluck_style_meta_from_features.ts @@ -113,18 +113,22 @@ function pluckOrdinalStyleMetaFromFeatures( property: IDynamicStyleProperty, features: Feature[] ): RangeFieldMeta | null { - if (!property.isOrdinal()) { + const field = property.getField(); + if (!field || !property.isOrdinal()) { return null; } const name = property.getFieldName(); - let min = Infinity; + const isCount = field.isCount(); + let min = isCount ? 1 : Infinity; let max = -Infinity; for (let i = 0; i < features.length; i++) { const feature = features[i]; const newValue = feature.properties ? parseFloat(feature.properties[name]) : NaN; if (!isNaN(newValue)) { - min = Math.min(min, newValue); + if (!isCount) { + min = Math.min(min, newValue); + } max = Math.max(max, newValue); } } diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.test.ts b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.test.ts new file mode 100644 index 0000000000000..c0e624d412bc3 --- /dev/null +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FIELD_ORIGIN } from '../../../../../common/constants'; +import { TileMetaFeature } from '../../../../../common/descriptor_types'; +import { pluckOrdinalStyleMeta } from './pluck_style_meta'; +import { IField } from '../../../fields/field'; +import { DynamicSizeProperty } from '../../../styles/vector/properties/dynamic_size_property'; + +describe('pluckOrdinalStyleMeta', () => { + test('should pluck range from metaFeatures', () => { + const mockField = { + isCount: () => { + return false; + }, + pluckRangeFromTileMetaFeature: (metaFeature: TileMetaFeature) => { + return { + max: metaFeature.properties['aggregations.avg_of_bytes.max'], + min: metaFeature.properties['aggregations.avg_of_bytes.min'], + }; + }, + } as unknown as IField; + const mockStyleProperty = { + getField: () => { + return mockField; + }, + isOrdinal: () => { + return true; + }, + getFieldOrigin: () => { + return FIELD_ORIGIN.SOURCE; + }, + } as unknown as DynamicSizeProperty; + const metaFeatures = [ + { + properties: { + 'aggregations.avg_of_bytes.max': 7565, + 'aggregations.avg_of_bytes.min': 1622, + }, + } as unknown as TileMetaFeature, + { + properties: { + 'aggregations.avg_of_bytes.max': 11869, + 'aggregations.avg_of_bytes.min': 659, + }, + } as unknown as TileMetaFeature, + ]; + expect(pluckOrdinalStyleMeta(mockStyleProperty, metaFeatures, undefined)).toEqual({ + max: 11869, + min: 659, + delta: 11210, + }); + }); + + test('should pluck range with min: 1 from metaFeatures for count field', () => { + const mockField = { + isCount: () => { + return true; + }, + pluckRangeFromTileMetaFeature: (metaFeature: TileMetaFeature) => { + return { + max: metaFeature.properties['aggregations._count.max'], + min: metaFeature.properties['aggregations._count.min'], + }; + }, + } as unknown as IField; + const mockStyleProperty = { + getField: () => { + return mockField; + }, + isOrdinal: () => { + return true; + }, + getFieldOrigin: () => { + return FIELD_ORIGIN.SOURCE; + }, + } as unknown as DynamicSizeProperty; + const metaFeatures = [ + { + properties: { + 'aggregations._count.max': 35, + 'aggregations._count.min': 3, + }, + } as unknown as TileMetaFeature, + { + properties: { + 'aggregations._count.max': 36, + 'aggregations._count.min': 5, + }, + } as unknown as TileMetaFeature, + ]; + expect(pluckOrdinalStyleMeta(mockStyleProperty, metaFeatures, undefined)).toEqual({ + max: 36, + min: 1, + delta: 35, + }); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.tsx b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.ts similarity index 92% rename from x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.tsx rename to x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.ts index 1f9784fb65dc0..564500b59742b 100644 --- a/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.tsx +++ b/x-pack/plugins/maps/public/classes/layers/vector_layer/mvt_vector_layer/pluck_style_meta.ts @@ -70,7 +70,7 @@ function pluckCategoricalStyleMeta( return []; } -function pluckOrdinalStyleMeta( +export function pluckOrdinalStyleMeta( property: IDynamicStyleProperty, metaFeatures: TileMetaFeature[], joinPropertiesMap: PropertiesMap | undefined @@ -80,13 +80,16 @@ function pluckOrdinalStyleMeta( return null; } - let min = Infinity; + const isCount = field.isCount(); + let min = isCount ? 1 : Infinity; let max = -Infinity; if (property.getFieldOrigin() === FIELD_ORIGIN.SOURCE) { for (let i = 0; i < metaFeatures.length; i++) { const range = field.pluckRangeFromTileMetaFeature(metaFeatures[i]); if (range) { - min = Math.min(range.min, min); + if (!isCount) { + min = Math.min(range.min, min); + } max = Math.max(range.max, max); } } @@ -94,7 +97,9 @@ function pluckOrdinalStyleMeta( joinPropertiesMap.forEach((value: { [key: string]: unknown }) => { const propertyValue = value[field.getName()]; if (typeof propertyValue === 'number') { - min = Math.min(propertyValue as number, min); + if (!isCount) { + min = Math.min(propertyValue as number, min); + } max = Math.max(propertyValue as number, max); } }); From 6af6863cc4a3ddd34d0b39acf38c78fa609fb446 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Thu, 12 May 2022 14:58:57 +0200 Subject: [PATCH 05/46] unskip test (#132009) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/test/functional/apps/lens/group3/drag_and_drop.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts b/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts index 6b772c8d13c05..dec72008d6f04 100644 --- a/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts +++ b/x-pack/test/functional/apps/lens/group3/drag_and_drop.ts @@ -13,8 +13,7 @@ export default function ({ getPageObjects }: FtrProviderContext) { const xyChartContainer = 'xyVisChart'; describe('lens drag and drop tests', () => { - // FLAKY: https://github.com/elastic/kibana/issues/108352 - describe.skip('basic drag and drop', () => { + describe('basic drag and drop', () => { it('should construct the basic split xy chart', async () => { await PageObjects.visualize.navigateToNewVisualization(); await PageObjects.visualize.clickVisType('lens'); From a10d698743d0823b0a426f2ee2e562b219ccd01c Mon Sep 17 00:00:00 2001 From: CohenIdo <90558359+CohenIdo@users.noreply.github.com> Date: Thu, 12 May 2022 16:23:52 +0300 Subject: [PATCH 06/46] [Cloud Posture] update CSP rules configuration template --- .../common/schemas/csp_configuration.ts | 6 ++++-- .../update_rules_configuration.test.ts | 17 +++++------------ .../configuration/update_rules_configuration.ts | 6 ++++-- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/cloud_security_posture/common/schemas/csp_configuration.ts b/x-pack/plugins/cloud_security_posture/common/schemas/csp_configuration.ts index f5d38e938e2cc..a796ace382d13 100644 --- a/x-pack/plugins/cloud_security_posture/common/schemas/csp_configuration.ts +++ b/x-pack/plugins/cloud_security_posture/common/schemas/csp_configuration.ts @@ -7,8 +7,10 @@ import { schema as rt, TypeOf } from '@kbn/config-schema'; export const cspRulesConfigSchema = rt.object({ - activated_rules: rt.object({ - cis_k8s: rt.arrayOf(rt.string()), + data_yaml: rt.object({ + activated_rules: rt.object({ + cis_k8s: rt.arrayOf(rt.string()), + }), }), }); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts index 27dcd3cee6703..d0326fb037b60 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.test.ts @@ -13,7 +13,6 @@ import { httpServerMock, } from '@kbn/core/server/mocks'; import { - convertRulesConfigToYaml, createRulesConfig, defineUpdateRulesConfigRoute, getCspRules, @@ -144,7 +143,9 @@ describe('Update rules configuration API', () => { ], } as unknown as SavedObjectsFindResponse; const cspConfig = await createRulesConfig(cspRules); - expect(cspConfig).toMatchObject({ activated_rules: { cis_k8s: ['cis_1_1_1', 'cis_1_1_3'] } }); + expect(cspConfig).toMatchObject({ + data_yaml: { activated_rules: { cis_k8s: ['cis_1_1_1', 'cis_1_1_3'] } }, + }); }); it('create empty csp rules config when all rules are disabled', async () => { @@ -169,21 +170,13 @@ describe('Update rules configuration API', () => { ], } as unknown as SavedObjectsFindResponse; const cspConfig = await createRulesConfig(cspRules); - expect(cspConfig).toMatchObject({ activated_rules: { cis_k8s: [] } }); - }); - - it('validate converting rules config object to Yaml', async () => { - const cspRuleConfig = { activated_rules: { cis_k8s: ['1.1.1', '1.1.2'] } }; - - const dataYaml = convertRulesConfigToYaml(cspRuleConfig); - - expect(dataYaml).toEqual('activated_rules:\n cis_k8s:\n - 1.1.1\n - 1.1.2\n'); + expect(cspConfig).toMatchObject({ data_yaml: { activated_rules: { cis_k8s: [] } } }); }); it('validate adding new data.yaml to package policy instance', async () => { const packagePolicy = createPackagePolicyMock(); - const dataYaml = 'activated_rules:\n cis_k8s:\n - 1.1.1\n - 1.1.2\n'; + const dataYaml = 'data_yaml:\n activated_rules:\n cis_k8s:\n - 1.1.1\n - 1.1.2\n'; const updatedPackagePolicy = setVarToPackagePolicy(packagePolicy, dataYaml); expect(updatedPackagePolicy.vars).toEqual({ dataYaml: { type: 'config', value: dataYaml } }); diff --git a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts index 21587394d51e8..72c19fd5e37dd 100644 --- a/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts +++ b/x-pack/plugins/cloud_security_posture/server/routes/configuration/update_rules_configuration.ts @@ -66,8 +66,10 @@ export const createRulesConfig = ( ): CspRulesConfigSchema => { const activatedRules = cspRules.saved_objects.filter((cspRule) => cspRule.attributes.enabled); const config = { - activated_rules: { - cis_k8s: activatedRules.map((activatedRule) => activatedRule.attributes.rego_rule_id), + data_yaml: { + activated_rules: { + cis_k8s: activatedRules.map((activatedRule) => activatedRule.attributes.rego_rule_id), + }, }, }; return config; From f4eb311f6a4e261830ce6b74b1804aaf33bfad58 Mon Sep 17 00:00:00 2001 From: Jeramy Soucy Date: Thu, 12 May 2022 15:26:41 +0200 Subject: [PATCH 07/46] Resolves UI Breaks from Malformed Roles (#131915) * Adds catch of exceptions from PrivilegeSerializer deserialize methods to role transform function. Resolves 124808 where malformed Elasticsearch roles cause Kibana users and roles UIs to not display correctly. * Adds logger to role transform functions * File accidentally not saved prior to last commit --- .../roles/elasticsearch_role.test.ts | 42 ++++++++++++++++++- .../authorization/roles/elasticsearch_role.ts | 31 ++++++++++---- .../deprecations/privilege_deprecations.ts | 3 +- .../server/routes/authorization/roles/get.ts | 10 ++++- .../routes/authorization/roles/get_all.ts | 10 ++++- 5 files changed, 82 insertions(+), 14 deletions(-) diff --git a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts index bfb26bdef7c67..e8f4ff719fde1 100644 --- a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.test.ts @@ -7,6 +7,7 @@ import { omit, pick } from 'lodash'; import { KibanaFeature } from '@kbn/features-plugin/server'; +import { loggerMock } from '@kbn/logging-mocks'; import { transformElasticsearchRoleToRole } from './elasticsearch_role'; import type { ElasticsearchRole } from './elasticsearch_role'; @@ -80,6 +81,23 @@ const roles = [ enabled: true, }, }, + { + name: 'global-malformed', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_securitySolutionCases.a;;'], + resources: ['*'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, { name: 'default-base-all', cluster: [], @@ -148,6 +166,23 @@ const roles = [ enabled: true, }, }, + { + name: 'default-malformed', + cluster: [], + indices: [], + applications: [ + { + application: 'kibana-.kibana', + privileges: ['feature_securitySolutionCases.a;;'], + resources: ['space:default'], + }, + ], + run_as: [], + metadata: {}, + transient_metadata: { + enabled: true, + }, + }, ]; function testRoles( @@ -161,7 +196,8 @@ function testRoles( features, omit(role, 'name'), role.name, - 'kibana-.kibana' + 'kibana-.kibana', + loggerMock.create() ); return pick(transformedRole, ['name', '_transform_error']); }); @@ -228,10 +264,12 @@ describe('#transformElasticsearchRoleToRole', () => { { name: 'global-base-read', _transform_error: [] }, { name: 'global-foo-all', _transform_error: [] }, { name: 'global-foo-read', _transform_error: [] }, + { name: 'global-malformed', _transform_error: ['kibana'] }, { name: 'default-base-all', _transform_error: [] }, { name: 'default-base-read', _transform_error: [] }, { name: 'default-foo-all', _transform_error: ['kibana'] }, { name: 'default-foo-read', _transform_error: [] }, + { name: 'default-malformed', _transform_error: ['kibana'] }, ]); testRoles( @@ -243,10 +281,12 @@ describe('#transformElasticsearchRoleToRole', () => { { name: 'global-base-read', _transform_error: [] }, { name: 'global-foo-all', _transform_error: [] }, { name: 'global-foo-read', _transform_error: ['kibana'] }, + { name: 'global-malformed', _transform_error: ['kibana'] }, { name: 'default-base-all', _transform_error: [] }, { name: 'default-base-read', _transform_error: [] }, { name: 'default-foo-all', _transform_error: [] }, { name: 'default-foo-read', _transform_error: ['kibana'] }, + { name: 'default-malformed', _transform_error: ['kibana'] }, ] ); }); diff --git a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts index 8d7553507f85f..a491594c4256f 100644 --- a/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts +++ b/x-pack/plugins/security/server/authorization/roles/elasticsearch_role.ts @@ -5,6 +5,7 @@ * 2.0. */ +import type { Logger } from '@kbn/core/server'; import type { KibanaFeature } from '@kbn/features-plugin/common'; import { @@ -12,6 +13,7 @@ import { RESERVED_PRIVILEGES_APPLICATION_WILDCARD, } from '../../../common/constants'; import type { Role, RoleKibanaPrivilege } from '../../../common/model'; +import { getDetailedErrorMessage } from '../../errors'; import { PrivilegeSerializer } from '../privilege_serializer'; import { ResourceSerializer } from '../resource_serializer'; @@ -30,12 +32,14 @@ export function transformElasticsearchRoleToRole( features: KibanaFeature[], elasticsearchRole: Omit, name: string, - application: string + application: string, + logger: Logger ): Role { const kibanaTransformResult = transformRoleApplicationsToKibanaPrivileges( features, elasticsearchRole.applications, - application + application, + logger ); return { name, @@ -58,7 +62,8 @@ export function transformElasticsearchRoleToRole( function transformRoleApplicationsToKibanaPrivileges( features: KibanaFeature[], roleApplications: ElasticsearchRole['applications'], - application: string + application: string, + logger: Logger ) { const roleKibanaApplications = roleApplications.filter( (roleApplication) => @@ -226,9 +231,9 @@ function transformRoleApplicationsToKibanaPrivileges( }; } - return { - success: true, - value: roleKibanaApplications.map(({ resources, privileges }) => { + // try/catch block ensures graceful return on deserialize exceptions + try { + const transformResult = roleKibanaApplications.map(({ resources, privileges }) => { // if we're dealing with a global entry, which we've ensured above is only possible if it's the only item in the array if (resources.length === 1 && resources[0] === GLOBAL_RESOURCE) { const reservedPrivileges = privileges.filter((privilege) => @@ -288,8 +293,18 @@ function transformRoleApplicationsToKibanaPrivileges( }, {} as RoleKibanaPrivilege['feature']), spaces: resources.map((resource) => ResourceSerializer.deserializeSpaceResource(resource)), }; - }), - }; + }); + + return { + success: true, + value: transformResult, + }; + } catch (e) { + logger.error(`Error transforming Elasticsearch role: ${getDetailedErrorMessage(e)}`); + return { + success: false, + }; + } } const extractUnrecognizedApplicationNames = ( diff --git a/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts index e735f01ed6c8c..c85f6b239d328 100644 --- a/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts +++ b/x-pack/plugins/security/server/deprecations/privilege_deprecations.ts @@ -51,7 +51,8 @@ export const getPrivilegeDeprecationsService = ({ // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` elasticsearchRole, roleName, - authz.applicationName + authz.applicationName, + logger ) ); } catch (e) { diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get.ts b/x-pack/plugins/security/server/routes/authorization/roles/get.ts index 9ae385b1ab3fb..428cd9b49dac4 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get.ts @@ -12,7 +12,12 @@ import { wrapIntoCustomErrorResponse } from '../../../errors'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; import { transformElasticsearchRoleToRole } from './model'; -export function defineGetRolesRoutes({ router, authz, getFeatures }: RouteDefinitionParams) { +export function defineGetRolesRoutes({ + router, + authz, + getFeatures, + logger, +}: RouteDefinitionParams) { router.get( { path: '/api/security/role/{name}', @@ -38,7 +43,8 @@ export function defineGetRolesRoutes({ router, authz, getFeatures }: RouteDefini // @ts-expect-error `SecurityIndicesPrivileges.names` expected to be `string[]` elasticsearchRole, request.params.name, - authz.applicationName + authz.applicationName, + logger ), }); } diff --git a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts index 20f967db598f3..757903c3e3dbe 100644 --- a/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts +++ b/x-pack/plugins/security/server/routes/authorization/roles/get_all.ts @@ -10,7 +10,12 @@ import { wrapIntoCustomErrorResponse } from '../../../errors'; import { createLicensedRouteHandler } from '../../licensed_route_handler'; import { transformElasticsearchRoleToRole } from './model'; -export function defineGetAllRolesRoutes({ router, authz, getFeatures }: RouteDefinitionParams) { +export function defineGetAllRolesRoutes({ + router, + authz, + getFeatures, + logger, +}: RouteDefinitionParams) { router.get( { path: '/api/security/role', validate: false }, createLicensedRouteHandler(async (context, request, response) => { @@ -30,7 +35,8 @@ export function defineGetAllRolesRoutes({ router, authz, getFeatures }: RouteDef // @ts-expect-error @elastic/elasticsearch SecurityIndicesPrivileges.names expected to be string[] elasticsearchRole, roleName, - authz.applicationName + authz.applicationName, + logger ) ) .sort((roleA, roleB) => { From fece4c9ddbbc92e62b1448a7877a5f8566a2bfab Mon Sep 17 00:00:00 2001 From: Kerry Gallagher Date: Thu, 12 May 2022 14:27:15 +0100 Subject: [PATCH 08/46] [Stack Monitoring] Fix error handling on pipelines page (#132055) * Fix error handling on pipelines page --- .../plugins/monitoring/server/lib/errors/index.ts | 1 + .../server/lib/errors/pipeline_errors.ts | 15 +++++++++++++++ .../server/lib/logstash/get_pipeline.ts | 6 ++---- .../server/lib/logstash/get_pipeline_vertex.ts | 6 ++---- .../server/routes/api/v1/logstash/pipeline.js | 7 ++++++- 5 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/monitoring/server/lib/errors/pipeline_errors.ts diff --git a/x-pack/plugins/monitoring/server/lib/errors/index.ts b/x-pack/plugins/monitoring/server/lib/errors/index.ts index 7c89a860b2fca..9f1827a50da09 100644 --- a/x-pack/plugins/monitoring/server/lib/errors/index.ts +++ b/x-pack/plugins/monitoring/server/lib/errors/index.ts @@ -7,3 +7,4 @@ export { handleError } from './handle_error'; export { handleSettingsError } from './handle_settings_error'; +export { PipelineNotFoundError } from './pipeline_errors'; diff --git a/x-pack/plugins/monitoring/server/lib/errors/pipeline_errors.ts b/x-pack/plugins/monitoring/server/lib/errors/pipeline_errors.ts new file mode 100644 index 0000000000000..47936abdcfe74 --- /dev/null +++ b/x-pack/plugins/monitoring/server/lib/errors/pipeline_errors.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export class PipelineNotFoundError extends Error { + constructor(pipelineId: string, versionHash: string, clusterUuid: string) { + super( + `Pipeline documents for [${pipelineId} @ ${versionHash}] not found in the selected time range for cluster [${clusterUuid}].` + ); + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline.ts b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline.ts index 8bc299b57e68b..be0fa1c7cabd8 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline.ts +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline.ts @@ -5,8 +5,8 @@ * 2.0. */ -import boom from '@hapi/boom'; import { get } from 'lodash'; +import { PipelineNotFoundError } from '../errors'; import { getPipelineStateDocument } from './get_pipeline_state_document'; import { getPipelineStatsAggregation } from './get_pipeline_stats_aggregation'; import { calculateTimeseriesInterval } from '../calculate_timeseries_interval'; @@ -151,9 +151,7 @@ export async function getPipeline( ]); if (stateDocument === null || !statsAggregation) { - return boom.notFound( - `Pipeline [${pipelineId} @ ${version.hash}] not found in the selected time range for cluster [${clusterUuid}].` - ); + throw new PipelineNotFoundError(pipelineId, version.hash, clusterUuid); } return _enrichStateWithStatsAggregation(stateDocument, statsAggregation, timeseriesInterval); diff --git a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_vertex.ts b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_vertex.ts index fcb38f2edc0a5..9208284dc1876 100644 --- a/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_vertex.ts +++ b/x-pack/plugins/monitoring/server/lib/logstash/get_pipeline_vertex.ts @@ -5,8 +5,8 @@ * 2.0. */ -import boom from '@hapi/boom'; import { get } from 'lodash'; +import { PipelineNotFoundError } from '../errors'; import { getPipelineStateDocument } from './get_pipeline_state_document'; import { getPipelineVertexStatsAggregation } from './get_pipeline_vertex_stats_aggregation'; import { calculateTimeseriesInterval } from '../calculate_timeseries_interval'; @@ -168,9 +168,7 @@ export async function getPipelineVertex( ]); if (stateDocument === null || !statsAggregation) { - return boom.notFound( - `Pipeline [${pipelineId} @ ${version.hash}] not found in the selected time range for cluster [${clusterUuid}].` - ); + throw new PipelineNotFoundError(pipelineId, version.hash, clusterUuid); } return _enrichVertexStateWithStatsAggregation( diff --git a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js index 128df1b147cc6..fc06e36fe9132 100644 --- a/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js +++ b/x-pack/plugins/monitoring/server/routes/api/v1/logstash/pipeline.js @@ -5,8 +5,9 @@ * 2.0. */ +import { notFound } from '@hapi/boom'; import { schema } from '@kbn/config-schema'; -import { handleError } from '../../../../lib/errors'; +import { handleError, PipelineNotFoundError } from '../../../../lib/errors'; import { getPipelineVersions } from '../../../../lib/logstash/get_pipeline_versions'; import { getPipeline } from '../../../../lib/logstash/get_pipeline'; import { getPipelineVertex } from '../../../../lib/logstash/get_pipeline_vertex'; @@ -82,6 +83,10 @@ export function logstashPipelineRoute(server) { vertex, }; } catch (err) { + if (err instanceof PipelineNotFoundError) { + req.getLogger().error(err.message); + throw notFound(err.message); + } return handleError(err, req); } }, From 504b72245c08e73a10cd2e85fd6a52bfc14fb7a4 Mon Sep 17 00:00:00 2001 From: Sergey Kleyman Date: Thu, 12 May 2022 16:30:46 +0300 Subject: [PATCH 09/46] Fixed typo in Kibana's APM PHP Agent configuration page (#132122) --- .../tutorial/config_agent/commands/get_commands.test.ts | 4 ++-- .../plugins/apm/public/tutorial/config_agent/commands/php.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/apm/public/tutorial/config_agent/commands/get_commands.test.ts b/x-pack/plugins/apm/public/tutorial/config_agent/commands/get_commands.test.ts index 9e2367238da10..4c7b311d935d0 100644 --- a/x-pack/plugins/apm/public/tutorial/config_agent/commands/get_commands.test.ts +++ b/x-pack/plugins/apm/public/tutorial/config_agent/commands/get_commands.test.ts @@ -511,7 +511,7 @@ describe('getCommands', () => { expect(commands).not.toBe(''); expect(commands).toMatchInlineSnapshot(` "elastic_apm.server_url=\\"\\" - elastic.apm.secret_token=\\"\\" + elastic_apm.secret_token=\\"\\" elastic_apm.service_name=\\"My service\\" " `); @@ -527,7 +527,7 @@ describe('getCommands', () => { expect(commands).not.toBe(''); expect(commands).toMatchInlineSnapshot(` "elastic_apm.server_url=\\"localhost:8220\\" - elastic.apm.secret_token=\\"foobar\\" + elastic_apm.secret_token=\\"foobar\\" elastic_apm.service_name=\\"My service\\" " `); diff --git a/x-pack/plugins/apm/public/tutorial/config_agent/commands/php.ts b/x-pack/plugins/apm/public/tutorial/config_agent/commands/php.ts index ea7e8764f89ad..dba4147b8afbc 100644 --- a/x-pack/plugins/apm/public/tutorial/config_agent/commands/php.ts +++ b/x-pack/plugins/apm/public/tutorial/config_agent/commands/php.ts @@ -6,6 +6,6 @@ */ export const php = `elastic_apm.server_url="{{{apmServerUrl}}}" -elastic.apm.secret_token="{{{secretToken}}}" +elastic_apm.secret_token="{{{secretToken}}}" elastic_apm.service_name="My service" `; From 5398394f24efb0351a57572f5fff6556cd4b185d Mon Sep 17 00:00:00 2001 From: Milton Hultgren Date: Thu, 12 May 2022 14:31:21 +0100 Subject: [PATCH 10/46] [Stack Monitoring] Prevent exceptions in rule when no data present (#131332) * [Stack Monitoring] Prevent exceptions in rule when no data present (#120111) * Remove optional chaining Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/lib/alerts/fetch_ccr_read_exceptions.ts | 7 ++++++- .../server/lib/alerts/fetch_disk_usage_node_stats.ts | 7 ++++++- .../server/lib/alerts/fetch_index_shard_size.ts | 9 +++++++-- .../server/lib/alerts/fetch_memory_usage_node_stats.ts | 7 ++++++- .../lib/alerts/fetch_thread_pool_rejections_stats.ts | 7 ++++++- 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts index 47c57f279de2f..b39c1f744f0e1 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_ccr_read_exceptions.ts @@ -133,8 +133,13 @@ export async function fetchCCRReadExceptions( const response = await esClient.search(params); const stats: CCRReadExceptionsStats[] = []; + + if (!response.aggregations) { + return stats; + } + // @ts-expect-error declare aggegations type explicitly - const { buckets: remoteClusterBuckets = [] } = response.aggregations?.remote_clusters; + const { buckets: remoteClusterBuckets = [] } = response.aggregations.remote_clusters; if (!remoteClusterBuckets?.length) { return stats; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts index ec3dd91283731..8004a71b60efc 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_disk_usage_node_stats.ts @@ -116,8 +116,13 @@ export async function fetchDiskUsageNodeStats( const response = await esClient.search(params); const stats: AlertDiskUsageNodeStats[] = []; + + if (!response.aggregations) { + return stats; + } + // @ts-expect-error declare type for aggregations explicitly - const { buckets: clusterBuckets } = response.aggregations?.clusters; + const { buckets: clusterBuckets } = response.aggregations.clusters; if (!clusterBuckets?.length) { return stats; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts index a6bd9d4a270cc..f3840f6f2c13b 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_index_shard_size.ts @@ -115,9 +115,14 @@ export async function fetchIndexShardSize( } const response = await esClient.search(params); - // @ts-expect-error declare aggegations type explicitly - const { buckets: clusterBuckets } = response.aggregations?.clusters; const stats: IndexShardSizeStats[] = []; + + if (!response.aggregations) { + return stats; + } + + // @ts-expect-error declare aggegations type explicitly + const { buckets: clusterBuckets } = response.aggregations.clusters; if (!clusterBuckets?.length) { return stats; } diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts index a7726e594f07b..da5d5c60815d2 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_memory_usage_node_stats.ts @@ -109,8 +109,13 @@ export async function fetchMemoryUsageNodeStats( const response = await esClient.search(params); const stats: AlertMemoryUsageNodeStats[] = []; + + if (!response.aggregations) { + return stats; + } + // @ts-expect-error declare type for aggregations explicitly - const { buckets: clusterBuckets } = response.aggregations?.clusters; + const { buckets: clusterBuckets } = response.aggregations.clusters; if (!clusterBuckets?.length) { return stats; diff --git a/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts b/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts index aee6502e0aaac..07cfa20c5ed4f 100644 --- a/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts +++ b/x-pack/plugins/monitoring/server/lib/alerts/fetch_thread_pool_rejections_stats.ts @@ -116,8 +116,13 @@ export async function fetchThreadPoolRejectionStats( const response = await esClient.search(params); const stats: AlertThreadPoolRejectionsStats[] = []; + + if (!response.aggregations) { + return stats; + } + // @ts-expect-error declare type for aggregations explicitly - const { buckets: clusterBuckets } = response.aggregations?.clusters; + const { buckets: clusterBuckets } = response.aggregations.clusters; if (!clusterBuckets?.length) { return stats; From 2c634d2a21cb2f8251431458b1d3b6f355d25760 Mon Sep 17 00:00:00 2001 From: spalger Date: Thu, 12 May 2022 08:34:56 -0500 Subject: [PATCH 11/46] skip flaky jest suite (#131346) --- .../synthetics/public/legacy_uptime/pages/overview.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/synthetics/public/legacy_uptime/pages/overview.test.tsx b/x-pack/plugins/synthetics/public/legacy_uptime/pages/overview.test.tsx index b3aa4714fa664..30ea0e361580a 100644 --- a/x-pack/plugins/synthetics/public/legacy_uptime/pages/overview.test.tsx +++ b/x-pack/plugins/synthetics/public/legacy_uptime/pages/overview.test.tsx @@ -9,7 +9,8 @@ import React from 'react'; import { OverviewPageComponent } from './overview'; import { render } from '../lib/helper/rtl_helpers'; -describe('MonitorPage', () => { +// FLAKY: https://github.com/elastic/kibana/issues/131346 +describe.skip('MonitorPage', () => { it('renders expected elements for valid props', async () => { const { findByText, findByPlaceholderText } = render(); From 32980bef70d722ed302138485267f45324e77ab3 Mon Sep 17 00:00:00 2001 From: Julia Rechkunova Date: Thu, 12 May 2022 15:41:51 +0200 Subject: [PATCH 12/46] [Discover][Alerting] Prevent rule flyout from being open simultaneously with other popovers like search suggestions (#132108) * [Discover][Alerting] Prevent rule flyout from being open simultaneously with other popovers like search suggestions * [Discover][Alerting] Update tests * [Discover][Alerting] Update tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../application/sections/rule_form/rule_add.tsx | 2 +- .../triggers_actions_ui/alert_create_flyout.ts | 17 ++++++++++++++--- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx index 2d6a7ec4bf63d..b8e0d70125a08 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/rule_form/rule_add.tsx @@ -232,7 +232,7 @@ const RuleAdd = ({ aria-labelledby="flyoutRuleAddTitle" size="m" maxWidth={620} - ownFocus={false} + ownFocus > diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 39ac7c5b94330..0ee1694d340d8 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -105,6 +105,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('test.always-firing-SelectOption'); } + async function discardNewRuleCreation() { + await testSubjects.click('cancelSaveRuleButton'); + await testSubjects.existOrFail('confirmRuleCloseModal'); + await testSubjects.click('confirmRuleCloseModal > confirmModalConfirmButton'); + await testSubjects.missingOrFail('confirmRuleCloseModal'); + } + describe('create alert', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); @@ -267,6 +274,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.existOrFail('confirmRuleCloseModal'); await testSubjects.click('confirmRuleCloseModal > confirmModalCancelButton'); await testSubjects.missingOrFail('confirmRuleCloseModal'); + + await discardNewRuleCreation(); }); it('should successfully test valid es_query alert', async () => { @@ -281,9 +290,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.existOrFail('testQuerySuccess'); await testSubjects.missingOrFail('testQueryError'); - await testSubjects.click('cancelSaveRuleButton'); - await testSubjects.existOrFail('confirmRuleCloseModal'); - await testSubjects.click('confirmRuleCloseModal > confirmModalConfirmButton'); + await discardNewRuleCreation(); }); it('should show error when es_query is invalid', async () => { @@ -299,6 +306,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await testSubjects.click('testQuery'); await testSubjects.missingOrFail('testQuerySuccess'); await testSubjects.existOrFail('testQueryError'); + + await discardNewRuleCreation(); }); it('should show all rule types on click euiFormControlLayoutClearButton', async () => { @@ -318,6 +327,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { '.triggersActionsUI__ruleTypeNodeHeading' ); expect(ruleTypesClearFilter.length).to.above(0); + + await discardNewRuleCreation(); }); }); }; From 6871c65849c9579b5a952b6ab4f80fd48c9789d8 Mon Sep 17 00:00:00 2001 From: mgiota Date: Thu, 12 May 2022 15:48:16 +0200 Subject: [PATCH 13/46] [Actionable Observability] Application usage for Alerts, Cases, Rules views (#132006) * add application usage to observability * wrap Alerts page in a TrackApplicationView component * define alerting pages in a config file * add UsageCollectionSetup contract * pass useCollection in the renderApp * fix failing tests * application usage for cases and rules --- x-pack/plugins/observability/kibana.json | 3 +- .../public/application/application.test.tsx | 6 ++ .../public/application/index.tsx | 61 +++++++++++-------- .../observability/public/config/index.ts | 6 ++ x-pack/plugins/observability/public/plugin.ts | 4 ++ .../observability/public/routes/index.tsx | 20 +++++- 6 files changed, 70 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/observability/kibana.json b/x-pack/plugins/observability/kibana.json index 2ba9572dfd243..6cadc6403ad17 100644 --- a/x-pack/plugins/observability/kibana.json +++ b/x-pack/plugins/observability/kibana.json @@ -41,7 +41,8 @@ "embeddable", "kibanaReact", "kibanaUtils", - "lens" + "lens", + "usageCollection" ], "extraPublicDirs": [ "common" diff --git a/x-pack/plugins/observability/public/application/application.test.tsx b/x-pack/plugins/observability/public/application/application.test.tsx index a03f007c2d751..9cf0998e5d7c7 100644 --- a/x-pack/plugins/observability/public/application/application.test.tsx +++ b/x-pack/plugins/observability/public/application/application.test.tsx @@ -83,6 +83,12 @@ describe('renderApp', () => { observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(), ObservabilityPageTemplate: KibanaPageTemplate, kibanaFeatures: [], + usageCollection: { + components: { + ApplicationUsageTrackingProvider: (props) => null, + }, + reportUiCounter: jest.fn(), + }, }); unmount(); }).not.toThrowError(); diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index bab6c03b5cf54..c48a663fefe5b 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -18,6 +18,7 @@ import { RedirectAppLinks, } from '@kbn/kibana-react-plugin/public'; import { Storage } from '@kbn/kibana-utils-plugin/public'; +import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { ConfigSchema } from '..'; import type { LazyObservabilityPageTemplateProps } from '../components/shared/page_template/lazy_page_template'; import { DatePickerContextProvider } from '../context/date_picker_context'; @@ -54,6 +55,7 @@ export const renderApp = ({ observabilityRuleTypeRegistry, ObservabilityPageTemplate, kibanaFeatures, + usageCollection, }: { config: ConfigSchema; core: CoreStart; @@ -62,6 +64,7 @@ export const renderApp = ({ appMountParameters: AppMountParameters; ObservabilityPageTemplate: React.ComponentType; kibanaFeatures: KibanaFeature[]; + usageCollection: UsageCollectionSetup; }) => { const { element, history, theme$ } = appMountParameters; const i18nCore = core.i18n; @@ -77,34 +80,40 @@ export const renderApp = ({ // ensure all divs are .kbnAppWrappers element.classList.add(APP_WRAPPER_CLASS); + const ApplicationUsageTrackingProvider = + usageCollection?.components.ApplicationUsageTrackingProvider ?? React.Fragment; ReactDOM.render( - - - + + - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + , element ); return () => { diff --git a/x-pack/plugins/observability/public/config/index.ts b/x-pack/plugins/observability/public/config/index.ts index fc6300acc4716..34d783180750b 100644 --- a/x-pack/plugins/observability/public/config/index.ts +++ b/x-pack/plugins/observability/public/config/index.ts @@ -7,3 +7,9 @@ export { paths } from './paths'; export { translations } from './translations'; + +export enum AlertingPages { + alerts = 'alerts', + cases = 'cases', + rules = 'rules', +} diff --git a/x-pack/plugins/observability/public/plugin.ts b/x-pack/plugins/observability/public/plugin.ts index cb8dcaf2dd7e4..434bce3c576bf 100644 --- a/x-pack/plugins/observability/public/plugin.ts +++ b/x-pack/plugins/observability/public/plugin.ts @@ -33,6 +33,7 @@ import { } from '@kbn/triggers-actions-ui-plugin/public'; import { KibanaFeature } from '@kbn/features-plugin/common'; +import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public'; import { ConfigSchema } from '.'; import { observabilityAppId, observabilityFeatureId, casesPath } from '../common'; import { createLazyObservabilityPageTemplate } from './components/shared'; @@ -52,9 +53,11 @@ export interface ObservabilityPublicPluginsSetup { data: DataPublicPluginSetup; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; home?: HomePublicPluginSetup; + usageCollection: UsageCollectionSetup; } export interface ObservabilityPublicPluginsStart { + usageCollection: UsageCollectionSetup; cases: CasesUiStart; embeddable: EmbeddableStart; home?: HomePublicPluginStart; @@ -169,6 +172,7 @@ export class Plugin observabilityRuleTypeRegistry, ObservabilityPageTemplate: navigation.PageTemplate, kibanaFeatures, + usageCollection: pluginsSetup.usageCollection, }); }; diff --git a/x-pack/plugins/observability/public/routes/index.tsx b/x-pack/plugins/observability/public/routes/index.tsx index 528dbfee06f9d..573eb0b7308e4 100644 --- a/x-pack/plugins/observability/public/routes/index.tsx +++ b/x-pack/plugins/observability/public/routes/index.tsx @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import React from 'react'; +import { TrackApplicationView } from '@kbn/usage-collection-plugin/public'; import { casesPath } from '../../common'; import { CasesPage } from '../pages/cases'; import { AlertsPage } from '../pages/alerts/containers/alerts_page'; @@ -16,6 +17,7 @@ import { OverviewPage } from '../pages/overview'; import { jsonRt } from './json_rt'; import { ObservabilityExploratoryView } from '../components/shared/exploratory_view/obsv_exploratory_view'; import { RulesPage } from '../pages/rules'; +import { AlertingPages } from '../config'; export type RouteParams = DecodeParams; @@ -60,14 +62,22 @@ export const routes = { }, [casesPath]: { handler: () => { - return ; + return ( + + + + ); }, params: {}, exact: false, }, '/alerts': { handler: () => { - return ; + return ( + + + + ); }, params: { // Technically gets a '_a' param by using Kibana URL state sync helpers @@ -90,7 +100,11 @@ export const routes = { }, '/alerts/rules': { handler: () => { - return ; + return ( + + + + ); }, params: {}, exact: true, From 5909f5400ccdfb4ab1473cf6fd8bbed785c3b0ce Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Thu, 12 May 2022 07:57:59 -0600 Subject: [PATCH 14/46] [Maps] fix Map panels should not show the user controls in a dashboard report (#131970) * [Maps] fix Map panels should not show the user controls in a dashboard report * fix jest tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/maps/kibana.json | 4 ++-- .../map_container/map_container.tsx | 4 ++-- .../connected_components/mb_map/mb_map.tsx | 7 ++++--- .../attribution_control.test.tsx.snap | 20 +++++++++---------- .../attribution_control.test.tsx | 6 ++++++ .../attribution_control.tsx | 13 +++++------- .../layer_control/layer_control.test.tsx | 6 ++++++ .../layer_control/layer_control.tsx | 4 ++++ x-pack/plugins/maps/public/kibana_services.ts | 3 +++ x-pack/plugins/maps/public/plugin.ts | 5 +++-- 10 files changed, 44 insertions(+), 28 deletions(-) diff --git a/x-pack/plugins/maps/kibana.json b/x-pack/plugins/maps/kibana.json index edbf4df979f7b..5945ee3d35d8b 100644 --- a/x-pack/plugins/maps/kibana.json +++ b/x-pack/plugins/maps/kibana.json @@ -25,8 +25,7 @@ "mapsEms", "savedObjects", "share", - "presentationUtil", - "screenshotMode" + "presentationUtil" ], "optionalPlugins": [ "cloud", @@ -34,6 +33,7 @@ "home", "savedObjectsTagging", "charts", + "screenshotMode", "security", "spaces", "usageCollection" diff --git a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx index 969a985601452..131883eff40ce 100644 --- a/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx +++ b/x-pack/plugins/maps/public/connected_components/map_container/map_container.tsx @@ -21,7 +21,7 @@ import { Timeslider } from '../timeslider'; import { ToolbarOverlay } from '../toolbar_overlay'; import { EditLayerPanel } from '../edit_layer_panel'; import { AddLayerPanel } from '../add_layer_panel'; -import { getData } from '../../kibana_services'; +import { getData, isScreenshotMode } from '../../kibana_services'; import { RawValue } from '../../../common/constants'; import { FLYOUT_STATE } from '../../reducers/ui'; import { MapSettings } from '../../reducers/map'; @@ -231,7 +231,7 @@ export class MapContainer extends Component { onSingleValueTrigger={onSingleValueTrigger} renderTooltipContent={renderTooltipContent} /> - {!this.props.settings.hideToolbarOverlay && ( + {!this.props.settings.hideToolbarOverlay && !isScreenshotMode() && ( { } if ( - this._prevDisableInteractive === undefined || - this._prevDisableInteractive !== this.props.settings.disableInteractive + !isScreenshotMode() && + (this._prevDisableInteractive === undefined || + this._prevDisableInteractive !== this.props.settings.disableInteractive) ) { this._prevDisableInteractive = this.props.settings.disableInteractive; if (this.props.settings.disableInteractive) { diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/__snapshots__/attribution_control.test.tsx.snap b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/__snapshots__/attribution_control.test.tsx.snap index 324a8f1e7fc45..014ee4d5f0f2a 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/__snapshots__/attribution_control.test.tsx.snap +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/__snapshots__/attribution_control.test.tsx.snap @@ -8,17 +8,15 @@ exports[`AttributionControl is rendered 1`] = ` size="xs" > - - - attribution with link - - , - attribution with no link - + + attribution with link + + , + attribution with no link diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.test.tsx index 630e06f014bc6..85b4208ae2251 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.test.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.test.tsx @@ -5,6 +5,12 @@ * 2.0. */ +jest.mock('../../../kibana_services', () => ({ + isScreenshotMode: () => { + return false; + }, +})); + import React from 'react'; import { shallow } from 'enzyme'; import { ILayer } from '../../../classes/layers/layer'; diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx index 4b42bc482a702..098f603a99061 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/attribution_control/attribution_control.tsx @@ -11,6 +11,7 @@ import { EuiText, EuiLink } from '@elastic/eui'; import classNames from 'classnames'; import { Attribution } from '../../../../common/descriptor_types'; import { ILayer } from '../../../classes/layers/layer'; +import { isScreenshotMode } from '../../../kibana_services'; export interface Props { isFullScreen: boolean; @@ -74,11 +75,9 @@ export class AttributionControl extends Component { }; _renderAttribution({ url, label }: Attribution) { - if (!url) { - return label; - } - - return ( + return !url || isScreenshotMode() ? ( + label + ) : ( {label} @@ -108,9 +107,7 @@ export class AttributionControl extends Component { })} > - - {this._renderAttributions()} - + {this._renderAttributions()} ); diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.test.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.test.tsx index 0526eddc6521d..649999ab49a9d 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.test.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.test.tsx @@ -11,6 +11,12 @@ jest.mock('./layer_toc', () => ({ }, })); +jest.mock('../../../kibana_services', () => ({ + isScreenshotMode: () => { + return false; + }, +})); + import React from 'react'; import { shallow } from 'enzyme'; diff --git a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.tsx b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.tsx index 0e692cb130237..d131bf9b98026 100644 --- a/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.tsx +++ b/x-pack/plugins/maps/public/connected_components/right_side_controls/layer_control/layer_control.tsx @@ -20,6 +20,7 @@ import { import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { LayerTOC } from './layer_toc'; +import { isScreenshotMode } from '../../../kibana_services'; import { ILayer } from '../../../classes/layers/layer'; export interface Props { @@ -82,6 +83,9 @@ export function LayerControl({ isFlyoutOpen, }: Props) { if (!isLayerTOCOpen) { + if (isScreenshotMode()) { + return null; + } const hasErrors = layerList.some((layer) => { return layer.hasErrors(); }); diff --git a/x-pack/plugins/maps/public/kibana_services.ts b/x-pack/plugins/maps/public/kibana_services.ts index ba536953bb8f0..5774a46684644 100644 --- a/x-pack/plugins/maps/public/kibana_services.ts +++ b/x-pack/plugins/maps/public/kibana_services.ts @@ -67,6 +67,9 @@ export const getSpacesApi = () => pluginsStart.spaces; export const getTheme = () => coreStart.theme; export const getUsageCollection = () => pluginsStart.usageCollection; export const getApplication = () => coreStart.application; +export const isScreenshotMode = () => { + return pluginsStart.screenshotMode ? pluginsStart.screenshotMode.isScreenshotMode() : false; +}; // xpack.maps.* kibana.yml settings from this plugin let mapAppConfig: MapsConfigType; diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index ed53f3a05aff0..846e7fc3d83f7 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -89,7 +89,7 @@ export interface MapsPluginSetupDependencies { share: SharePluginSetup; licensing: LicensingPluginSetup; usageCollection?: UsageCollectionSetup; - screenshotMode: ScreenshotModePluginSetup; + screenshotMode?: ScreenshotModePluginSetup; } export interface MapsPluginStartDependencies { @@ -112,6 +112,7 @@ export interface MapsPluginStartDependencies { security?: SecurityPluginStart; spaces?: SpacesPluginStart; mapsEms: MapsEmsPluginPublicStart; + screenshotMode?: ScreenshotModePluginSetup; usageCollection?: UsageCollectionSetup; } @@ -151,7 +152,7 @@ export class MapsPlugin // Override this when we know we are taking a screenshot (i.e. no user interaction) // to avoid a blank-canvas issue when rendering maps on a PDF - preserveDrawingBuffer: plugins.screenshotMode.isScreenshotMode() + preserveDrawingBuffer: plugins.screenshotMode?.isScreenshotMode() ? true : config.preserveDrawingBuffer, }); From 8808a1c8f1df62c8505c9ab9889f2f2efa8e6146 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Thu, 12 May 2022 10:22:24 -0400 Subject: [PATCH 15/46] [Security Solution][Admin][Policy] Fixes a bug where the last side nav item is hidden by the policy details sticky save bar (#131646) --- .../public/app/home/template_wrapper/index.tsx | 15 +++++++++++++++ .../components/policy_form_confirm_update.tsx | 10 +++++----- .../components/policy_form_layout.test.tsx | 2 +- .../components/policy_form_layout.tsx | 2 +- .../management/pages/policy/view/policy_hooks.ts | 8 ++++++++ .../plugins/translations/translations/fr-FR.json | 1 - .../plugins/translations/translations/zh-CN.json | 1 - 7 files changed, 30 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index dbeea0f4d9fe7..d3325e0acd9aa 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -25,6 +25,7 @@ import { useShowTimeline } from '../../../common/utils/timeline/use_show_timelin import { gutterTimeline } from '../../../common/lib/helpers'; import { useKibana } from '../../../common/lib/kibana'; import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view'; +import { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks'; /** * Need to apply the styles via a className to effect the containing bottom bar @@ -33,6 +34,7 @@ import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ $isShowingTimelineOverlay?: boolean; $isTimelineBottomBarVisible?: boolean; + $isPolicySettingsVisible?: boolean; }>` .${BOTTOM_BAR_CLASSNAME} { animation: 'none !important'; // disable the default bottom bar slide animation @@ -59,6 +61,17 @@ const StyledKibanaPageTemplate = styled(KibanaPageTemplate)<{ } } `} + + // If the policy settings bottom bar is visible add padding to the navigation + ${({ $isPolicySettingsVisible }) => + $isPolicySettingsVisible && + ` + @media (min-width: 768px) { + .kbnPageTemplateSolutionNav { + padding-bottom: ${gutterTimeline}; + } + } + `} `; interface SecuritySolutionPageWrapperProps { @@ -68,6 +81,7 @@ interface SecuritySolutionPageWrapperProps { export const SecuritySolutionTemplateWrapper: React.FC = React.memo(({ children, onAppLeave }) => { const solutionNav = useSecuritySolutionNavigation(); + const isPolicySettingsVisible = useIsPolicySettingsBarVisible(); const [isTimelineBottomBarVisible] = useShowTimeline(); const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []); const { show: isShowingTimelineOverlay } = useDeepEqualSelector((state) => @@ -94,6 +108,7 @@ export const SecuritySolutionTemplateWrapper: React.FC diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_confirm_update.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_confirm_update.tsx index d2e06a4b2116b..a6f8e7b818fef 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_confirm_update.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_confirm_update.tsx @@ -11,10 +11,10 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; export const ConfirmUpdate = React.memo<{ - hostCount: number; + endpointCount: number; onConfirm: () => void; onCancel: () => void; -}>(({ hostCount, onCancel, onConfirm }) => { +}>(({ endpointCount, onCancel, onConfirm }) => { return ( - {hostCount > 0 && ( + {endpointCount > 0 && ( <> diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.test.tsx index 7068d20308309..c83e2d5ad0cab 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.test.tsx @@ -183,7 +183,7 @@ describe('Policy Form Layout', () => { ); expect(warningCallout).toHaveLength(1); expect(warningCallout.text()).toEqual( - 'This action will update 5 hostsSaving these changes will apply updates to all endpoints assigned to this agent policy.' + 'This action will update 5 endpointsSaving these changes will apply updates to all endpoints assigned to this agent policy.' ); }); it('should close dialog if cancel button is clicked', () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.tsx index 27eac2e5d69cd..81a29e8a528fd 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/components/policy_form_layout.tsx @@ -146,7 +146,7 @@ export const PolicyFormLayout = React.memo(() => { <> {showConfirm && ( diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts index 2716f81d3230f..3b4452606f968 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_hooks.ts @@ -26,6 +26,7 @@ import { getPolicyHostIsolationExceptionsPath, } from '../../../common/routing'; import { getCurrentArtifactsLocation, policyIdFromParams } from '../store/policy_details/selectors'; +import { POLICIES_PATH } from '../../../../../common/constants'; /** * Narrows global state down to the PolicyDetailsState before calling the provided Policy Details Selector @@ -101,3 +102,10 @@ export function usePolicyDetailsArtifactsNavigateCallback(listId: string) { [getPath, history] ); } + +export const useIsPolicySettingsBarVisible = () => { + return ( + window.location.pathname.includes(POLICIES_PATH) && + window.location.pathname.includes('/settings') + ); +}; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 2c37188f1760f..912ff4b27eb05 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -25269,7 +25269,6 @@ "xpack.securitySolution.endpoint.policy.details.updateConfirm.message": "Impossible d'annuler cette action. Voulez-vous vraiment continuer ?", "xpack.securitySolution.endpoint.policy.details.updateConfirm.title": "Enregistrer et déployer les modifications", "xpack.securitySolution.endpoint.policy.details.updateConfirm.warningMessage": "L'enregistrement de ces modifications appliquera des mises à jour sur tous les points de terminaison affectés à cette politique d'agent.", - "xpack.securitySolution.endpoint.policy.details.updateConfirm.warningTitle": "Cette action mettra à jour {hostCount, plural, one {# hôte} other {# hôtes}}", "xpack.securitySolution.endpoint.policy.details.updateErrorTitle": "Cette action a échoué !", "xpack.securitySolution.endpoint.policy.details.updateSuccessMessage": "L'intégration {name} a été mise à jour.", "xpack.securitySolution.endpoint.policy.details.updateSuccessTitle": "Cette action a réussi !", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 747722e70a942..2bc68dd4c30f8 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -25465,7 +25465,6 @@ "xpack.securitySolution.endpoint.policy.details.updateConfirm.message": "此操作无法撤消。是否确定要继续?", "xpack.securitySolution.endpoint.policy.details.updateConfirm.title": "保存并部署更改", "xpack.securitySolution.endpoint.policy.details.updateConfirm.warningMessage": "保存这些更改会将更新应用于分配到此代理策略的所有终端。", - "xpack.securitySolution.endpoint.policy.details.updateConfirm.warningTitle": "此操作将更新 {hostCount, plural, other {# 个主机}}", "xpack.securitySolution.endpoint.policy.details.updateErrorTitle": "失败!", "xpack.securitySolution.endpoint.policy.details.updateSuccessMessage": "集成 {name} 已更新。", "xpack.securitySolution.endpoint.policy.details.updateSuccessTitle": "成功!", From 5603c945681eb827c38261f5072acbf836dd3c52 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Thu, 12 May 2022 17:36:55 +0300 Subject: [PATCH 16/46] Controls fixes the flakiness (#132107) --- .../dashboard_elements/controls/options_list.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index 09584dd7f6a51..e13965bb95a96 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -27,8 +27,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'header', ]); - // FAILING: https://github.com/elastic/kibana/issues/132049 - describe.skip('Dashboard options list integration', () => { + describe('Dashboard options list integration', () => { before(async () => { await common.navigateToApp('dashboard'); await dashboard.gotoDashboardLandingPage(); @@ -267,7 +266,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('Selections made in control apply to dashboard', async () => { it('Shows available options in options list', async () => { - await ensureAvailableOptionsEql(allAvailableOptions); + await queryBar.setQuery(''); + await queryBar.submitQuery(); + await dashboard.waitForRenderComplete(); + await header.waitUntilLoadingHasFinished(); + await retry.try(async () => { + await ensureAvailableOptionsEql(allAvailableOptions); + }); }); it('Can search options list for available options', async () => { @@ -305,9 +310,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const selectionString = await dashboardControls.optionsListGetSelectionsString(controlId); expect(selectionString).to.be('hiss, grr'); - }); - after(async () => { await dashboardControls.optionsListOpenPopover(controlId); await dashboardControls.optionsListPopoverClearSelections(); await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); From 23bde01d17a2b1471ed0be41a3177fbc2e035fa9 Mon Sep 17 00:00:00 2001 From: Lisa Cawley Date: Thu, 12 May 2022 07:39:51 -0700 Subject: [PATCH 17/46] Minor UI text edits in Cases (#132074) --- x-pack/plugins/cases/public/common/translations.ts | 11 +++++------ .../cases/public/common/use_cases_toast.test.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/x-pack/plugins/cases/public/common/translations.ts b/x-pack/plugins/cases/public/common/translations.ts index dbc57e163d3ff..908a0dd5d52df 100644 --- a/x-pack/plugins/cases/public/common/translations.ts +++ b/x-pack/plugins/cases/public/common/translations.ts @@ -148,7 +148,7 @@ export const TAGS_HELP = i18n.translate('xpack.cases.createCase.fieldTagsHelpTex }); export const TAGS_EMPTY_ERROR = i18n.translate('xpack.cases.createCase.fieldTagsEmptyError', { - defaultMessage: 'A tag must contain at least one non-space character', + defaultMessage: 'A tag must contain at least one non-space character.', }); export const NO_TAGS = i18n.translate('xpack.cases.caseView.noTags', { @@ -229,8 +229,7 @@ export const SYNC_ALERTS_SWITCH_LABEL_OFF = i18n.translate( ); export const SYNC_ALERTS_HELP = i18n.translate('xpack.cases.components.create.syncAlertHelpText', { - defaultMessage: - 'Enabling this option will sync the status of alerts in this case with the case status.', + defaultMessage: 'Enabling this option will sync the alert statuses with the case status.', }); export const ALERT = i18n.translate('xpack.cases.common.alertLabel', { @@ -268,18 +267,18 @@ export const CASE_SUCCESS_TOAST = (title: string) => export const CASE_ALERT_SUCCESS_TOAST = (title: string) => i18n.translate('xpack.cases.actions.caseAlertSuccessToast', { values: { title }, - defaultMessage: 'An alert has been added to "{title}"', + defaultMessage: 'An alert was added to "{title}"', }); export const CASE_ALERT_SUCCESS_SYNC_TEXT = i18n.translate( 'xpack.cases.actions.caseAlertSuccessSyncText', { - defaultMessage: 'Alerts in this case have their status synched with the case status', + defaultMessage: 'The alert statuses are synched with the case status.', } ); export const VIEW_CASE = i18n.translate('xpack.cases.actions.viewCase', { - defaultMessage: 'View Case', + defaultMessage: 'View case', }); export const APP_TITLE = i18n.translate('xpack.cases.common.appTitle', { diff --git a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx index 4dfbe6495364d..e788f7b399bb4 100644 --- a/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx +++ b/x-pack/plugins/cases/public/common/use_cases_toast.test.tsx @@ -83,7 +83,7 @@ describe('Use cases toast hook', () => { theCase: mockCase, attachments: [alertComment as SupportedCaseAttachment], }); - validateTitle('An alert has been added to "Another horrible breach!!'); + validateTitle('An alert was added to "Another horrible breach!!'); }); it('should display a generic title when called with a non-alert attachament', () => { @@ -130,7 +130,7 @@ describe('Use cases toast hook', () => { theCase: mockCase, attachments: [alertComment as SupportedCaseAttachment], }); - validateContent('Alerts in this case have their status synched with the case status'); + validateContent('The alert statuses are synched with the case status.'); }); it('renders empty content when called with an alert attachment and sync off', () => { @@ -144,7 +144,7 @@ describe('Use cases toast hook', () => { theCase: { ...mockCase, settings: { ...mockCase.settings, syncAlerts: false } }, attachments: [alertComment as SupportedCaseAttachment], }); - validateContent('View Case'); + validateContent('View case'); }); it('renders a correct successful message content', () => { @@ -152,7 +152,7 @@ describe('Use cases toast hook', () => { ); expect(result.getByTestId('toaster-content-sync-text')).toHaveTextContent('my content'); - expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); + expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View case'); expect(onViewCaseClick).not.toHaveBeenCalled(); }); @@ -161,7 +161,7 @@ describe('Use cases toast hook', () => { ); expect(result.queryByTestId('toaster-content-sync-text')).toBeFalsy(); - expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View Case'); + expect(result.getByTestId('toaster-content-case-view-link')).toHaveTextContent('View case'); expect(onViewCaseClick).not.toHaveBeenCalled(); }); From 56dedf38562fb5cf6b0624711b80d321cac086f5 Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Thu, 12 May 2022 10:59:44 -0400 Subject: [PATCH 18/46] Make some data fetching hooks state updates atomic (#132069) --- .../public/common/containers/source/index.tsx | 34 ++++++++++++------- .../containers/source/use_data_view.tsx | 2 +- .../timelines/containers/details/index.tsx | 15 +++++--- 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx index 39626f7c14562..4112aaa72d0a0 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/index.tsx @@ -8,6 +8,7 @@ import { isEmpty, isEqual, isUndefined, keyBy, pick } from 'lodash/fp'; import memoizeOne from 'memoize-one'; import { useCallback, useEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; import type { DataViewBase } from '@kbn/es-query'; import { Subscription } from 'rxjs'; @@ -23,6 +24,7 @@ import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/common'; import { useKibana } from '../../lib/kibana'; import * as i18n from './translations'; import { useAppToasts } from '../../hooks/use_app_toasts'; +import { getDataViewStateFromIndexFields } from './use_data_view'; export type { BrowserField, BrowserFields, DocValueFields }; @@ -155,19 +157,27 @@ export const useFetchIndex = ( .subscribe({ next: (response) => { if (isCompleteResponse(response)) { - const stringifyIndices = response.indicesExist.sort().join(); - - previousIndexesName.current = response.indicesExist; - setLoading(false); - setState({ - browserFields: getBrowserFields(stringifyIndices, response.indexFields), - docValueFields: getDocValueFields(stringifyIndices, response.indexFields), - indexes: response.indicesExist, - indexExists: response.indicesExist.length > 0, - indexPatterns: getIndexFields(stringifyIndices, response.indexFields), + Promise.resolve().then(() => { + ReactDOM.unstable_batchedUpdates(() => { + const stringifyIndices = response.indicesExist.sort().join(); + + previousIndexesName.current = response.indicesExist; + const { browserFields, docValueFields } = getDataViewStateFromIndexFields( + stringifyIndices, + response.indexFields + ); + setLoading(false); + setState({ + browserFields, + docValueFields, + indexes: response.indicesExist, + indexExists: response.indicesExist.length > 0, + indexPatterns: getIndexFields(stringifyIndices, response.indexFields), + }); + + searchSubscription$.current.unsubscribe(); + }); }); - - searchSubscription$.current.unsubscribe(); } else if (isErrorResponse(response)) { setLoading(false); addWarning(i18n.ERROR_BEAT_FIELDS); diff --git a/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx b/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx index 3e30d02edbabd..d578340c7c691 100644 --- a/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/source/use_data_view.tsx @@ -47,7 +47,7 @@ interface DataViewInfo { * HOT Code path where the fields can be 16087 in length or larger. This is * VERY mutatious on purpose to improve the performance of the transform. */ -const getDataViewStateFromIndexFields = memoizeOne( +export const getDataViewStateFromIndexFields = memoizeOne( (_title: string, fields: IndexField[]): DataViewInfo => { // Adds two dangerous casts to allow for mutations within this function type DangerCastForMutation = Record; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx index 0173b6cfce0ed..f844defff4ec2 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/containers/details/index.tsx @@ -7,6 +7,7 @@ import { isEmpty } from 'lodash/fp'; import { useCallback, useEffect, useRef, useState } from 'react'; +import ReactDOM from 'react-dom'; import deepEqual from 'fast-deep-equal'; import { Subscription } from 'rxjs'; @@ -90,11 +91,15 @@ export const useTimelineEventsDetails = ({ .subscribe({ next: (response) => { if (isCompleteResponse(response)) { - setLoading(false); - setTimelineDetailsResponse(response.data || []); - setRawEventData(response.rawResponse.hits.hits[0]); - setEcsData(response.ecs || null); - searchSubscription$.current.unsubscribe(); + Promise.resolve().then(() => { + ReactDOM.unstable_batchedUpdates(() => { + setLoading(false); + setTimelineDetailsResponse(response.data || []); + setRawEventData(response.rawResponse.hits.hits[0]); + setEcsData(response.ecs || null); + searchSubscription$.current.unsubscribe(); + }); + }); } else if (isErrorResponse(response)) { setLoading(false); addWarning(i18n.FAIL_TIMELINE_DETAILS); From 179cf309ecb91f2bfccebeb12a7e5f01749667be Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Thu, 12 May 2022 10:03:39 -0500 Subject: [PATCH 19/46] [artifacts testing] Re-enable FTR smoke tests (#132076) * [artifacts testing] Re-enable FTR smoke tests * --quiet * docker disable chromium sandbox * newline --- .buildkite/scripts/steps/package_testing/test.sh | 12 +++++++----- .../roles/install_kibana_docker/tasks/main.yml | 1 + x-pack/test/functional/apps/visualize/reporting.ts | 3 ++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.buildkite/scripts/steps/package_testing/test.sh b/.buildkite/scripts/steps/package_testing/test.sh index 390adc2dbacee..86e7bf8138875 100755 --- a/.buildkite/scripts/steps/package_testing/test.sh +++ b/.buildkite/scripts/steps/package_testing/test.sh @@ -41,9 +41,11 @@ trap "echoKibanaLogs" EXIT vagrant provision "$TEST_PACKAGE" -# export TEST_BROWSER_HEADLESS=1 -# export TEST_KIBANA_URL="http://elastic:changeme@$KIBANA_IP_ADDRESS:5601" -# export TEST_ES_URL=http://elastic:changeme@192.168.56.1:9200 +export TEST_BROWSER_HEADLESS=1 +export TEST_KIBANA_URL="http://elastic:changeme@$KIBANA_IP_ADDRESS:5601" +export TEST_ES_URL="http://elastic:changeme@192.168.56.1:9200" -# cd x-pack -# node scripts/functional_test_runner.js --include-tag=smoke +cd x-pack + +echo "--- FTR - Reporting" +node scripts/functional_test_runner.js --config test/functional/apps/visualize/config.ts --include-tag=smoke --quiet diff --git a/test/package/roles/install_kibana_docker/tasks/main.yml b/test/package/roles/install_kibana_docker/tasks/main.yml index 2b0b70de30b6c..01dcf9f00bcce 100644 --- a/test/package/roles/install_kibana_docker/tasks/main.yml +++ b/test/package/roles/install_kibana_docker/tasks/main.yml @@ -24,3 +24,4 @@ ELASTICSEARCH_HOSTS: http://192.168.56.1:9200 ELASTICSEARCH_USERNAME: '{{ elasticsearch_username }}' ELASTICSEARCH_PASSWORD: '{{ elasticsearch_password }}' + XPACK_REPORTING_CAPTURE_BROWSER_CHROMIUM_DISABLESANDBOX: 'true' diff --git a/x-pack/test/functional/apps/visualize/reporting.ts b/x-pack/test/functional/apps/visualize/reporting.ts index 07ce3d9b23128..45f1e23224b7e 100644 --- a/x-pack/test/functional/apps/visualize/reporting.ts +++ b/x-pack/test/functional/apps/visualize/reporting.ts @@ -25,7 +25,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'visEditor', ]); - describe('Visualize Reporting Screenshots', () => { + describe('Visualize Reporting Screenshots', function () { + this.tags(['smoke']); before('initialize tests', async () => { log.debug('ReportingPage:initTests'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/reporting/ecommerce'); From 0b162b68815e4880c7e784da9b89e63728b98e6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Thu, 12 May 2022 17:28:31 +0200 Subject: [PATCH 20/46] [Security Solution][Endpoint] Adds generic functional test for artifac list pages (#131532) * Adds generic functional test for artifac list pages * Unify page objects to be generic. Moved test data into a mock file * Fix wrong blocklist js comments Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../artifact_list_page/artifact_list_page.tsx | 1 + .../view/components/blocklist_form.tsx | 11 +- .../apps/endpoint/artifact_entries_list.ts | 191 ++++++++++ .../apps/endpoint/index.ts | 1 + .../apps/endpoint/mocks.ts | 333 ++++++++++++++++++ .../test/security_solution_endpoint/config.ts | 2 + .../artifact_entries_list_page.ts | 41 +++ .../page_objects/index.ts | 2 + 8 files changed, 579 insertions(+), 3 deletions(-) create mode 100644 x-pack/test/security_solution_endpoint/apps/endpoint/artifact_entries_list.ts create mode 100644 x-pack/test/security_solution_endpoint/apps/endpoint/mocks.ts create mode 100644 x-pack/test/security_solution_endpoint/page_objects/artifact_entries_list_page.ts diff --git a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx index a299f932bc21d..411526fa6dc3b 100644 --- a/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/components/artifact_list_page/artifact_list_page.tsx @@ -254,6 +254,7 @@ export const ArtifactListPage = memo( ) } + data-test-subj={getTestId('container')} > {isFlyoutOpened && ( ( }, [item?.os_types]); const selectedValues = useMemo(() => { - return blocklistEntry.value.map((label) => ({ label })); - }, [blocklistEntry.value]); + return blocklistEntry.value.map((label) => ({ + label, + 'data-test-subj': getTestId(`values-input-${label}`), + })); + }, [blocklistEntry.value, getTestId]); const osOptions: Array> = useMemo( () => @@ -186,17 +189,19 @@ export const BlockListForm = memo( value: field, inputDisplay: CONDITION_FIELD_TITLE[field], dropdownDisplay: getDropdownDisplay(field), + 'data-test-subj': getTestId(field), })); if (selectedOs === OperatingSystem.WINDOWS) { selectableFields.push({ value: 'file.Ext.code_signature', inputDisplay: CONDITION_FIELD_TITLE['file.Ext.code_signature'], dropdownDisplay: getDropdownDisplay('file.Ext.code_signature'), + 'data-test-subj': getTestId('file.Ext.code_signature'), }); } return selectableFields; - }, [selectedOs]); + }, [selectedOs, getTestId]); const valueLabel = useMemo(() => { return ( diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/artifact_entries_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/artifact_entries_list.ts new file mode 100644 index 0000000000000..b52db3c2c266e --- /dev/null +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/artifact_entries_list.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { unzip } from 'zlib'; +import { promisify } from 'util'; +import expect from '@kbn/expect'; +import { IndexedHostsAndAlertsResponse } from '@kbn/security-solution-plugin/common/endpoint/index_data'; +import { FtrProviderContext } from '../../ftr_provider_context'; +import { ArtifactBodyType, ArtifactResponseType, getArtifactsListTestsData } from './mocks'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'artifactEntriesList']); + const testSubjects = getService('testSubjects'); + const browser = getService('browser'); + const endpointTestResources = getService('endpointTestResources'); + const policyTestResources = getService('policyTestResources'); + const retry = getService('retry'); + const esClient = getService('es'); + const unzipPromisify = promisify(unzip); + + describe('For each artifact list under management', function () { + let indexedData: IndexedHostsAndAlertsResponse; + + const checkFleetArtifacts = async ( + type: string, + identifier: string, + expectedArtifact: ArtifactResponseType, + expectedDecodedBodyArtifact: ArtifactBodyType + ) => { + // Check edited artifact is in the list with new values (wait for list to be updated) + let updatedArtifact: ArtifactResponseType | undefined; + await retry.waitForWithTimeout('fleet artifact is updated', 120_000, async () => { + const { + hits: { hits: windowsArtifactResults }, + } = await esClient.search({ + index: '.fleet-artifacts', + size: 1, + query: { + bool: { + filter: [ + { + match: { + type, + }, + }, + { + match: { + identifier, + }, + }, + ], + }, + }, + }); + const windowsArtifact = windowsArtifactResults[0] as ArtifactResponseType; + const isUpdated = windowsArtifact._source.body === expectedArtifact._source.body; + if (isUpdated) updatedArtifact = windowsArtifact; + return isUpdated; + }); + + updatedArtifact!._source.created = expectedArtifact._source.created; + const bodyFormBuffer = Buffer.from(updatedArtifact!._source.body, 'base64'); + const unzippedBody = await unzipPromisify(bodyFormBuffer); + + // Check decoded body first to detect possible body changes + expect(JSON.parse(unzippedBody.toString())).eql(expectedDecodedBodyArtifact); + expect(updatedArtifact).eql(expectedArtifact); + }; + + for (const testData of getArtifactsListTestsData()) { + describe(`When on the ${testData.title} entries list`, function () { + before(async () => { + const endpointPackage = await policyTestResources.getEndpointPackage(); + await endpointTestResources.setMetadataTransformFrequency('1s', endpointPackage.version); + indexedData = await endpointTestResources.loadEndpointData(); + await browser.refresh(); + await pageObjects.artifactEntriesList.navigateToList(testData.urlPath); + }); + after(async () => { + await endpointTestResources.unloadEndpointData(indexedData); + }); + + it(`should not show page title if there is no ${testData.title} entry`, async () => { + await testSubjects.missingOrFail('header-page-title'); + }); + + it(`should be able to add a new ${testData.title} entry`, async () => { + this.timeout(150_000); + + // Opens add flyout + await testSubjects.click(`${testData.pagePrefix}-emptyState-addButton`); + + for (const formAction of testData.create.formFields) { + if (formAction.type === 'click') { + await testSubjects.click(formAction.selector); + } else if (formAction.type === 'input') { + await testSubjects.setValue(formAction.selector, formAction.value || ''); + } + } + + // Submit create artifact form + await testSubjects.click(`${testData.pagePrefix}-flyout-submitButton`); + // Check new artifact is in the list + for (const checkResult of testData.create.checkResults) { + expect(await testSubjects.getVisibleText(checkResult.selector)).to.equal( + checkResult.value + ); + } + await pageObjects.common.closeToast(); + + // Title is shown after adding an item + expect(await testSubjects.getVisibleText('header-page-title')).to.equal(testData.title); + + // Checks if fleet artifact has been updated correctly + await checkFleetArtifacts( + testData.fleetArtifact.type, + testData.fleetArtifact.identifier, + testData.fleetArtifact.getExpectedUpdatedtArtifactWhenCreate(), + testData.fleetArtifact.getExpectedUpdatedArtifactBodyWhenCreate() + ); + }); + + it(`should be able to update an existing ${testData.title} entry`, async () => { + this.timeout(150_000); + + // Opens edit flyout + await pageObjects.artifactEntriesList.clickCardActionMenu(testData.pagePrefix); + await testSubjects.click(`${testData.pagePrefix}-card-cardEditAction`); + + for (const formAction of testData.update.formFields) { + if (formAction.type === 'click') { + await testSubjects.click(formAction.selector); + } else if (formAction.type === 'input') { + await testSubjects.setValue(formAction.selector, formAction.value || ''); + } else if (formAction.type === 'clear') { + await ( + await (await testSubjects.find(formAction.selector)).findByCssSelector('button') + ).click(); + } + } + + // Submit edit artifact form + await testSubjects.click(`${testData.pagePrefix}-flyout-submitButton`); + + // Check edited artifact is in the list with new values (wait for list to be updated) + await retry.waitForWithTimeout('entry is updated in list', 10000, async () => { + const currentValue = await testSubjects.getVisibleText( + `${testData.pagePrefix}-card-criteriaConditions` + ); + return currentValue === testData.update.waitForValue; + }); + + for (const checkResult of testData.update.checkResults) { + expect(await testSubjects.getVisibleText(checkResult.selector)).to.equal( + checkResult.value + ); + } + + await pageObjects.common.closeToast(); + + // Title still shown after editing an item + expect(await testSubjects.getVisibleText('header-page-title')).to.equal(testData.title); + + // Checks if fleet artifact has been updated correctly + await checkFleetArtifacts( + testData.fleetArtifact.type, + testData.fleetArtifact.identifier, + testData.fleetArtifact.getExpectedUpdatedArtifactWhenUpdate(), + testData.fleetArtifact.getExpectedUpdatedArtifactBodyWhenUpdate() + ); + }); + + it(`should be able to delete the existing ${testData.title} entry`, async () => { + // Remove it + await pageObjects.artifactEntriesList.clickCardActionMenu(testData.pagePrefix); + await testSubjects.click(`${testData.pagePrefix}-card-cardDeleteAction`); + await testSubjects.click(`${testData.pagePrefix}-deleteModal-submitButton`); + await testSubjects.waitForDeleted(testData.delete.confirmSelector); + // We only expect one artifact to have been visible + await testSubjects.missingOrFail(testData.delete.card); + // Header has gone because there is no artifact + await testSubjects.missingOrFail('header-page-title'); + }); + }); + } + }); +}; diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts index f74bd3b91cfce..d700027cbc835 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts @@ -41,5 +41,6 @@ export default function (providerContext: FtrProviderContext) { loadTestFile(require.resolve('./trusted_apps_list')); loadTestFile(require.resolve('./fleet_integrations')); loadTestFile(require.resolve('./endpoint_permissions')); + loadTestFile(require.resolve('./artifact_entries_list')); }); } diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/mocks.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/mocks.ts new file mode 100644 index 0000000000000..98b84b9b40649 --- /dev/null +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/mocks.ts @@ -0,0 +1,333 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { ArtifactElasticsearchProperties } from '@kbn/fleet-plugin/server/services/artifacts/types'; +import { TranslatedExceptionListItem } from '@kbn/security-solution-plugin/server/endpoint/schemas/artifacts/lists'; + +export interface ArtifactResponseType { + _index: string; + _id: string; + _score: number; + _source: ArtifactElasticsearchProperties; +} + +export interface ArtifactBodyType { + entries: TranslatedExceptionListItem[]; +} + +export const getArtifactsListTestsData = () => [ + { + title: 'Blocklist', + pagePrefix: 'blocklistPage', + create: { + formFields: [ + { + type: 'input', + selector: 'blocklist-form-name-input', + value: 'Blocklist name', + }, + { + type: 'input', + selector: 'blocklist-form-description-input', + value: 'This is the blocklist description', + }, + { + type: 'click', + selector: 'blocklist-form-field-select', + }, + { + type: 'click', + selector: 'blocklist-form-file.hash.*', + }, + { + type: 'input', + selector: 'blocklist-form-values-input', + value: 'A4370C0CF81686C0B696FA6261c9d3e0d810ae704ab8301839dffd5d5112f476', + }, + ], + checkResults: [ + { + selector: 'blocklistPage-card-criteriaConditions', + value: + 'OSIS Windows\nAND file.hash.*IS ONE OF\nA4370C0CF81686C0B696FA6261c9d3e0d810ae704ab8301839dffd5d5112f476', + }, + ], + }, + update: { + formFields: [ + { + type: 'input', + selector: 'blocklist-form-name-input', + value: 'Blocklist name edited', + }, + { + type: 'input', + selector: 'blocklist-form-description-input', + value: 'This is the blocklist description edited', + }, + { + type: 'click', + selector: 'blocklist-form-field-select', + }, + { + type: 'click', + selector: 'blocklist-form-file.path', + }, + { + type: 'clear', + selector: + 'blocklist-form-values-input-A4370C0CF81686C0B696FA6261c9d3e0d810ae704ab8301839dffd5d5112f476', + }, + { + type: 'input', + selector: 'blocklist-form-values-input', + value: 'c:\\randomFolder\\randomFile.exe, c:\\randomFolder\\randomFile2.exe', + }, + ], + checkResults: [ + { + selector: 'blocklistPage-card-criteriaConditions', + value: + 'OSIS Windows\nAND file.pathIS ONE OF\nc:\\randomFolder\\randomFile.exe\nc:\\randomFolder\\randomFile2.exe', + }, + { + selector: 'blocklistPage-card-header-title', + value: 'Blocklist name edited', + }, + { + selector: 'blocklistPage-card-description', + value: 'This is the blocklist description edited', + }, + ], + waitForValue: + 'OSIS Windows\nAND file.pathIS ONE OF\nc:\\randomFolder\\randomFile.exe\nc:\\randomFolder\\randomFile2.exe', + }, + delete: { + confirmSelector: 'blocklistDeletionConfirm', + card: 'blocklistCard', + }, + pageObject: 'blocklist', + urlPath: 'blocklist', + fleetArtifact: { + identifier: 'endpoint-blocklist-windows-v1', + type: 'blocklist', + getExpectedUpdatedtArtifactWhenCreate: (): ArtifactResponseType => ({ + _index: '.fleet-artifacts-7', + _id: 'endpoint:endpoint-blocklist-windows-v1-d2b12779ee542a6c4742d505cd0c684b0f55436a97074c62e7de7155344c74bc', + _score: 0, + _source: { + type: 'blocklist', + identifier: 'endpoint-blocklist-windows-v1', + relative_url: + '/api/fleet/artifacts/endpoint-blocklist-windows-v1/d2b12779ee542a6c4742d505cd0c684b0f55436a97074c62e7de7155344c74bc', + body: 'eJxVzEEKgzAUBNC7ZF0kMRqjOyt4CSnym/+DgVTFxFKR3r0pdFNmN2+Yk9EcN0eBNcPJ4rESa1hwj9UTu/yZdeQxoXWesgnClIUJ8lKl2bLSBnHZkrrZ+B0JU/s7oxeYOBoIhCPMR4In+D3JwNpCVrzjXa+F0qrjV1WrvlW5EqZGSRy14EAVL+CuJRda1mgtllgKkduiUuz2/uYDrE49EA==', + encryption_algorithm: 'none', + package_name: 'endpoint', + encoded_size: 160, + encoded_sha256: '8620957e33599029c5f96fa689e0df2206960f582130ccdea64f22403fc05e50', + decoded_size: 196, + decoded_sha256: 'd2b12779ee542a6c4742d505cd0c684b0f55436a97074c62e7de7155344c74bc', + compression_algorithm: 'zlib', + created: '2000-01-01T00:00:00.000Z', + }, + }), + getExpectedUpdatedArtifactBodyWhenCreate: (): ArtifactBodyType => ({ + entries: [ + { + type: 'simple', + entries: [ + { + field: 'file.hash.sha256', + operator: 'included', + type: 'exact_cased_any', + value: ['A4370C0CF81686C0B696FA6261c9d3e0d810ae704ab8301839dffd5d5112f476'], + }, + ], + }, + ], + }), + getExpectedUpdatedArtifactWhenUpdate: (): ArtifactResponseType => ({ + _index: '.fleet-artifacts-7', + _id: 'endpoint:endpoint-blocklist-windows-v1-2df413b3c01b54be7e9106e92c39297ca72d32bcd626c3f7eb7d395db8e905fe', + _score: 0, + _source: { + type: 'blocklist', + identifier: 'endpoint-blocklist-windows-v1', + relative_url: + '/api/fleet/artifacts/endpoint-blocklist-windows-v1/2df413b3c01b54be7e9106e92c39297ca72d32bcd626c3f7eb7d395db8e905fe', + body: 'eJx9jcEKwjAQRH9F9iwePOYD/IlWypKdYmCbhCSVltJ/dysieJE5zbxhZiPEVgIquW6jtmaQoxqmrKDzDxsDVAyOQXHJ3B7GU0bhlorFIXqdBWLpZwUL+zZ4rpCB42rgyTob6ci7vi8cJU23pILydcc2luP69K9zfZfu+6EXorpEbA==', + encryption_algorithm: 'none', + package_name: 'endpoint', + encoded_size: 130, + encoded_sha256: '3fb42b56c16ef38f8ecb62c082a7f3dddf4a52998a83c97d16688e854e15a502', + decoded_size: 194, + decoded_sha256: '2df413b3c01b54be7e9106e92c39297ca72d32bcd626c3f7eb7d395db8e905fe', + compression_algorithm: 'zlib', + created: '2000-01-01T00:00:00.000Z', + }, + }), + getExpectedUpdatedArtifactBodyWhenUpdate: (): ArtifactBodyType => ({ + entries: [ + { + type: 'simple', + entries: [ + { + field: 'file.path', + operator: 'included', + type: 'exact_cased_any', + value: ['c:\\randomFolder\\randomFile.exe', ' c:\\randomFolder\\randomFile2.exe'], + }, + ], + }, + ], + }), + }, + }, + { + title: 'Host isolation exceptions', + pagePrefix: 'hostIsolationExceptionsListPage', + create: { + formFields: [ + { + type: 'input', + selector: 'hostIsolationExceptions-form-name-input', + value: 'Host Isolation exception name', + }, + { + type: 'input', + selector: 'hostIsolationExceptions-form-description-input', + value: 'This is the host isolation exception description', + }, + { + type: 'input', + selector: 'hostIsolationExceptions-form-ip-input', + value: '1.1.1.1', + }, + ], + checkResults: [ + { + selector: 'hostIsolationExceptionsListPage-card-criteriaConditions', + value: 'OSIS Windows, Linux, Mac\nAND destination.ipIS 1.1.1.1', + }, + ], + }, + update: { + formFields: [ + { + type: 'input', + selector: 'hostIsolationExceptions-form-name-input', + value: 'Host Isolation exception name edited', + }, + { + type: 'input', + selector: 'hostIsolationExceptions-form-description-input', + value: 'This is the host isolation exception description edited', + }, + { + type: 'input', + selector: 'hostIsolationExceptions-form-ip-input', + value: '2.2.2.2/24', + }, + ], + checkResults: [ + { + selector: 'hostIsolationExceptionsListPage-card-criteriaConditions', + value: 'OSIS Windows, Linux, Mac\nAND destination.ipIS 2.2.2.2/24', + }, + { + selector: 'hostIsolationExceptionsListPage-card-header-title', + value: 'Host Isolation exception name edited', + }, + { + selector: 'hostIsolationExceptionsListPage-card-description', + value: 'This is the host isolation exception description edited', + }, + ], + waitForValue: 'OSIS Windows, Linux, Mac\nAND destination.ipIS 2.2.2.2/24', + }, + delete: { + confirmSelector: 'hostIsolationExceptionsDeletionConfirm', + card: 'hostIsolationExceptionsCard', + }, + pageObject: 'hostIsolationExceptions', + urlPath: 'host_isolation_exceptions', + fleetArtifact: { + identifier: 'endpoint-hostisolationexceptionlist-windows-v1', + type: 'hostisolationexceptionlist', + getExpectedUpdatedtArtifactWhenCreate: (): ArtifactResponseType => ({ + _index: '.fleet-artifacts-7', + _id: 'endpoint:endpoint-hostisolationexceptionlist-windows-v1-2c3ee2b5e7f86f8c336a3df7e59a1151b11d7eec382442032e69712d6a6459e0', + _score: 0, + _source: { + type: 'hostisolationexceptionlist', + identifier: 'endpoint-hostisolationexceptionlist-windows-v1', + relative_url: + '/api/fleet/artifacts/endpoint-hostisolationexceptionlist-windows-v1/2c3ee2b5e7f86f8c336a3df7e59a1151b11d7eec382442032e69712d6a6459e0', + body: 'eJxVjEEKgDAMBP+Sswhe/YqIhHaFQG1LG0UR/24ULzK3mWVPQtQiqNQPJ+mRQT1VWXIANb82C4K36FFVIquk2Eq2UcoorKlYk+jC6uHNflfY2enkuL5y47A+tmtf6BqNG647LBE=', + encryption_algorithm: 'none', + package_name: 'endpoint', + encoded_size: 101, + encoded_sha256: 'ee949ea39fe547e06add448956fa7d94ea14d1c30a368dce7058a1cb6ac278f9', + decoded_size: 131, + decoded_sha256: '2c3ee2b5e7f86f8c336a3df7e59a1151b11d7eec382442032e69712d6a6459e0', + compression_algorithm: 'zlib', + created: '2000-01-01T00:00:00.000Z', + }, + }), + getExpectedUpdatedArtifactBodyWhenCreate: (): ArtifactBodyType => ({ + entries: [ + { + type: 'simple', + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'exact_cased', + value: '1.1.1.1', + }, + ], + }, + ], + }), + getExpectedUpdatedArtifactWhenUpdate: (): ArtifactResponseType => ({ + _index: '.fleet-artifacts-7', + _id: 'endpoint:endpoint-hostisolationexceptionlist-windows-v1-4b62473b4cf057277b3297896771cc1061c3bea2c4f7ec1ef5c2546f33d5d9e8', + _score: 0, + _source: { + type: 'hostisolationexceptionlist', + identifier: 'endpoint-hostisolationexceptionlist-windows-v1', + relative_url: + '/api/fleet/artifacts/endpoint-hostisolationexceptionlist-windows-v1/4b62473b4cf057277b3297896771cc1061c3bea2c4f7ec1ef5c2546f33d5d9e8', + body: 'eJxVjEEKgzAQRe8ya4kgXeUqIjIkvzAQk5CMYpHevVPpprzde59/EbI2QSc/X6SvCvLUZasJNPy1pyBFixFdJbNKyU6qjUpFYy3NmuSQ9oho9neFk4OugfstD077107uZpwe9F6MDzBbLKo=', + encryption_algorithm: 'none', + package_name: 'endpoint', + encoded_size: 107, + encoded_sha256: 'dbcc8f50044d43453fbffb4edda6aa0cd42075621827986393d625404f2b6b81', + decoded_size: 134, + decoded_sha256: '4b62473b4cf057277b3297896771cc1061c3bea2c4f7ec1ef5c2546f33d5d9e8', + compression_algorithm: 'zlib', + created: '2000-01-01T00:00:00.000Z', + }, + }), + getExpectedUpdatedArtifactBodyWhenUpdate: (): ArtifactBodyType => ({ + entries: [ + { + type: 'simple', + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'exact_cased', + value: '2.2.2.2/24', + }, + ], + }, + ], + }), + }, + }, +]; diff --git a/x-pack/test/security_solution_endpoint/config.ts b/x-pack/test/security_solution_endpoint/config.ts index b5b52b7bc5cd5..4142d94f05b4c 100644 --- a/x-pack/test/security_solution_endpoint/config.ts +++ b/x-pack/test/security_solution_endpoint/config.ts @@ -46,6 +46,8 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { // always install Endpoint package by default when Fleet sets up `--xpack.fleet.packages.0.name=endpoint`, `--xpack.fleet.packages.0.version=latest`, + // set the packagerTaskInterval to 5s in order to speed up test executions when checking fleet artifacts + '--xpack.securitySolution.packagerTaskInterval=5s', ], }, layout: { diff --git a/x-pack/test/security_solution_endpoint/page_objects/artifact_entries_list_page.ts b/x-pack/test/security_solution_endpoint/page_objects/artifact_entries_list_page.ts new file mode 100644 index 0000000000000..e06c56ac22f5b --- /dev/null +++ b/x-pack/test/security_solution_endpoint/page_objects/artifact_entries_list_page.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +export function ArtifactEntriesListPageProvider({ + getService, + getPageObjects, +}: FtrProviderContext) { + const pageObjects = getPageObjects(['common', 'header', 'endpointPageUtils']); + const testSubjects = getService('testSubjects'); + + return { + async navigateToList(artifactType: string, searchParams?: string) { + await pageObjects.common.navigateToUrlWithBrowserHistory( + 'securitySolutionManagement', + `/${artifactType}${searchParams ? `?${searchParams}` : ''}` + ); + await pageObjects.header.waitUntilLoadingHasFinished(); + }, + + // /** + // * ensures that the ArtifactType page is the currently display view + // */ + async ensureIsOnArtifactTypePage(artifactTypePage: string) { + await testSubjects.existOrFail(`${artifactTypePage}-container`); + }, + + // /** + // * Clicks on the actions menu icon in the (only one) ArtifactType card to show the popup with list of actions + // */ + async clickCardActionMenu(artifactTypePage: string) { + await testSubjects.existOrFail(`${artifactTypePage}-container`); + await testSubjects.click(`${artifactTypePage}-card-header-actions-button`); + }, + }; +} diff --git a/x-pack/test/security_solution_endpoint/page_objects/index.ts b/x-pack/test/security_solution_endpoint/page_objects/index.ts index 7d32ba7b71e26..c82f375937070 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/index.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/index.ts @@ -14,12 +14,14 @@ import { IngestManagerCreatePackagePolicy } from './ingest_manager_create_packag import { FleetIntegrations } from './fleet_integrations_page'; import { DetectionsPageObject } from '../../security_solution_ftr/page_objects/detections'; import { HostsPageObject } from '../../security_solution_ftr/page_objects/hosts'; +import { ArtifactEntriesListPageProvider } from './artifact_entries_list_page'; export const pageObjects = { ...xpackFunctionalPageObjects, endpoint: EndpointPageProvider, policy: EndpointPolicyPageProvider, trustedApps: TrustedAppsPageProvider, + artifactEntriesList: ArtifactEntriesListPageProvider, endpointPageUtils: EndpointPageUtils, ingestManagerCreatePackagePolicy: IngestManagerCreatePackagePolicy, fleetIntegrations: FleetIntegrations, From cddd41dfae426ea224a3488e518833ce0b0d6584 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Thu, 12 May 2022 11:37:28 -0400 Subject: [PATCH 21/46] [Security Solution] Better threshold rule error checking (#131088) * Better threshold rule error checking * Add more type dependent checks * create/import/update/add-prepackaged * Fix tests * whoops Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ..._prepackaged_rules_type_dependents.test.ts | 32 ++++++ .../add_prepackaged_rules_type_dependents.ts | 38 ++++--- .../request/create_rules_type_dependents.ts | 37 +++++-- .../import_rules_type_dependents.test.ts | 13 --- .../request/import_rules_type_dependents.ts | 35 ++++--- .../patch_rules_type_dependents.test.ts | 32 ++++++ .../request/patch_rules_type_dependents.ts | 37 ++++--- .../request/update_rules_type_dependents.ts | 29 +++++- .../threshold/find_threshold_signals.test.ts | 1 - .../group1/create_rules.ts | 84 ++++++++++++++++ .../group1/import_rules.ts | 99 +++++++++++++++++++ .../group1/update_rules.ts | 96 ++++++++++++++++++ .../utils/get_simple_rule_update.ts | 2 +- 13 files changed, 468 insertions(+), 67 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts index dec2b5fcefaa2..738825bb166cc 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts @@ -91,4 +91,36 @@ describe('add_prepackaged_rules_type_dependents', () => { const errors = addPrepackagedRuleValidateTypeDependents(schema); expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); }); + + test('threshold.field should contain 3 items or less', () => { + const schema: AddPrepackagedRulesSchema = { + ...getAddPrepackagedRulesSchemaMock(), + type: 'threshold', + threshold: { + field: ['field-1', 'field-2', 'field-3', 'field-4'], + value: 1, + }, + }; + const errors = addPrepackagedRuleValidateTypeDependents(schema); + expect(errors).toEqual(['Number of fields must be 3 or less']); + }); + + test('threshold.cardinality[0].field should not be in threshold.field', () => { + const schema: AddPrepackagedRulesSchema = { + ...getAddPrepackagedRulesSchemaMock(), + type: 'threshold', + threshold: { + field: ['field-1', 'field-2', 'field-3'], + value: 1, + cardinality: [ + { + field: 'field-1', + value: 2, + }, + ], + }, + }; + const errors = addPrepackagedRuleValidateTypeDependents(schema); + expect(errors).toEqual(['Cardinality of a field that is being aggregated on is always 1']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts index 5d28a928390c9..fb47e4a90c1bd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts @@ -96,29 +96,39 @@ export const validateTimelineTitle = (rule: AddPrepackagedRulesSchema): string[] }; export const validateThreshold = (rule: AddPrepackagedRulesSchema): string[] => { + const errors: string[] = []; if (isThresholdRule(rule.type)) { if (!rule.threshold) { - return ['when "type" is "threshold", "threshold" is required']; - } else if (rule.threshold.value <= 0) { - return ['"threshold.value" has to be bigger than 0']; + errors.push('when "type" is "threshold", "threshold" is required'); } else { - return []; + if (rule.threshold.value <= 0) { + errors.push('"threshold.value" has to be bigger than 0'); + } + if ( + rule.threshold.cardinality?.length && + rule.threshold.field.includes(rule.threshold.cardinality[0].field) + ) { + errors.push('Cardinality of a field that is being aggregated on is always 1'); + } + if (Array.isArray(rule.threshold.field) && rule.threshold.field.length > 3) { + errors.push('Number of fields must be 3 or less'); + } } } - return []; + return errors; }; export const addPrepackagedRuleValidateTypeDependents = ( - schema: AddPrepackagedRulesSchema + rule: AddPrepackagedRulesSchema ): string[] => { return [ - ...validateAnomalyThreshold(schema), - ...validateQuery(schema), - ...validateLanguage(schema), - ...validateSavedId(schema), - ...validateMachineLearningJobId(schema), - ...validateTimelineId(schema), - ...validateTimelineTitle(schema), - ...validateThreshold(schema), + ...validateAnomalyThreshold(rule), + ...validateQuery(rule), + ...validateLanguage(rule), + ...validateSavedId(rule), + ...validateMachineLearningJobId(rule), + ...validateTimelineId(rule), + ...validateTimelineTitle(rule), + ...validateThreshold(rule), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts index ba28ca2a53f43..c6ee6a1af94b4 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts @@ -34,22 +34,43 @@ export const validateTimelineTitle = (rule: CreateRulesSchema): string[] => { }; export const validateThreatMapping = (rule: CreateRulesSchema): string[] => { - let errors: string[] = []; + const errors: string[] = []; if (rule.type === 'threat_match') { + if (rule.concurrent_searches != null && rule.items_per_search == null) { + errors.push('when "concurrent_searches" exists, "items_per_search" must also exist'); + } if (rule.concurrent_searches == null && rule.items_per_search != null) { - errors = ['when "items_per_search" exists, "concurrent_searches" must also exist', ...errors]; + errors.push('when "items_per_search" exists, "concurrent_searches" must also exist'); } - if (rule.concurrent_searches != null && rule.items_per_search == null) { - errors = ['when "concurrent_searches" exists, "items_per_search" must also exist', ...errors]; + } + return errors; +}; + +export const validateThreshold = (rule: CreateRulesSchema): string[] => { + const errors: string[] = []; + if (rule.type === 'threshold') { + if (!rule.threshold) { + errors.push('when "type" is "threshold", "threshold" is required'); + } else { + if ( + rule.threshold.cardinality?.length && + rule.threshold.field.includes(rule.threshold.cardinality[0].field) + ) { + errors.push('Cardinality of a field that is being aggregated on is always 1'); + } + if (Array.isArray(rule.threshold.field) && rule.threshold.field.length > 3) { + errors.push('Number of fields must be 3 or less'); + } } } return errors; }; -export const createRuleValidateTypeDependents = (schema: CreateRulesSchema): string[] => { +export const createRuleValidateTypeDependents = (rule: CreateRulesSchema): string[] => { return [ - ...validateTimelineId(schema), - ...validateTimelineTitle(schema), - ...validateThreatMapping(schema), + ...validateTimelineId(rule), + ...validateTimelineTitle(rule), + ...validateThreatMapping(rule), + ...validateThreshold(rule), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts index 478424eaa3d76..c3f4ca78e3b8d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts @@ -75,17 +75,4 @@ describe('import_rules_type_dependents', () => { const errors = importRuleValidateTypeDependents(schema); expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); }); - - test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { - const schema: ImportRulesSchema = { - ...getImportRulesSchemaMock(), - type: 'threshold', - threshold: { - field: '', - value: -1, - }, - }; - const errors = importRuleValidateTypeDependents(schema); - expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); - }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts index 8557661fd213f..9bc90e63238e7 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts @@ -96,27 +96,34 @@ export const validateTimelineTitle = (rule: ImportRulesSchema): string[] => { }; export const validateThreshold = (rule: ImportRulesSchema): string[] => { + const errors: string[] = []; if (isThresholdRule(rule.type)) { if (!rule.threshold) { - return ['when "type" is "threshold", "threshold" is required']; - } else if (rule.threshold.value <= 0) { - return ['"threshold.value" has to be bigger than 0']; + errors.push('when "type" is "threshold", "threshold" is required'); } else { - return []; + if ( + rule.threshold.cardinality?.length && + rule.threshold.field.includes(rule.threshold.cardinality[0].field) + ) { + errors.push('Cardinality of a field that is being aggregated on is always 1'); + } + if (Array.isArray(rule.threshold.field) && rule.threshold.field.length > 3) { + errors.push('Number of fields must be 3 or less'); + } } } - return []; + return errors; }; -export const importRuleValidateTypeDependents = (schema: ImportRulesSchema): string[] => { +export const importRuleValidateTypeDependents = (rule: ImportRulesSchema): string[] => { return [ - ...validateAnomalyThreshold(schema), - ...validateQuery(schema), - ...validateLanguage(schema), - ...validateSavedId(schema), - ...validateMachineLearningJobId(schema), - ...validateTimelineId(schema), - ...validateTimelineTitle(schema), - ...validateThreshold(schema), + ...validateAnomalyThreshold(rule), + ...validateQuery(rule), + ...validateLanguage(rule), + ...validateSavedId(rule), + ...validateMachineLearningJobId(rule), + ...validateTimelineId(rule), + ...validateTimelineTitle(rule), + ...validateThreshold(rule), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.test.ts index ec557d1500225..0c4783acd0705 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.test.ts @@ -101,4 +101,36 @@ describe('patch_rules_type_dependents', () => { const errors = patchRuleValidateTypeDependents(schema); expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); }); + + test('threshold.field should contain 3 items or less', () => { + const schema: PatchRulesSchema = { + ...getPatchRulesSchemaMock(), + type: 'threshold', + threshold: { + field: ['field-1', 'field-2', 'field-3', 'field-4'], + value: 1, + }, + }; + const errors = patchRuleValidateTypeDependents(schema); + expect(errors).toEqual(['Number of fields must be 3 or less']); + }); + + test('threshold.cardinality[0].field should not be in threshold.field', () => { + const schema: PatchRulesSchema = { + ...getPatchRulesSchemaMock(), + type: 'threshold', + threshold: { + field: ['field-1', 'field-2', 'field-3'], + value: 1, + cardinality: [ + { + field: 'field-1', + value: 2, + }, + ], + }, + }; + const errors = patchRuleValidateTypeDependents(schema); + expect(errors).toEqual(['Cardinality of a field that is being aggregated on is always 1']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts index b1d31f662b18c..7229b403c92ad 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts @@ -6,7 +6,6 @@ */ import { isMlRule } from '../../../machine_learning/helpers'; -import { isThresholdRule } from '../../utils'; import { PatchRulesSchema } from './patch_rules_schema'; export const validateQuery = (rule: PatchRulesSchema): string[] => { @@ -70,25 +69,35 @@ export const validateId = (rule: PatchRulesSchema): string[] => { }; export const validateThreshold = (rule: PatchRulesSchema): string[] => { - if (isThresholdRule(rule.type)) { + const errors: string[] = []; + if (rule.type === 'threshold') { if (!rule.threshold) { - return ['when "type" is "threshold", "threshold" is required']; - } else if (rule.threshold.value <= 0) { - return ['"threshold.value" has to be bigger than 0']; + errors.push('when "type" is "threshold", "threshold" is required'); } else { - return []; + if ( + rule.threshold.cardinality?.length && + rule.threshold.field.includes(rule.threshold.cardinality[0].field) + ) { + errors.push('Cardinality of a field that is being aggregated on is always 1'); + } + if (rule.threshold.value <= 0) { + errors.push('"threshold.value" has to be bigger than 0'); + } + if (Array.isArray(rule.threshold.field) && rule.threshold.field.length > 3) { + errors.push('Number of fields must be 3 or less'); + } } } - return []; + return errors; }; -export const patchRuleValidateTypeDependents = (schema: PatchRulesSchema): string[] => { +export const patchRuleValidateTypeDependents = (rule: PatchRulesSchema): string[] => { return [ - ...validateId(schema), - ...validateQuery(schema), - ...validateLanguage(schema), - ...validateTimelineId(schema), - ...validateTimelineTitle(schema), - ...validateThreshold(schema), + ...validateId(rule), + ...validateQuery(rule), + ...validateLanguage(rule), + ...validateTimelineId(rule), + ...validateTimelineTitle(rule), + ...validateThreshold(rule), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts index f63287edd96e7..277b5eb4e2116 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts @@ -43,6 +43,31 @@ export const validateId = (rule: UpdateRulesSchema): string[] => { } }; -export const updateRuleValidateTypeDependents = (schema: UpdateRulesSchema): string[] => { - return [...validateId(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema)]; +export const validateThreshold = (rule: UpdateRulesSchema): string[] => { + const errors: string[] = []; + if (rule.type === 'threshold') { + if (!rule.threshold) { + errors.push('when "type" is "threshold", "threshold" is required'); + } else { + if ( + rule.threshold.cardinality?.length && + rule.threshold.field.includes(rule.threshold.cardinality[0].field) + ) { + errors.push('Cardinality of a field that is being aggregated on is always 1'); + } + if (Array.isArray(rule.threshold.field) && rule.threshold.field.length > 3) { + errors.push('Number of fields must be 3 or less'); + } + } + } + return errors; +}; + +export const updateRuleValidateTypeDependents = (rule: UpdateRulesSchema): string[] => { + return [ + ...validateId(rule), + ...validateTimelineId(rule), + ...validateTimelineTitle(rule), + ...validateThreshold(rule), + ]; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts index 786739760a68d..21fb6f5fa5cf6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.test.ts @@ -22,7 +22,6 @@ const buildRuleMessage = buildRuleMessageFactory({ const queryFilter = getQueryFilter('', 'kuery', [], ['*'], []); const mockSingleSearchAfter = jest.fn(); -// Failing with rule registry enabled describe('findThresholdSignals', () => { let mockService: RuleExecutorServicesMock; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts index 8f9a057a7e90e..1b7e22fb21c57 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/create_rules.ts @@ -30,6 +30,7 @@ import { getRuleForSignalTestingWithTimestampOverride, waitForAlertToComplete, waitForSignalsToBePresent, + getThresholdRuleForSignalTesting, } from '../../utils'; import { createUserAndRole, deleteUserAndRole } from '../../../common/services/security_solution'; @@ -269,6 +270,89 @@ export default ({ getService }: FtrProviderContext) => { .expect(403); }); }); + + describe('threshold validation', () => { + it('should result in 400 error if no threshold-specific fields are provided', async () => { + const { threshold, ...rule } = getThresholdRuleForSignalTesting(['*']); + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(rule) + .expect(400); + + expect(body).to.eql({ + error: 'Bad Request', + message: '[request body]: Invalid value "undefined" supplied to "threshold"', + statusCode: 400, + }); + }); + + it('should result in 400 error if more than 3 threshold fields', async () => { + const rule = getThresholdRuleForSignalTesting(['*']); + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + ...rule, + threshold: { + ...rule.threshold, + field: ['field-1', 'field-2', 'field-3', 'field-4'], + }, + }) + .expect(400); + + expect(body).to.eql({ + message: ['Number of fields must be 3 or less'], + status_code: 400, + }); + }); + + it('should result in 400 error if threshold value is less than 1', async () => { + const rule = getThresholdRuleForSignalTesting(['*']); + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + ...rule, + threshold: { + ...rule.threshold, + value: 0, + }, + }) + .expect(400); + + expect(body).to.eql({ + error: 'Bad Request', + message: '[request body]: Invalid value "0" supplied to "threshold,value"', + statusCode: 400, + }); + }); + + it('should result in 400 error if cardinality is also an agg field', async () => { + const rule = getThresholdRuleForSignalTesting(['*']); + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send({ + ...rule, + threshold: { + ...rule.threshold, + cardinality: [ + { + field: 'process.name', + value: 5, + }, + ], + }, + }) + .expect(400); + + expect(body).to.eql({ + message: ['Cardinality of a field that is being aggregated on is always 1'], + status_code: 400, + }); + }); + }); }); describe('missing timestamps', () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/import_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/import_rules.ts index dea25e0147a08..fe806db614c01 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/import_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/import_rules.ts @@ -7,6 +7,7 @@ import expect from '@kbn/expect'; +import { CreateRulesSchema } from '@kbn/security-solution-plugin/common/detection_engine/schemas/request'; import { EXCEPTION_LIST_ITEM_URL, EXCEPTION_LIST_URL } from '@kbn/securitysolution-list-constants'; import { getCreateExceptionListMinimalSchemaMock } from '@kbn/lists-plugin/common/schemas/request/create_exception_list_schema.mock'; import { DETECTION_ENGINE_RULES_URL } from '@kbn/security-solution-plugin/common/constants'; @@ -24,6 +25,7 @@ import { getSimpleRule, getSimpleRuleAsNdjson, getSimpleRuleOutput, + getThresholdRuleForSignalTesting, getWebHookAction, removeServerGeneratedProperties, ruleToNdjson, @@ -218,6 +220,103 @@ export default ({ getService }: FtrProviderContext): void => { }); }); }); + + describe('threshold validation', () => { + it('should result in partial success if no threshold-specific fields are provided', async () => { + const { threshold, ...rule } = getThresholdRuleForSignalTesting(['*']); + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', ruleToNdjson(rule as CreateRulesSchema), 'rules.ndjson') + .expect(200); + + expect(body.errors[0]).to.eql({ + rule_id: '(unknown id)', + error: { + message: 'when "type" is "threshold", "threshold" is required', + status_code: 400, + }, + }); + }); + + it('should result in partial success if more than 3 threshold fields', async () => { + const baseRule = getThresholdRuleForSignalTesting(['*']); + const rule = { + ...baseRule, + threshold: { + ...baseRule.threshold, + field: ['field-1', 'field-2', 'field-3', 'field-4'], + }, + }; + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', ruleToNdjson(rule), 'rules.ndjson') + .expect(200); + + expect(body.errors[0]).to.eql({ + rule_id: '(unknown id)', + error: { + message: 'Number of fields must be 3 or less', + status_code: 400, + }, + }); + }); + + it('should result in partial success if threshold value is less than 1', async () => { + const baseRule = getThresholdRuleForSignalTesting(['*']); + const rule = { + ...baseRule, + threshold: { + ...baseRule.threshold, + value: 0, + }, + }; + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', ruleToNdjson(rule), 'rules.ndjson') + .expect(200); + + expect(body.errors[0]).to.eql({ + rule_id: '(unknown id)', + error: { + message: 'Invalid value "0" supplied to "threshold,value"', + status_code: 400, + }, + }); + }); + + it('should result in 400 error if cardinality is also an agg field', async () => { + const baseRule = getThresholdRuleForSignalTesting(['*']); + const rule = { + ...baseRule, + threshold: { + ...baseRule.threshold, + cardinality: [ + { + field: 'process.name', + value: 5, + }, + ], + }, + }; + const { body } = await supertest + .post(`${DETECTION_ENGINE_RULES_URL}/_import`) + .set('kbn-xsrf', 'true') + .attach('file', ruleToNdjson(rule), 'rules.ndjson') + .expect(200); + + expect(body.errors[0]).to.eql({ + rule_id: '(unknown id)', + error: { + message: 'Cardinality of a field that is being aggregated on is always 1', + status_code: 400, + }, + }); + }); + }); + describe('importing rules with an index', () => { beforeEach(async () => { await createSignalsIndex(supertest, log); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/update_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/update_rules.ts index 0291164fd39cb..583746db3936b 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/update_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group1/update_rules.ts @@ -24,6 +24,7 @@ import { createRule, getSimpleRule, createLegacyRuleAction, + getThresholdRuleForSignalTesting, } from '../../utils'; // eslint-disable-next-line import/no-default-export @@ -354,6 +355,101 @@ export default ({ getService }: FtrProviderContext) => { message: 'rule_id: "fake_id" not found', }); }); + + describe('threshold validation', () => { + it('should result in 400 error if no threshold-specific fields are provided', async () => { + const existingRule = getThresholdRuleForSignalTesting(['*']); + await createRule(supertest, log, existingRule); + + const { threshold, ...rule } = existingRule; + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(rule) + .expect(400); + + expect(body).to.eql({ + error: 'Bad Request', + message: '[request body]: Invalid value "undefined" supplied to "threshold"', + statusCode: 400, + }); + }); + + it('should result in 400 error if more than 3 threshold fields', async () => { + const existingRule = getThresholdRuleForSignalTesting(['*']); + await createRule(supertest, log, existingRule); + + const rule = { + ...existingRule, + threshold: { + ...existingRule.threshold, + field: ['field-1', 'field-2', 'field-3', 'field-4'], + }, + }; + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(rule) + .expect(400); + + expect(body).to.eql({ + message: ['Number of fields must be 3 or less'], + status_code: 400, + }); + }); + + it('should result in 400 error if threshold value is less than 1', async () => { + const existingRule = getThresholdRuleForSignalTesting(['*']); + await createRule(supertest, log, existingRule); + + const rule = { + ...existingRule, + threshold: { + ...existingRule.threshold, + value: 0, + }, + }; + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(rule) + .expect(400); + + expect(body).to.eql({ + error: 'Bad Request', + message: '[request body]: Invalid value "0" supplied to "threshold,value"', + statusCode: 400, + }); + }); + + it('should result in 400 error if cardinality is also an agg field', async () => { + const existingRule = getThresholdRuleForSignalTesting(['*']); + await createRule(supertest, log, existingRule); + + const rule = { + ...existingRule, + threshold: { + ...existingRule.threshold, + cardinality: [ + { + field: 'process.name', + value: 5, + }, + ], + }, + }; + const { body } = await supertest + .post(DETECTION_ENGINE_RULES_URL) + .set('kbn-xsrf', 'true') + .send(rule) + .expect(400); + + expect(body).to.eql({ + message: ['Cardinality of a field that is being aggregated on is always 1'], + status_code: 400, + }); + }); + }); }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_update.ts b/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_update.ts index 0749abe811784..f4365386a8328 100644 --- a/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_update.ts +++ b/x-pack/test/detection_engine_api_integration/utils/get_simple_rule_update.ts @@ -10,7 +10,7 @@ import type { UpdateRulesSchema } from '@kbn/security-solution-plugin/common/det /** * This is a typical simple rule for testing that is easy for most basic testing * @param ruleId The rule id - * @param enabled Set to tru to enable it, by default it is off + * @param enabled Set to true to enable it, by default it is off */ export const getSimpleRuleUpdate = (ruleId = 'rule-1', enabled = false): UpdateRulesSchema => ({ name: 'Simple Rule Query', From e51e4579c42a281fbe177cd4e44e90e1abcda5b2 Mon Sep 17 00:00:00 2001 From: Rickyanto Ang Date: Thu, 12 May 2022 09:23:01 -0700 Subject: [PATCH 22/46] [8.3] [Session View]Metadata Tab (#131465) * Metadata tab + new fields, edited Host tab jest, need to add more unit test and delete unused fields * added accordion for Host, modified new fields * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * added more unit test for helper function, removed dropped fields from ECS spreadsheet, PR comments * PR Comments, Fixing Checks issue, more Jest * eslint * changed the logic for checking which accordion not to render * PR comments * PR Comments, localized new accordion * renaming file and component name from Host to Metadata * merge main + PR comments Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../common/types/process_tree/index.ts | 30 ++ .../detail_panel_accordion/index.tsx | 6 + .../detail_panel_accordion/styles.ts | 4 + .../detail_panel_host_tab/helpers.test.ts | 85 ---- .../detail_panel_host_tab/helpers.ts | 49 --- .../detail_panel_host_tab/index.test.tsx | 88 ---- .../detail_panel_host_tab/index.tsx | 198 --------- .../detail_panel_metadata_tab/helpers.test.ts | 181 ++++++++ .../detail_panel_metadata_tab/helpers.ts | 115 +++++ .../detail_panel_metadata_tab/index.test.tsx | 206 +++++++++ .../detail_panel_metadata_tab/index.tsx | 413 ++++++++++++++++++ .../detail_panel_metadata_tab/styles.ts | 27 ++ .../components/session_view/index.test.tsx | 2 +- .../session_view_detail_panel/index.test.tsx | 2 +- .../session_view_detail_panel/index.tsx | 16 +- x-pack/plugins/session_view/public/types.ts | 28 ++ .../translations/translations/fr-FR.json | 1 - .../translations/translations/ja-JP.json | 1 - .../translations/translations/zh-CN.json | 1 - 19 files changed, 1023 insertions(+), 430 deletions(-) delete mode 100644 x-pack/plugins/session_view/public/components/detail_panel_host_tab/helpers.test.ts delete mode 100644 x-pack/plugins/session_view/public/components/detail_panel_host_tab/helpers.ts delete mode 100644 x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx delete mode 100644 x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/helpers.test.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/helpers.ts create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/index.test.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/index.tsx create mode 100644 x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/styles.ts diff --git a/x-pack/plugins/session_view/common/types/process_tree/index.ts b/x-pack/plugins/session_view/common/types/process_tree/index.ts index f337b6a38c742..239a9f34632cd 100644 --- a/x-pack/plugins/session_view/common/types/process_tree/index.ts +++ b/x-pack/plugins/session_view/common/types/process_tree/index.ts @@ -149,6 +149,8 @@ export interface ProcessEvent { kibana?: { alert?: ProcessEventAlert; }; + container?: ProcessEventContainer; + orchestrator?: ProcessEventOrchestrator; } export interface ProcessEventsPage { @@ -187,3 +189,31 @@ export interface Process { export type ProcessMap = { [key: string]: Process; }; + +export interface ProcessEventContainer { + id?: string; + name?: string; + image?: { + name?: string; + tag?: string; + hash?: { + all?: string; + }; + }; +} + +export interface ProcessEventOrchestrator { + resource?: { + name?: string; + type?: string; + ip?: string; + }; + namespace?: string; + cluster?: { + name?: string; + id?: string; + }; + parent?: { + type?: string; + }; +} diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx index e3c44dd80d1ca..34ee8123b2654 100644 --- a/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/index.tsx @@ -19,6 +19,8 @@ interface DetailPanelAccordionDeps { tooltipContent?: string; extraActionTitle?: string; onExtraActionClick?: () => void; + children?: ReactNode; + initialIsOpen?: boolean; } /** @@ -31,6 +33,8 @@ export const DetailPanelAccordion = ({ tooltipContent, extraActionTitle, onExtraActionClick, + children, + initialIsOpen = false, }: DetailPanelAccordionDeps) => { const styles = useStyles(); @@ -38,6 +42,7 @@ export const DetailPanelAccordion = ({ + {children} ); }; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts index 96eddb2b2bf98..3df5bcfba44c4 100644 --- a/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts +++ b/x-pack/plugins/session_view/public/components/detail_panel_accordion/styles.ts @@ -25,6 +25,10 @@ export const useStyles = () => { dl: { paddingTop: '0px', }, + '&:only-child': { + border: euiTheme.border.thin, + borderRadius: euiTheme.border.radius.medium, + }, }; const accordionButton: CSSObject = { diff --git a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/helpers.test.ts b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/helpers.test.ts deleted file mode 100644 index a2d096d91310e..0000000000000 --- a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/helpers.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import { ProcessEventHost } from '../../../common/types/process_tree'; -import { DASH } from '../../constants'; -import { getHostData } from './helpers'; - -const MOCK_HOST_DATA: ProcessEventHost = { - architecture: 'x86_64', - hostname: 'james-fleet-714-2', - id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', - ip: ['127.0.0.1', '::1', '10.132.0.50', 'fe80::7d39:3147:4d9a:f809'], - mac: ['42:01:0a:84:00:32'], - name: 'james-fleet-714-2', - os: { - family: 'centos', - full: 'CentOS 7.9.2009', - kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', - name: 'Linux', - platform: 'centos', - version: '7.9.2009', - }, -}; - -describe('detail panel host tab helpers tests', () => { - it('getHostData returns fields with a dash with undefined host', () => { - const result = getHostData(undefined); - expect(result.architecture).toEqual(DASH); - expect(result.hostname).toEqual(DASH); - expect(result.id).toEqual(DASH); - expect(result.ip).toEqual(DASH); - expect(result.mac).toEqual(DASH); - expect(result.name).toEqual(DASH); - expect(result.os.family).toEqual(DASH); - expect(result.os.full).toEqual(DASH); - expect(result.os.kernel).toEqual(DASH); - expect(result.os.name).toEqual(DASH); - expect(result.os.platform).toEqual(DASH); - expect(result.os.version).toEqual(DASH); - }); - - it('getHostData returns dashes for missing fields', () => { - const result = getHostData({ - ...MOCK_HOST_DATA, - ip: ['127.0.0.1', '', '', 'fe80::7d39:3147:4d9a:f809'], - name: undefined, - os: { - ...MOCK_HOST_DATA.os, - full: undefined, - platform: undefined, - }, - }); - expect(result.architecture).toEqual(MOCK_HOST_DATA.architecture); - expect(result.hostname).toEqual(MOCK_HOST_DATA.hostname); - expect(result.id).toEqual(MOCK_HOST_DATA.id); - expect(result.ip).toEqual(['127.0.0.1', DASH, DASH, 'fe80::7d39:3147:4d9a:f809'].join(', ')); - expect(result.mac).toEqual(MOCK_HOST_DATA.mac?.join(', ')); - expect(result.name).toEqual(DASH); - expect(result.os.family).toEqual(MOCK_HOST_DATA.os?.family); - expect(result.os.full).toEqual(DASH); - expect(result.os.kernel).toEqual(MOCK_HOST_DATA.os?.kernel); - expect(result.os.name).toEqual(MOCK_HOST_DATA.os?.name); - expect(result.os.platform).toEqual(DASH); - expect(result.os.version).toEqual(MOCK_HOST_DATA.os?.version); - }); - - it('getHostData returns all data provided', () => { - const result = getHostData(MOCK_HOST_DATA); - expect(result.architecture).toEqual(MOCK_HOST_DATA.architecture); - expect(result.hostname).toEqual(MOCK_HOST_DATA.hostname); - expect(result.id).toEqual(MOCK_HOST_DATA.id); - expect(result.ip).toEqual(MOCK_HOST_DATA.ip?.join(', ')); - expect(result.mac).toEqual(MOCK_HOST_DATA.mac?.join(', ')); - expect(result.name).toEqual(MOCK_HOST_DATA.name); - expect(result.os.family).toEqual(MOCK_HOST_DATA.os?.family); - expect(result.os.full).toEqual(MOCK_HOST_DATA.os?.full); - expect(result.os.kernel).toEqual(MOCK_HOST_DATA.os?.kernel); - expect(result.os.name).toEqual(MOCK_HOST_DATA.os?.name); - expect(result.os.platform).toEqual(MOCK_HOST_DATA.os?.platform); - expect(result.os.version).toEqual(MOCK_HOST_DATA.os?.version); - }); -}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/helpers.ts b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/helpers.ts deleted file mode 100644 index 72565f5885e37..0000000000000 --- a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/helpers.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ProcessEventHost } from '../../../common/types/process_tree'; -import { DASH } from '../../constants'; -import { DetailPanelHost } from '../../types'; -import { dataOrDash } from '../../utils/data_or_dash'; - -export const getHostData = (host: ProcessEventHost | undefined): DetailPanelHost => { - const detailPanelHost: DetailPanelHost = { - architecture: DASH, - hostname: DASH, - id: DASH, - ip: DASH, - mac: DASH, - name: DASH, - os: { - family: DASH, - full: DASH, - kernel: DASH, - name: DASH, - platform: DASH, - version: DASH, - }, - }; - - if (!host) { - return detailPanelHost; - } - - detailPanelHost.hostname = dataOrDash(host.hostname).toString(); - detailPanelHost.id = dataOrDash(host.id).toString(); - detailPanelHost.ip = host.ip?.map?.((ip) => dataOrDash(ip)).join(', ') ?? DASH; - detailPanelHost.mac = host.mac?.map?.((mac) => dataOrDash(mac)).join(', ') ?? DASH; - detailPanelHost.name = dataOrDash(host.name).toString(); - detailPanelHost.architecture = dataOrDash(host.architecture).toString(); - detailPanelHost.os.family = dataOrDash(host.os?.family).toString(); - detailPanelHost.os.full = dataOrDash(host.os?.full).toString(); - detailPanelHost.os.kernel = dataOrDash(host.os?.kernel).toString(); - detailPanelHost.os.name = dataOrDash(host.os?.name).toString(); - detailPanelHost.os.platform = dataOrDash(host.os?.platform).toString(); - detailPanelHost.os.version = dataOrDash(host.os?.version).toString(); - - return detailPanelHost; -}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx deleted file mode 100644 index 41a5ada524974..0000000000000 --- a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; -import { ProcessEventHost } from '../../../common/types/process_tree'; -import { DetailPanelHostTab } from '.'; - -const TEST_ARCHITECTURE = 'x86_64'; -const TEST_HOSTNAME = 'host-james-fleet-714-2'; -const TEST_ID = '48c1b3f1ac5da4e0057fc9f60f4d1d5d'; -const TEST_IP = ['127.0.0.1', '::1', '10.132.0.50', 'fe80::7d39:3147:4d9a:f809']; -const TEST_MAC = ['42:01:0a:84:00:32']; -const TEST_NAME = 'name-james-fleet-714-2'; -const TEST_OS_FAMILY = 'family-centos'; -const TEST_OS_FULL = 'full-CentOS 7.9.2009'; -const TEST_OS_KERNEL = '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021'; -const TEST_OS_NAME = 'os-Linux'; -const TEST_OS_PLATFORM = 'platform-centos'; -const TEST_OS_VERSION = 'version-7.9.2009'; - -const TEST_HOST: ProcessEventHost = { - architecture: TEST_ARCHITECTURE, - hostname: TEST_HOSTNAME, - id: TEST_ID, - ip: TEST_IP, - mac: TEST_MAC, - name: TEST_NAME, - os: { - family: TEST_OS_FAMILY, - full: TEST_OS_FULL, - kernel: TEST_OS_KERNEL, - name: TEST_OS_NAME, - platform: TEST_OS_PLATFORM, - version: TEST_OS_VERSION, - }, -}; - -describe('DetailPanelHostTab component', () => { - let render: () => ReturnType; - let renderResult: ReturnType; - let mockedContext: AppContextTestRender; - - beforeEach(() => { - mockedContext = createAppRootMockRenderer(); - }); - - describe('When DetailPanelHostTab is mounted', () => { - it('renders DetailPanelHostTab correctly', async () => { - renderResult = mockedContext.render(); - - expect(renderResult.queryByText('architecture')).toBeVisible(); - expect(renderResult.queryByText('hostname')).toBeVisible(); - expect(renderResult.queryByText('id')).toBeVisible(); - expect(renderResult.queryByText('ip')).toBeVisible(); - expect(renderResult.queryByText('mac')).toBeVisible(); - expect(renderResult.queryByText('name')).toBeVisible(); - expect(renderResult.queryByText(TEST_ARCHITECTURE)).toBeVisible(); - expect(renderResult.queryByText(TEST_HOSTNAME)).toBeVisible(); - expect(renderResult.queryByText(TEST_ID)).toBeVisible(); - expect(renderResult.queryByText(TEST_IP.join(', '))).toBeVisible(); - expect(renderResult.queryByText(TEST_MAC.join(', '))).toBeVisible(); - expect(renderResult.queryByText(TEST_NAME)).toBeVisible(); - - // expand host os accordion - renderResult - .queryByTestId('sessionView:detail-panel-accordion') - ?.querySelector('button') - ?.click(); - expect(renderResult.queryByText('os.family')).toBeVisible(); - expect(renderResult.queryByText('os.full')).toBeVisible(); - expect(renderResult.queryByText('os.kernel')).toBeVisible(); - expect(renderResult.queryByText('os.name')).toBeVisible(); - expect(renderResult.queryByText('os.platform')).toBeVisible(); - expect(renderResult.queryByText('os.version')).toBeVisible(); - expect(renderResult.queryByText(TEST_OS_FAMILY)).toBeVisible(); - expect(renderResult.queryByText(TEST_OS_FULL)).toBeVisible(); - expect(renderResult.queryByText(TEST_OS_KERNEL)).toBeVisible(); - expect(renderResult.queryByText(TEST_OS_NAME)).toBeVisible(); - expect(renderResult.queryByText(TEST_OS_PLATFORM)).toBeVisible(); - expect(renderResult.queryByText(TEST_OS_VERSION)).toBeVisible(); - }); - }); -}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx deleted file mode 100644 index 2b1c2f97fa738..0000000000000 --- a/x-pack/plugins/session_view/public/components/detail_panel_host_tab/index.tsx +++ /dev/null @@ -1,198 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ -import React, { useMemo } from 'react'; -import { EuiTextColor } from '@elastic/eui'; -import { ProcessEventHost } from '../../../common/types/process_tree'; -import { DetailPanelAccordion } from '../detail_panel_accordion'; -import { DetailPanelCopy } from '../detail_panel_copy'; -import { DetailPanelDescriptionList } from '../detail_panel_description_list'; -import { DetailPanelListItem } from '../detail_panel_list_item'; -import { useStyles } from '../detail_panel_process_tab/styles'; -import { getHostData } from './helpers'; - -interface DetailPanelHostTabDeps { - processHost?: ProcessEventHost; -} - -/** - * Host Panel of session view detail panel. - */ -export const DetailPanelHostTab = ({ processHost }: DetailPanelHostTabDeps) => { - const styles = useStyles(); - const hostData = useMemo(() => getHostData(processHost), [processHost]); - - return ( - <> - hostname, - description: ( - - - {hostData.hostname} - - - ), - }, - { - title: id, - description: ( - - - {hostData.id} - - - ), - }, - { - title: ip, - description: ( - - - {hostData.ip} - - - ), - }, - { - title: mac, - description: ( - - - {hostData.mac} - - - ), - }, - { - title: name, - description: ( - - - {hostData.name} - - - ), - }, - ]} - /> - architecture, - description: ( - - - {hostData.architecture} - - - ), - }, - { - title: os.family, - description: ( - - - {hostData.os.family} - - - ), - }, - { - title: os.full, - description: ( - - - {hostData.os.full} - - - ), - }, - { - title: os.kernel, - description: ( - - - {hostData.os.kernel} - - - ), - }, - { - title: os.name, - description: ( - - - {hostData.os.name} - - - ), - }, - { - title: os.platform, - description: ( - - - {hostData.os.platform} - - - ), - }, - { - title: os.version, - description: ( - - - {hostData.os.version} - - - ), - }, - ]} - /> - - ); -}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/helpers.test.ts b/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/helpers.test.ts new file mode 100644 index 0000000000000..5689af3c950c0 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/helpers.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { + ProcessEventHost, + ProcessEventContainer, + ProcessEventOrchestrator, +} from '../../../common/types/process_tree'; +import { DASH } from '../../constants'; +import { getHostData, getContainerData, getOrchestratorData } from './helpers'; + +const MOCK_HOST_DATA: ProcessEventHost = { + architecture: 'x86_64', + hostname: 'james-fleet-714-2', + id: '48c1b3f1ac5da4e0057fc9f60f4d1d5d', + ip: ['127.0.0.1', '::1', '10.132.0.50', 'fe80::7d39:3147:4d9a:f809'], + mac: ['42:01:0a:84:00:32'], + name: 'james-fleet-714-2', + os: { + family: 'centos', + full: 'CentOS 7.9.2009', + kernel: '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021', + name: 'Linux', + platform: 'centos', + version: '7.9.2009', + }, +}; + +const MOCK_CONTAINER_DATA: ProcessEventContainer = { + id: 'containerd://5fe98d5566148268631302790833b7a14317a2fd212e3e4117bede77d0ca9ba6', + name: 'gce-pd-driver', + image: { + name: 'gke.gcr.io/gcp-compute-persistent-disk-csi-driver', + tag: 'v1.3.5-gke.0', + hash: { + all: 'PLACEHOLDER_FOR_IMAGE.HASH.ALL', + }, + }, +}; + +const MOCK_ORCHESTRATOR_DATA: ProcessEventOrchestrator = { + resource: { + name: 'pdcsi-node-6hvsp', + type: 'pod', + ip: 'PLACEHOLDER_FOR_RESOURCE.IP', + }, + namespace: 'kube-system', + cluster: { + name: 'elastic-k8s-cluster', + id: 'PLACEHOLDER_FOR_CLUSTER.ID', + }, + parent: { + type: 'PLACEHOLDER_FOR_PARENT.TYPE', + }, +}; + +describe('detail panel host tab helpers tests', () => { + it('getHostData returns fields with a dash with undefined host', () => { + const result = getHostData(undefined); + expect(result.architecture).toEqual(DASH); + expect(result.hostname).toEqual(DASH); + expect(result.id).toEqual(DASH); + expect(result.ip).toEqual(DASH); + expect(result.mac).toEqual(DASH); + expect(result.name).toEqual(DASH); + expect(result.os.family).toEqual(DASH); + expect(result.os.full).toEqual(DASH); + expect(result.os.kernel).toEqual(DASH); + expect(result.os.name).toEqual(DASH); + expect(result.os.platform).toEqual(DASH); + expect(result.os.version).toEqual(DASH); + }); + + it('getHostData returns dashes for missing fields', () => { + const result = getHostData({ + ...MOCK_HOST_DATA, + ip: ['127.0.0.1', '', '', 'fe80::7d39:3147:4d9a:f809'], + name: undefined, + os: { + ...MOCK_HOST_DATA.os, + full: undefined, + platform: undefined, + }, + }); + expect(result.architecture).toEqual(MOCK_HOST_DATA.architecture); + expect(result.hostname).toEqual(MOCK_HOST_DATA.hostname); + expect(result.id).toEqual(MOCK_HOST_DATA.id); + expect(result.ip).toEqual(['127.0.0.1', DASH, DASH, 'fe80::7d39:3147:4d9a:f809'].join(', ')); + expect(result.mac).toEqual(MOCK_HOST_DATA.mac?.join(', ')); + expect(result.name).toEqual(DASH); + expect(result.os.family).toEqual(MOCK_HOST_DATA.os?.family); + expect(result.os.full).toEqual(DASH); + expect(result.os.kernel).toEqual(MOCK_HOST_DATA.os?.kernel); + expect(result.os.name).toEqual(MOCK_HOST_DATA.os?.name); + expect(result.os.platform).toEqual(DASH); + expect(result.os.version).toEqual(MOCK_HOST_DATA.os?.version); + }); + + it('getHostData returns all data provided', () => { + const result = getHostData(MOCK_HOST_DATA); + expect(result.architecture).toEqual(MOCK_HOST_DATA.architecture); + expect(result.hostname).toEqual(MOCK_HOST_DATA.hostname); + expect(result.id).toEqual(MOCK_HOST_DATA.id); + expect(result.ip).toEqual(MOCK_HOST_DATA.ip?.join(', ')); + expect(result.mac).toEqual(MOCK_HOST_DATA.mac?.join(', ')); + expect(result.name).toEqual(MOCK_HOST_DATA.name); + expect(result.os.family).toEqual(MOCK_HOST_DATA.os?.family); + expect(result.os.full).toEqual(MOCK_HOST_DATA.os?.full); + expect(result.os.kernel).toEqual(MOCK_HOST_DATA.os?.kernel); + expect(result.os.name).toEqual(MOCK_HOST_DATA.os?.name); + expect(result.os.platform).toEqual(MOCK_HOST_DATA.os?.platform); + expect(result.os.version).toEqual(MOCK_HOST_DATA.os?.version); + }); + + it('getContainerData returns dashes for missing fields', () => { + const result = getContainerData({ + id: undefined, + name: 'gce-pd-driver', + image: { + name: undefined, + tag: 'v1.3.5-gke.0', + hash: { + all: undefined, + }, + }, + }); + expect(result.id).toEqual(DASH); + expect(result.name).toEqual(MOCK_CONTAINER_DATA.name); + expect(result.image.name).toEqual(DASH); + expect(result.image.tag).toEqual(MOCK_CONTAINER_DATA?.image?.tag); + expect(result.image.hash.all).toEqual(DASH); + }); + + it('getContainerData returns all data provided', () => { + const result = getContainerData(MOCK_CONTAINER_DATA); + expect(result.id).toEqual(MOCK_CONTAINER_DATA.id); + expect(result.name).toEqual(MOCK_CONTAINER_DATA.name); + expect(result.image.name).toEqual(MOCK_CONTAINER_DATA?.image?.name); + expect(result.image.tag).toEqual(MOCK_CONTAINER_DATA?.image?.tag); + expect(result.image.hash.all).toEqual(MOCK_CONTAINER_DATA?.image?.hash?.all); + }); + + it('getOchestratorData returns dashes for missing fields', () => { + const result = getOrchestratorData({ + resource: { + name: undefined, + type: 'pod', + ip: undefined, + }, + namespace: 'kube-system', + cluster: { + name: 'elastic-k8s-cluster', + id: undefined, + }, + parent: { + type: 'PLACEHOLDER_FOR_PARENT.TYPE', + }, + }); + expect(result.resource.name).toEqual(DASH); + expect(result.resource.type).toEqual(MOCK_ORCHESTRATOR_DATA?.resource?.type); + expect(result.resource.ip).toEqual(DASH); + expect(result.namespace).toEqual(MOCK_ORCHESTRATOR_DATA?.namespace); + expect(result.cluster.name).toEqual(MOCK_ORCHESTRATOR_DATA?.cluster?.name); + expect(result.cluster.id).toEqual(DASH); + expect(result.parent.type).toEqual(MOCK_ORCHESTRATOR_DATA?.parent?.type); + }); + + it('getOchestratorData returns all data provided', () => { + const result = getOrchestratorData(MOCK_ORCHESTRATOR_DATA); + expect(result.resource.name).toEqual(MOCK_ORCHESTRATOR_DATA?.resource?.name); + expect(result.resource.type).toEqual(MOCK_ORCHESTRATOR_DATA?.resource?.type); + expect(result.resource.ip).toEqual(MOCK_ORCHESTRATOR_DATA?.resource?.ip); + expect(result.namespace).toEqual(MOCK_ORCHESTRATOR_DATA?.namespace); + expect(result.cluster.name).toEqual(MOCK_ORCHESTRATOR_DATA?.cluster?.name); + expect(result.cluster.id).toEqual(MOCK_ORCHESTRATOR_DATA?.cluster?.id); + expect(result.parent.type).toEqual(MOCK_ORCHESTRATOR_DATA?.parent?.type); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/helpers.ts b/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/helpers.ts new file mode 100644 index 0000000000000..384e8698a37b2 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/helpers.ts @@ -0,0 +1,115 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + ProcessEventHost, + ProcessEventContainer, + ProcessEventOrchestrator, +} from '../../../common/types/process_tree'; +import { DASH } from '../../constants'; +import { DetailPanelHost, DetailPanelContainer, DetailPanelOrchestrator } from '../../types'; +import { dataOrDash } from '../../utils/data_or_dash'; + +export const getHostData = (host: ProcessEventHost | undefined): DetailPanelHost => { + const detailPanelHost: DetailPanelHost = { + architecture: DASH, + hostname: DASH, + id: DASH, + ip: DASH, + mac: DASH, + name: DASH, + os: { + family: DASH, + full: DASH, + kernel: DASH, + name: DASH, + platform: DASH, + version: DASH, + }, + }; + + if (!host) { + return detailPanelHost; + } + + detailPanelHost.hostname = dataOrDash(host.hostname).toString(); + detailPanelHost.id = dataOrDash(host.id).toString(); + detailPanelHost.ip = host.ip?.map?.((ip) => dataOrDash(ip)).join(', ') ?? DASH; + detailPanelHost.mac = host.mac?.map?.((mac) => dataOrDash(mac)).join(', ') ?? DASH; + detailPanelHost.name = dataOrDash(host.name).toString(); + detailPanelHost.architecture = dataOrDash(host.architecture).toString(); + detailPanelHost.os.family = dataOrDash(host.os?.family).toString(); + detailPanelHost.os.full = dataOrDash(host.os?.full).toString(); + detailPanelHost.os.kernel = dataOrDash(host.os?.kernel).toString(); + detailPanelHost.os.name = dataOrDash(host.os?.name).toString(); + detailPanelHost.os.platform = dataOrDash(host.os?.platform).toString(); + detailPanelHost.os.version = dataOrDash(host.os?.version).toString(); + + return detailPanelHost; +}; + +export const getContainerData = ( + container: ProcessEventContainer | undefined +): DetailPanelContainer => { + const detailPanelContainer: DetailPanelContainer = { + id: DASH, + name: DASH, + image: { + name: DASH, + tag: DASH, + hash: { + all: DASH, + }, + }, + }; + + if (!container) { + return detailPanelContainer; + } + + detailPanelContainer.id = dataOrDash(container.id).toString(); + detailPanelContainer.name = dataOrDash(container.name).toString(); + detailPanelContainer.image.name = dataOrDash(container?.image?.name).toString(); + detailPanelContainer.image.tag = dataOrDash(container?.image?.tag).toString(); + detailPanelContainer.image.hash.all = dataOrDash(container?.image?.hash?.all).toString(); + + return detailPanelContainer; +}; + +export const getOrchestratorData = ( + orchestrator: ProcessEventOrchestrator | undefined +): DetailPanelOrchestrator => { + const detailPanelOrchestrator: DetailPanelOrchestrator = { + resource: { + name: DASH, + type: DASH, + ip: DASH, + }, + namespace: DASH, + cluster: { + name: DASH, + id: DASH, + }, + parent: { + type: DASH, + }, + }; + + if (!orchestrator) { + return detailPanelOrchestrator; + } + + detailPanelOrchestrator.resource.name = dataOrDash(orchestrator?.resource?.name).toString(); + detailPanelOrchestrator.resource.type = dataOrDash(orchestrator?.resource?.type).toString(); + detailPanelOrchestrator.resource.ip = dataOrDash(orchestrator?.resource?.ip).toString(); + detailPanelOrchestrator.namespace = dataOrDash(orchestrator?.namespace).toString(); + detailPanelOrchestrator.cluster.name = dataOrDash(orchestrator?.cluster?.name).toString(); + detailPanelOrchestrator.cluster.id = dataOrDash(orchestrator?.cluster?.id).toString(); + detailPanelOrchestrator.parent.type = dataOrDash(orchestrator?.parent?.type).toString(); + + return detailPanelOrchestrator; +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/index.test.tsx b/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/index.test.tsx new file mode 100644 index 0000000000000..19d14743b5245 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/index.test.tsx @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../test'; +import { + ProcessEventHost, + ProcessEventContainer, + ProcessEventOrchestrator, +} from '../../../common/types/process_tree'; +import { DetailPanelMetadataTab } from '.'; + +const TEST_ARCHITECTURE = 'x86_64'; +const TEST_HOSTNAME = 'host-james-fleet-714-2'; +const TEST_ID = '48c1b3f1ac5da4e0057fc9f60f4d1d5d'; +const TEST_IP = ['127.0.0.1', '::1', '10.132.0.50', 'fe80::7d39:3147:4d9a:f809']; +const TEST_MAC = ['42:01:0a:84:00:32']; +const TEST_NAME = 'name-james-fleet-714-2'; +const TEST_OS_FAMILY = 'family-centos'; +const TEST_OS_FULL = 'full-CentOS 7.9.2009'; +const TEST_OS_KERNEL = '3.10.0-1160.31.1.el7.x86_64 #1 SMP Thu Jun 10 13:32:12 UTC 2021'; +const TEST_OS_NAME = 'os-Linux'; +const TEST_OS_PLATFORM = 'platform-centos'; +const TEST_OS_VERSION = 'version-7.9.2009'; + +// Container data +const TEST_CONTAINER_ID = + 'containerd://5fe98d5566148268631302790833b7a14317a2fd212e3e4117bede77d0ca9ba6'; +const TEST_CONTAINER_NAME = 'gce-pd-driver'; +const TEST_CONTAINER_IMAGE_NAME = 'gke.gcr.io/gcp-compute-persistent-disk-csi-driver'; +const TEST_CONTAINER_IMAGE_TAG = 'v1.3.5-gke.0'; +const TEST_CONTAINER_IMAGE_HASH_ALL = 'PLACEHOLDER_FOR_IMAGE.HASH.ALL'; + +// Orchestrator data +const TEST_ORCHESTRATOR_RESOURCE_NAME = 'pdcsi-node-6hvsp'; +const TEST_ORCHESTRATOR_RESOURCE_TYPE = 'pod'; +const TEST_ORCHESTRATOR_RESOURCE_IP = 'PLACEHOLDER_FOR_RESOURCE.IP'; +const TEST_ORCHESTRATOR_NAMESPACE = 'kube-system'; +const TEST_ORCHESTRATOR_PARENT_TYPE = 'elastic-k8s-cluster'; +const TEST_ORCHESTRATOR_CLUSTER_ID = 'PLACEHOLDER_FOR_CLUSTER.ID'; +const TEST_ORCHESTRATOR_CLUSTER_NAME = 'PLACEHOLDER_FOR_PARENT.TYPE'; + +const TEST_HOST: ProcessEventHost = { + architecture: TEST_ARCHITECTURE, + hostname: TEST_HOSTNAME, + id: TEST_ID, + ip: TEST_IP, + mac: TEST_MAC, + name: TEST_NAME, + os: { + family: TEST_OS_FAMILY, + full: TEST_OS_FULL, + kernel: TEST_OS_KERNEL, + name: TEST_OS_NAME, + platform: TEST_OS_PLATFORM, + version: TEST_OS_VERSION, + }, +}; + +const TEST_CONTAINER: ProcessEventContainer = { + id: TEST_CONTAINER_ID, + name: TEST_CONTAINER_NAME, + image: { + name: TEST_CONTAINER_IMAGE_NAME, + tag: TEST_CONTAINER_IMAGE_TAG, + hash: { + all: TEST_CONTAINER_IMAGE_HASH_ALL, + }, + }, +}; + +const TEST_ORCHESTRATOR: ProcessEventOrchestrator = { + resource: { + name: TEST_ORCHESTRATOR_RESOURCE_NAME, + type: TEST_ORCHESTRATOR_RESOURCE_TYPE, + ip: TEST_ORCHESTRATOR_RESOURCE_IP, + }, + namespace: TEST_ORCHESTRATOR_NAMESPACE, + cluster: { + name: TEST_ORCHESTRATOR_CLUSTER_NAME, + id: TEST_ORCHESTRATOR_CLUSTER_ID, + }, + parent: { + type: TEST_ORCHESTRATOR_PARENT_TYPE, + }, +}; + +describe('DetailPanelMetadataTab component', () => { + let render: () => ReturnType; + let renderResult: ReturnType; + let mockedContext: AppContextTestRender; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + }); + + describe('When DetailPanelMetadataTab is mounted', () => { + it('renders DetailPanelMetadataTab correctly (non cloud environment)', async () => { + renderResult = mockedContext.render(); + + expect(renderResult.queryByText('architecture')).toBeVisible(); + expect(renderResult.queryByText('hostname')).toBeVisible(); + expect(renderResult.queryAllByText('id').length).toBe(1); + expect(renderResult.queryByText('ip')).toBeVisible(); + expect(renderResult.queryByText('mac')).toBeVisible(); + expect(renderResult.queryAllByText('name').length).toBe(1); + expect(renderResult.queryByText(TEST_ARCHITECTURE)).toBeVisible(); + expect(renderResult.queryByText(TEST_HOSTNAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_ID)).toBeVisible(); + expect(renderResult.queryByText(TEST_IP.join(', '))).toBeVisible(); + expect(renderResult.queryByText(TEST_MAC.join(', '))).toBeVisible(); + expect(renderResult.queryByText(TEST_NAME)).toBeVisible(); + + // expand host os accordion + renderResult.queryByText('Host OS')?.querySelector('button')?.click(); + expect(renderResult.queryByText('os.family')).toBeVisible(); + expect(renderResult.queryByText('os.full')).toBeVisible(); + expect(renderResult.queryByText('os.kernel')).toBeVisible(); + expect(renderResult.queryByText('os.name')).toBeVisible(); + expect(renderResult.queryByText('os.platform')).toBeVisible(); + expect(renderResult.queryByText('os.version')).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_FAMILY)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_FULL)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_KERNEL)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_NAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_PLATFORM)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_VERSION)).toBeVisible(); + + // Orchestrator and Container should be missing if session came from a Non-cloud env + expect(renderResult.queryByText('Container')).toBeNull(); + expect(renderResult.queryByText('Orchestrator')).toBeNull(); + }); + + it('renders DetailPanelMetadataTab correctly (cloud environment)', async () => { + renderResult = mockedContext.render( + + ); + + expect(renderResult.queryByText('architecture')).toBeVisible(); + expect(renderResult.queryByText('hostname')).toBeVisible(); + expect(renderResult.queryByText('ip')).toBeVisible(); + expect(renderResult.queryByText('mac')).toBeVisible(); + expect(renderResult.queryByText(TEST_ARCHITECTURE)).toBeVisible(); + expect(renderResult.queryByText(TEST_HOSTNAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_ID)).toBeVisible(); + expect(renderResult.queryByText(TEST_IP.join(', '))).toBeVisible(); + expect(renderResult.queryByText(TEST_MAC.join(', '))).toBeVisible(); + expect(renderResult.queryByText(TEST_NAME)).toBeVisible(); + + // Checks for existence of id and name fields in Host and Container accordion + expect(renderResult.queryAllByText('id').length).toBe(2); + expect(renderResult.queryAllByText('name').length).toBe(2); + + // expand host os accordion + renderResult.queryByText('Host OS')?.querySelector('button')?.click(); + expect(renderResult.queryByText('os.family')).toBeVisible(); + expect(renderResult.queryByText('os.full')).toBeVisible(); + expect(renderResult.queryByText('os.kernel')).toBeVisible(); + expect(renderResult.queryByText('os.name')).toBeVisible(); + expect(renderResult.queryByText('os.platform')).toBeVisible(); + expect(renderResult.queryByText('os.version')).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_FAMILY)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_FULL)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_KERNEL)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_NAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_PLATFORM)).toBeVisible(); + expect(renderResult.queryByText(TEST_OS_VERSION)).toBeVisible(); + + // expand Container Accordion + renderResult.queryByText('Container')?.querySelector('button')?.click(); + expect(renderResult.queryByText('image.name')).toBeVisible(); + expect(renderResult.queryByText('image.tag')).toBeVisible(); + expect(renderResult.queryByText('image.hash.all')).toBeVisible(); + expect(renderResult.queryByText(TEST_CONTAINER_ID)).toBeVisible(); + expect(renderResult.queryByText(TEST_CONTAINER_NAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_CONTAINER_IMAGE_NAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_CONTAINER_IMAGE_TAG)).toBeVisible(); + expect(renderResult.queryByText(TEST_CONTAINER_IMAGE_HASH_ALL)).toBeVisible(); + + // expand Orchestrator Accordion + renderResult.queryByText('Orchestrator')?.querySelector('button')?.click(); + expect(renderResult.queryByText('resource.name')).toBeVisible(); + expect(renderResult.queryByText('resource.type')).toBeVisible(); + expect(renderResult.queryByText('resource.ip')).toBeVisible(); + expect(renderResult.queryByText('namespace')).toBeVisible(); + expect(renderResult.queryByText('parent.type')).toBeVisible(); + expect(renderResult.queryByText('cluster.id')).toBeVisible(); + expect(renderResult.queryByText('cluster.name')).toBeVisible(); + expect(renderResult.queryByText(TEST_ORCHESTRATOR_RESOURCE_NAME)).toBeVisible(); + expect(renderResult.queryByText(TEST_ORCHESTRATOR_RESOURCE_TYPE)).toBeVisible(); + expect(renderResult.queryByText(TEST_ORCHESTRATOR_RESOURCE_IP)).toBeVisible(); + expect(renderResult.queryByText(TEST_ORCHESTRATOR_NAMESPACE)).toBeVisible(); + expect(renderResult.queryByText(TEST_ORCHESTRATOR_PARENT_TYPE)).toBeVisible(); + expect(renderResult.queryByText(TEST_ORCHESTRATOR_CLUSTER_ID)).toBeVisible(); + expect(renderResult.queryByText(TEST_ORCHESTRATOR_CLUSTER_NAME)).toBeVisible(); + }); + }); +}); diff --git a/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/index.tsx b/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/index.tsx new file mode 100644 index 0000000000000..457c98ede4258 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/index.tsx @@ -0,0 +1,413 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useMemo } from 'react'; +import { EuiTextColor, EuiPanel } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + ProcessEventHost, + ProcessEventContainer, + ProcessEventOrchestrator, +} from '../../../common/types/process_tree'; +import { DetailPanelAccordion } from '../detail_panel_accordion'; +import { DetailPanelCopy } from '../detail_panel_copy'; +import { DetailPanelListItem } from '../detail_panel_list_item'; +import { useStyles } from '../detail_panel_process_tab/styles'; +import { useStyles as useStylesChild } from './styles'; +import { getHostData, getContainerData, getOrchestratorData } from './helpers'; + +interface DetailPanelMetadataTabDeps { + processHost?: ProcessEventHost; + processContainer?: ProcessEventContainer; + processOrchestrator?: ProcessEventOrchestrator; +} + +/** + * Host Panel of session view detail panel. + */ +export const DetailPanelMetadataTab = ({ + processHost, + processContainer, + processOrchestrator, +}: DetailPanelMetadataTabDeps) => { + const styles = useStyles(); + const stylesChild = useStylesChild(); + const hostData = useMemo(() => getHostData(processHost), [processHost]); + const containerData = useMemo(() => getContainerData(processContainer), [processContainer]); + const orchestratorData = useMemo( + () => getOrchestratorData(processOrchestrator), + [processOrchestrator] + ); + + return ( + <> + hostname, + description: ( + + + {hostData.hostname} + + + ), + }, + { + title: id, + description: ( + + + {hostData.id} + + + ), + }, + { + title: ip, + description: ( + + + {hostData.ip} + + + ), + }, + { + title: mac, + description: ( + + + {hostData.mac} + + + ), + }, + { + title: name, + description: ( + + + {hostData.name} + + + ), + }, + ]} + > + + architecture, + description: ( + + + {hostData.architecture} + + + ), + }, + { + title: os.family, + description: ( + + + {hostData.os.family} + + + ), + }, + { + title: os.full, + description: ( + + + {hostData.os.full} + + + ), + }, + { + title: os.kernel, + description: ( + + + {hostData.os.kernel} + + + ), + }, + { + title: os.name, + description: ( + + + {hostData.os.name} + + + ), + }, + { + title: os.platform, + description: ( + + + {hostData.os.platform} + + + ), + }, + { + title: os.version, + description: ( + + + {hostData.os.version} + + + ), + }, + ]} + /> + + + + {processContainer && ( + <> + id, + description: ( + + + {containerData.id} + + + ), + }, + { + title: name, + description: ( + + + {containerData.name} + + + ), + }, + { + title: image.name, + description: ( + + + {containerData.image.name} + + + ), + }, + { + title: image.tag, + description: ( + + + {containerData.image.tag} + + + ), + }, + { + title: image.hash.all, + description: ( + + + {containerData.image.hash.all} + + + ), + }, + ]} + /> + + )} + {processOrchestrator && ( + <> + resource.ip, + description: ( + + + {orchestratorData.resource.ip} + + + ), + }, + { + title: resource.name, + description: ( + + + {orchestratorData.resource.name} + + + ), + }, + { + title: resource.type, + description: ( + + + {orchestratorData.resource.type} + + + ), + }, + { + title: namespace, + description: ( + + + {orchestratorData.namespace} + + + ), + }, + { + title: cluster.id, + description: ( + + + {orchestratorData.cluster.id} + + + ), + }, + { + title: cluster.name, + description: ( + + + {orchestratorData.cluster.name} + + + ), + }, + { + title: parent.type, + description: ( + + + {orchestratorData.parent.type} + + + ), + }, + ]} + /> + + )} + + ); +}; diff --git a/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/styles.ts b/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/styles.ts new file mode 100644 index 0000000000000..0e6348e3d15f5 --- /dev/null +++ b/x-pack/plugins/session_view/public/components/detail_panel_metadata_tab/styles.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; +import { useEuiTheme } from '@elastic/eui'; +import { CSSObject } from '@emotion/react'; + +export const useStyles = () => { + const { euiTheme } = useEuiTheme(); + + const cached = useMemo(() => { + const metadataHostOS: CSSObject = { + margin: `${euiTheme.size.m} ${euiTheme.size.base} ${euiTheme.size.base} ${euiTheme.size.base}`, + paddingBottom: euiTheme.size.base, + }; + + return { + metadataHostOS, + }; + }, [euiTheme]); + + return cached; +}; diff --git a/x-pack/plugins/session_view/public/components/session_view/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view/index.test.tsx index f51bdf16c6e56..89775f867fcec 100644 --- a/x-pack/plugins/session_view/public/components/session_view/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/session_view/index.test.tsx @@ -110,7 +110,7 @@ describe('SessionView component', () => { userEvent.click(renderResult.getByTestId('sessionView:sessionViewDetailPanelToggle')); expect(renderResult.getByText('Process')).toBeTruthy(); - expect(renderResult.getByText('Host')).toBeTruthy(); + expect(renderResult.getByText('Metadata')).toBeTruthy(); expect(renderResult.getByText('Alerts')).toBeTruthy(); }); diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx index 9ddefa25cea07..b89a5b5314764 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.test.tsx @@ -58,7 +58,7 @@ describe('SessionView component', () => { /> ); - renderResult.queryByText('Host')?.click(); + renderResult.queryByText('Metadata')?.click(); expect(renderResult.queryByText('hostname')).toBeVisible(); expect(renderResult.queryAllByText('james-fleet-714-2')).toHaveLength(2); }); diff --git a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx index a22ad026c4395..cd52bc5ff23bb 100644 --- a/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx +++ b/x-pack/plugins/session_view/public/components/session_view_detail_panel/index.tsx @@ -11,7 +11,7 @@ import { EuiTabProps } from '../../types'; import { Process, ProcessEvent } from '../../../common/types/process_tree'; import { getSelectedTabContent } from './helpers'; import { DetailPanelProcessTab } from '../detail_panel_process_tab'; -import { DetailPanelHostTab } from '../detail_panel_host_tab'; +import { DetailPanelMetadataTab } from '../detail_panel_metadata_tab'; import { useStyles } from './styles'; import { DetailPanelAlertTab } from '../detail_panel_alert_tab'; import { ALERT_COUNT_THRESHOLD } from '../../../common/constants'; @@ -56,11 +56,17 @@ export const SessionViewDetailPanel = ({ content: , }, { - id: 'host', - name: i18n.translate('xpack.sessionView.detailsPanel.host', { - defaultMessage: 'Host', + id: 'metadata', + name: i18n.translate('xpack.sessionView.detailsPanel.metadata', { + defaultMessage: 'Metadata', }), - content: , + content: ( + + ), }, { id: 'alerts', diff --git a/x-pack/plugins/session_view/public/types.ts b/x-pack/plugins/session_view/public/types.ts index 1f90ae05b0791..8617a580bf522 100644 --- a/x-pack/plugins/session_view/public/types.ts +++ b/x-pack/plugins/session_view/public/types.ts @@ -90,6 +90,34 @@ export interface DetailPanelHost { }; } +export interface DetailPanelContainer { + id: string; + name: string; + image: { + name: string; + tag: string; + hash: { + all: string; + }; + }; +} + +export interface DetailPanelOrchestrator { + resource: { + name: string; + type: string; + ip: string; + }; + namespace: string; + cluster: { + name: string; + id: string; + }; + parent: { + type: string; + }; +} + export interface SessionViewStart { getSessionView: (props: SessionViewDeps) => JSX.Element; } diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 912ff4b27eb05..093c8828b7642 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -26853,7 +26853,6 @@ "xpack.sessionView.detailPanelAlertsEmptyTitle": "Aucune alerte", "xpack.sessionView.detailPanelCopy.copyButton": "Copier", "xpack.sessionView.detailsPanel.alerts": "Alertes", - "xpack.sessionView.detailsPanel.host": "Hôte", "xpack.sessionView.detailsPanel.process": "Processus", "xpack.sessionView.emptyDataMessage": "Aucun événement de processus n'a été trouvé pour cette requête.", "xpack.sessionView.emptyDataTitle": "Aucune donnée à rendre", diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 29356fece58f5..71f752480d644 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -27022,7 +27022,6 @@ "xpack.sessionView.detailPanelAlertsEmptyTitle": "アラートなし", "xpack.sessionView.detailPanelCopy.copyButton": "コピー", "xpack.sessionView.detailsPanel.alerts": "アラート", - "xpack.sessionView.detailsPanel.host": "ホスト", "xpack.sessionView.detailsPanel.process": "プロセス", "xpack.sessionView.emptyDataMessage": "このクエリのプロセスイベントが見つかりません。", "xpack.sessionView.emptyDataTitle": "表示するデータがありません", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 2bc68dd4c30f8..3d391ce40f8c4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -27056,7 +27056,6 @@ "xpack.sessionView.detailPanelAlertsEmptyTitle": "无告警", "xpack.sessionView.detailPanelCopy.copyButton": "复制", "xpack.sessionView.detailsPanel.alerts": "告警", - "xpack.sessionView.detailsPanel.host": "主机", "xpack.sessionView.detailsPanel.process": "进程", "xpack.sessionView.emptyDataMessage": "找不到此查询的进程事件。", "xpack.sessionView.emptyDataTitle": "没有可呈现的数据", From e9b1d3834ab925c48e83519c7cbffe7413f7d58a Mon Sep 17 00:00:00 2001 From: gchaps <33642766+gchaps@users.noreply.github.com> Date: Thu, 12 May 2022 09:23:30 -0700 Subject: [PATCH 23/46] [DOCS] Fixes typo in Console doc (#132078) * [DOCS] Fixes typo in Console doc This PR fixes a typo in the Console doc. * Update docs/dev-tools/console/console.asciidoc * Update docs/dev-tools/console/console.asciidoc --- docs/dev-tools/console/console.asciidoc | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev-tools/console/console.asciidoc b/docs/dev-tools/console/console.asciidoc index 21334c31011f4..69f81d838c143 100644 --- a/docs/dev-tools/console/console.asciidoc +++ b/docs/dev-tools/console/console.asciidoc @@ -12,7 +12,7 @@ To get started, open the main menu, click *Dev Tools*, then click *Console*. [role="screenshot"] image::dev-tools/console/images/console.png["Console"] -NOTE: You cannot to interact with the REST API of {kib} with the Console. +NOTE: **Console** supports only Elasticsearch APIs. You are unable to interact with the {kib} APIs with **Console** and must use curl or another HTTP tool instead. [float] [[console-api]] @@ -137,4 +137,4 @@ shortcuts, click *Help*. If you don’t want to use *Console*, you can disable it by setting `console.ui.enabled` to `false` in your `kibana.yml` configuration file. Changing this setting causes the server to regenerate assets on the next startup, -which might cause a delay before pages start being served. \ No newline at end of file +which might cause a delay before pages start being served. From b932dcab0b6941e252cdcbb2aca95a1fe7ad7ace Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Thu, 12 May 2022 12:29:06 -0400 Subject: [PATCH 24/46] [Connectors] `ConnectorTokenClient` improvements (#131955) * Throwing error if service now access token is null. Properly returning rejected promise * Setting time to calculate token expiration to before the token is created * Returning null access token if stored access token has invalid expiresAt date * Adding response interceptor to delete connector token if using access token returns 4xx error * Adding test for tokenRequestDate * Handling 4xx errors in the response * Fixing unit tests * Fixing types --- .../lib/connector_token_client.test.ts | 107 ++++++++++- .../lib/connector_token_client.ts | 35 +++- ...th_client_credentials_access_token.test.ts | 21 ++- ...t_oauth_client_credentials_access_token.ts | 4 + .../lib/get_oauth_jwt_access_token.test.ts | 19 +- .../lib/get_oauth_jwt_access_token.ts | 4 + .../lib/send_email.test.ts | 123 ++++++++++-- .../builtin_action_types/lib/send_email.ts | 30 ++- .../lib/send_email_graph_api.ts | 8 +- .../servicenow/utils.test.ts | 175 ++++++++++++++++-- .../builtin_action_types/servicenow/utils.ts | 29 ++- 11 files changed, 490 insertions(+), 65 deletions(-) diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts index 54765b9e01b8f..dfa307ca3cd91 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.test.ts @@ -5,6 +5,7 @@ * 2.0. */ +import sinon from 'sinon'; import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; import { ConnectorTokenClient } from './connector_token_client'; @@ -23,7 +24,13 @@ const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); let connectorTokenClient: ConnectorTokenClient; +let clock: sinon.SinonFakeTimers; + +beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); +}); beforeEach(() => { + clock.reset(); jest.resetAllMocks(); connectorTokenClient = new ConnectorTokenClient({ unsecuredSavedObjectsClient, @@ -31,6 +38,7 @@ beforeEach(() => { logger, }); }); +afterAll(() => clock.restore()); describe('create()', () => { test('creates connector_token with all given properties', async () => { @@ -131,7 +139,7 @@ describe('get()', () => { expect(result).toEqual({ connectorToken: null, hasErrors: false }); }); - test('return null and log the error if unsecuredSavedObjectsClient thows an error', async () => { + test('return null and log the error if unsecuredSavedObjectsClient throws an error', async () => { unsecuredSavedObjectsClient.find.mockRejectedValueOnce(new Error('Fail')); const result = await connectorTokenClient.get({ @@ -145,7 +153,7 @@ describe('get()', () => { expect(result).toEqual({ connectorToken: null, hasErrors: true }); }); - test('return null and log the error if encryptedSavedObjectsClient decrypt method thows an error', async () => { + test('return null and log the error if encryptedSavedObjectsClient decrypt method throws an error', async () => { const expectedResult = { total: 1, per_page: 10, @@ -178,6 +186,47 @@ describe('get()', () => { ]); expect(result).toEqual({ connectorToken: null, hasErrors: true }); }); + + test('return null and log the error if expiresAt is NaN', async () => { + const expectedResult = { + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + createdAt: new Date().toISOString(), + expiresAt: 'yo', + }, + score: 1, + references: [], + }, + ], + }; + unsecuredSavedObjectsClient.find.mockResolvedValueOnce(expectedResult); + encryptedSavedObjectsClient.getDecryptedAsInternalUser.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + references: [], + attributes: { + token: 'testtokenvalue', + }, + }); + + const result = await connectorTokenClient.get({ + connectorId: '123', + tokenType: 'access_token', + }); + + expect(logger.error.mock.calls[0]).toMatchObject([ + `Failed to get connector_token for connectorId "123" and tokenType: "access_token". Error: expiresAt is not a valid Date "yo"`, + ]); + expect(result).toEqual({ connectorToken: null, hasErrors: true }); + }); }); describe('update()', () => { @@ -375,12 +424,60 @@ describe('updateOrReplace()', () => { connectorId: '1', token: null, newToken: 'newToken', + tokenRequestDate: undefined as unknown as number, expiresInSec: 1000, deleteExisting: false, }); expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); - expect((unsecuredSavedObjectsClient.create.mock.calls[0][1] as ConnectorToken).token).toBe( - 'newToken' + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'connector_token', + { + connectorId: '1', + createdAt: '2021-01-01T12:00:00.000Z', + expiresAt: '2021-01-01T12:16:40.000Z', + token: 'newToken', + tokenType: 'access_token', + updatedAt: '2021-01-01T12:00:00.000Z', + }, + { id: 'mock-saved-object-id' } + ); + + expect(unsecuredSavedObjectsClient.find).not.toHaveBeenCalled(); + expect(unsecuredSavedObjectsClient.delete).not.toHaveBeenCalled(); + }); + + test('uses tokenRequestDate to determine expire time if provided', async () => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'connector_token', + attributes: { + connectorId: '123', + tokenType: 'access_token', + token: 'testtokenvalue', + expiresAt: new Date('2021-01-01T08:00:00.000Z').toISOString(), + }, + references: [], + }); + await connectorTokenClient.updateOrReplace({ + connectorId: '1', + token: null, + newToken: 'newToken', + tokenRequestDate: new Date('2021-03-03T00:00:00.000Z').getTime(), + expiresInSec: 1000, + deleteExisting: false, + }); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledTimes(1); + expect(unsecuredSavedObjectsClient.create).toHaveBeenCalledWith( + 'connector_token', + { + connectorId: '1', + createdAt: '2021-01-01T12:00:00.000Z', + expiresAt: '2021-03-03T00:16:40.000Z', + token: 'newToken', + tokenType: 'access_token', + updatedAt: '2021-01-01T12:00:00.000Z', + }, + { id: 'mock-saved-object-id' } ); expect(unsecuredSavedObjectsClient.find).not.toHaveBeenCalled(); @@ -434,6 +531,7 @@ describe('updateOrReplace()', () => { connectorId: '1', token: null, newToken: 'newToken', + tokenRequestDate: Date.now(), expiresInSec: 1000, deleteExisting: true, }); @@ -483,6 +581,7 @@ describe('updateOrReplace()', () => { expiresAt: new Date().toISOString(), }, newToken: 'newToken', + tokenRequestDate: Date.now(), expiresInSec: 1000, deleteExisting: true, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts index 6ce91fad94546..df1615d503329 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/connector_token_client.ts @@ -38,6 +38,7 @@ interface UpdateOrReplaceOptions { token: ConnectorToken | null; newToken: string; expiresInSec: number; + tokenRequestDate: number; deleteExisting: boolean; } export class ConnectorTokenClient { @@ -195,6 +196,7 @@ export class ConnectorTokenClient { return { hasErrors: false, connectorToken: null }; } + let accessToken: string; try { const { attributes: { token }, @@ -203,14 +205,7 @@ export class ConnectorTokenClient { connectorTokensResult[0].id ); - return { - hasErrors: false, - connectorToken: { - id: connectorTokensResult[0].id, - ...connectorTokensResult[0].attributes, - token, - }, - }; + accessToken = token; } catch (err) { this.logger.error( `Failed to decrypt connector_token for connectorId "${connectorId}" and tokenType: "${ @@ -219,6 +214,24 @@ export class ConnectorTokenClient { ); return { hasErrors: true, connectorToken: null }; } + + if (isNaN(Date.parse(connectorTokensResult[0].attributes.expiresAt))) { + this.logger.error( + `Failed to get connector_token for connectorId "${connectorId}" and tokenType: "${ + tokenType ?? 'access_token' + }". Error: expiresAt is not a valid Date "${connectorTokensResult[0].attributes.expiresAt}"` + ); + return { hasErrors: true, connectorToken: null }; + } + + return { + hasErrors: false, + connectorToken: { + id: connectorTokensResult[0].id, + ...connectorTokensResult[0].attributes, + token: accessToken, + }, + }; } /** @@ -258,9 +271,11 @@ export class ConnectorTokenClient { token, newToken, expiresInSec, + tokenRequestDate, deleteExisting, }: UpdateOrReplaceOptions) { expiresInSec = expiresInSec ?? 3600; + tokenRequestDate = tokenRequestDate ?? Date.now(); if (token === null) { if (deleteExisting) { await this.deleteConnectorTokens({ @@ -272,14 +287,14 @@ export class ConnectorTokenClient { await this.create({ connectorId, token: newToken, - expiresAtMillis: new Date(Date.now() + expiresInSec * 1000).toISOString(), + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), tokenType: 'access_token', }); } else { await this.update({ id: token.id!.toString(), token: newToken, - expiresAtMillis: new Date(Date.now() + expiresInSec * 1000).toISOString(), + expiresAtMillis: new Date(tokenRequestDate + expiresInSec * 1000).toISOString(), tokenType: 'access_token', }); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.test.ts index 2efa79cf09c48..c3464a11e557e 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.test.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import sinon from 'sinon'; import { Logger } from '@kbn/core/server'; import { asyncForEach } from '@kbn/std'; import { loggingSystemMock } from '@kbn/core/server/mocks'; @@ -20,7 +21,15 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; const configurationUtilities = actionsConfigMock.create(); const connectorTokenClient = connectorTokenClientMock.create(); +let clock: sinon.SinonFakeTimers; + describe('getOAuthClientCredentialsAccessToken', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); + }); + beforeEach(() => clock.reset()); + afterAll(() => clock.restore()); + const getOAuthClientCredentialsAccessTokenOpts = { connectorId: '123', logger, @@ -52,8 +61,8 @@ describe('getOAuthClientCredentialsAccessToken', () => { connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 10000000000).toISOString(), + createdAt: new Date('2021-01-01T08:00:00.000Z').toISOString(), + expiresAt: new Date('2021-01-02T13:00:00.000Z').toISOString(), }, }); const accessToken = await getOAuthClientCredentialsAccessToken( @@ -95,14 +104,15 @@ describe('getOAuthClientCredentialsAccessToken', () => { connectorId: '123', token: null, newToken: 'access_token brandnewaccesstoken', + tokenRequestDate: 1609502400000, expiresInSec: 1000, deleteExisting: false, }); }); test('creates new assertion if stored access token exists but is expired', async () => { - const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() - 100).toISOString(); + const createdAt = new Date('2021-01-01T08:00:00.000Z').toISOString(); + const expiresAt = new Date('2021-01-01T09:00:00.000Z').toISOString(); connectorTokenClient.get.mockResolvedValueOnce({ hasErrors: false, connectorToken: { @@ -147,6 +157,7 @@ describe('getOAuthClientCredentialsAccessToken', () => { expiresAt, }, newToken: 'access_token brandnewaccesstoken', + tokenRequestDate: 1609502400000, expiresInSec: 1000, deleteExisting: false, }); @@ -210,6 +221,7 @@ describe('getOAuthClientCredentialsAccessToken', () => { (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ tokenType: 'access_token', accessToken: 'brandnewaccesstoken', + tokenRequestDate: 1609502400000, expiresIn: 1000, }); connectorTokenClient.updateOrReplace.mockRejectedValueOnce(new Error('updateOrReplace error')); @@ -268,6 +280,7 @@ describe('getOAuthClientCredentialsAccessToken', () => { (requestOAuthClientCredentialsToken as jest.Mock).mockResolvedValueOnce({ tokenType: 'access_token', accessToken: 'brandnewaccesstoken', + tokenRequestDate: 1609502400000, expiresIn: 1000, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.ts index 803cce2db7668..1cce245a154c2 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_client_credentials_access_token.ts @@ -62,6 +62,9 @@ export const getOAuthClientCredentialsAccessToken = async ({ } if (connectorToken === null || Date.parse(connectorToken.expiresAt) <= Date.now()) { + // Save the time before requesting token so we can use it to calculate expiration + const requestTokenStart = Date.now(); + // request access token with jwt assertion const tokenResult = await requestOAuthClientCredentialsToken( tokenUrl, @@ -82,6 +85,7 @@ export const getOAuthClientCredentialsAccessToken = async ({ connectorId, token: connectorToken, newToken: accessToken, + tokenRequestDate: requestTokenStart, expiresInSec: tokenResult.expiresIn, deleteExisting: hasErrors, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.test.ts index b48456ddd2a8c..0fe837fc0581a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.test.ts @@ -4,6 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ +import sinon from 'sinon'; import { Logger } from '@kbn/core/server'; import { asyncForEach } from '@kbn/std'; import { loggingSystemMock } from '@kbn/core/server/mocks'; @@ -24,7 +25,15 @@ const logger = loggingSystemMock.create().get() as jest.Mocked; const configurationUtilities = actionsConfigMock.create(); const connectorTokenClient = connectorTokenClientMock.create(); +let clock: sinon.SinonFakeTimers; + describe('getOAuthJwtAccessToken', () => { + beforeAll(() => { + clock = sinon.useFakeTimers(new Date('2021-01-01T12:00:00.000Z')); + }); + beforeEach(() => clock.reset()); + afterAll(() => clock.restore()); + const getOAuthJwtAccessTokenOpts = { connectorId: '123', logger, @@ -58,8 +67,8 @@ describe('getOAuthJwtAccessToken', () => { connectorId: '123', tokenType: 'access_token', token: 'testtokenvalue', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 10000000000).toISOString(), + createdAt: new Date('2021-01-01T08:00:00.000Z').toISOString(), + expiresAt: new Date('2021-01-02T13:00:00.000Z').toISOString(), }, }); const accessToken = await getOAuthJwtAccessToken(getOAuthJwtAccessTokenOpts); @@ -105,14 +114,15 @@ describe('getOAuthJwtAccessToken', () => { connectorId: '123', token: null, newToken: 'access_token brandnewaccesstoken', + tokenRequestDate: 1609502400000, expiresInSec: 1000, deleteExisting: false, }); }); test('creates new assertion if stored access token exists but is expired', async () => { - const createdAt = new Date().toISOString(); - const expiresAt = new Date(Date.now() - 100).toISOString(); + const createdAt = new Date('2021-01-01T08:00:00.000Z').toISOString(); + const expiresAt = new Date('2021-01-01T09:00:00.000Z').toISOString(); connectorTokenClient.get.mockResolvedValueOnce({ hasErrors: false, connectorToken: { @@ -161,6 +171,7 @@ describe('getOAuthJwtAccessToken', () => { createdAt, expiresAt, }, + tokenRequestDate: 1609502400000, newToken: 'access_token brandnewaccesstoken', expiresInSec: 1000, deleteExisting: false, diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.ts index a4867d99556e7..1233a61c0f3c8 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/get_oauth_jwt_access_token.ts @@ -72,6 +72,9 @@ export const getOAuthJwtAccessToken = async ({ keyId: jwtKeyId, }); + // Save the time before requesting token so we can use it to calculate expiration + const requestTokenStart = Date.now(); + // request access token with jwt assertion const tokenResult = await requestOAuthJWTToken( tokenUrl, @@ -92,6 +95,7 @@ export const getOAuthJwtAccessToken = async ({ connectorId, token: connectorToken, newToken: accessToken, + tokenRequestDate: requestTokenStart, expiresInSec: tokenResult.expiresIn, deleteExisting: hasErrors, }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts index fbf0d90541659..fe6fc3492492a 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.test.ts @@ -5,10 +5,21 @@ * 2.0. */ +import axios from 'axios'; +import { Logger } from '@kbn/core/server'; +import { sendEmail } from './send_email'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import nodemailer from 'nodemailer'; +import { ProxySettings } from '../../types'; +import { actionsConfigMock } from '../../actions_config.mock'; +import { CustomHostSettings } from '../../config'; +import { sendEmailGraphApi } from './send_email_graph_api'; +import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token'; +import { connectorTokenClientMock } from './connector_token_client.mock'; + jest.mock('nodemailer', () => ({ createTransport: jest.fn(), })); - jest.mock('./send_email_graph_api', () => ({ sendEmailGraphApi: jest.fn(), })); @@ -16,36 +27,32 @@ jest.mock('./get_oauth_client_credentials_access_token', () => ({ getOAuthClientCredentialsAccessToken: jest.fn(), })); -import { Logger } from '@kbn/core/server'; -import { sendEmail } from './send_email'; -import { loggingSystemMock, savedObjectsClientMock } from '@kbn/core/server/mocks'; -import nodemailer from 'nodemailer'; -import { ProxySettings } from '../../types'; -import { actionsConfigMock } from '../../actions_config.mock'; -import { CustomHostSettings } from '../../config'; -import { sendEmailGraphApi } from './send_email_graph_api'; -import { getOAuthClientCredentialsAccessToken } from './get_oauth_client_credentials_access_token'; -import { ConnectorTokenClient } from './connector_token_client'; -import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; +jest.mock('axios'); +const mockAxiosInstanceInterceptor = { + request: { eject: jest.fn(), use: jest.fn() }, + response: { eject: jest.fn(), use: jest.fn() }, +}; const createTransportMock = nodemailer.createTransport as jest.Mock; const sendMailMockResult = { result: 'does not matter' }; const sendMailMock = jest.fn(); const mockLogger = loggingSystemMock.create().get() as jest.Mocked; -const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); -const encryptedSavedObjectsClient = encryptedSavedObjectsMock.createClient(); -const connectorTokenClient = new ConnectorTokenClient({ - unsecuredSavedObjectsClient, - encryptedSavedObjectsClient, - logger: mockLogger, -}); +const connectorTokenClient = connectorTokenClientMock.create(); describe('send_email module', () => { beforeEach(() => { jest.resetAllMocks(); createTransportMock.mockReturnValue({ sendMail: sendMailMock }); sendMailMock.mockResolvedValue(sendMailMockResult); + + axios.create = jest.fn(() => { + const actual = jest.requireActual('axios'); + return { + ...actual.create, + interceptors: mockAxiosInstanceInterceptor, + }; + }); }); test('handles authenticated email using service', async () => { @@ -125,6 +132,7 @@ describe('send_email module', () => { delete sendEmailGraphApiMock.mock.calls[0][0].options.configurationUtilities; sendEmailGraphApiMock.mock.calls[0].pop(); + sendEmailGraphApiMock.mock.calls[0].pop(); expect(sendEmailGraphApiMock.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -647,6 +655,83 @@ describe('send_email module', () => { ] `); }); + + test('deletes saved access tokens if 4xx response received', async () => { + const createAxiosInstanceMock = axios.create as jest.Mock; + const sendEmailOptions = getSendEmailOptions({ + transport: { + service: 'exchange_server', + clientId: '123456', + tenantId: '98765', + clientSecret: 'sdfhkdsjhfksdjfh', + }, + }); + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValueOnce( + 'Bearer clienttokentokentoken' + ); + + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(mockAxiosInstanceInterceptor.response.use).toHaveBeenCalledTimes(1); + + const mockResponseCallback = (mockAxiosInstanceInterceptor.response.use as jest.Mock).mock + .calls[0][1]; + + const errorResponse = { + response: { + status: 403, + statusText: 'Forbidden', + data: { + error: { + message: 'Insufficient rights to query records', + detail: 'Field(s) present in the query do not have permission to be read', + }, + status: 'failure', + }, + }, + }; + + await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse); + + expect(connectorTokenClient.deleteConnectorTokens).toHaveBeenCalledWith({ + connectorId: '1', + }); + }); + + test('does not delete saved access token if not 4xx error response received', async () => { + const createAxiosInstanceMock = axios.create as jest.Mock; + const sendEmailOptions = getSendEmailOptions({ + transport: { + service: 'exchange_server', + clientId: '123456', + tenantId: '98765', + clientSecret: 'sdfhkdsjhfksdjfh', + }, + }); + (getOAuthClientCredentialsAccessToken as jest.Mock).mockResolvedValueOnce( + 'Bearer clienttokentokentoken' + ); + + await sendEmail(mockLogger, sendEmailOptions, connectorTokenClient); + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(mockAxiosInstanceInterceptor.response.use).toHaveBeenCalledTimes(1); + + const mockResponseCallback = (mockAxiosInstanceInterceptor.response.use as jest.Mock).mock + .calls[0][1]; + + const errorResponse = { + response: { + status: 500, + statusText: 'Server error', + }, + }; + + await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse); + + expect(connectorTokenClient.deleteConnectorTokens).not.toHaveBeenCalled(); + }); }); function getSendEmailOptions( diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts index f2b059e51e0d6..2fee4dd8b377d 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email.ts @@ -5,6 +5,7 @@ * 2.0. */ +import axios, { AxiosResponse } from 'axios'; // info on nodemailer: https://nodemailer.com/about/ import nodemailer from 'nodemailer'; import { default as MarkdownIt } from 'markdown-it'; @@ -77,7 +78,7 @@ export async function sendEmail( } // send an email using MS Exchange Graph API -async function sendEmailWithExchange( +export async function sendEmailWithExchange( logger: Logger, options: SendEmailOptions, messageHTML: string, @@ -113,6 +114,30 @@ async function sendEmailWithExchange( Authorization: accessToken, }; + const axiosInstance = axios.create(); + axiosInstance.interceptors.response.use( + async (response: AxiosResponse) => { + // Look for 4xx errors that indicate something is wrong with the request + // We don't know for sure that it is an access token issue but remove saved + // token just to be sure + if (response.status >= 400 && response.status < 500) { + await connectorTokenClient.deleteConnectorTokens({ connectorId }); + } + return response; + }, + async (error) => { + const statusCode = error?.response?.status; + + // Look for 4xx errors that indicate something is wrong with the request + // We don't know for sure that it is an access token issue but remove saved + // token just to be sure + if (statusCode >= 400 && statusCode < 500) { + await connectorTokenClient.deleteConnectorTokens({ connectorId }); + } + return Promise.reject(error); + } + ); + return await sendEmailGraphApi( { options, @@ -121,7 +146,8 @@ async function sendEmailWithExchange( graphApiUrl: configurationUtilities.getMicrosoftGraphApiUrl(), }, logger, - configurationUtilities + configurationUtilities, + axiosInstance ); } diff --git a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts index c16cd884cb753..6475426143af7 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/lib/send_email_graph_api.ts @@ -7,7 +7,7 @@ // @ts-expect-error missing type def import stringify from 'json-stringify-safe'; -import axios, { AxiosResponse } from 'axios'; +import axios, { AxiosInstance, AxiosResponse } from 'axios'; import { Logger } from '@kbn/core/server'; import { request } from './axios_utils'; import { ActionsConfigurationUtilities } from '../../actions_config'; @@ -25,11 +25,13 @@ const MICROSOFT_GRAPH_API_HOST = 'https://graph.microsoft.com/v1.0'; export async function sendEmailGraphApi( sendEmailOptions: SendEmailGraphApiOptions, logger: Logger, - configurationUtilities: ActionsConfigurationUtilities + configurationUtilities: ActionsConfigurationUtilities, + axiosInstance?: AxiosInstance ): Promise { const { options, headers, messageHTML, graphApiUrl } = sendEmailOptions; - const axiosInstance = axios.create(); + // Create a new axios instance if one is not provided + axiosInstance = axiosInstance ?? axios.create(); // POST /users/{id | userPrincipalName}/sendMail const res = await request({ diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts index 54cfa146c7e22..fcd2023dc8e27 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.test.ts @@ -192,17 +192,6 @@ describe('utils', () => { }); test('creates axios instance with interceptor when isOAuth is true and OAuth fields are defined', async () => { - connectorTokenClient.get.mockResolvedValueOnce({ - hasErrors: false, - connectorToken: { - id: '1', - connectorId: '123', - tokenType: 'access_token', - token: 'testtokenvalue', - createdAt: new Date().toISOString(), - expiresAt: new Date(Date.now() + 10000000000).toISOString(), - }, - }); getAxiosInstance({ connectorId: '123', logger, @@ -260,5 +249,169 @@ describe('utils', () => { connectorTokenClient, }); }); + + test('throws expected error if getOAuthJwtAccessToken returns null access token', async () => { + getAxiosInstance({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + apiUrl: 'https://servicenow', + usesTableApi: true, + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + username: null, + password: null, + }, + }, + snServiceUrl: 'https://dev23432523.service-now.com', + connectorTokenClient, + }); + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1); + + (getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce(null); + + const mockRequestCallback = (axiosInstanceMock.interceptors.request.use as jest.Mock).mock + .calls[0][0]; + + await expect(() => + mockRequestCallback({ headers: {} }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Unable to retrieve access token for connectorId: 123"` + ); + + expect(getOAuthJwtAccessToken as jest.Mock).toHaveBeenCalledWith({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + }, + }, + tokenUrl: 'https://dev23432523.service-now.com/oauth_token.do', + connectorTokenClient, + }); + }); + + test('deletes saved access tokens if 4xx response received', async () => { + getAxiosInstance({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + apiUrl: 'https://servicenow', + usesTableApi: true, + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + username: null, + password: null, + }, + }, + snServiceUrl: 'https://dev23432523.service-now.com', + connectorTokenClient, + }); + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1); + expect(axiosInstanceMock.interceptors.response.use).toHaveBeenCalledTimes(1); + + (getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce('Bearer tokentokentoken'); + + const mockResponseCallback = (axiosInstanceMock.interceptors.response.use as jest.Mock).mock + .calls[0][1]; + + const errorResponse = { + response: { + status: 403, + statusText: 'Forbidden', + data: { + error: { + message: 'Insufficient rights to query records', + detail: 'Field(s) present in the query do not have permission to be read', + }, + status: 'failure', + }, + }, + }; + + await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse); + + expect(connectorTokenClient.deleteConnectorTokens).toHaveBeenCalledWith({ + connectorId: '123', + }); + }); + + test('does not delete saved access token if not 4xx error response received', async () => { + getAxiosInstance({ + connectorId: '123', + logger, + configurationUtilities, + credentials: { + config: { + apiUrl: 'https://servicenow', + usesTableApi: true, + isOAuth: true, + clientId: 'clientId', + jwtKeyId: 'jwtKeyId', + userIdentifierValue: 'userIdentifierValue', + }, + secrets: { + clientSecret: 'clientSecret', + privateKey: 'privateKey', + privateKeyPassword: null, + username: null, + password: null, + }, + }, + snServiceUrl: 'https://dev23432523.service-now.com', + connectorTokenClient, + }); + expect(createAxiosInstanceMock).toHaveBeenCalledTimes(1); + expect(createAxiosInstanceMock).toHaveBeenCalledWith(); + expect(axiosInstanceMock.interceptors.request.use).toHaveBeenCalledTimes(1); + expect(axiosInstanceMock.interceptors.response.use).toHaveBeenCalledTimes(1); + + (getOAuthJwtAccessToken as jest.Mock).mockResolvedValueOnce('Bearer tokentokentoken'); + + const mockResponseCallback = (axiosInstanceMock.interceptors.response.use as jest.Mock).mock + .calls[0][1]; + + const errorResponse = { + response: { + status: 500, + statusText: 'Server error', + }, + }; + + await expect(() => mockResponseCallback(errorResponse)).rejects.toEqual(errorResponse); + + expect(connectorTokenClient.deleteConnectorTokens).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts index f457220debb6d..92fd13d86e608 100644 --- a/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts +++ b/x-pack/plugins/actions/server/builtin_action_types/servicenow/utils.ts @@ -5,7 +5,7 @@ * 2.0. */ -import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; +import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import { Logger } from '@kbn/core/server'; import { ExternalServiceCredentials, @@ -83,12 +83,12 @@ export const throwIfSubActionIsNotSupported = ({ }; export interface GetAxiosInstanceOpts { - connectorId?: string; + connectorId: string; logger: Logger; configurationUtilities: ActionsConfigurationUtilities; credentials: ExternalServiceCredentials; snServiceUrl: string; - connectorTokenClient?: ConnectorTokenClientContract; + connectorTokenClient: ConnectorTokenClientContract; } export const getAxiosInstance = ({ @@ -134,15 +134,28 @@ export const getAxiosInstance = ({ tokenUrl: `${snServiceUrl}/oauth_token.do`, connectorTokenClient, }); - - if (accessToken) { - axiosConfig.headers = { ...axiosConfig.headers, Authorization: accessToken }; + if (!accessToken) { + throw new Error(`Unable to retrieve access token for connectorId: ${connectorId}`); } - + axiosConfig.headers = { ...axiosConfig.headers, Authorization: accessToken }; return axiosConfig; }, (error) => { - Promise.reject(error); + return Promise.reject(error); + } + ); + axiosInstance.interceptors.response.use( + (response: AxiosResponse) => response, + async (error) => { + const statusCode = error?.response?.status; + + // Look for 4xx errors that indicate something is wrong with the request + // We don't know for sure that it is an access token issue but remove saved + // token just to be sure + if (statusCode >= 400 && statusCode < 500) { + await connectorTokenClient.deleteConnectorTokens({ connectorId }); + } + return Promise.reject(error); } ); } From eda92d49e02230e38b196791dab576b6b69a06d3 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 12 May 2022 12:01:28 -0500 Subject: [PATCH 25/46] [precommit-hook] autofix precommit check failures on CI (#132141) Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .buildkite/scripts/steps/checks.sh | 2 +- .../scripts/steps/checks/commit/commit.sh | 14 ---------- .../checks/commit/commit_check_runner.sh | 13 ---------- .../scripts/steps/checks/precommit_hook.sh | 26 +++++++++++++++++++ src/dev/run_precommit_hook.js | 14 +++++----- 5 files changed, 35 insertions(+), 34 deletions(-) delete mode 100755 .buildkite/scripts/steps/checks/commit/commit.sh delete mode 100755 .buildkite/scripts/steps/checks/commit/commit_check_runner.sh create mode 100755 .buildkite/scripts/steps/checks/precommit_hook.sh diff --git a/.buildkite/scripts/steps/checks.sh b/.buildkite/scripts/steps/checks.sh index 024037a8a4bb9..8388dc82f5254 100755 --- a/.buildkite/scripts/steps/checks.sh +++ b/.buildkite/scripts/steps/checks.sh @@ -5,7 +5,7 @@ set -euo pipefail export DISABLE_BOOTSTRAP_VALIDATION=false .buildkite/scripts/bootstrap.sh -.buildkite/scripts/steps/checks/commit/commit.sh +.buildkite/scripts/steps/checks/precommit_hook.sh .buildkite/scripts/steps/checks/bazel_packages.sh .buildkite/scripts/steps/checks/telemetry.sh .buildkite/scripts/steps/checks/ts_projects.sh diff --git a/.buildkite/scripts/steps/checks/commit/commit.sh b/.buildkite/scripts/steps/checks/commit/commit.sh deleted file mode 100755 index 5ff2632103a63..0000000000000 --- a/.buildkite/scripts/steps/checks/commit/commit.sh +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -source .buildkite/scripts/common/util.sh - -# Runs pre-commit hook script for the files touched in the last commit. -# That way we can ensure a set of quick commit checks earlier as we removed -# the pre-commit hook installation by default. -# If files are more than 200 we will skip it and just use -# the further ci steps that already check linting and file casing for the entire repo. -echo --- Quick commit checks -checks-reporter-with-killswitch "Quick commit checks" \ - "$(dirname "${0}")/commit_check_runner.sh" diff --git a/.buildkite/scripts/steps/checks/commit/commit_check_runner.sh b/.buildkite/scripts/steps/checks/commit/commit_check_runner.sh deleted file mode 100755 index 8d35c3698f3e1..0000000000000 --- a/.buildkite/scripts/steps/checks/commit/commit_check_runner.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -run_quick_commit_checks() { - echo "!!!!!!!! ATTENTION !!!!!!!! -That check is intended to provide earlier CI feedback after we remove the automatic install for the local pre-commit hook. -If you want, you can still manually install the pre-commit hook locally by running 'node scripts/register_git_hook locally' -!!!!!!!!!!!!!!!!!!!!!!!!!!! -" - - node scripts/precommit_hook.js --ref HEAD~1..HEAD --max-files 200 --verbose -} - -run_quick_commit_checks diff --git a/.buildkite/scripts/steps/checks/precommit_hook.sh b/.buildkite/scripts/steps/checks/precommit_hook.sh new file mode 100755 index 0000000000000..8fa51a4f4d23c --- /dev/null +++ b/.buildkite/scripts/steps/checks/precommit_hook.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source .buildkite/scripts/common/util.sh + +# Runs pre-commit hook script for the files touched in the last commit. +# That way we can ensure a set of quick commit checks earlier as we removed +# the pre-commit hook installation by default. +# If files are more than 200 we will skip it and just use +# the further ci steps that already check linting and file casing for the entire repo. +echo --- Run Precommit Hook + +echo "!!!!!!!! ATTENTION !!!!!!!! +That check is intended to provide earlier CI feedback after we remove the automatic install for the local pre-commit hook. +If you want, you can still manually install the pre-commit hook locally by running 'node scripts/register_git_hook locally' +!!!!!!!!!!!!!!!!!!!!!!!!!!!" + +node scripts/precommit_hook.js \ + --ref HEAD~1..HEAD \ + --max-files 200 \ + --verbose \ + --fix \ + --no-stage # we have to disable staging or check_for_changed_files won't see the changes + +check_for_changed_files 'node scripts/precommit_hook.js --ref HEAD~1..HEAD --fix' true diff --git a/src/dev/run_precommit_hook.js b/src/dev/run_precommit_hook.js index bc50aeddc619e..a86bb5c7dabcc 100644 --- a/src/dev/run_precommit_hook.js +++ b/src/dev/run_precommit_hook.js @@ -49,7 +49,7 @@ run( fix: flags.fix, }); - if (flags.fix) { + if (flags.fix && flags.stage) { const simpleGit = new SimpleGit(REPO_ROOT); await simpleGit.add(filesToLint); } @@ -68,16 +68,18 @@ run( Run checks on files that are staged for commit by default `, flags: { - boolean: ['fix'], + boolean: ['fix', 'stage'], string: ['max-files', 'ref'], default: { fix: false, + stage: true, }, help: ` - --fix Execute eslint in --fix mode - --max-files Max files number to check against. If exceeded the script will skip the execution - --ref Run checks against any git ref files (example HEAD or ) instead of running against staged ones - `, + --fix Execute eslint in --fix mode + --max-files Max files number to check against. If exceeded the script will skip the execution + --ref Run checks against any git ref files (example HEAD or ) instead of running against staged ones + --no-stage By default when using --fix the changes are staged, use --no-stage to disable that behavior + `, }, } ); From 11ae98d81238114a294ce076d3d49c55040967e1 Mon Sep 17 00:00:00 2001 From: Devon Thomson Date: Thu, 12 May 2022 13:22:30 -0400 Subject: [PATCH 26/46] [Dashboard][Controls] Make Text Field Based Options List Controls Case Insensitive (#131198) * Adds case insensitive search and run past timeout option --- .../control_types/options_list/types.ts | 37 +- .../public/__stories__/controls.stories.tsx | 7 +- .../options_list/options_list_editor.tsx | 77 ++++- .../options_list/options_list_embeddable.tsx | 24 +- .../options_list/options_list_strings.ts | 4 + .../public/services/kibana/options_list.ts | 16 +- .../controls/public/services/options_list.ts | 17 +- .../public/services/storybook/options_list.ts | 5 +- .../options_list/options_list_queries.test.ts | 321 ++++++++++++++++++ .../options_list/options_list_queries.ts | 174 ++++++++++ .../options_list_suggestions_route.ts | 147 +++----- .../components/field_picker/field_picker.tsx | 2 +- .../controls/options_list.ts | 8 + 13 files changed, 691 insertions(+), 148 deletions(-) create mode 100644 src/plugins/controls/server/control_types/options_list/options_list_queries.test.ts create mode 100644 src/plugins/controls/server/control_types/options_list/options_list_queries.ts diff --git a/src/plugins/controls/common/control_types/options_list/types.ts b/src/plugins/controls/common/control_types/options_list/types.ts index 0f889bed7bacb..7dfdfab742d1a 100644 --- a/src/plugins/controls/common/control_types/options_list/types.ts +++ b/src/plugins/controls/common/control_types/options_list/types.ts @@ -6,27 +6,60 @@ * Side Public License, v 1. */ -import { BoolQuery } from '@kbn/es-query'; -import { FieldSpec } from '@kbn/data-views-plugin/common'; +import { TimeRange } from '@kbn/data-plugin/common'; +import { Filter, Query, BoolQuery } from '@kbn/es-query'; +import { FieldSpec, DataView, DataViewField } from '@kbn/data-views-plugin/common'; + import { DataControlInput } from '../../types'; export const OPTIONS_LIST_CONTROL = 'optionsListControl'; export interface OptionsListEmbeddableInput extends DataControlInput { selectedOptions?: string[]; + runPastTimeout?: boolean; + textFieldName?: string; singleSelect?: boolean; loading?: boolean; } +export type OptionsListField = DataViewField & { + textFieldName?: string; + parentFieldName?: string; + childFieldName?: string; +}; + +/** + * The Options list response is returned from the serverside Options List route. + */ export interface OptionsListResponse { suggestions: string[]; totalCardinality: number; invalidSelections?: string[]; } +/** + * The Options list request type taken in by the public Options List service. + */ +export type OptionsListRequest = Omit< + OptionsListRequestBody, + 'filters' | 'fieldName' | 'fieldSpec' | 'textFieldName' +> & { + timeRange?: TimeRange; + field: OptionsListField; + runPastTimeout?: boolean; + dataView: DataView; + filters?: Filter[]; + query?: Query; +}; + +/** + * The Options list request body is sent to the serverside Options List route and is used to create the ES query. + */ export interface OptionsListRequestBody { filters?: Array<{ bool: BoolQuery }>; selectedOptions?: string[]; + runPastTimeout?: boolean; + textFieldName?: string; searchString?: string; fieldSpec?: FieldSpec; fieldName: string; diff --git a/src/plugins/controls/public/__stories__/controls.stories.tsx b/src/plugins/controls/public/__stories__/controls.stories.tsx index 481016af72a36..e8133e7dae503 100644 --- a/src/plugins/controls/public/__stories__/controls.stories.tsx +++ b/src/plugins/controls/public/__stories__/controls.stories.tsx @@ -31,12 +31,11 @@ import { decorators } from './decorators'; import { ControlsPanels } from '../control_group/types'; import { ControlGroupContainer } from '../control_group'; import { pluginServices, registry } from '../services/storybook'; -import { replaceValueSuggestionMethod } from '../services/storybook/unified_search'; import { injectStorybookDataView } from '../services/storybook/data_views'; -import { populateStorybookControlFactories } from './storybook_control_factories'; -import { OptionsListRequest } from '../services/options_list'; -import { OptionsListResponse } from '../control_types/options_list/types'; import { replaceOptionsListMethod } from '../services/storybook/options_list'; +import { populateStorybookControlFactories } from './storybook_control_factories'; +import { replaceValueSuggestionMethod } from '../services/storybook/unified_search'; +import { OptionsListResponse, OptionsListRequest } from '../control_types/options_list/types'; export default { title: 'Controls', diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx index d77cf2b2c1a71..19ad5fc3dce67 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx @@ -8,24 +8,25 @@ import useMount from 'react-use/lib/useMount'; import React, { useEffect, useState } from 'react'; -import { EuiFormRow, EuiSwitch } from '@elastic/eui'; -import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; import { LazyDataViewPicker, LazyFieldPicker, withSuspense, } from '@kbn/presentation-util-plugin/public'; +import { IFieldSubTypeMulti } from '@kbn/es-query'; +import { EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { DataViewListItem, DataView } from '@kbn/data-views-plugin/common'; + import { pluginServices } from '../../services'; import { ControlEditorProps } from '../../types'; -import { OptionsListEmbeddableInput } from './types'; import { OptionsListStrings } from './options_list_strings'; - +import { OptionsListEmbeddableInput, OptionsListField } from './types'; interface OptionsListEditorState { singleSelect?: boolean; - + runPastTimeout?: boolean; dataViewListItems: DataViewListItem[]; - + fieldsMap?: { [key: string]: OptionsListField }; dataView?: DataView; fieldName?: string; } @@ -48,6 +49,7 @@ export const OptionsListEditor = ({ const [state, setState] = useState({ fieldName: initialInput?.fieldName, singleSelect: initialInput?.singleSelect, + runPastTimeout: initialInput?.runPastTimeout, dataViewListItems: [], }); @@ -64,13 +66,54 @@ export const OptionsListEditor = ({ dataView = await get(initialId); } if (!mounted) return; - setState((s) => ({ ...s, dataView, dataViewListItems })); + setState((s) => ({ ...s, dataView, dataViewListItems, fieldsMap: {} })); })(); return () => { mounted = false; }; }); + useEffect(() => { + if (!state.dataView) return; + + // double link the parent-child relationship so that we can filter in fields which are multi-typed to text / keyword + const doubleLinkedFields: OptionsListField[] = state.dataView?.fields.getAll(); + for (const field of doubleLinkedFields) { + const parentFieldName = (field.subType as IFieldSubTypeMulti)?.multi?.parent; + if (parentFieldName) { + (field as OptionsListField).parentFieldName = parentFieldName; + const parentField = state.dataView?.getFieldByName(parentFieldName); + (parentField as OptionsListField).childFieldName = field.name; + } + } + + const newFieldsMap: OptionsListEditorState['fieldsMap'] = {}; + for (const field of doubleLinkedFields) { + if (field.type === 'boolean') { + newFieldsMap[field.name] = field; + } + + // field type is keyword, check if this field is related to a text mapped field and include it. + else if (field.aggregatable && field.type === 'string') { + const childField = + (field.childFieldName && state.dataView?.fields.getByName(field.childFieldName)) || + undefined; + const parentField = + (field.parentFieldName && state.dataView?.fields.getByName(field.parentFieldName)) || + undefined; + + const textFieldName = childField?.esTypes?.includes('text') + ? childField.name + : parentField?.esTypes?.includes('text') + ? parentField.name + : undefined; + + newFieldsMap[field.name] = { ...field, textFieldName } as OptionsListField; + } + } + setState((s) => ({ ...s, fieldsMap: newFieldsMap })); + }, [state.dataView]); + useEffect( () => setValidState(Boolean(state.fieldName) && Boolean(state.dataView)), [state.fieldName, setValidState, state.dataView] @@ -100,14 +143,16 @@ export const OptionsListEditor = ({ - (field.aggregatable && field.type === 'string') || field.type === 'boolean' - } + filterPredicate={(field) => Boolean(state.fieldsMap?.[field.name])} selectedFieldName={fieldName} dataView={dataView} onSelectField={(field) => { setDefaultTitle(field.displayName ?? field.name); - onChange({ fieldName: field.name }); + const textFieldName = state.fieldsMap?.[field.name].textFieldName; + onChange({ + fieldName: field.name, + textFieldName, + }); setState((s) => ({ ...s, fieldName: field.name })); }} /> @@ -122,6 +167,16 @@ export const OptionsListEditor = ({ }} /> + + { + onChange({ runPastTimeout: !state.runPastTimeout }); + setState((s) => ({ ...s, runPastTimeout: !s.runPastTimeout })); + }} + /> + ); }; diff --git a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx index b315fd00392ea..edf4cb6ddaff1 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_embeddable.tsx @@ -20,21 +20,22 @@ import deepEqual from 'fast-deep-equal'; import { merge, Subject, Subscription, BehaviorSubject } from 'rxjs'; import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; -import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; -import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; import { withSuspense, LazyReduxEmbeddableWrapper, ReduxEmbeddableWrapperPropsWithChildren, } from '@kbn/presentation-util-plugin/public'; +import { DataView } from '@kbn/data-views-plugin/public'; +import { Embeddable, IContainer } from '@kbn/embeddable-plugin/public'; + +import { OptionsListEmbeddableInput, OptionsListField, OPTIONS_LIST_CONTROL } from './types'; import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; -import { OptionsListEmbeddableInput, OPTIONS_LIST_CONTROL } from './types'; +import { ControlsOptionsListService } from '../../services/options_list'; import { ControlsDataViewsService } from '../../services/data_views'; import { optionsListReducers } from './options_list_reducers'; import { OptionsListStrings } from './options_list_strings'; import { ControlInput, ControlOutput } from '../..'; import { pluginServices } from '../../services'; -import { ControlsOptionsListService } from '../../services/options_list'; const OptionsListReduxWrapper = withSuspense< ReduxEmbeddableWrapperPropsWithChildren @@ -76,7 +77,7 @@ export class OptionsListEmbeddable extends Embeddable = new Subject(); private abortController?: AbortController; private dataView?: DataView; - private field?: DataViewField; + private field?: OptionsListField; private searchString = ''; // State to be passed down to component @@ -176,9 +177,9 @@ export class OptionsListEmbeddable extends Embeddable => { - const { dataViewId, fieldName } = this.getInput(); + const { dataViewId, fieldName, textFieldName } = this.getInput(); if (!this.dataView || this.dataView.id !== dataViewId) { this.dataView = await this.dataViewsService.get(dataViewId); if (this.dataView === undefined) { @@ -190,7 +191,10 @@ export class OptionsListEmbeddable extends Embeddable + i18n.translate('controls.optionsList.editor.runPastTimeout', { + defaultMessage: 'Run past timeout', + }), }, popover: { getLoadingMessage: () => diff --git a/src/plugins/controls/public/services/kibana/options_list.ts b/src/plugins/controls/public/services/kibana/options_list.ts index 54bd33f5890bd..050972f6234ef 100644 --- a/src/plugins/controls/public/services/kibana/options_list.ts +++ b/src/plugins/controls/public/services/kibana/options_list.ts @@ -7,19 +7,22 @@ */ import { memoize } from 'lodash'; + import dateMath from '@kbn/datemath'; import { buildEsQuery } from '@kbn/es-query'; - import { TimeRange } from '@kbn/data-plugin/public'; import { KibanaPluginServiceFactory } from '@kbn/presentation-util-plugin/public'; -import { ControlsOptionsListService, OptionsListRequest } from '../options_list'; + import { - OptionsListRequestBody, + OptionsListRequest, OptionsListResponse, + OptionsListRequestBody, + OptionsListField, } from '../../control_types/options_list/types'; -import { ControlsPluginStartDeps } from '../../types'; -import { ControlsDataService } from '../data'; import { ControlsHTTPService } from '../http'; +import { ControlsDataService } from '../data'; +import { ControlsPluginStartDeps } from '../../types'; +import { ControlsOptionsListService } from '../options_list'; class OptionsListService implements ControlsOptionsListService { private data: ControlsDataService; @@ -40,6 +43,7 @@ class OptionsListService implements ControlsOptionsListService { filters, timeRange, searchString, + runPastTimeout, selectedOptions, field: { name: fieldName }, dataView: { title: dataViewTitle }, @@ -50,6 +54,7 @@ class OptionsListService implements ControlsOptionsListService { selectedOptions?.join(','), JSON.stringify(filters), JSON.stringify(query), + runPastTimeout, dataViewTitle, searchString, fieldName, @@ -83,6 +88,7 @@ class OptionsListService implements ControlsOptionsListService { filters: esFilters, fieldName: field.name, fieldSpec: field.toSpec?.(), + textFieldName: (field as OptionsListField).textFieldName, }; }; diff --git a/src/plugins/controls/public/services/options_list.ts b/src/plugins/controls/public/services/options_list.ts index e133dc1362698..510631d929083 100644 --- a/src/plugins/controls/public/services/options_list.ts +++ b/src/plugins/controls/public/services/options_list.ts @@ -6,22 +6,7 @@ * Side Public License, v 1. */ -import { Filter, Query } from '@kbn/es-query'; - -import { TimeRange } from '@kbn/data-plugin/public'; -import { DataView, DataViewField } from '@kbn/data-views-plugin/public'; -import { OptionsListRequestBody, OptionsListResponse } from '../control_types/options_list/types'; - -export type OptionsListRequest = Omit< - OptionsListRequestBody, - 'filters' | 'fieldName' | 'fieldSpec' -> & { - timeRange?: TimeRange; - field: DataViewField; - dataView: DataView; - filters?: Filter[]; - query?: Query; -}; +import { OptionsListRequest, OptionsListResponse } from '../control_types/options_list/types'; export interface ControlsOptionsListService { runOptionsListRequest: ( diff --git a/src/plugins/controls/public/services/storybook/options_list.ts b/src/plugins/controls/public/services/storybook/options_list.ts index f5288980bab35..4175d2337a8ea 100644 --- a/src/plugins/controls/public/services/storybook/options_list.ts +++ b/src/plugins/controls/public/services/storybook/options_list.ts @@ -7,8 +7,9 @@ */ import { PluginServiceFactory } from '@kbn/presentation-util-plugin/public'; -import { OptionsListResponse } from '../../control_types/options_list/types'; -import { ControlsOptionsListService, OptionsListRequest } from '../options_list'; + +import { ControlsOptionsListService } from '../options_list'; +import { OptionsListRequest, OptionsListResponse } from '../../control_types/options_list/types'; export type OptionsListServiceFactory = PluginServiceFactory; diff --git a/src/plugins/controls/server/control_types/options_list/options_list_queries.test.ts b/src/plugins/controls/server/control_types/options_list/options_list_queries.test.ts new file mode 100644 index 0000000000000..e1e27b13ee403 --- /dev/null +++ b/src/plugins/controls/server/control_types/options_list/options_list_queries.test.ts @@ -0,0 +1,321 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { FieldSpec } from '@kbn/data-views-plugin/common'; +import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; + +import { + getSuggestionAggregationBuilder, + getValidationAggregationBuilder, +} from './options_list_queries'; +import { OptionsListRequestBody } from '../../../common/control_types/options_list/types'; + +describe('options list queries', () => { + let rawSearchResponseMock: SearchResponse = {} as SearchResponse; + + beforeEach(() => { + rawSearchResponseMock = { + hits: { + total: 10, + max_score: 10, + hits: [], + }, + took: 10, + timed_out: false, + _shards: { + failed: 0, + successful: 1, + total: 1, + skipped: 0, + }, + aggregations: {}, + }; + }); + + describe('validation aggregation and parsing', () => { + test('creates validation aggregation when given selections', () => { + const validationAggBuilder = getValidationAggregationBuilder(); + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolTestField', + selectedOptions: ['coolOption1', 'coolOption2', 'coolOption3'], + }; + expect(validationAggBuilder.buildAggregation(optionsListRequestBodyMock)) + .toMatchInlineSnapshot(` + Object { + "filters": Object { + "filters": Object { + "coolOption1": Object { + "match": Object { + "coolTestField": "coolOption1", + }, + }, + "coolOption2": Object { + "match": Object { + "coolTestField": "coolOption2", + }, + }, + "coolOption3": Object { + "match": Object { + "coolTestField": "coolOption3", + }, + }, + }, + }, + } + `); + }); + + test('returns undefined when not given selections', () => { + const validationAggBuilder = getValidationAggregationBuilder(); + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolTestField', + }; + expect(validationAggBuilder.buildAggregation(optionsListRequestBodyMock)).toBeUndefined(); + }); + + test('parses validation result', () => { + const validationAggBuilder = getValidationAggregationBuilder(); + rawSearchResponseMock.aggregations = { + validation: { + buckets: { + cool1: { doc_count: 0 }, + cool2: { doc_count: 15 }, + cool3: { doc_count: 0 }, + cool4: { doc_count: 2 }, + cool5: { doc_count: 112 }, + cool6: { doc_count: 0 }, + }, + }, + }; + expect(validationAggBuilder.parse(rawSearchResponseMock)).toMatchInlineSnapshot(` + Array [ + "cool1", + "cool3", + "cool6", + ] + `); + }); + }); + + describe('suggestion aggregation and parsing', () => { + test('creates case insensitive aggregation for a text / keyword field with a search string', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolTestField.keyword', + textFieldName: 'coolTestField', + searchString: 'cooool', + fieldSpec: { aggregatable: true } as unknown as FieldSpec, + }; + const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock); + expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) + .toMatchInlineSnapshot(` + Object { + "aggs": Object { + "keywordSuggestions": Object { + "terms": Object { + "field": "coolTestField.keyword", + "shard_size": 10, + }, + }, + }, + "filter": Object { + "match_phrase_prefix": Object { + "coolTestField": "cooool", + }, + }, + } + `); + }); + + test('creates keyword aggregation for a text / keyword field without a search string', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolTestField.keyword', + textFieldName: 'coolTestField', + fieldSpec: { aggregatable: true } as unknown as FieldSpec, + }; + const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock); + expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) + .toMatchInlineSnapshot(` + Object { + "terms": Object { + "execution_hint": "map", + "field": "coolTestField.keyword", + "include": ".*", + "shard_size": 10, + }, + } + `); + }); + + test('creates boolean aggregation for boolean field', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolean', + fieldSpec: { type: 'boolean' } as unknown as FieldSpec, + }; + const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock); + expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) + .toMatchInlineSnapshot(` + Object { + "terms": Object { + "execution_hint": "map", + "field": "coolean", + "shard_size": 10, + }, + } + `); + }); + + test('creates nested aggregation for nested field', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolNestedField', + searchString: 'cooool', + fieldSpec: { subType: { nested: { path: 'path.to.nested' } } } as unknown as FieldSpec, + }; + const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock); + expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) + .toMatchInlineSnapshot(` + Object { + "aggs": Object { + "nestedSuggestions": Object { + "terms": Object { + "execution_hint": "map", + "field": "coolNestedField", + "include": "cooool.*", + "shard_size": 10, + }, + }, + }, + "nested": Object { + "path": "path.to.nested", + }, + } + `); + }); + + test('creates keyword only aggregation', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolTestField.keyword', + searchString: 'cooool', + fieldSpec: { aggregatable: true } as unknown as FieldSpec, + }; + const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock); + expect(suggestionAggBuilder.buildAggregation(optionsListRequestBodyMock)) + .toMatchInlineSnapshot(` + Object { + "terms": Object { + "execution_hint": "map", + "field": "coolTestField.keyword", + "include": "cooool.*", + "shard_size": 10, + }, + } + `); + }); + + test('parses keyword / text result', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolTestField.keyword', + textFieldName: 'coolTestField', + searchString: 'cooool', + fieldSpec: { aggregatable: true } as unknown as FieldSpec, + }; + const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock); + rawSearchResponseMock.aggregations = { + suggestions: { + keywordSuggestions: { + buckets: [ + { doc_count: 5, key: 'cool1' }, + { doc_count: 15, key: 'cool2' }, + { doc_count: 10, key: 'cool3' }, + ], + }, + }, + }; + expect(suggestionAggBuilder.parse(rawSearchResponseMock)).toMatchInlineSnapshot(` + Array [ + "cool1", + "cool2", + "cool3", + ] + `); + }); + + test('parses boolean result', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolean', + fieldSpec: { type: 'boolean' } as unknown as FieldSpec, + }; + const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock); + rawSearchResponseMock.aggregations = { + suggestions: { + buckets: [ + { doc_count: 55, key_as_string: 'false' }, + { doc_count: 155, key_as_string: 'true' }, + ], + }, + }; + expect(suggestionAggBuilder.parse(rawSearchResponseMock)).toMatchInlineSnapshot(` + Array [ + "false", + "true", + ] + `); + }); + + test('parses nested result', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolNestedField', + searchString: 'cooool', + fieldSpec: { subType: { nested: { path: 'path.to.nested' } } } as unknown as FieldSpec, + }; + const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock); + rawSearchResponseMock.aggregations = { + suggestions: { + nestedSuggestions: { + buckets: [ + { doc_count: 5, key: 'cool1' }, + { doc_count: 15, key: 'cool2' }, + { doc_count: 10, key: 'cool3' }, + ], + }, + }, + }; + expect(suggestionAggBuilder.parse(rawSearchResponseMock)).toMatchInlineSnapshot(` + Array [ + "cool1", + "cool2", + "cool3", + ] + `); + }); + + test('parses keyword only result', () => { + const optionsListRequestBodyMock: OptionsListRequestBody = { + fieldName: 'coolTestField.keyword', + searchString: 'cooool', + fieldSpec: { aggregatable: true } as unknown as FieldSpec, + }; + const suggestionAggBuilder = getSuggestionAggregationBuilder(optionsListRequestBodyMock); + rawSearchResponseMock.aggregations = { + suggestions: { + buckets: [ + { doc_count: 5, key: 'cool1' }, + { doc_count: 15, key: 'cool2' }, + { doc_count: 10, key: 'cool3' }, + ], + }, + }; + expect(suggestionAggBuilder.parse(rawSearchResponseMock)).toMatchInlineSnapshot(` + Array [ + "cool1", + "cool2", + "cool3", + ] + `); + }); + }); +}); diff --git a/src/plugins/controls/server/control_types/options_list/options_list_queries.ts b/src/plugins/controls/server/control_types/options_list/options_list_queries.ts new file mode 100644 index 0000000000000..83fd6e2b67552 --- /dev/null +++ b/src/plugins/controls/server/control_types/options_list/options_list_queries.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 + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { get, isEmpty } from 'lodash'; + +import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import { getFieldSubtypeNested } from '@kbn/data-views-plugin/common'; + +import { OptionsListRequestBody } from '../../../common/control_types/options_list/types'; + +export interface OptionsListAggregationBuilder { + buildAggregation: (req: OptionsListRequestBody) => unknown; + parse: (response: SearchResponse) => string[]; +} + +/** + * Validation aggregations + */ +export const getValidationAggregationBuilder: () => OptionsListAggregationBuilder = () => ({ + buildAggregation: ({ selectedOptions, fieldName }: OptionsListRequestBody) => { + const selectedOptionsFilters = selectedOptions?.reduce((acc, currentOption) => { + acc[currentOption] = { match: { [fieldName]: currentOption } }; + return acc; + }, {} as { [key: string]: { match: { [key: string]: string } } }); + + return selectedOptionsFilters && !isEmpty(selectedOptionsFilters) + ? { + filters: { + filters: selectedOptionsFilters, + }, + } + : undefined; + }, + parse: (rawEsResult) => { + const rawInvalidSuggestions = get(rawEsResult, 'aggregations.validation.buckets') as { + [key: string]: { doc_count: number }; + }; + return rawInvalidSuggestions && !isEmpty(rawInvalidSuggestions) + ? Object.entries(rawInvalidSuggestions) + ?.filter(([, value]) => value?.doc_count === 0) + ?.map(([key]) => key) + : []; + }, +}); + +/** + * Suggestion aggregations + */ +export const getSuggestionAggregationBuilder = ({ + fieldSpec, + textFieldName, + searchString, +}: OptionsListRequestBody) => { + if (textFieldName && fieldSpec?.aggregatable && searchString) { + return suggestionAggSubtypes.keywordAndText; + } + if (fieldSpec?.type === 'boolean') { + return suggestionAggSubtypes.boolean; + } + if (fieldSpec && getFieldSubtypeNested(fieldSpec)) { + return suggestionAggSubtypes.subtypeNested; + } + return suggestionAggSubtypes.keywordOnly; +}; + +const getEscapedQuery = (q: string = '') => + q.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, (match) => `\\${match}`); + +const suggestionAggSubtypes: { [key: string]: OptionsListAggregationBuilder } = { + /** + * the "Keyword only" query / parser should be used when the options list is built on a field which has only keyword mappings. + */ + keywordOnly: { + buildAggregation: ({ fieldName, searchString }: OptionsListRequestBody) => ({ + terms: { + field: fieldName, + include: `${getEscapedQuery(searchString)}.*`, + execution_hint: 'map', + shard_size: 10, + }, + }), + parse: (rawEsResult) => + get(rawEsResult, 'aggregations.suggestions.buckets')?.map( + (suggestion: { key: string }) => suggestion.key + ), + }, + + /** + * the "Keyword and text" query / parser should be used when the options list is built on a multi-field which has both keyword and text mappings. It supports case-insensitive searching + */ + keywordAndText: { + buildAggregation: (req: OptionsListRequestBody) => { + if (!req.textFieldName) { + // if there is no textFieldName specified, or if there is no search string yet fall back to keywordOnly + return suggestionAggSubtypes.keywordOnly.buildAggregation(req); + } + const { fieldName, searchString, textFieldName } = req; + return { + filter: { + match_phrase_prefix: { + [textFieldName]: getEscapedQuery(searchString), + }, + }, + aggs: { + keywordSuggestions: { + terms: { + field: fieldName, + shard_size: 10, + }, + }, + }, + }; + }, + parse: (rawEsResult) => + get(rawEsResult, 'aggregations.suggestions.keywordSuggestions.buckets')?.map( + (suggestion: { key: string }) => suggestion.key + ), + }, + + /** + * the "Boolean" query / parser should be used when the options list is built on a field of type boolean. The query is slightly different than a keyword query. + */ + boolean: { + buildAggregation: ({ fieldName }: OptionsListRequestBody) => ({ + terms: { + field: fieldName, + execution_hint: 'map', + shard_size: 10, + }, + }), + parse: (rawEsResult) => + get(rawEsResult, 'aggregations.suggestions.buckets')?.map( + (suggestion: { key_as_string: string }) => suggestion.key_as_string + ), + }, + + /** + * the "Subtype Nested" query / parser should be used when the options list is built on a field with subtype nested. + */ + subtypeNested: { + buildAggregation: (req: OptionsListRequestBody) => { + const { fieldSpec, fieldName, searchString } = req; + const subTypeNested = fieldSpec && getFieldSubtypeNested(fieldSpec); + if (!subTypeNested) { + // if this field is not subtype nested, fall back to keywordOnly + return suggestionAggSubtypes.keywordOnly.buildAggregation(req); + } + return { + nested: { + path: subTypeNested.nested.path, + }, + aggs: { + nestedSuggestions: { + terms: { + field: fieldName, + include: `${getEscapedQuery(searchString)}.*`, + execution_hint: 'map', + shard_size: 10, + }, + }, + }, + }; + }, + parse: (rawEsResult) => + get(rawEsResult, 'aggregations.suggestions.nestedSuggestions.buckets')?.map( + (suggestion: { key: string }) => suggestion.key + ), + }, +}; diff --git a/src/plugins/controls/server/control_types/options_list/options_list_suggestions_route.ts b/src/plugins/controls/server/control_types/options_list/options_list_suggestions_route.ts index 9af4800ca00fb..e5943a3de41cb 100644 --- a/src/plugins/controls/server/control_types/options_list/options_list_suggestions_route.ts +++ b/src/plugins/controls/server/control_types/options_list/options_list_suggestions_route.ts @@ -6,20 +6,23 @@ * Side Public License, v 1. */ -import { get, isEmpty } from 'lodash'; -import { schema } from '@kbn/config-schema'; -import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; - import { Observable } from 'rxjs'; +import { get } from 'lodash'; -import { CoreSetup, ElasticsearchClient } from '@kbn/core/server'; -import { getKbnServerError, reportServerError } from '@kbn/kibana-utils-plugin/server'; -import { FieldSpec, getFieldSubtypeNested } from '@kbn/data-views-plugin/common'; import { PluginSetup as UnifiedSearchPluginSetup } from '@kbn/unified-search-plugin/server'; +import { getKbnServerError, reportServerError } from '@kbn/kibana-utils-plugin/server'; +import { CoreSetup, ElasticsearchClient } from '@kbn/core/server'; +import { SearchRequest } from '@kbn/data-plugin/common'; +import { schema } from '@kbn/config-schema'; + import { OptionsListRequestBody, OptionsListResponse, } from '../../../common/control_types/options_list/types'; +import { + getSuggestionAggregationBuilder, + getValidationAggregationBuilder, +} from './options_list_queries'; export const setupOptionsListSuggestionsRoute = ( { http }: CoreSetup, @@ -82,93 +85,40 @@ export const setupOptionsListSuggestionsRoute = ( const abortController = new AbortController(); abortedEvent$.subscribe(() => abortController.abort()); - const { fieldName, searchString, selectedOptions, filters, fieldSpec } = request; - const body = getOptionsListBody(fieldName, fieldSpec, searchString, selectedOptions, filters); - - const rawEsResult = await esClient.search({ index, body }, { signal: abortController.signal }); + /** + * Build ES Query + */ + const { runPastTimeout, filters, fieldName } = request; - // parse raw ES response into OptionsListSuggestionResponse - const totalCardinality = get(rawEsResult, 'aggregations.unique_terms.value'); - - const suggestions = get(rawEsResult, 'aggregations.suggestions.buckets')?.map( - (suggestion: { key: string; key_as_string: string }) => - fieldSpec?.type === 'string' ? suggestion.key : suggestion.key_as_string - ); - - const rawInvalidSuggestions = get(rawEsResult, 'aggregations.validation.buckets') as { - [key: string]: { doc_count: number }; - }; - const invalidSelections = - rawInvalidSuggestions && !isEmpty(rawInvalidSuggestions) - ? Object.entries(rawInvalidSuggestions) - ?.filter(([, value]) => value?.doc_count === 0) - ?.map(([key]) => key) - : undefined; - - return { - suggestions, - totalCardinality, - invalidSelections, - }; - }; - - const getOptionsListBody = ( - fieldName: string, - fieldSpec?: FieldSpec, - searchString?: string, - selectedOptions?: string[], - filters: estypes.QueryDslQueryContainer[] = [] - ) => { - // https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html#_standard_operators - const getEscapedQuery = (q: string = '') => - q.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, (match) => `\\${match}`); + const { terminateAfter, timeout } = getAutocompleteSettings(); + const timeoutSettings = runPastTimeout + ? {} + : { timeout: `${timeout}ms`, terminate_after: terminateAfter }; - // Helps ensure that the regex is not evaluated eagerly against the terms dictionary - const executionHint = 'map' as const; + const suggestionBuilder = getSuggestionAggregationBuilder(request); + const validationBuilder = getValidationAggregationBuilder(); - // Suggestions - const shardSize = 10; - const suggestionsAgg = { - terms: { - field: fieldName, - // terms on boolean fields don't support include - ...(fieldSpec?.type !== 'boolean' && { - include: `${getEscapedQuery(searchString ?? '')}.*`, - }), - execution_hint: executionHint, - shard_size: shardSize, - }, + const suggestionAggregations = { + suggestions: suggestionBuilder.buildAggregation(request), }; - - // Validation - const selectedOptionsFilters = selectedOptions?.reduce((acc, currentOption) => { - acc[currentOption] = { match: { [fieldName]: currentOption } }; - return acc; - }, {} as { [key: string]: { match: { [key: string]: string } } }); - - const validationAgg = - selectedOptionsFilters && !isEmpty(selectedOptionsFilters) - ? { - filters: { - filters: selectedOptionsFilters, - }, - } - : undefined; - - const { terminateAfter, timeout } = getAutocompleteSettings(); - - const body = { + const builtValidationAggregation = validationBuilder.buildAggregation(request); + const validationAggregations = builtValidationAggregation + ? { + validation: builtValidationAggregation, + } + : {}; + + const body: SearchRequest['body'] = { size: 0, - timeout: `${timeout}ms`, - terminate_after: terminateAfter, + ...timeoutSettings, query: { bool: { filter: filters, }, }, aggs: { - suggestions: suggestionsAgg, - ...(validationAgg ? { validation: validationAgg } : {}), + ...suggestionAggregations, + ...validationAggregations, unique_terms: { cardinality: { field: fieldName, @@ -177,21 +127,22 @@ export const setupOptionsListSuggestionsRoute = ( }, }; - const subTypeNested = fieldSpec && getFieldSubtypeNested(fieldSpec); - if (subTypeNested) { - return { - ...body, - aggs: { - nestedSuggestions: { - nested: { - path: subTypeNested.nested.path, - }, - aggs: body.aggs, - }, - }, - }; - } + /** + * Run ES query + */ + const rawEsResult = await esClient.search({ index, body }, { signal: abortController.signal }); + + /** + * Parse ES response into Options List Response + */ + const totalCardinality = get(rawEsResult, 'aggregations.unique_terms.value'); + const suggestions = suggestionBuilder.parse(rawEsResult); + const invalidSelections = validationBuilder.parse(rawEsResult); - return body; + return { + suggestions, + totalCardinality, + invalidSelections, + }; }; }; diff --git a/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx b/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx index b47e425f67bb2..3c8a084f2686b 100644 --- a/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx +++ b/src/plugins/presentation_util/public/components/field_picker/field_picker.tsx @@ -40,7 +40,7 @@ export const FieldPicker = ({ dataView.fields .filter( (f) => - f.name.includes(nameFilter) && + f.name.toLowerCase().includes(nameFilter.toLowerCase()) && (typesFilter.length === 0 || typesFilter.includes(f.type as string)) ) .filter((f) => (filterPredicate ? filterPredicate(f) : true)), diff --git a/test/functional/apps/dashboard_elements/controls/options_list.ts b/test/functional/apps/dashboard_elements/controls/options_list.ts index e13965bb95a96..f1e7143191c52 100644 --- a/test/functional/apps/dashboard_elements/controls/options_list.ts +++ b/test/functional/apps/dashboard_elements/controls/options_list.ts @@ -283,6 +283,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); }); + it('Can search options list for available options case insensitive', async () => { + await dashboardControls.optionsListOpenPopover(controlId); + await dashboardControls.optionsListPopoverSearchForOption('MEO'); + await ensureAvailableOptionsEql(['meow'], true); + await dashboardControls.optionsListPopoverClearSearch(); + await dashboardControls.optionsListEnsurePopoverIsClosed(controlId); + }); + it('Can select multiple available options', async () => { await dashboardControls.optionsListOpenPopover(controlId); await dashboardControls.optionsListPopoverSelectOption('hiss'); From e267d2edbb5d717e942f897431ce376efe66d7b3 Mon Sep 17 00:00:00 2001 From: Sergi Massaneda Date: Thu, 12 May 2022 19:54:40 +0200 Subject: [PATCH 27/46] [Security] New side navigation (#131437) * initial implementation * feature flag and setting added * Simplify mobile view to use EuiCollapsibleNavGroup * PR suggestions * Fix mobile title * [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' * style with theme vars, tests * more theming * remove emoji * test fixes * fix users page cypress test * snapshot updated * new nav tests * test fixes * refactor css to styled components * remove duplicated deeplink * nav panel test added and conflig cleaning Co-authored-by: cchaos Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/solution_nav.test.tsx.snap | 542 ++++++++++-------- .../solution_nav/solution_nav.scss | 2 +- .../solution_nav/solution_nav.stories.tsx | 8 + .../solution_nav/solution_nav.test.tsx | 39 +- .../solution_nav/solution_nav.tsx | 151 +++-- .../security_solution/common/constants.ts | 14 +- .../common/experimental_features.ts | 1 + .../cypress/integration/urls/state.spec.ts | 1 + .../public/app/deep_links/index.ts | 3 +- .../template_wrapper/bottom_bar/index.tsx | 2 +- .../app/home/template_wrapper/index.test.tsx | 25 +- .../app/home/template_wrapper/index.tsx | 13 +- .../components/link_to/__mocks__/index.ts | 5 + .../public/common/components/link_to/index.ts | 67 ++- .../common/components/links/index.test.tsx | 2 +- .../public/common/components/links/index.tsx | 80 +-- .../common/components/navigation/helpers.ts | 112 ++-- .../solution_grouped_nav/icons/spaces.tsx | 25 + .../navigation/solution_grouped_nav/index.ts | 8 + .../solution_grouped_nav.styles.tsx | 26 + .../solution_grouped_nav.test.tsx | 149 +++++ .../solution_grouped_nav.tsx | 167 ++++++ .../solution_grouped_nav_item.tsx | 188 ++++++ .../solution_grouped_nav_panel.styles.tsx | 32 ++ .../solution_grouped_nav_panel.test.tsx | 119 ++++ .../solution_grouped_nav_panel.tsx | 109 ++++ .../navigation/use_get_url_search.tsx | 11 +- .../index.test.tsx | 8 +- .../use_primary_navigation.tsx | 16 +- .../common/lib/kibana/__mocks__/index.ts | 9 + .../utils/timeline/use_show_timeline.test.tsx | 42 +- .../utils/timeline/use_show_timeline.tsx | 36 +- .../components/landing_links_icons.test.tsx | 12 +- .../overview/pages/detection_response.tsx | 4 +- .../security_solution/server/ui_settings.ts | 21 + 35 files changed, 1596 insertions(+), 453 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/icons/spaces.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/index.ts create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.styles.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.styles.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx create mode 100644 x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap b/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap index 069192708e47b..dfdf85d0d3563 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/__snapshots__/solution_nav.test.tsx.snap @@ -2,266 +2,322 @@ exports[`KibanaPageTemplateSolutionNav accepts EuiSideNavProps 1`] = ` - - - Solution - - + initialIsOpen={false} + isCollapsible={true} + paddingSize="m" + title={ + +

+ + + +

+
} - isOpenOnMobile={false} - items={ - Array [ - Object { - "id": "1", - "items": Array [ - Object { - "id": "1.1", - "items": undefined, - "name": "Ingest Node Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.2", - "items": undefined, - "name": "Logstash Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.3", - "items": undefined, - "name": "Beats Central Management", - "tabIndex": undefined, - }, - ], - "name": "Ingest", - "tabIndex": undefined, - }, - Object { - "id": "2", - "items": Array [ - Object { - "id": "2.1", - "items": undefined, - "name": "Index Management", - "tabIndex": undefined, - }, - Object { - "id": "2.2", - "items": undefined, - "name": "Index Lifecycle Policies", - "tabIndex": undefined, - }, - Object { - "id": "2.3", - "items": undefined, - "name": "Snapshot and Restore", - "tabIndex": undefined, - }, - ], - "name": "Data", - "tabIndex": undefined, - }, - ] - } - mobileTitle={ - - - + titleElement="span" + > + + +
+`; + +exports[`KibanaPageTemplateSolutionNav heading accepts more headingProps 1`] = ` + + +

+ + + +

+
} - toggleOpenOnMobile={[Function]} + titleElement="span" /> `; exports[`KibanaPageTemplateSolutionNav renders 1`] = ` - - - Solution - - + initialIsOpen={false} + isCollapsible={true} + paddingSize="m" + title={ + +

+ + + +

+
} - isOpenOnMobile={false} - items={ - Array [ - Object { - "id": "1", - "items": Array [ - Object { - "id": "1.1", - "items": undefined, - "name": "Ingest Node Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.2", - "items": undefined, - "name": "Logstash Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.3", - "items": undefined, - "name": "Beats Central Management", - "tabIndex": undefined, - }, - ], - "name": "Ingest", - "tabIndex": undefined, - }, - Object { - "id": "2", - "items": Array [ - Object { - "id": "2.1", - "items": undefined, - "name": "Index Management", - "tabIndex": undefined, - }, - Object { - "id": "2.2", - "items": undefined, - "name": "Index Lifecycle Policies", - "tabIndex": undefined, - }, - Object { - "id": "2.3", - "items": undefined, - "name": "Snapshot and Restore", - "tabIndex": undefined, - }, - ], - "name": "Data", - "tabIndex": undefined, - }, - ] - } - mobileTitle={ - - - - } - toggleOpenOnMobile={[Function]} - /> + titleElement="span" + > + +
`; exports[`KibanaPageTemplateSolutionNav renders with icon 1`] = ` - - - - Solution - - + initialIsOpen={false} + isCollapsible={true} + paddingSize="m" + title={ + +

+ + + + +

+
} - isOpenOnMobile={false} - items={ - Array [ - Object { - "id": "1", - "items": Array [ - Object { - "id": "1.1", - "items": undefined, - "name": "Ingest Node Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.2", - "items": undefined, - "name": "Logstash Pipelines", - "tabIndex": undefined, - }, - Object { - "id": "1.3", - "items": undefined, - "name": "Beats Central Management", - "tabIndex": undefined, - }, - ], - "name": "Ingest", - "tabIndex": undefined, - }, - Object { - "id": "2", - "items": Array [ - Object { - "id": "2.1", - "items": undefined, - "name": "Index Management", - "tabIndex": undefined, - }, - Object { - "id": "2.2", - "items": undefined, - "name": "Index Lifecycle Policies", - "tabIndex": undefined, - }, - Object { - "id": "2.3", - "items": undefined, - "name": "Snapshot and Restore", - "tabIndex": undefined, - }, - ], - "name": "Data", - "tabIndex": undefined, - }, - ] - } - mobileTitle={ - - - - - } - toggleOpenOnMobile={[Function]} - /> + titleElement="span" + > + +
`; diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss index d0070cef729b7..91b96641047e8 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.scss @@ -15,7 +15,7 @@ $euiSideNavEmphasizedBackgroundColor: transparentize($euiColorLightShade, .7); padding: $euiSizeL; } - .kbnPageTemplateSolutionNavAvatar { + .kbnPageTemplateSolutionNav__avatar { margin-right: $euiSize; } } diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.tsx index 5ff1e2c07d9d8..28550a7789a9f 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.stories.tsx @@ -69,4 +69,12 @@ PureComponent.argTypes = { options: ['logoKibana', 'logoObservability', 'logoSecurity'], defaultValue: 'logoKibana', }, + children: { + control: 'text', + defaultValue: '', + }, +}; + +PureComponent.parameters = { + layout: 'fullscreen', }; diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.tsx index 2792ae518e5a2..9e2eac4cf20d6 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.test.tsx @@ -10,14 +10,15 @@ import React from 'react'; import { shallow } from 'enzyme'; import { KibanaPageTemplateSolutionNav, KibanaPageTemplateSolutionNavProps } from './solution_nav'; -jest.mock('@elastic/eui', () => ({ - useIsWithinBreakpoints: (args: string[]) => { - return args[0] === 'xs'; - }, - EuiSideNav: function Component() { - // no-op - }, -})); +jest.mock('@elastic/eui', () => { + const original = jest.requireActual('@elastic/eui'); + return { + ...original, + useIsWithinBreakpoints: (args: string[]) => { + return args[0] === 'xs'; + }, + }; +}); const items: KibanaPageTemplateSolutionNavProps['items'] = [ { @@ -59,6 +60,19 @@ const items: KibanaPageTemplateSolutionNavProps['items'] = [ ]; describe('KibanaPageTemplateSolutionNav', () => { + describe('heading', () => { + test('accepts more headingProps', () => { + const component = shallow( + + ); + + expect(component).toMatchSnapshot(); + }); + }); + test('renders', () => { const component = shallow(); expect(component).toMatchSnapshot(); @@ -71,6 +85,15 @@ describe('KibanaPageTemplateSolutionNav', () => { expect(component).toMatchSnapshot(); }); + test('renders with children', () => { + const component = shallow( + + + + ); + expect(component.find('#dummy_component').length > 0).toBeTruthy(); + }); + test('accepts EuiSideNavProps', () => { const component = shallow( diff --git a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx index 4993d910e08be..191e56db530a9 100644 --- a/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx +++ b/packages/kbn-shared-ux-components/src/page_template/solution_nav/solution_nav.tsx @@ -7,23 +7,31 @@ */ import './solution_nav.scss'; -import React, { FunctionComponent, useState } from 'react'; +import React, { FunctionComponent, useState, useMemo } from 'react'; import classNames from 'classnames'; import { EuiAvatarProps, + EuiCollapsibleNavGroup, EuiFlyout, + EuiFlyoutProps, EuiSideNav, EuiSideNavItemType, EuiSideNavProps, + EuiSpacer, + EuiTitle, + htmlIdGenerator, useIsWithinBreakpoints, } from '@elastic/eui'; - import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; import { KibanaSolutionAvatar } from '@kbn/shared-ux-avatar-solution'; import { KibanaPageTemplateSolutionNavCollapseButton } from './solution_nav_collapse_button'; -export type KibanaPageTemplateSolutionNavProps = EuiSideNavProps<{}> & { +export type KibanaPageTemplateSolutionNavProps = Omit< + EuiSideNavProps<{}>, + 'children' | 'items' | 'heading' +> & { /** * Name of the solution, i.e. "Observability" */ @@ -32,6 +40,19 @@ export type KibanaPageTemplateSolutionNavProps = EuiSideNavProps<{}> & { * Solution logo, i.e. "logoObservability" */ icon?: EuiAvatarProps['iconType']; + /** + * An array of #EuiSideNavItem objects. Lists navigation menu items. + */ + items?: EuiSideNavProps<{}>['items']; + /** + * Renders the children instead of default EuiSideNav + */ + children?: React.ReactNode; + /** + * The position of the close button when the navigation flyout is open. + * Note that side navigation turns into a flyout only when the screen has medium size. + */ + closeFlyoutButtonPosition?: EuiFlyoutProps['closeButtonPosition']; /** * Control the collapsed state */ @@ -50,13 +71,26 @@ const setTabIndex = (items: Array>, isHidden: boolean) => }); }; +const generateId = htmlIdGenerator('KibanaPageTemplateSolutionNav'); + /** * A wrapper around EuiSideNav but also creates the appropriate title with optional solution logo */ export const KibanaPageTemplateSolutionNav: FunctionComponent< KibanaPageTemplateSolutionNavProps -> = ({ name, icon, items, isOpenOnDesktop = false, onCollapse, ...rest }) => { - const isSmallerBreakpoint = useIsWithinBreakpoints(['xs', 's']); +> = ({ + children, + headingProps, + icon, + isOpenOnDesktop = false, + items, + mobileBreakpoints = ['xs', 's'], + closeFlyoutButtonPosition = 'outside', + name, + onCollapse, + ...rest +}) => { + const isSmallerBreakpoint = useIsWithinBreakpoints(mobileBreakpoints); const isMediumBreakpoint = useIsWithinBreakpoints(['m']); const isLargerBreakpoint = useIsWithinBreakpoints(['l', 'xl']); @@ -67,68 +101,81 @@ export const KibanaPageTemplateSolutionNav: FunctionComponent< }; const isHidden = isLargerBreakpoint && !isOpenOnDesktop; + const isCustomSideNav = !!children; - /** - * Create the avatar - */ - const solutionAvatar = icon ? ( - - ) : null; + const sideNavClasses = classNames('kbnPageTemplateSolutionNav', { + 'kbnPageTemplateSolutionNav--hidden': isHidden, + }); /** - * Create the titles + * Create the avatar and titles */ + const headingID = headingProps?.id || generateId('heading'); + const HeadingElement = headingProps?.element || 'h2'; const titleText = ( - <> - {solutionAvatar} - {name} - - ); - const mobileTitleText = ( - + + + {icon && ( + + )} + + + + + ); /** - * Create the side nav component + * Create the side nav content */ - - const sideNav = () => { + const sideNavContent = useMemo(() => { + if (isCustomSideNav) { + return children; + } if (!items) { return null; } - const sideNavClasses = classNames('kbnPageTemplateSolutionNav', { - 'kbnPageTemplateSolutionNav--hidden': isHidden, - }); return ( - {solutionAvatar} - {mobileTitleText} - - } - toggleOpenOnMobile={toggleOpenOnMobile} - isOpenOnMobile={isSideNavOpenOnMobile} items={setTabIndex(items, isHidden)} + mobileBreakpoints={[]} // prevent EuiSideNav to apply mobile version, already implemented here {...rest} /> ); - }; + }, [children, headingID, isCustomSideNav, isHidden, items, rest]); return ( <> - {isSmallerBreakpoint && sideNav()} + {isSmallerBreakpoint && ( + + {sideNavContent} + + )} {isMediumBreakpoint && ( <> {isSideNavOpenOnMobile && ( @@ -138,10 +185,14 @@ export const KibanaPageTemplateSolutionNav: FunctionComponent< onClose={() => setIsSideNavOpenOnMobile(false)} side="left" size={FLYOUT_SIZE} - closeButtonPosition="outside" + closeButtonPosition={closeFlyoutButtonPosition} className="kbnPageTemplateSolutionNav__flyout" > - {sideNav()} +
+ {titleText} + + {sideNavContent} +
)} - {sideNav()} +
+ {titleText} + + {sideNavContent} +
{ it('Do not clears kql when navigating to a new page', () => { visitWithoutDateRange(ABSOLUTE_DATE_RANGE.urlKqlHostsHosts); + kqlSearch('source.ip: "10.142.0.9"{enter}'); navigateFromHeaderTo(NETWORK); cy.get(KQL_INPUT).should('have.text', 'source.ip: "10.142.0.9"'); }); diff --git a/x-pack/plugins/security_solution/public/app/deep_links/index.ts b/x-pack/plugins/security_solution/public/app/deep_links/index.ts index e9735b8c0b903..8d8871305b034 100644 --- a/x-pack/plugins/security_solution/public/app/deep_links/index.ts +++ b/x-pack/plugins/security_solution/public/app/deep_links/index.ts @@ -58,6 +58,7 @@ import { USERS_PATH, THREAT_HUNTING_PATH, DASHBOARDS_PATH, + MANAGE_PATH, } from '../../../common/constants'; import { ExperimentalFeatures } from '../../../common/experimental_features'; @@ -433,7 +434,7 @@ export const securitySolutionsDeepLinks: SecuritySolutionDeepLink[] = [ { id: SecurityPageName.administration, title: MANAGE, - path: ENDPOINTS_PATH, + path: MANAGE_PATH, navLinkStatus: AppNavLinkStatus.hidden, features: [FEATURE.general], keywords: [ diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx index 9b74a2410a7e8..ac669a4b33180 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/bottom_bar/index.tsx @@ -8,7 +8,7 @@ /* eslint-disable react/display-name */ import React from 'react'; -import { KibanaPageTemplateProps } from '@kbn/kibana-react-plugin/public'; +import { KibanaPageTemplateProps } from '@kbn/shared-ux-components'; import { AppLeaveHandler } from '@kbn/core/public'; import { useShowTimeline } from '../../../../common/utils/timeline/use_show_timeline'; import { useSourcererDataView } from '../../../../common/containers/sourcerer'; diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.test.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.test.tsx index 0403071391d0d..005e671bb48c7 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.test.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.test.tsx @@ -5,12 +5,17 @@ * 2.0. */ -import { render } from '@testing-library/react'; +import { render, waitFor } from '@testing-library/react'; import React from 'react'; import { TestProviders } from '../../../common/mock'; import { SecuritySolutionTemplateWrapper } from '.'; +jest.mock('../../../common/utils/timeline/use_show_timeline', () => ({ + ...jest.requireActual('../../../common/utils/timeline/use_show_timeline'), + useShowTimeline: () => [true], +})); + jest.mock('./bottom_bar', () => ({ ...jest.requireActual('./bottom_bar'), SecuritySolutionBottomBar: () =>
{'Bottom Bar'}
, @@ -75,20 +80,26 @@ const renderComponent = () => { describe('SecuritySolutionTemplateWrapper', () => { beforeEach(() => { jest.clearAllMocks(); - mockSiemUserCanCrud.mockReturnValue({ show: true }); }); it('Should render to the page with bottom bar if user has SIEM show', async () => { + mockSiemUserCanCrud.mockReturnValue({ show: true }); const { getByText } = renderComponent(); - expect(getByText('child of wrapper')).toBeInTheDocument(); - expect(getByText('Bottom Bar')).toBeInTheDocument(); + + await waitFor(() => { + expect(getByText('child of wrapper')).toBeInTheDocument(); + expect(getByText('Bottom Bar')).toBeInTheDocument(); + }); }); it('Should not show bottom bar if user does not have SIEM show', async () => { mockSiemUserCanCrud.mockReturnValue({ show: false }); - const { getByText } = renderComponent(); - expect(getByText('child of wrapper')).toBeInTheDocument(); - expect(() => getByText('Bottom Bar')).toThrow(); + const { getByText, queryByText } = renderComponent(); + + await waitFor(() => { + expect(getByText('child of wrapper')).toBeInTheDocument(); + expect(queryByText('Bottom Bar')).not.toBeInTheDocument(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx index d3325e0acd9aa..3b436d2bdefc1 100644 --- a/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/template_wrapper/index.tsx @@ -10,7 +10,7 @@ import styled from 'styled-components'; import { EuiPanel } from '@elastic/eui'; import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid'; import { AppLeaveHandler } from '@kbn/core/public'; -import { KibanaPageTemplate, NO_DATA_PAGE_TEMPLATE_PROPS } from '@kbn/kibana-react-plugin/public'; +import { KibanaPageTemplate } from '@kbn/shared-ux-components'; import { useSecuritySolutionNavigation } from '../../../common/components/navigation/use_security_solution_navigation'; import { TimelineId } from '../../../../common/types/timeline'; import { getTimelineShowStatusByIdSelector } from '../../../timelines/components/flyout/selectors'; @@ -27,6 +27,17 @@ import { useKibana } from '../../../common/lib/kibana'; import { useShowPagesWithEmptyView } from '../../../common/utils/empty_view/use_show_pages_with_empty_view'; import { useIsPolicySettingsBarVisible } from '../../../management/pages/policy/view/policy_hooks'; +const NO_DATA_PAGE_MAX_WIDTH = 950; + +const NO_DATA_PAGE_TEMPLATE_PROPS = { + restrictWidth: NO_DATA_PAGE_MAX_WIDTH, + template: 'centeredBody', + pageContentProps: { + hasShadow: false, + color: 'transparent', + }, +}; + /** * Need to apply the styles via a className to effect the containing bottom bar * rather than applying them to the timeline bar directly diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts index 15ac66ffddfd1..dc169c870488a 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/__mocks__/index.ts @@ -23,3 +23,8 @@ export const useFormatUrl = (page: SecurityPageName) => ({ formatUrl: (path: string) => path, search: '', }); + +export const useGetSecuritySolutionUrl = + () => + ({ path }: { path: string }) => + path; diff --git a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts index 2f9b3542d765b..0db0699628cc0 100644 --- a/x-pack/plugins/security_solution/public/common/components/link_to/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/link_to/index.ts @@ -7,7 +7,7 @@ import { isEmpty } from 'lodash/fp'; import { useCallback } from 'react'; -import { useGetUrlSearch } from '../navigation/use_get_url_search'; +import { useGetUrlSearch, useGetUrlStateQueryString } from '../navigation/use_get_url_search'; import { navTabs } from '../../../app/home/home_navigations'; import { useAppUrl } from '../../lib/kibana/hooks'; import { SecurityNavKey } from '../navigation/types'; @@ -39,16 +39,7 @@ export const useFormatUrl = (page: SecurityPageName) => { const formatUrl = useCallback( (path: string, { absolute = false, skipSearch = false } = {}) => { - const pathArr = path.split('?'); - const formattedPath = `${pathArr[0]}${ - !skipSearch - ? isEmpty(pathArr[1]) - ? search - : `?${pathArr[1]}${isEmpty(search) ? '' : `&${search}`}` - : isEmpty(pathArr[1]) - ? '' - : `?${pathArr[1]}` - }`; + const formattedPath = formatPath(path, search, skipSearch); return getAppUrl({ deepLinkId: page, path: formattedPath, absolute }); }, [getAppUrl, page, search] @@ -56,3 +47,57 @@ export const useFormatUrl = (page: SecurityPageName) => { return { formatUrl, search }; }; + +type GetSecuritySolutionUrl = (param: { + deepLinkId: SecurityPageName; + path?: string; + absolute?: boolean; + skipSearch?: boolean; +}) => string; + +export const useGetSecuritySolutionUrl = () => { + const { getAppUrl } = useAppUrl(); + const getUrlStateQueryString = useGetUrlStateQueryString(); + + const getSecuritySolutionUrl = useCallback( + ({ deepLinkId, path = '', absolute = false, skipSearch = false }) => { + const search = needsUrlState(deepLinkId) ? getUrlStateQueryString() : ''; + const formattedPath = formatPath(path, search, skipSearch); + return getAppUrl({ deepLinkId, path: formattedPath, absolute }); + }, + [getAppUrl, getUrlStateQueryString] + ); + + return getSecuritySolutionUrl; +}; + +function formatPath(path: string, search: string, skipSearch?: boolean) { + const [urlPath, parameterPath] = path.split('?'); + const formattedPath = `${urlPath}${ + !skipSearch + ? isEmpty(parameterPath) + ? search + : `?${parameterPath}${isEmpty(search) ? '' : `&${search}`}` + : isEmpty(parameterPath) + ? '' + : `?${parameterPath}` + }`; + return formattedPath; +} + +// TODO: migrate to links.needsUrlState +function needsUrlState(pageId: SecurityPageName) { + return ( + pageId !== SecurityPageName.dashboardsLanding && + pageId !== SecurityPageName.threatHuntingLanding && + pageId !== SecurityPageName.administration && + pageId !== SecurityPageName.rules && + pageId !== SecurityPageName.exceptions && + pageId !== SecurityPageName.endpoints && + pageId !== SecurityPageName.policies && + pageId !== SecurityPageName.trustedApps && + pageId !== SecurityPageName.eventFilters && + pageId !== SecurityPageName.blocklist && + pageId !== SecurityPageName.hostIsolationExceptions + ); +} diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx index e7c82420ef9e0..77d473564f6b1 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.test.tsx @@ -43,7 +43,7 @@ jest.mock('../../lib/kibana', () => { }, }, }), - useNavigation: () => ({ + useNavigateTo: () => ({ navigateTo: mockNavigateTo, }), }; diff --git a/x-pack/plugins/security_solution/public/common/components/links/index.tsx b/x-pack/plugins/security_solution/public/common/components/links/index.tsx index 0dd0bba916cc9..78038350eff62 100644 --- a/x-pack/plugins/security_solution/public/common/components/links/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/links/index.tsx @@ -28,12 +28,13 @@ import { getNetworkDetailsUrl, getCreateCaseUrl, useFormatUrl, + useGetSecuritySolutionUrl, } from '../link_to'; import { FlowTarget, FlowTargetSourceDest, } from '../../../../common/search_strategy/security_solution/network'; -import { useUiSetting$, useKibana, useNavigation } from '../../lib/kibana'; +import { useUiSetting$, useKibana, useNavigateTo } from '../../lib/kibana'; import { isUrlInvalid } from '../../utils/validators'; import * as i18n from './translations'; @@ -533,20 +534,46 @@ interface SecuritySolutionLinkProps { path?: string; } -type LinkClickEvent = MouseEvent; -type LinkClickEventHandler = MouseEventHandler; - -interface SecuritySolutionInjectedLinkProps { - onClick?: LinkClickEventHandler; - href?: string; +interface LinkProps { + onClick: MouseEventHandler; + href: string; } +type GetSecuritySolutionProps = ( + params: SecuritySolutionLinkProps & { onClick?: MouseEventHandler } +) => LinkProps; + +/** + * It returns the `onClick` and `href` props to use in link components based on the` deepLinkId` and `path` parameters. + */ +export const useGetSecuritySolutionLinkProps = (): GetSecuritySolutionProps => { + const getSecuritySolutionUrl = useGetSecuritySolutionUrl(); + const { navigateTo } = useNavigateTo(); + + const getSecuritySolutionProps = useCallback( + ({ deepLinkId, path, onClick: onClickProps }) => { + const url = getSecuritySolutionUrl({ deepLinkId, path }); + return { + href: url, + onClick: (ev: MouseEvent) => { + ev.preventDefault(); + navigateTo({ url }); + if (onClickProps) { + onClickProps(ev); + } + }, + }; + }, + [getSecuritySolutionUrl, navigateTo] + ); + + return getSecuritySolutionProps; +}; + /** * HOC that wraps any Link component and makes it a Security solutions internal navigation Link. - * - * It injects `onClick` and 'href' into the Link component calculated based on the` deepLinkId` and `path` parameters. */ -export const withSecuritySolutionLink = ( +export const withSecuritySolutionLink = >( WrappedComponent: React.FC ) => { const SecuritySolutionLink: React.FC> = ({ @@ -555,38 +582,27 @@ export const withSecuritySolutionLink = { - const { formatUrl } = useFormatUrl(deepLinkId); - const { navigateTo } = useNavigation(); - const url = useMemo(() => formatUrl(path ?? ''), [formatUrl, path]); - - const onClick = useCallback( - (ev: LinkClickEvent) => { - ev.preventDefault(); - - if (onClickProps) { - onClickProps(ev); - } - - navigateTo({ url }); - }, - [navigateTo, url, onClickProps] - ); - - return ; + const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); + const { onClick, href } = getSecuritySolutionLinkProps({ + deepLinkId, + path, + onClick: onClickProps, + }); + return ; }; return SecuritySolutionLink; }; /** - * Security Solutions internal link. + * Security Solutions internal link button. * - * `const example = () => ;` + * `;` */ export const SecuritySolutionLinkButton = withSecuritySolutionLink(LinkButton); /** - * Security Solutions internal link. + * Security Solutions internal link anchor. * - * `const example = () => ;` + * `;` */ export const SecuritySolutionLinkAnchor = withSecuritySolutionLink(LinkAnchor); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/helpers.ts b/x-pack/plugins/security_solution/public/common/components/navigation/helpers.ts index 35b5f62629dca..5569d8c85afa8 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/helpers.ts @@ -9,6 +9,8 @@ import { isEmpty } from 'lodash/fp'; import { Location } from 'history'; import type { Filter, Query } from '@kbn/es-query'; +import { useUiSetting$ } from '../../lib/kibana'; +import { ENABLE_GROUPED_NAVIGATION } from '../../../../common/constants'; import { UrlInputsModel } from '../../store/inputs/model'; import { TimelineUrl } from '../../../timelines/store/timeline/model'; import { CONSTANTS } from '../url_state/constants'; @@ -21,58 +23,72 @@ import { import { SearchNavTab } from './types'; import { SourcererUrlState } from '../../store/sourcerer/model'; +import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features'; export const getSearch = (tab: SearchNavTab, urlState: UrlState): string => { if (tab && tab.urlKey != null && !isAdministration(tab.urlKey)) { - return ALL_URL_STATE_KEYS.reduce( - (myLocation: Location, urlKey: KeyUrlState) => { - let urlStateToReplace: - | Filter[] - | Query - | SourcererUrlState - | TimelineUrl - | UrlInputsModel - | string = ''; + return getUrlStateSearch(urlState); + } + return ''; +}; - if (urlKey === CONSTANTS.appQuery && urlState.query != null) { - if (urlState.query.query === '') { - urlStateToReplace = ''; - } else { - urlStateToReplace = urlState.query; - } - } else if (urlKey === CONSTANTS.filters && urlState.filters != null) { - if (isEmpty(urlState.filters)) { - urlStateToReplace = ''; - } else { - urlStateToReplace = urlState.filters; - } - } else if (urlKey === CONSTANTS.timerange) { - urlStateToReplace = urlState[CONSTANTS.timerange]; - } else if (urlKey === CONSTANTS.sourcerer) { - urlStateToReplace = urlState[CONSTANTS.sourcerer]; - } else if (urlKey === CONSTANTS.timeline && urlState[CONSTANTS.timeline] != null) { - const timeline = urlState[CONSTANTS.timeline]; - if (timeline.id === '') { - urlStateToReplace = ''; - } else { - urlStateToReplace = timeline; - } +export const getUrlStateSearch = (urlState: UrlState): string => + ALL_URL_STATE_KEYS.reduce( + (myLocation: Location, urlKey: KeyUrlState) => { + let urlStateToReplace: + | Filter[] + | Query + | SourcererUrlState + | TimelineUrl + | UrlInputsModel + | string = ''; + + if (urlKey === CONSTANTS.appQuery && urlState.query != null) { + if (urlState.query.query === '') { + urlStateToReplace = ''; + } else { + urlStateToReplace = urlState.query; + } + } else if (urlKey === CONSTANTS.filters && urlState.filters != null) { + if (isEmpty(urlState.filters)) { + urlStateToReplace = ''; + } else { + urlStateToReplace = urlState.filters; + } + } else if (urlKey === CONSTANTS.timerange) { + urlStateToReplace = urlState[CONSTANTS.timerange]; + } else if (urlKey === CONSTANTS.sourcerer) { + urlStateToReplace = urlState[CONSTANTS.sourcerer]; + } else if (urlKey === CONSTANTS.timeline && urlState[CONSTANTS.timeline] != null) { + const timeline = urlState[CONSTANTS.timeline]; + if (timeline.id === '') { + urlStateToReplace = ''; + } else { + urlStateToReplace = timeline; } - return replaceQueryStringInLocation( - myLocation, - replaceStateKeyInQueryString( - urlKey, - urlStateToReplace - )(getQueryStringFromLocation(myLocation.search)) - ); - }, - { - pathname: '', - hash: '', - search: '', - state: '', } - ).search; - } - return ''; + return replaceQueryStringInLocation( + myLocation, + replaceStateKeyInQueryString( + urlKey, + urlStateToReplace + )(getQueryStringFromLocation(myLocation.search)) + ); + }, + { + pathname: '', + hash: '', + search: '', + state: '', + } + ).search; + +/** + * Hook to check if the new grouped navigation is enabled on both experimental flag and advanced settings + * TODO: remove this function when flag and setting not needed + */ +export const useIsGroupedNavigationEnabled = () => { + const groupedNavFlagEnabled = useIsExperimentalFeatureEnabled('groupedNavigation'); + const [groupedNavSettingEnabled] = useUiSetting$(ENABLE_GROUPED_NAVIGATION); + return groupedNavFlagEnabled && groupedNavSettingEnabled; }; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/icons/spaces.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/icons/spaces.tsx new file mode 100644 index 0000000000000..665ceb993b769 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/icons/spaces.tsx @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { SVGProps } from 'react'; + +export const EuiIconSpaces: React.FC> = ({ ...props }) => ( + + + +); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/index.ts new file mode 100644 index 0000000000000..513976920a216 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { SolutionGroupedNav } from './solution_grouped_nav'; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.styles.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.styles.tsx new file mode 100644 index 0000000000000..aaeb7e65bc7e8 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.styles.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiListGroupItem, transparentize } from '@elastic/eui'; +import styled from 'styled-components'; + +export const EuiListGroupItemStyled = styled(EuiListGroupItem)` + font-weight: ${({ theme }) => theme.eui.euiFontWeightRegular}; + &.solutionGroupedNavItem--isPrimary { + font-weight: ${({ theme }) => theme.eui.euiFontWeightBold}; + } + &:focus, + &:focus-within, + &:hover, + &.solutionGroupedNavItem--isActive { + background-color: ${({ theme }) => transparentize(theme.eui.euiColorPrimary, 0.1)}; + } + .solutionGroupedNavItemButton:focus, + .solutionGroupedNavItemButton:focus-within, + .solutionGroupedNavItemButton:hover { + background-color: ${({ theme }) => transparentize(theme.eui.euiColorPrimary, 0.1)}; + } +`; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx new file mode 100644 index 0000000000000..f141264bd97e4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.test.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { SecurityPageName } from '../../../../app/types'; +import { TestProviders } from '../../../mock'; +import { NavItem } from './solution_grouped_nav_item'; +import { SolutionGroupedNav, SolutionGroupedNavProps } from './solution_grouped_nav'; + +const mockUseShowTimeline = jest.fn((): [boolean] => [false]); +jest.mock('../../../utils/timeline/use_show_timeline', () => ({ + useShowTimeline: () => mockUseShowTimeline(), +})); + +const mockItems: NavItem[] = [ + { + id: SecurityPageName.dashboardsLanding, + label: 'Dashboards', + href: '/dashboards', + items: [ + { + id: SecurityPageName.overview, + label: 'Overview', + href: '/overview', + description: 'Overview description', + }, + ], + }, + { + id: SecurityPageName.alerts, + label: 'Alerts', + href: '/alerts', + }, +]; + +const renderNav = (props: Partial = {}) => + render(, { + wrapper: TestProviders, + }); + +describe('SolutionGroupedNav', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render all main items', () => { + const result = renderNav(); + expect(result.getByText('Dashboards')).toBeInTheDocument(); + expect(result.getByText('Alerts')).toBeInTheDocument(); + }); + + describe('links', () => { + it('should contain correct href in links', () => { + const result = renderNav(); + expect( + result + .getByTestId(`groupedNavItemLink-${SecurityPageName.dashboardsLanding}`) + .getAttribute('href') + ).toBe('/dashboards'); + expect( + result.getByTestId(`groupedNavItemLink-${SecurityPageName.alerts}`).getAttribute('href') + ).toBe('/alerts'); + }); + + it('should call onClick callback if link clicked', () => { + const mockOnClick = jest.fn((ev) => { + ev.preventDefault(); + }); + const items = [ + ...mockItems, + { + id: SecurityPageName.threatHuntingLanding, + label: 'Threat Hunting', + href: '/threat_hunting', + onClick: mockOnClick, + }, + ]; + const result = renderNav({ items }); + result.getByTestId(`groupedNavItemLink-${SecurityPageName.threatHuntingLanding}`).click(); + expect(mockOnClick).toHaveBeenCalled(); + }); + }); + + describe('panel button toggle', () => { + it('should render the group button only for grouped items', () => { + const result = renderNav(); + expect( + result.getByTestId(`groupedNavItemButton-${SecurityPageName.dashboardsLanding}`) + ).toBeInTheDocument(); + expect( + result.queryByTestId(`groupedNavItemButton-${SecurityPageName.alerts}`) + ).not.toBeInTheDocument(); + }); + + it('should render the group panel when button is clicked', () => { + const result = renderNav(); + expect(result.queryByTestId('groupedNavPanel')).not.toBeInTheDocument(); + + result.getByTestId(`groupedNavItemButton-${SecurityPageName.dashboardsLanding}`).click(); + expect(result.getByTestId('groupedNavPanel')).toBeInTheDocument(); + expect(result.getByText('Overview')).toBeInTheDocument(); + }); + + it('should close the group panel when the same button is clicked', () => { + const result = renderNav(); + result.getByTestId(`groupedNavItemButton-${SecurityPageName.dashboardsLanding}`).click(); + expect(result.getByTestId('groupedNavPanel')).toBeInTheDocument(); + + result.getByTestId(`groupedNavItemButton-${SecurityPageName.dashboardsLanding}`).click(); + + waitFor(() => { + expect(result.queryByTestId('groupedNavPanel')).not.toBeInTheDocument(); + }); + }); + + it('should open other group panel when other button is clicked while open', () => { + const items = [ + ...mockItems, + { + id: SecurityPageName.threatHuntingLanding, + label: 'Threat Hunting', + href: '/threat_hunting', + items: [ + { + id: SecurityPageName.users, + label: 'Users', + href: '/users', + description: 'Users description', + }, + ], + }, + ]; + const result = renderNav({ items }); + + result.getByTestId(`groupedNavItemButton-${SecurityPageName.dashboardsLanding}`).click(); + expect(result.getByTestId('groupedNavPanel')).toBeInTheDocument(); + expect(result.getByText('Overview')).toBeInTheDocument(); + + result.getByTestId(`groupedNavItemButton-${SecurityPageName.threatHuntingLanding}`).click(); + expect(result.queryByTestId('groupedNavPanel')).toBeInTheDocument(); + expect(result.getByText('Users')).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx new file mode 100644 index 0000000000000..fcfcc9d6b1b4b --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav.tsx @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment, useCallback, useMemo, useRef, useState } from 'react'; +import { + EuiListGroup, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + useIsWithinBreakpoints, +} from '@elastic/eui'; + +import classNames from 'classnames'; +import { SolutionGroupedNavPanel } from './solution_grouped_nav_panel'; +import { EuiListGroupItemStyled } from './solution_grouped_nav.styles'; +import { + isCustomNavItem, + isDefaultNavItem, + NavItem, + PortalNavItem, +} from './solution_grouped_nav_item'; +import { EuiIconSpaces } from './icons/spaces'; + +export interface SolutionGroupedNavProps { + items: NavItem[]; + selectedId: string; + footerItems?: NavItem[]; +} +type ActivePortalNav = string | null; + +export const SolutionGroupedNavComponent: React.FC = ({ + items, + selectedId, + footerItems = [], +}) => { + const isMobileSize = useIsWithinBreakpoints(['xs', 's']); + + const [activePortalNavId, setActivePortalNavId] = useState(null); + const activePortalNavIdRef = useRef(null); + + const openPortalNav = (navId: string) => { + activePortalNavIdRef.current = navId; + setActivePortalNavId(navId); + }; + + const closePortalNav = () => { + activePortalNavIdRef.current = null; + setActivePortalNavId(null); + }; + + const onClosePortalNav = useCallback(() => { + const currentPortalNavId = activePortalNavIdRef.current; + setTimeout(() => { + // This event is triggered on outside click. + // Closing the side nav at the end of event loop to make sure it + // closes also if the active "nav group" button has been clicked (toggle), + // but it does not close if any some other "nav group" open button has been clicked. + if (activePortalNavIdRef.current === currentPortalNavId) { + closePortalNav(); + } + }); + }, []); + + const navItemsById = useMemo( + () => + [...items, ...footerItems].reduce< + Record + >((acc, navItem) => { + if (isDefaultNavItem(navItem) && navItem.items && navItem.items.length > 0) { + acc[navItem.id] = { + title: navItem.label, + subItems: navItem.items, + }; + } + return acc; + }, {}), + [items, footerItems] + ); + + const portalNav = useMemo(() => { + if (activePortalNavId == null || !navItemsById[activePortalNavId]) { + return null; + } + const { subItems, title } = navItemsById[activePortalNavId]; + return ; + }, [activePortalNavId, navItemsById, onClosePortalNav]); + + const renderNavItem = useCallback( + (navItem: NavItem) => { + if (isCustomNavItem(navItem)) { + return {navItem.render()}; + } + const { id, href, label, onClick } = navItem; + const isActive = activePortalNavId === id; + const isCurrentNav = selectedId === id; + + const itemClassNames = classNames('solutionGroupedNavItem', { + 'solutionGroupedNavItem--isActive': isActive, + 'solutionGroupedNavItem--isPrimary': isCurrentNav, + }); + const buttonClassNames = classNames('solutionGroupedNavItemButton'); + + return ( + // eslint-disable-next-line @elastic/eui/href-or-on-click + + { + ev.preventDefault(); + ev.stopPropagation(); + openPortalNav(id); + }, + iconType: EuiIconSpaces, + iconSize: 'm', + 'aria-label': 'Toggle group nav', + 'data-test-subj': `groupedNavItemButton-${id}`, + alwaysShow: true, + }, + } + : {})} + /> + + ); + }, + [activePortalNavId, isMobileSize, navItemsById, selectedId] + ); + + return ( + <> + + + + + {items.map(renderNavItem)} + + + {footerItems.map(renderNavItem)} + + + + + + {portalNav} + + ); +}; + +export const SolutionGroupedNav = React.memo(SolutionGroupedNavComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx new file mode 100644 index 0000000000000..df7e08ad46f95 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_item.tsx @@ -0,0 +1,188 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { useGetSecuritySolutionLinkProps } from '../../links'; +import { SecurityPageName } from '../../../../../common/constants'; + +export type NavItemCategories = Array<{ label: string; itemIds: string[] }>; +export interface DefaultNavItem { + id: string; + label: string; + href: string; + onClick?: React.MouseEventHandler; + items?: PortalNavItem[]; + categories?: NavItemCategories; +} + +export interface CustomNavItem { + id: string; + render: () => React.ReactNode; +} + +export type NavItem = DefaultNavItem | CustomNavItem; + +export interface PortalNavItem { + id: string; + label: string; + href: string; + onClick?: React.MouseEventHandler; + description?: string; +} + +export const isCustomNavItem = (navItem: NavItem): navItem is CustomNavItem => 'render' in navItem; +export const isDefaultNavItem = (navItem: NavItem): navItem is DefaultNavItem => + !isCustomNavItem(navItem); + +export const useNavItems: () => NavItem[] = () => { + const getSecuritySolutionLinkProps = useGetSecuritySolutionLinkProps(); + return [ + { + id: SecurityPageName.dashboardsLanding, + label: 'Dashboards', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.dashboardsLanding }), + items: [ + { + id: 'overview', + label: 'Overview', + description: 'The description goes here', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.overview }), + }, + { + id: 'detection_response', + label: 'Detection & Response', + description: 'The description goes here', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.detectionAndResponse }), + }, + // TODO: add the cloudPostureFindings to the config here + // { + // id: SecurityPageName.cloudPostureFindings, + // label: 'Cloud Posture Findings', + // description: 'The description goes here', + // ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.cloudPostureFindings }), + // }, + ], + }, + { + id: SecurityPageName.alerts, + label: 'Alerts', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.alerts }), + }, + { + id: SecurityPageName.timelines, + label: 'Timelines', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.timelines }), + }, + { + id: SecurityPageName.case, + label: 'Cases', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.case }), + }, + { + id: SecurityPageName.threatHuntingLanding, + label: 'Threat Hunting', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.threatHuntingLanding }), + items: [ + { + id: SecurityPageName.hosts, + label: 'Hosts', + description: + 'Computer or other device that communicates with other hosts on a network. Hosts on a network include clients and servers -- that send or receive data, services or applications.', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.hosts }), + }, + { + id: SecurityPageName.network, + label: 'Network', + description: + 'The action or process of interacting with others to exchange information and develop professional or social contacts.', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.network }), + }, + { + id: SecurityPageName.users, + label: 'Users', + description: 'Sudo commands dashboard from the Logs System integration.', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.users }), + }, + ], + }, + // TODO: implement footer and move management + { + id: SecurityPageName.administration, + label: 'Manage', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.administration }), + categories: [ + { label: 'SIEM', itemIds: [SecurityPageName.rules, SecurityPageName.exceptions] }, + { + label: 'ENDPOINTS', + itemIds: [ + SecurityPageName.endpoints, + SecurityPageName.policies, + SecurityPageName.trustedApps, + SecurityPageName.eventFilters, + SecurityPageName.blocklist, + SecurityPageName.hostIsolationExceptions, + ], + }, + ], + items: [ + { + id: SecurityPageName.rules, + label: 'Rules', + description: 'The description here', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.rules }), + }, + { + id: SecurityPageName.exceptions, + label: 'Exceptions', + description: 'The description goes here', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.exceptions }), + }, + { + id: SecurityPageName.endpoints, + label: 'Endpoints', + description: 'The description goes here', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.endpoints }), + }, + { + id: SecurityPageName.policies, + label: 'Policies', + description: 'The description goes here', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.policies }), + }, + { + id: SecurityPageName.trustedApps, + label: 'Trusted applications', + description: 'The description goes here', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.trustedApps }), + }, + { + id: SecurityPageName.eventFilters, + label: 'Event filters', + description: 'The description goes here', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.eventFilters }), + }, + { + id: SecurityPageName.blocklist, + label: 'Blocklist', + description: 'The description goes here', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.blocklist }), + }, + { + id: SecurityPageName.hostIsolationExceptions, + label: 'Host Isolation IP exceptions', + description: 'The description goes here', + ...getSecuritySolutionLinkProps({ deepLinkId: SecurityPageName.hostIsolationExceptions }), + }, + ], + }, + ]; +}; + +export const useFooterNavItems: () => NavItem[] = () => { + // TODO: implement footer items + return []; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.styles.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.styles.tsx new file mode 100644 index 0000000000000..069c146ca0719 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.styles.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiPanel } from '@elastic/eui'; +import styled from 'styled-components'; + +export const EuiPanelStyled = styled(EuiPanel)<{ $hasBottomBar: boolean }>` + position: fixed; + top: 95px; + left: 247px; + bottom: 0; + width: 340px; + height: inherit; + + // If the bottom bar is visible add padding to the navigation + ${({ $hasBottomBar, theme }) => + $hasBottomBar && + ` + height: inherit; + bottom: 51px; + box-shadow: + // left + -${theme.eui.euiSizeS} 0 ${theme.eui.euiSizeS} -${theme.eui.euiSizeS} rgb(0 0 0 / 15%), + // right + ${theme.eui.euiSizeS} 0 ${theme.eui.euiSizeS} -${theme.eui.euiSizeS} rgb(0 0 0 / 15%), + // bottom inset to match timeline bar top shadow + inset 0 -${theme.eui.euiSizeXS} ${theme.eui.euiSizeXS} -${theme.eui.euiSizeXS} rgb(0 0 0 / 6%); + `} +`; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.tsx new file mode 100644 index 0000000000000..93d46c35d6bed --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.test.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { SecurityPageName } from '../../../../app/types'; +import { TestProviders } from '../../../mock'; +import { PortalNavItem } from './solution_grouped_nav_item'; +import { + SolutionGroupedNavPanel, + SolutionGroupedNavPanelProps, +} from './solution_grouped_nav_panel'; + +const mockUseShowTimeline = jest.fn((): [boolean] => [false]); +jest.mock('../../../utils/timeline/use_show_timeline', () => ({ + useShowTimeline: () => mockUseShowTimeline(), +})); + +const mockItems: PortalNavItem[] = [ + { + id: SecurityPageName.hosts, + label: 'Hosts', + href: '/hosts', + description: 'Hosts description', + }, + { + id: SecurityPageName.network, + label: 'Network', + href: '/network', + description: 'Network description', + }, +]; + +const PANEL_TITLE = 'test title'; +const mockOnClose = jest.fn(); +const renderNavPanel = (props: Partial = {}) => + render( + <> +
+ + , + { + wrapper: TestProviders, + } + ); + +describe('SolutionGroupedNav', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render all main items', () => { + const result = renderNavPanel(); + + expect(result.getByText(PANEL_TITLE)).toBeInTheDocument(); + + mockItems.forEach((item) => { + expect(result.getByText(item.label)).toBeInTheDocument(); + if (item.description) { + expect(result.getByText(item.description)).toBeInTheDocument(); + } + }); + }); + + describe('links', () => { + it('should contain correct href in links', () => { + const result = renderNavPanel(); + expect( + result.getByTestId(`groupedNavPanelLink-${SecurityPageName.hosts}`).getAttribute('href') + ).toBe('/hosts'); + expect( + result.getByTestId(`groupedNavPanelLink-${SecurityPageName.network}`).getAttribute('href') + ).toBe('/network'); + }); + + it('should call onClick callback if link clicked', () => { + const mockOnClick = jest.fn((ev) => { + ev.preventDefault(); + }); + const items = [ + ...mockItems, + { + id: SecurityPageName.users, + label: 'Users', + href: '/users', + onClick: mockOnClick, + }, + ]; + const result = renderNavPanel({ items }); + result.getByTestId(`groupedNavPanelLink-${SecurityPageName.users}`).click(); + expect(mockOnClick).toHaveBeenCalled(); + }); + }); + + describe('close', () => { + it('should call onClose callback if link clicked', () => { + const result = renderNavPanel(); + result.getByTestId(`groupedNavPanelLink-${SecurityPageName.hosts}`).click(); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('should call onClose callback if outside clicked', () => { + const result = renderNavPanel(); + result.getByTestId('outsideClickDummy').click(); + waitFor(() => { + expect(mockOnClose).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx new file mode 100644 index 0000000000000..c1615a97264eb --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/components/navigation/solution_grouped_nav/solution_grouped_nav_panel.tsx @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Fragment, useCallback } from 'react'; +import { + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiFlexGroup, + EuiFlexItem, + EuiFocusTrap, + EuiOutsideClickDetector, + EuiPortal, + EuiTitle, + EuiWindowEvent, + keys, + useIsWithinBreakpoints, +} from '@elastic/eui'; +import classNames from 'classnames'; +import { EuiPanelStyled } from './solution_grouped_nav_panel.styles'; +import { PortalNavItem } from './solution_grouped_nav_item'; +import { useShowTimeline } from '../../../utils/timeline/use_show_timeline'; + +export interface SolutionGroupedNavPanelProps { + onClose: () => void; + title: string; + items: PortalNavItem[]; +} + +const SolutionGroupedNavPanelComponent: React.FC = ({ + onClose, + title, + items, +}) => { + const [hasTimelineBar] = useShowTimeline(); + const isLargerBreakpoint = useIsWithinBreakpoints(['l', 'xl']); + const isTimelineVisible = hasTimelineBar && isLargerBreakpoint; + const panelClasses = classNames('eui-yScroll'); + + /** + * ESC key closes SideNav + */ + const onKeyDown = useCallback( + (ev: KeyboardEvent) => { + if (ev.key === keys.ESCAPE) { + onClose(); + } + }, + [onClose] + ); + + return ( + <> + + + + onClose()}> + + + + + {title} + + + + + + {items.map(({ id, href, onClick, label, description }: PortalNavItem) => ( + + + { + onClose(); + if (onClick) { + onClick(ev); + } + }} + > + {label} + + + {description} + + ))} + + + + + + + + + ); +}; + +export const SolutionGroupedNavPanel = React.memo(SolutionGroupedNavPanelComponent); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx index 258dad531837a..9ec86ee2b24ef 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_get_url_search.tsx @@ -5,11 +5,11 @@ * 2.0. */ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useDeepEqualSelector } from '../../hooks/use_selector'; import { makeMapStateToProps } from '../url_state/helpers'; -import { getSearch } from './helpers'; +import { getSearch, getUrlStateSearch } from './helpers'; import { SearchNavTab } from './types'; export const useGetUrlSearch = (tab?: SearchNavTab) => { @@ -18,3 +18,10 @@ export const useGetUrlSearch = (tab?: SearchNavTab) => { const urlSearch = useMemo(() => (tab ? getSearch(tab, urlState) : ''), [tab, urlState]); return urlSearch; }; + +export const useGetUrlStateQueryString = () => { + const mapState = makeMapStateToProps(); + const { urlState } = useDeepEqualSelector(mapState); + const getUrlStateQueryString = useCallback(() => getUrlStateSearch(urlState), [urlState]); + return getUrlStateQueryString; +}; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx index 3f30facd5e41e..c515f43ee181d 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/index.test.tsx @@ -6,7 +6,7 @@ */ import { renderHook } from '@testing-library/react-hooks'; -import { KibanaPageTemplateProps } from '@kbn/kibana-react-plugin/public'; +import { KibanaPageTemplateProps } from '@kbn/shared-ux-components'; import { useKibana } from '../../../lib/kibana/kibana_react'; import { useGetUserCasesPermissions } from '../../../lib/kibana'; import { SecurityPageName } from '../../../../app/types'; @@ -289,7 +289,7 @@ describe('useSecuritySolutionNavigation', () => { expect(result?.current?.items?.[2].items?.[2].id).toEqual(SecurityPageName.users); }); - // TODO: Sergi/detectionAndResponse remove when no longer experimental + // TODO: [detectionResponse] remove when page is no longer experimental it('should include detectionAndResponse when feature flag is on', async () => { (useIsExperimentalFeatureEnabled as jest.Mock).mockReturnValue(true); const { result } = renderHook<{}, KibanaPageTemplateProps['solutionNav']>( @@ -307,8 +307,10 @@ describe('useSecuritySolutionNavigation', () => { () => useSecuritySolutionNavigation(), { wrapper: TestProviders } ); + const items = result.current?.items; + expect(items).toBeDefined(); expect( - result.current?.items + items! .find((item) => item.id === 'manage') ?.items?.find((item) => item.id === 'host_isolation_exceptions') ).toBeUndefined(); diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx index da03e6cf35e21..1dbcf929ed81f 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/use_security_solution_navigation/use_primary_navigation.tsx @@ -5,12 +5,15 @@ * 2.0. */ -import { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; -import { KibanaPageTemplateProps } from '@kbn/kibana-react-plugin/public'; +import { KibanaPageTemplateProps } from '@kbn/shared-ux-components'; import { PrimaryNavigationProps } from './types'; import { usePrimaryNavigationItems } from './use_navigation_items'; +import { SolutionGroupedNav } from '../solution_grouped_nav'; +import { useNavItems } from '../solution_grouped_nav/solution_grouped_nav_item'; +import { useIsGroupedNavigationEnabled } from '../helpers'; const translatedNavTitle = i18n.translate('xpack.securitySolution.navigation.mainLabel', { defaultMessage: 'Security', @@ -27,6 +30,7 @@ export const usePrimaryNavigation = ({ timeline, timerange, }: PrimaryNavigationProps): KibanaPageTemplateProps['solutionNav'] => { + const isGroupedNavigationEnabled = useIsGroupedNavigationEnabled(); const mapLocationToTab = useCallback( (): string => ((tabName && navTabs[tabName]) || navTabs[pageName])?.id ?? '', [pageName, tabName, navTabs] @@ -44,6 +48,7 @@ export const usePrimaryNavigation = ({ // we do need navTabs in case the selectedTabId appears after initial load (ex. checking permissions for anomalies) }, [pageName, navTabs, mapLocationToTab, selectedTabId]); + const navLinkItems = useNavItems(); const navItems = usePrimaryNavigationItems({ navTabs, selectedTabId, @@ -58,6 +63,11 @@ export const usePrimaryNavigation = ({ return { name: translatedNavTitle, icon: 'logoSecurity', - items: navItems, + ...(isGroupedNavigationEnabled + ? { + children: , + closeFlyoutButtonPosition: 'inside', + } + : { items: navItems }), }; }; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts index c4c5c36497289..11fdb39b5315a 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/__mocks__/index.ts @@ -75,3 +75,12 @@ export const useAppUrl = jest.fn().mockReturnValue({ mockStartServicesMock.application.getUrlForApp(appId, options) ), }); +export const useNavigateTo = jest.fn().mockReturnValue({ + navigateTo: jest.fn().mockImplementation(({ appId = APP_UI_ID, url, ...options }) => { + if (url) { + mockStartServicesMock.application.navigateToUrl(url); + } else { + mockStartServicesMock.application.navigateToApp(appId, options); + } + }), +}); diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx index 91b223732da7e..18e4af5886064 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.test.tsx @@ -7,28 +7,50 @@ import { renderHook, act } from '@testing-library/react-hooks'; import { useShowTimeline } from './use_show_timeline'; -import { globalNode } from '../../mock'; + +const mockUseLocation = jest.fn().mockReturnValue({ pathname: '/overview' }); +jest.mock('react-router-dom', () => { + const original = jest.requireActual('react-router-dom'); + return { + ...original, + useLocation: () => mockUseLocation(), + }; +}); describe('use show timeline', () => { it('shows timeline for routes on default', async () => { await act(async () => { const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); await waitForNextUpdate(); - const uninitializedTimeline = result.current; - expect(uninitializedTimeline).toEqual([true]); + const showTimeline = result.current; + expect(showTimeline).toEqual([true]); }); }); it('hides timeline for blacklist routes', async () => { + mockUseLocation.mockReturnValueOnce({ pathname: '/rules/create' }); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); + await waitForNextUpdate(); + const showTimeline = result.current; + expect(showTimeline).toEqual([false]); + }); + }); + it('shows timeline for partial blacklist routes', async () => { + mockUseLocation.mockReturnValueOnce({ pathname: '/rules' }); + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); + await waitForNextUpdate(); + const showTimeline = result.current; + expect(showTimeline).toEqual([true]); + }); + }); + it('hides timeline for sub blacklist routes', async () => { + mockUseLocation.mockReturnValueOnce({ pathname: '/administration/policy' }); await act(async () => { - Object.defineProperty(globalNode.window, 'location', { - value: { - pathname: `/cases/configure`, - }, - }); const { result, waitForNextUpdate } = renderHook(() => useShowTimeline()); await waitForNextUpdate(); - const uninitializedTimeline = result.current; - expect(uninitializedTimeline).toEqual([false]); + const showTimeline = result.current; + expect(showTimeline).toEqual([false]); }); }); }); diff --git a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx index e24a4d0025aee..3378b13f8cb73 100644 --- a/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx +++ b/x-pack/plugins/security_solution/public/common/utils/timeline/use_show_timeline.tsx @@ -6,29 +6,29 @@ */ import { useState, useEffect } from 'react'; -import { useRouteSpy } from '../route/use_route_spy'; +import { matchPath, useLocation } from 'react-router-dom'; -const hideTimelineForRoutes = [`/cases/configure`, '/administration', 'rules/create']; +const HIDDEN_TIMELINE_ROUTES: readonly string[] = [ + `/cases/configure`, + '/administration', + '/rules/create', + '/get_started', + '/threat_hunting', + '/dashboards', + '/manage', +]; -export const useShowTimeline = () => { - const [{ pageName, pathName }] = useRouteSpy(); +const isHiddenTimelinePath = (currentPath: string): boolean => { + return !!HIDDEN_TIMELINE_ROUTES.find((route) => matchPath(currentPath, route)); +}; - const [showTimeline, setShowTimeline] = useState( - !hideTimelineForRoutes.includes(window.location.pathname) - ); +export const useShowTimeline = () => { + const { pathname } = useLocation(); + const [showTimeline, setShowTimeline] = useState(!isHiddenTimelinePath(pathname)); useEffect(() => { - if ( - hideTimelineForRoutes.filter((route) => window.location.pathname.includes(route)).length > 0 - ) { - if (showTimeline) { - setShowTimeline(false); - } - } else if (!showTimeline) { - setShowTimeline(true); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pageName, pathName]); + setShowTimeline(!isHiddenTimelinePath(pathname)); + }, [pathname]); return [showTimeline]; }; diff --git a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx index 3553f44cc621f..c2ab11ceead79 100644 --- a/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx +++ b/x-pack/plugins/security_solution/public/landing_pages/components/landing_links_icons.test.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { fireEvent, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import React from 'react'; import { SecurityPageName } from '../../app/types'; import { TestProviders } from '../../common/mock'; @@ -23,7 +23,7 @@ jest.mock('../../common/lib/kibana', () => { const originalModule = jest.requireActual('../../common/lib/kibana'); return { ...originalModule, - useNavigation: () => ({ + useNavigateTo: () => ({ navigateTo: mockNavigateTo, }), }; @@ -33,10 +33,8 @@ jest.mock('../../common/components/link_to', () => { const originalModule = jest.requireActual('../../common/components/link_to'); return { ...originalModule, - useFormatUrl: (id: string) => ({ - formatUrl: jest.fn().mockImplementation((path: string) => `/${id}`), - search: '', - }), + useGetSecuritySolutionUrl: () => + jest.fn(({ deepLinkId }: { deepLinkId: string }) => `/${deepLinkId}`), }; }); @@ -63,7 +61,7 @@ describe('LandingLinksIcons', () => { ); - fireEvent.click(getByText(label)); + getByText(label).click(); expect(mockNavigateTo).toHaveBeenCalledWith({ url: '/administration' }); }); diff --git a/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx b/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx index fedcca0aac7b4..0112de7612fe5 100644 --- a/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx +++ b/x-pack/plugins/security_solution/public/overview/pages/detection_response.tsx @@ -11,13 +11,13 @@ import { SecuritySolutionPageWrapper } from '../../common/components/page_wrappe import { SpyRoute } from '../../common/utils/route/spy_routes'; import { SecurityPageName } from '../../app/types'; import { useSourcererDataView } from '../../common/containers/sourcerer'; +import { useSignalIndex } from '../../detections/containers/detection_engine/alerts/use_signal_index'; +import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { HeaderPage } from '../../common/components/header_page'; import { useKibana, useGetUserCasesPermissions } from '../../common/lib/kibana'; import { EmptyPage } from '../../common/components/empty_page'; import { LandingPageComponent } from '../../common/components/landing_page'; -import { useSignalIndex } from '../../detections/containers/detection_engine/alerts/use_signal_index'; -import { useAlertsPrivileges } from '../../detections/containers/detection_engine/alerts/use_alerts_privileges'; import { AlertsByStatus } from '../components/detection_response/alerts_by_status'; import { HostAlertsTable } from '../components/detection_response/host_alerts_table'; import { RuleAlertsTable } from '../components/detection_response/rule_alerts_table'; diff --git a/x-pack/plugins/security_solution/server/ui_settings.ts b/x-pack/plugins/security_solution/server/ui_settings.ts index c35e8ea1bad4f..b389f3b0effab 100644 --- a/x-pack/plugins/security_solution/server/ui_settings.ts +++ b/x-pack/plugins/security_solution/server/ui_settings.ts @@ -25,6 +25,7 @@ import { DEFAULT_THREAT_INDEX_KEY, DEFAULT_THREAT_INDEX_VALUE, DEFAULT_TO, + ENABLE_GROUPED_NAVIGATION, ENABLE_NEWS_FEED_SETTING, IP_REPUTATION_LINKS_SETTING, IP_REPUTATION_LINKS_SETTING_DEFAULT, @@ -144,6 +145,26 @@ export const initUiSettings = ( requiresPageReload: true, schema: schema.number(), }, + ...(experimentalFeatures.groupedNavigation + ? { + [ENABLE_GROUPED_NAVIGATION]: { + name: i18n.translate('xpack.securitySolution.uiSettings.enableGroupedNavigation', { + defaultMessage: 'Enable grouped navigation', + }), + value: false, + type: 'boolean', + description: i18n.translate( + 'xpack.securitySolution.uiSettings.enableGroupedNavigationDescription', + { + defaultMessage: '

Enables the grouped side navigation for Security Solution

', + } + ), + category: [APP_ID], + requiresPageReload: false, + schema: schema.boolean(), + }, + } + : {}), [ENABLE_NEWS_FEED_SETTING]: { name: i18n.translate('xpack.securitySolution.uiSettings.enableNewsFeedLabel', { defaultMessage: 'News feed', From 1f915017fccdc449cada1172914ae4b3aa05952c Mon Sep 17 00:00:00 2001 From: Yara Tercero Date: Thu, 12 May 2022 11:25:06 -0700 Subject: [PATCH 28/46] [Security Solution][Exceptions] - Add back exceptions export success toast (#131952) Addresses #88449 It appears that the success toaster code was deleted at some point as the text for the toaster was already there just not in use. Simple fix to add back in. Added check for toaster to existing cypress test. --- .../exceptions/exceptions_table.spec.ts | 14 +++++++++----- .../rules/all/exceptions/exceptions_table.tsx | 5 +++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts index d3bd4d210378b..b037c4f6d62ce 100644 --- a/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/exceptions/exceptions_table.spec.ts @@ -28,6 +28,7 @@ import { } from '../../screens/exceptions'; import { createExceptionList } from '../../tasks/api_calls/exceptions'; import { esArchiverResetKibana } from '../../tasks/es_archiver'; +import { TOASTER } from '../../screens/alerts_detection_rules'; const getExceptionList1 = () => ({ ...getExceptionList(), @@ -79,11 +80,14 @@ describe('Exceptions Table', () => { waitForExceptionsTableToBeLoaded(); exportExceptionList(); - cy.wait('@export').then(({ response }) => - cy - .wrap(response?.body) - .should('eql', expectedExportedExceptionList(this.exceptionListResponse)) - ); + cy.wait('@export').then(({ response }) => { + cy.wrap(response?.body).should( + 'eql', + expectedExportedExceptionList(this.exceptionListResponse) + ); + + cy.get(TOASTER).should('have.text', 'Exception list export success'); + }); }); it('Filters exception lists on search', () => { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx index 72984a8bcbe92..0d01872a904e3 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/exceptions/exceptions_table.tsx @@ -96,7 +96,7 @@ export const ExceptionListsTable = React.memo(() => { const [exportingListIds, setExportingListIds] = useState([]); const [exportDownload, setExportDownload] = useState<{ name?: string; blob?: Blob }>({}); const { navigateToUrl } = application; - const { addError } = useAppToasts(); + const { addError, addSuccess } = useAppToasts(); const handleDeleteSuccess = useCallback( (listId?: string) => () => { @@ -165,9 +165,10 @@ export const ExceptionListsTable = React.memo(() => { const handleExportSuccess = useCallback( (listId: string) => (blob: Blob): void => { + addSuccess(i18n.EXCEPTION_EXPORT_SUCCESS); setExportDownload({ name: listId, blob }); }, - [] + [addSuccess] ); const handleExportError = useCallback( From 595522b5c2541adb5b5cd976c3ac21d5859c735a Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 12 May 2022 12:36:40 -0600 Subject: [PATCH 29/46] [Controls] [Dashboard] Allow existing controls to change type (#129385) * Replace is half working * Child embeddable listens for change in type * Fix control order on replace * Fix factory & remove duplicated onPanelAdded/Removed * Comments + clean up code * Set invalid editor state on type change * Add functional tests and clean test code * Comment out time slider tests * Remove getReplacementPanelState * Fix promise syntax * Fix mutation * Fix flaky test * Fix conflicts --- .../component/control_frame_component.tsx | 10 +- .../component/control_group_component.tsx | 1 + .../component/control_group_sortable_item.tsx | 76 ++++---- .../control_group/editor/control_editor.tsx | 22 ++- .../control_group/editor/edit_control.tsx | 172 ++++++++++-------- .../options_list/options_list_editor.tsx | 18 +- .../range_slider/range_slider_editor.tsx | 18 +- .../time_slider.component.stories.tsx | 10 +- .../time_slider/time_slider.component.tsx | 11 +- .../control_types/time_slider/time_slider.tsx | 3 +- .../time_slider/time_slider_editor.tsx | 18 +- .../public/hooks/use_child_embeddable.ts | 4 +- src/plugins/controls/public/types.ts | 2 + .../public/lib/containers/container.ts | 31 ++++ .../public/lib/containers/i_container.ts | 10 + .../redux_embeddable_wrapper.tsx | 1 + .../components/redux_embeddables/types.ts | 6 +- .../controls/control_group_chaining.ts | 10 +- .../controls/control_group_settings.ts | 13 +- .../controls/controls_callout.ts | 5 +- .../apps/dashboard_elements/controls/index.ts | 1 + .../controls/options_list.ts | 13 +- .../controls/range_slider.ts | 10 +- .../controls/replace_controls.ts | 139 ++++++++++++++ .../page_objects/dashboard_page_controls.ts | 88 ++++----- 25 files changed, 477 insertions(+), 215 deletions(-) create mode 100644 test/functional/apps/dashboard_elements/controls/replace_controls.ts diff --git a/src/plugins/controls/public/control_group/component/control_frame_component.tsx b/src/plugins/controls/public/control_group/component/control_frame_component.tsx index 36879bea110d8..dabe351376b7f 100644 --- a/src/plugins/controls/public/control_group/component/control_frame_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_frame_component.tsx @@ -28,9 +28,15 @@ export interface ControlFrameProps { customPrepend?: JSX.Element; enableActions?: boolean; embeddableId: string; + embeddableType: string; } -export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: ControlFrameProps) => { +export const ControlFrame = ({ + customPrepend, + enableActions, + embeddableId, + embeddableType, +}: ControlFrameProps) => { const embeddableRoot: React.RefObject = useMemo(() => React.createRef(), []); const { useEmbeddableSelector, @@ -42,7 +48,7 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con const { overlays } = pluginServices.getHooks(); const { openConfirm } = overlays.useService(); - const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId }); + const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId, embeddableType }); const [title, setTitle] = useState(); diff --git a/src/plugins/controls/public/control_group/component/control_group_component.tsx b/src/plugins/controls/public/control_group/component/control_group_component.tsx index 3abee52002db1..72dc49b2f9fbb 100644 --- a/src/plugins/controls/public/control_group/component/control_group_component.tsx +++ b/src/plugins/controls/public/control_group/component/control_group_component.tsx @@ -144,6 +144,7 @@ export const ControlGroup = () => { isEditable={isEditable} dragInfo={{ index, draggingIndex }} embeddableId={controlId} + embeddableType={panels[controlId].type} key={controlId} /> ) diff --git a/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx b/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx index 2741752b4df88..bdf1851a0daa1 100644 --- a/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx +++ b/src/plugins/controls/public/control_group/component/control_group_sortable_item.tsx @@ -60,44 +60,50 @@ export const SortableControl = (frameProps: SortableControlProps) => { const SortableControlInner = forwardRef< HTMLButtonElement, SortableControlProps & { style: HTMLAttributes['style'] } ->(({ embeddableId, dragInfo, style, isEditable, ...dragHandleProps }, dragHandleRef) => { - const { isOver, isDragging, draggingIndex, index } = dragInfo; - const { useEmbeddableSelector } = useReduxContainerContext(); - const { panels } = useEmbeddableSelector((state) => state); +>( + ( + { embeddableId, embeddableType, dragInfo, style, isEditable, ...dragHandleProps }, + dragHandleRef + ) => { + const { isOver, isDragging, draggingIndex, index } = dragInfo; + const { useEmbeddableSelector } = useReduxContainerContext(); + const { panels } = useEmbeddableSelector((state) => state); - const width = panels[embeddableId].width; + const width = panels[embeddableId].width; - const dragHandle = ( - - ); + const dragHandle = ( + + ); - return ( - (draggingIndex ?? -1), - })} - style={style} - > - - - ); -}); + return ( + (draggingIndex ?? -1), + })} + style={style} + > + + + ); + } +); /** * A simplified clone version of the control which is dragged. This version only shows diff --git a/src/plugins/controls/public/control_group/editor/control_editor.tsx b/src/plugins/controls/public/control_group/editor/control_editor.tsx index 3cd5b92e503c1..eb7eff4abb42a 100644 --- a/src/plugins/controls/public/control_group/editor/control_editor.tsx +++ b/src/plugins/controls/public/control_group/editor/control_editor.tsx @@ -41,6 +41,7 @@ import { ControlEmbeddable, ControlInput, ControlWidth, + DataControlInput, IEditableControlFactory, } from '../../types'; import { CONTROL_WIDTH_OPTIONS } from './editor_constants'; @@ -85,6 +86,11 @@ export const ControlEditor = ({ const [currentTitle, setCurrentTitle] = useState(title); const [currentWidth, setCurrentWidth] = useState(width); const [controlEditorValid, setControlEditorValid] = useState(false); + const [selectedField, setSelectedField] = useState( + embeddable + ? (embeddable.getInput() as DataControlInput).fieldName // CLEAN THIS ONCE OTHER PR GETS IN + : undefined + ); const getControlTypeEditor = (type: string) => { const factory = getControlFactory(type); @@ -96,6 +102,8 @@ export const ControlEditor = ({ onChange={onTypeEditorChange} setValidState={setControlEditorValid} initialInput={embeddable?.getInput()} + selectedField={selectedField} + setSelectedField={setSelectedField} setDefaultTitle={(newDefaultTitle) => { if (!currentTitle || currentTitle === defaultTitle) { setCurrentTitle(newDefaultTitle); @@ -107,8 +115,8 @@ export const ControlEditor = ({ ) : null; }; - const getTypeButtons = (controlTypes: string[]) => { - return controlTypes.map((type) => { + const getTypeButtons = () => { + return getControlTypes().map((type) => { const factory = getControlFactory(type); const icon = (factory as EmbeddableFactoryDefinition).getIconType?.(); const tooltip = (factory as EmbeddableFactoryDefinition).getDescription?.(); @@ -120,6 +128,12 @@ export const ControlEditor = ({ isSelected={selectedType === type} onClick={() => { setSelectedType(type); + if (!isCreate) + setSelectedField( + embeddable && type === embeddable.type + ? (embeddable.getInput() as DataControlInput).fieldName + : undefined + ); }} > @@ -150,9 +164,7 @@ export const ControlEditor = ({ - - {isCreate ? getTypeButtons(getControlTypes()) : getTypeButtons([selectedType])} - + {getTypeButtons()} {selectedType && ( <> diff --git a/src/plugins/controls/public/control_group/editor/edit_control.tsx b/src/plugins/controls/public/control_group/editor/edit_control.tsx index 11a2e705a13f3..6866148ac7e9d 100644 --- a/src/plugins/controls/public/control_group/editor/edit_control.tsx +++ b/src/plugins/controls/public/control_group/editor/edit_control.tsx @@ -22,6 +22,11 @@ import { IEditableControlFactory, ControlInput } from '../../types'; import { controlGroupReducers } from '../state/control_group_reducers'; import { ControlGroupContainer, setFlyoutRef } from '../embeddable/control_group_container'; +interface EditControlResult { + type: string; + controlInput: Omit; +} + export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => { // Controls Services Context const { overlays, controls } = pluginServices.getHooks(); @@ -34,7 +39,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => typeof controlGroupReducers >(); const { - containerActions: { untilEmbeddableLoaded, removeEmbeddable, updateInputForChild }, + containerActions: { untilEmbeddableLoaded, removeEmbeddable, replaceEmbeddable }, actions: { setControlWidth }, useEmbeddableSelector, useEmbeddableDispatch, @@ -52,88 +57,107 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const editControl = async () => { const panel = panels[embeddableId]; - const factory = getControlFactory(panel.type); + let factory = getControlFactory(panel.type); + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + const embeddable = await untilEmbeddableLoaded(embeddableId); const controlGroup = embeddable.getRoot() as ControlGroupContainer; - let inputToReturn: Partial = {}; + const initialInputPromise = new Promise((resolve, reject) => { + let inputToReturn: Partial = {}; - if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); + let removed = false; + const onCancel = (ref: OverlayRef) => { + if ( + removed || + (isEqual(latestPanelState.current.explicitInput, { + ...panel.explicitInput, + ...inputToReturn, + }) && + isEqual(latestPanelState.current.width, panel.width)) + ) { + reject(); + ref.close(); + return; + } + openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(), + cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(), + title: ControlGroupStrings.management.discardChanges.getTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + dispatch(setControlWidth({ width: panel.width, embeddableId })); + reject(); + ref.close(); + } + }); + }; - let removed = false; - const onCancel = (ref: OverlayRef) => { - if ( - removed || - (isEqual(latestPanelState.current.explicitInput, { - ...panel.explicitInput, - ...inputToReturn, - }) && - isEqual(latestPanelState.current.width, panel.width)) - ) { + const onSave = (type: string, ref: OverlayRef) => { + // if the control now has a new type, need to replace the old factory with + // one of the correct new type + if (latestPanelState.current.type !== type) { + factory = getControlFactory(type); + if (!factory) throw new EmbeddableFactoryNotFoundError(type); + } + const editableFactory = factory as IEditableControlFactory; + if (editableFactory.presaveTransformFunction) { + inputToReturn = editableFactory.presaveTransformFunction(inputToReturn, embeddable); + } + resolve({ type, controlInput: inputToReturn }); ref.close(); - return; - } - openConfirm(ControlGroupStrings.management.discardChanges.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.discardChanges.getConfirm(), - cancelButtonText: ControlGroupStrings.management.discardChanges.getCancel(), - title: ControlGroupStrings.management.discardChanges.getTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - dispatch(setControlWidth({ width: panel.width, embeddableId })); - ref.close(); + }; + + const flyoutInstance = openFlyout( + forwardAllContext( + onCancel(flyoutInstance)} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} + setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} + updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} + onTypeEditorChange={(partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }} + onSave={(type) => onSave(type, flyoutInstance)} + removeControl={() => { + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + removeEmbeddable(embeddableId); + removed = true; + flyoutInstance.close(); + } + }); + }} + />, + reduxContainerContext + ), + { + outsideClickCloses: false, + onClose: (flyout) => { + setFlyoutRef(undefined); + onCancel(flyout); + }, } - }); - }; + ); + setFlyoutRef(flyoutInstance); + }); - const flyoutInstance = openFlyout( - forwardAllContext( - onCancel(flyoutInstance)} - updateTitle={(newTitle) => (inputToReturn.title = newTitle)} - setLastUsedDataViewId={(lastUsed) => controlGroup.setLastUsedDataViewId(lastUsed)} - updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} - onTypeEditorChange={(partialInput) => - (inputToReturn = { ...inputToReturn, ...partialInput }) - } - onSave={() => { - const editableFactory = factory as IEditableControlFactory; - if (editableFactory.presaveTransformFunction) { - inputToReturn = editableFactory.presaveTransformFunction(inputToReturn, embeddable); - } - updateInputForChild(embeddableId, inputToReturn); - flyoutInstance.close(); - }} - removeControl={() => { - openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { - confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), - cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), - title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), - buttonColor: 'danger', - }).then((confirmed) => { - if (confirmed) { - removeEmbeddable(embeddableId); - removed = true; - flyoutInstance.close(); - } - }); - }} - />, - reduxContainerContext - ), - { - outsideClickCloses: false, - onClose: (flyout) => { - setFlyoutRef(undefined); - onCancel(flyout); - }, - } + initialInputPromise.then( + async (promise) => { + await replaceEmbeddable(embeddable.id, promise.controlInput, promise.type); + }, + () => {} // swallow promise rejection because it can be part of normal flow ); - setFlyoutRef(flyoutInstance); }; return ( diff --git a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx index 19ad5fc3dce67..b6d5a0877d7ce 100644 --- a/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx +++ b/src/plugins/controls/public/control_types/options_list/options_list_editor.tsx @@ -28,7 +28,6 @@ interface OptionsListEditorState { dataViewListItems: DataViewListItem[]; fieldsMap?: { [key: string]: OptionsListField }; dataView?: DataView; - fieldName?: string; } const FieldPicker = withSuspense(LazyFieldPicker, null); @@ -41,13 +40,14 @@ export const OptionsListEditor = ({ setDefaultTitle, getRelevantDataViewId, setLastUsedDataViewId, + selectedField, + setSelectedField, }: ControlEditorProps) => { // Controls Services Context const { dataViews } = pluginServices.getHooks(); const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); const [state, setState] = useState({ - fieldName: initialInput?.fieldName, singleSelect: initialInput?.singleSelect, runPastTimeout: initialInput?.runPastTimeout, dataViewListItems: [], @@ -55,7 +55,7 @@ export const OptionsListEditor = ({ useMount(() => { let mounted = true; - if (state.fieldName) setDefaultTitle(state.fieldName); + if (selectedField) setDefaultTitle(selectedField); (async () => { const dataViewListItems = await getIdsWithTitle(); const initialId = @@ -115,11 +115,11 @@ export const OptionsListEditor = ({ }, [state.dataView]); useEffect( - () => setValidState(Boolean(state.fieldName) && Boolean(state.dataView)), - [state.fieldName, setValidState, state.dataView] + () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), + [selectedField, setValidState, state.dataView] ); - const { dataView, fieldName } = state; + const { dataView } = state; return ( <> @@ -131,7 +131,7 @@ export const OptionsListEditor = ({ if (dataViewId === dataView?.id) return; onChange({ dataViewId }); - setState((s) => ({ ...s, fieldName: undefined })); + setSelectedField(undefined); get(dataViewId).then((newDataView) => { setState((s) => ({ ...s, dataView: newDataView })); }); @@ -144,7 +144,7 @@ export const OptionsListEditor = ({ Boolean(state.fieldsMap?.[field.name])} - selectedFieldName={fieldName} + selectedFieldName={selectedField} dataView={dataView} onSelectField={(field) => { setDefaultTitle(field.displayName ?? field.name); @@ -153,7 +153,7 @@ export const OptionsListEditor = ({ fieldName: field.name, textFieldName, }); - setState((s) => ({ ...s, fieldName: field.name })); + setSelectedField(field.name); }} /> diff --git a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx b/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx index fa0c2c7d3cc45..13f688c5dd318 100644 --- a/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx +++ b/src/plugins/controls/public/control_types/range_slider/range_slider_editor.tsx @@ -24,7 +24,6 @@ import { RangeSliderStrings } from './range_slider_strings'; interface RangeSliderEditorState { dataViewListItems: DataViewListItem[]; dataView?: DataView; - fieldName?: string; } const FieldPicker = withSuspense(LazyFieldPicker, null); @@ -37,19 +36,20 @@ export const RangeSliderEditor = ({ setDefaultTitle, getRelevantDataViewId, setLastUsedDataViewId, + selectedField, + setSelectedField, }: ControlEditorProps) => { // Controls Services Context const { dataViews } = pluginServices.getHooks(); const { getIdsWithTitle, getDefaultId, get } = dataViews.useService(); const [state, setState] = useState({ - fieldName: initialInput?.fieldName, dataViewListItems: [], }); useMount(() => { let mounted = true; - if (state.fieldName) setDefaultTitle(state.fieldName); + if (selectedField) setDefaultTitle(selectedField); (async () => { const dataViewListItems = await getIdsWithTitle(); const initialId = @@ -68,11 +68,11 @@ export const RangeSliderEditor = ({ }); useEffect( - () => setValidState(Boolean(state.fieldName) && Boolean(state.dataView)), - [state.fieldName, setValidState, state.dataView] + () => setValidState(Boolean(selectedField) && Boolean(state.dataView)), + [selectedField, setValidState, state.dataView] ); - const { dataView, fieldName } = state; + const { dataView } = state; return ( <> @@ -84,7 +84,7 @@ export const RangeSliderEditor = ({ if (dataViewId === dataView?.id) return; onChange({ dataViewId }); - setState((s) => ({ ...s, fieldName: undefined })); + setSelectedField(undefined); get(dataViewId).then((newDataView) => { setState((s) => ({ ...s, dataView: newDataView })); }); @@ -97,12 +97,12 @@ export const RangeSliderEditor = ({ field.aggregatable && field.type === 'number'} - selectedFieldName={fieldName} + selectedFieldName={selectedField} dataView={dataView} onSelectField={(field) => { setDefaultTitle(field.displayName ?? field.name); onChange({ fieldName: field.name }); - setState((s) => ({ ...s, fieldName: field.name })); + setSelectedField(field.name); }} /> diff --git a/src/plugins/controls/public/control_types/time_slider/__stories__/time_slider.component.stories.tsx b/src/plugins/controls/public/control_types/time_slider/__stories__/time_slider.component.stories.tsx index 7ae7871497045..90ea07dc276bd 100644 --- a/src/plugins/controls/public/control_types/time_slider/__stories__/time_slider.component.stories.tsx +++ b/src/plugins/controls/public/control_types/time_slider/__stories__/time_slider.component.stories.tsx @@ -16,7 +16,7 @@ export default { description: '', }; -const TimeSliderWrapper: FC> = (props) => { +const TimeSliderWrapper: FC> = (props) => { const [value, setValue] = useState(props.value); const onChange = useCallback( (newValue: [number | null, number | null]) => { @@ -31,7 +31,13 @@ const TimeSliderWrapper: FC> = ( return (
- +
); diff --git a/src/plugins/controls/public/control_types/time_slider/time_slider.component.tsx b/src/plugins/controls/public/control_types/time_slider/time_slider.component.tsx index 89efce270d14c..1bb2f90b44121 100644 --- a/src/plugins/controls/public/control_types/time_slider/time_slider.component.tsx +++ b/src/plugins/controls/public/control_types/time_slider/time_slider.component.tsx @@ -70,6 +70,7 @@ export function getInterval(min: number, max: number, steps = 6): number { } export interface TimeSliderProps { + id: string; range?: [number | undefined, number | undefined]; value: [number | null, number | null]; onChange: (range: [number | null, number | null]) => void; @@ -167,10 +168,15 @@ export const TimeSlider: FC = (props) => { } const button = ( -
+ +
+
+
+
+ +
+
+
+ + + +
+
+
+
+ - +
+
- - -
-
- -
-
-
- - - -
-
-
-
+ Analytics + +
-
- - - -
-
- -
+ + +
+
+
+
- - - - - - -

- Analytics -

-
-
- - } - buttonElement="button" - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-kibana" - element="div" - id="generated-id" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- - - - -
-
- -
-
-
- - - -
-
-
-
-
-
- - - - + +
  • - - - - - -

    - Observability -

    -
    -
    - - } - buttonElement="button" - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-observability" - element="div" - id="generated-id" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" - > -
    -
    + +
  • +
  • - - - - -
  • -
    + + +
    +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    - - - - - - -

    - Security -

    -
    -
    - - } - buttonElement="button" - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-securitySolution" - element="div" - id="generated-id" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
    -
    -
    - - + +
  • + - - -
  • -
    + + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    - - - - - - -

    - Management -

    -
    -
    - - } - buttonElement="button" - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-management" - element="div" - id="generated-id" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
    -
    -
    - - - - -
    -
    + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    -
    - - - -
    + monitoring + + + +
    - +
    - - - - - +
    +
    +
    +
    + +
    +
    +
    + , +] `; exports[`CollapsibleNav renders the default nav 1`] = ` diff --git a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx index 0102343ca6eb7..787fdc031f1a5 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.test.tsx @@ -113,7 +113,7 @@ describe('CollapsibleNav', () => { customNavLink$={new BehaviorSubject(customNavLink)} /> ); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); it('remembers collapsible section state', () => { diff --git a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap index a3e3ca7a7c207..777e7876c1476 100644 --- a/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap +++ b/src/core/public/i18n/__snapshots__/i18n_service.test.tsx.snap @@ -141,6 +141,7 @@ exports[`#start() returns \`Context\` component 1`] = ` "euiImage.openImage": [Function], "euiLink.external.ariaLabel": "External link", "euiLink.newTarget.screenReaderOnlyText": "(opens in a new tab or window)", + "euiLoadingChart.ariaLabel": "Loading", "euiMark.highlightEnd": "highlight end", "euiMark.highlightStart": "highlight start", "euiMarkdownEditorFooter.closeButton": "Close", diff --git a/src/core/public/i18n/i18n_eui_mapping.tsx b/src/core/public/i18n/i18n_eui_mapping.tsx index 5344fddc4fe2e..bf14153ef0337 100644 --- a/src/core/public/i18n/i18n_eui_mapping.tsx +++ b/src/core/public/i18n/i18n_eui_mapping.tsx @@ -618,6 +618,9 @@ export const getEuiContextMapping = (): EuiTokensObject => { defaultMessage: '(opens in a new tab or window)', } ), + 'euiLoadingChart.ariaLabel': i18n.translate('core.euiLoadingChart.ariaLabel', { + defaultMessage: 'Loading', + }), 'euiMark.highlightStart': i18n.translate('core.euiMark.highlightStart', { defaultMessage: 'highlight start', }), diff --git a/src/dev/license_checker/config.ts b/src/dev/license_checker/config.ts index bd5fd75e30998..0ccab6fcf1b24 100644 --- a/src/dev/license_checker/config.ts +++ b/src/dev/license_checker/config.ts @@ -77,6 +77,6 @@ export const LICENSE_OVERRIDES = { 'jsts@1.6.2': ['Eclipse Distribution License - v 1.0'], // cf. https://github.com/bjornharrtell/jsts '@mapbox/jsonlint-lines-primitives@2.0.2': ['MIT'], // license in readme https://github.com/tmcw/jsonlint '@elastic/ems-client@8.3.0': ['Elastic License 2.0'], - '@elastic/eui@55.0.1': ['SSPL-1.0 OR Elastic License 2.0'], + '@elastic/eui@55.1.2': ['SSPL-1.0 OR Elastic License 2.0'], 'language-subtag-registry@0.3.21': ['CC-BY-4.0'], // retired ODC‑By license https://github.com/mattcg/language-subtag-registry }; diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap index e416dced4f8a1..f4e5d91956479 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -1,1052 +1,134 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DashboardEmptyScreen renders correctly with edit mode 1`] = ` - - - +
    +

    + Add your first visualization +

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

    - Add your first visualization -

    -
    - -
    - - -
    - -
    - - Create content that tells a story about your data. - -
    -
    -
    -
    -
    - - - - + + Create content that tells a story about your data. + +
    +
    +
    `; exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` - - - +
    - - + + +
    +

    -

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

    - This dashboard is empty. -

    -
    -
    - -
    - -
    - You need additional privileges to edit this dashboard. -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    - - - - - + This dashboard is empty. +

    +
    +
    +
    + You need additional privileges to edit this dashboard. +
    +
    +
    +
    +
    `; exports[`DashboardEmptyScreen renders correctly with view mode 1`] = ` - - - +
    - - + + +
    +

    + This dashboard is empty. Let’s fill it up! +

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

    - This dashboard is empty. Let’s fill it up! -

    -
    -
    - -
    - -
    - -
    - -
    -

    - Click edit in the menu bar above to start adding panels. -

    -
    -
    -
    -
    -
    -
    - - -
    - +

    + Click edit in the menu bar above to start adding panels. +

    - - - - - +
    +
    +
    +
    +
    `; diff --git a/src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.test.tsx b/src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.test.tsx index 158dc61e7964d..b3ee8ddf758b2 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/empty_screen/dashboard_empty_screen.test.tsx @@ -29,14 +29,14 @@ describe('DashboardEmptyScreen', () => { test('renders correctly with view mode', () => { const component = mountComponent(); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); const enterEditModeParagraph = component.find('.dshStartScreen__panelDesc'); expect(enterEditModeParagraph.length).toBe(1); }); test('renders correctly with edit mode', () => { const component = mountComponent({ isEditMode: true }); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); const paragraph = component.find('.dshStartScreen__panelDesc'); expect(paragraph.length).toBe(0); const emptyPanel = findTestSubject(component, 'emptyDashboardWidget'); @@ -45,7 +45,7 @@ describe('DashboardEmptyScreen', () => { test('renders correctly with readonly mode', () => { const component = mountComponent({ isReadonlyMode: true }); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); const paragraph = component.find('.dshStartScreen__panelDesc'); expect(paragraph.length).toBe(0); const emptyPanel = findTestSubject(component, 'emptyDashboardWidget'); diff --git a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap index 7e7389364a9bd..d7bb938eb26d8 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap +++ b/src/plugins/data/public/utils/table_inspector_view/components/__snapshots__/data_view.test.tsx.snap @@ -1,1676 +1,49 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Inspector Data View component should render empty state 1`] = ` - - - -

    - -

    - - } - title={ -

    - -

    - } +
    - -
    -
    -
    -
    - -

    - - No data available - -

    -
    - - - -
    - - -
    -

    - - The element did not provide any data. - -

    -
    -
    - - -
    -
    -
    -
    - - - - -`; - -exports[`Inspector Data View component should render loading state 1`] = ` - - loading -
    - } - intl={ - Object { - "defaultFormats": Object {}, - "defaultLocale": "en", - "formatDate": [Function], - "formatHTMLMessage": [Function], - "formatMessage": [Function], - "formatNumber": [Function], - "formatPlural": [Function], - "formatRelative": [Function], - "formatTime": [Function], - "formats": Object { - "date": Object { - "full": Object { - "day": "numeric", - "month": "long", - "weekday": "long", - "year": "numeric", - }, - "long": Object { - "day": "numeric", - "month": "long", - "year": "numeric", - }, - "medium": Object { - "day": "numeric", - "month": "short", - "year": "numeric", - }, - "short": Object { - "day": "numeric", - "month": "numeric", - "year": "2-digit", - }, - }, - "number": Object { - "currency": Object { - "style": "currency", - }, - "percent": Object { - "style": "percent", - }, - }, - "relative": Object { - "days": Object { - "units": "day", - }, - "hours": Object { - "units": "hour", - }, - "minutes": Object { - "units": "minute", - }, - "months": Object { - "units": "month", - }, - "seconds": Object { - "units": "second", - }, - "years": Object { - "units": "year", - }, - }, - "time": Object { - "full": Object { - "hour": "numeric", - "minute": "numeric", - "second": "numeric", - "timeZoneName": "short", - }, - "long": Object { - "hour": "numeric", - "minute": "numeric", - "second": "numeric", - "timeZoneName": "short", - }, - "medium": Object { - "hour": "numeric", - "minute": "numeric", - "second": "numeric", - }, - "short": Object { - "hour": "numeric", - "minute": "numeric", - }, - }, - }, - "formatters": Object { - "getDateTimeFormat": [Function], - "getMessageFormat": [Function], - "getNumberFormat": [Function], - "getPluralFormat": [Function], - "getRelativeFormat": [Function], - }, - "locale": "en", - "messages": Object {}, - "now": [Function], - "onError": [Function], - "textComponent": Symbol(react.fragment), - "timeZone": null, - } - } -> - -
    - loading -
    - -`; - -exports[`Inspector Data View component should render single table without selector 1`] = ` - - -
    - -
    - - +
    - - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="inspectorDownloadData" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - repositionOnScroll={true} - > -
    -
    - - - - - -
    -
    -
    -
    -
    -
    -
    - - -
    - - - - - } - onChange={[Function]} - pagination={ - Object { - "pageIndex": 0, - "pageSize": 20, - "pageSizeOptions": Array [ - 10, - 20, - 50, - ], - "showPerPageOptions": undefined, - "totalItemCount": 1, - } - } - responsive={true} - sorting={ - Object { - "allowNeutralSort": true, - "sort": undefined, - } - } - tableLayout="fixed" - > + class="euiSpacer euiSpacer--m css-hg1jdf-euiSpacer-m" + />
    -
    - -
    - -
    - -
    - - -
    - -
    - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > -
    -
    - - - -
    -
    -
    -
    -
    -
    -
    -
    - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - -
    -
    - column1 -
    -
    - -
    - -
    - 123 -
    -
    - -
    - -
    - -
    - -
    - -
    -
    -
    -
    - - -
    - -
    - - - -
    - -
    - - - - : - 20 - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > -
    -
    - - - -
    -
    -
    -
    -
    - -
    - - - -
    -
    -
    -
    -
    -
    - - + class="euiText euiText--medium" + > +

    + The element did not provide any data. +

    - - - - - + +
    +
    +
    +
    `; -exports[`Inspector Data View component should support multiple datatables 1`] = ` - + loading +
    } intl={ Object { @@ -1774,1436 +147,655 @@ exports[`Inspector Data View component should support multiple datatables 1`] = "timeZone": null, } } - title="Test Data" > - +
    + loading +
    + +`; + +exports[`Inspector Data View component should render single table without selector 1`] = ` +Array [ +
    +
    +
    +
    +
    + +
    +
    +
    +
    , +
    , +
    - +
    -
    +
    -
    -

    - - There are 2 tables in total - -

    + + + + Sorting + + + +
    - - +
    +
    +
    + + + + + + + + + + + +
    +
    -
    - - + + + column1 + + + +
    - + column1 + +
    - -
    - - - Selected: - - -
    -
    - +
    - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="inspectorTableChooser" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - repositionOnScroll={true} - > -
    -
    - - - -
    -
    -
    -
    - + class="euiFlexGroup euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow" + /> +
    - - -
    - - +
    +
    +
    +
    +
    +
    - - + + + - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="inspectorDownloadData" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - repositionOnScroll={true} + Rows per page: 20 + + + +
    +
    +
    +
    +
    - + 1 + + + + + + + +
    - - +
    , +] +`; + +exports[`Inspector Data View component should support multiple datatables 1`] = ` +Array [ +
    +
    +

    + There are 2 tables in total +

    +
    +
    - - +
    + + Selected: + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    - +
    + +
    +
    +
    +
    , +
    , +
    +
    +
    - - } - onChange={[Function]} - pagination={ - Object { - "pageIndex": 0, - "pageSize": 20, - "pageSizeOptions": Array [ - 10, - 20, - 50, - ], - "showPerPageOptions": undefined, - "totalItemCount": 1, - } - } - responsive={true} - sorting={ - Object { - "allowNeutralSort": true, - "sort": undefined, - } - } - tableLayout="fixed" +
    +
    -
    - +
    +
    - -
    - -
    - - + -
    - -
    - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > -
    -
    - - - -
    -
    -
    -
    -
    -
    -
    -
    - + Sorting + + +
    - - +
    +
    +
    +
    + + + + + + + + + + + +
    +
    + +
    - -
    - +
    +
    +
    + 123 +
    +
    - - - -
    - -
    - - - - : - 20 - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > -
    -
    - - - -
    -
    -
    -
    -
    - -
    - - - -
    -
    -
    -
    -
    +
    - - +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    - - - - - +
    +
    + +
    +
    +
    +
    , +] `; diff --git a/src/plugins/data/public/utils/table_inspector_view/components/data_view.test.tsx b/src/plugins/data/public/utils/table_inspector_view/components/data_view.test.tsx index 00817e3516720..08f5984ba9f6d 100644 --- a/src/plugins/data/public/utils/table_inspector_view/components/data_view.test.tsx +++ b/src/plugins/data/public/utils/table_inspector_view/components/data_view.test.tsx @@ -63,7 +63,7 @@ describe('Inspector Data View', () => { adapters.tables.logDatatable({ columns: [{ id: '1' }], rows: [{ '1': 123 }] }); // After the loader has resolved we'll still need one update, to "flush" the state changes component.update(); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); it('should render single table without selector', async () => { @@ -80,7 +80,7 @@ describe('Inspector Data View', () => { component.update(); expect(component.find('[data-test-subj="inspectorDataViewSelectorLabel"]')).toHaveLength(0); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); it('should support multiple datatables', async () => { @@ -104,7 +104,7 @@ describe('Inspector Data View', () => { component.update(); expect(component.find('[data-test-subj="inspectorDataViewSelectorLabel"]')).toHaveLength(1); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); }); }); diff --git a/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/__snapshots__/warning_call_out.test.tsx.snap b/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/__snapshots__/warning_call_out.test.tsx.snap index 5d417dadca923..9e68c1b787b76 100644 --- a/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/__snapshots__/warning_call_out.test.tsx.snap +++ b/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/__snapshots__/warning_call_out.test.tsx.snap @@ -1,224 +1,88 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ScriptingWarningCallOut should render normally 1`] = ` - - -
    -

    - - - , - "scriptsInAggregation": - - , - } - } +

    + + Familiarize yourself with + - - and - - - - before using this feature. Scripted fields can be used to display and aggregate calculated values. As such, they can be very slow and, if done incorrectly, can cause Kibana to become unusable. + scripted fields - -

    -
    -
    - + and + + before using this feature. Scripted fields can be used to display and aggregate calculated values. As such, they can be very slow and, if done incorrectly, can cause Kibana to become unusable. + +

    +
    , +
    , +
    - - +
    - - - - - Scripted fields are deprecated - - - -
    -
    - -
    - + + For greater flexibility and Painless script support, use + - - . - - -

    -
    - -
    - + + runtime fields + + + . + +

    - +
    - - -
    - - +
    , +
    , +] `; exports[`ScriptingWarningCallOut should render nothing if not visible 1`] = ` diff --git a/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.test.tsx b/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.test.tsx index 1233bb853f3a0..c06226cfc2521 100644 --- a/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.test.tsx +++ b/src/plugins/data_view_management/public/components/field_editor/components/scripting_call_outs/warning_call_out.test.tsx @@ -23,7 +23,7 @@ describe('ScriptingWarningCallOut', () => { }, }); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); it('should render nothing if not visible', async () => { diff --git a/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/__snapshots__/source.test.tsx.snap b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/__snapshots__/source.test.tsx.snap index 41b7ee37413d9..81bf42c51a2e0 100644 --- a/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/__snapshots__/source.test.tsx.snap +++ b/src/plugins/discover/public/services/doc_views/components/doc_viewer_source/__snapshots__/source.test.tsx.snap @@ -1,179 +1,71 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Source Viewer component renders error state 1`] = ` - - - Could not fetch data at this time. Refresh the tab to try again. - - - Refresh - -
    - } - iconType="alert" - title={ -

    - An Error Occurred -

    - } +
    - + +
    +
    -
    + An Error Occurred + +
    - - - -
    + class="euiSpacer euiSpacer--m css-hg1jdf-euiSpacer-m" + />
    -
    - -

    - An Error Occurred -

    -
    - + Could not fetch data at this time. Refresh the tab to try again. +
    + - - -
    -
    - + Refresh + - +
    -
    +
    - - - +
    +
    +
    `; exports[`Source Viewer component renders json code editor 1`] = ` @@ -258,8 +150,91 @@ exports[`Source Viewer component renders json code editor 1`] = ` size="s" >
    + css="unknown styles" + > + + + + , + "ctr": 2, + "insertionPoint": undefined, + "isSpeedy": false, + "key": "css", + "nonce": undefined, + "prepend": undefined, + "tags": Array [ + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jb21wb25lbnRzL3NwYWNlci9zcGFjZXIuc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQW9CUSIsImZpbGUiOiIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9zcGFjZXIvc3BhY2VyLnN0eWxlcy50cyIsInNvdXJjZXNDb250ZW50IjpbIi8qXG4gKiBDb3B5cmlnaHQgRWxhc3RpY3NlYXJjaCBCLlYuIGFuZC9vciBsaWNlbnNlZCB0byBFbGFzdGljc2VhcmNoIEIuVi4gdW5kZXIgb25lXG4gKiBvciBtb3JlIGNvbnRyaWJ1dG9yIGxpY2Vuc2UgYWdyZWVtZW50cy4gTGljZW5zZWQgdW5kZXIgdGhlIEVsYXN0aWMgTGljZW5zZVxuICogMi4wIGFuZCB0aGUgU2VydmVyIFNpZGUgUHVibGljIExpY2Vuc2UsIHYgMTsgeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHRcbiAqIGluIGNvbXBsaWFuY2Ugd2l0aCwgYXQgeW91ciBlbGVjdGlvbiwgdGhlIEVsYXN0aWMgTGljZW5zZSAyLjAgb3IgdGhlIFNlcnZlclxuICogU2lkZSBQdWJsaWMgTGljZW5zZSwgdiAxLlxuICovXG5cbmltcG9ydCB7IGNzcyB9IGZyb20gJ0BlbW90aW9uL3JlYWN0JztcbmltcG9ydCB7IFVzZUV1aVRoZW1lIH0gZnJvbSAnLi4vLi4vc2VydmljZXMnO1xuXG5leHBvcnQgY29uc3QgZXVpU3BhY2VyU3R5bGVzID0gKHsgZXVpVGhlbWUgfTogVXNlRXVpVGhlbWUpID0+ICh7XG4gIC8vIGJhc2VcbiAgZXVpU3BhY2VyOiBjc3NgXG4gICAgZmxleC1zaHJpbms6IDA7IC8vIGRvbid0IGV2ZXIgbGV0IHRoaXMgc2hyaW5rIGluIGhlaWdodCBpZiBkaXJlY3QgZGVzY2VuZGVudCBvZiBmbGV4O1xuICBgLFxuICAvLyB2YXJpYW50c1xuICB4czogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnhzfTtcbiAgYCxcbiAgczogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnN9O1xuICBgLFxuICBtOiBjc3NgXG4gICAgaGVpZ2h0OiAke2V1aVRoZW1lLnNpemUuYmFzZX07XG4gIGAsXG4gIGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS5sfTtcbiAgYCxcbiAgeGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS54bH07XG4gIGAsXG4gIHh4bDogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnh4bH07XG4gIGAsXG59KTtcbiJdfQ== */", + "name": "i2qclb-euiSpacer-s", + "next": undefined, + "styles": "flex-shrink:0;label:euiSpacer;;;height:8px;;label:s;;;", + "toString": [Function], + } + } + /> +
    +
    { /> ); - expect(comp.children()).toMatchSnapshot(); + expect(comp.children().render()).toMatchSnapshot(); const errorPrompt = comp.find(EuiEmptyPrompt); expect(errorPrompt.length).toBe(1); const refreshButton = comp.find(EuiButton); diff --git a/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap index 2d850ee8082f9..7d957737284c3 100644 --- a/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap +++ b/src/plugins/es_ui_shared/public/components/view_api_request_flyout/__snapshots__/view_api_request_flyout.test.tsx.snap @@ -41,10 +41,10 @@ exports[`ViewApiRequestFlyout is rendered 1`] = `

    diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap
    index d970dd5416816..8527a9a109647 100644
    --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap
    +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap
    @@ -1,475 +1,125 @@
     // Jest Snapshot v1, https://goo.gl/fbAQLP
     
     exports[`bulkCreate should display error message when bulkCreate request fails 1`] = `
    -
    -  
    -    

    - Load Kibana objects -

    -
    - , +
    - -
    - -
    -

    - Imports index pattern, visualizations and pre-defined dashboards. -

    -
    -
    -
    -
    - +

    + Imports index pattern, visualizations and pre-defined dashboards. +

    +
    +
    +
    + - - -
    - + Load Kibana objects + + +
    - - -
    - - , +
    , +
    -
    - - Request failed, Error: simulated bulkRequest error - -
    + Request failed, Error: simulated bulkRequest error +
    - - +
    , +] `; exports[`bulkCreate should display success message when bulkCreate is successful 1`] = ` - - -

    - Load Kibana objects -

    -
    - , +
    - -
    - -
    -

    - Imports index pattern, visualizations and pre-defined dashboards. -

    -
    -
    -
    -
    - +

    + Imports index pattern, visualizations and pre-defined dashboards. +

    +
    +
    +
    + - - -
    - + Load Kibana objects + + +
    - - -
    - - , +
    , +
    -
    - - 1 saved objects successfully added - -
    + 1 saved objects successfully added +
    - - +
    , +] `; exports[`renders 1`] = ` diff --git a/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js b/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js index 67ae2d1dd2eed..27dad0f378ab2 100644 --- a/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js +++ b/src/plugins/home/public/application/components/tutorial/saved_objects_installer.test.js @@ -45,7 +45,7 @@ describe('bulkCreate', () => { // Ensure the state changes are reflected component.update(); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); test('should display error message when bulkCreate request fails', async () => { @@ -66,7 +66,7 @@ describe('bulkCreate', () => { // Ensure the state changes are reflected component.update(); - expect(component).toMatchSnapshot(); + expect(component.render()).toMatchSnapshot(); }); test('should filter out saved object version before calling bulkCreate', async () => { diff --git a/src/plugins/newsfeed/public/components/flyout_list.tsx b/src/plugins/newsfeed/public/components/flyout_list.tsx index 622ae287bd0c1..8abc0896fff4f 100644 --- a/src/plugins/newsfeed/public/components/flyout_list.tsx +++ b/src/plugins/newsfeed/public/components/flyout_list.tsx @@ -11,6 +11,7 @@ import { EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, + EuiFlyoutProps, EuiTitle, EuiLink, EuiFlyoutFooter, @@ -28,13 +29,14 @@ import { NewsfeedItem } from '../types'; import { NewsEmptyPrompt } from './empty_news'; import { NewsLoadingPrompt } from './loading_news'; -export const NewsfeedFlyout = () => { +export const NewsfeedFlyout = (props: Partial) => { const { newsFetchResult, setFlyoutVisible } = useContext(NewsfeedContext); const closeFlyout = useCallback(() => setFlyoutVisible(false), [setFlyoutVisible]); return ( { return newsFetchResult ? newsFetchResult.hasNew : false; }, [newsFetchResult]); + const buttonRef = useRef(null); + const setButtonRef = (node: HTMLButtonElement | null) => (buttonRef.current = node); + useEffect(() => { const subscription = newsfeedApi.fetchResults$.subscribe((results) => { setNewsFetchResult(results); @@ -49,6 +52,7 @@ export const NewsfeedNavButton = ({ newsfeedApi }: Props) => { <> { > - {flyoutVisible ? : null} + {flyoutVisible ? : null} ); diff --git a/x-pack/plugins/apm/public/components/shared/stacktrace/cause_stacktrace.tsx b/x-pack/plugins/apm/public/components/shared/stacktrace/cause_stacktrace.tsx index afa9fd640472d..b132239902f9d 100644 --- a/x-pack/plugins/apm/public/components/shared/stacktrace/cause_stacktrace.tsx +++ b/x-pack/plugins/apm/public/components/shared/stacktrace/cause_stacktrace.tsx @@ -18,7 +18,7 @@ const Accordion = euiStyled(EuiAccordion)` `; const CausedByContainer = euiStyled('h5')` - padding: ${({ theme }) => theme.eui.spacerSizes.s} 0; + padding: ${({ theme }) => theme.eui.euiSizeS} 0; `; const CausedByHeading = euiStyled('span')` diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/extended_template.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/extended_template.stories.storyshot index beab512ea62e1..49e87d067e63a 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/extended_template.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/axis_config/__stories__/__snapshots__/extended_template.stories.storyshot @@ -75,7 +75,7 @@ exports[`Storyshots arguments/AxisConfig extended 1`] = `
    `; @@ -155,7 +155,7 @@ exports[`Storyshots arguments/AxisConfig/components extended 1`] = `
    `; diff --git a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/__stories__/__snapshots__/date_format.stories.storyshot b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/__stories__/__snapshots__/date_format.stories.storyshot index 3cb7e726a9389..a868d023ec135 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/__stories__/__snapshots__/date_format.stories.storyshot +++ b/x-pack/plugins/canvas/canvas_plugin_src/uis/arguments/date_format/__stories__/__snapshots__/date_format.stories.storyshot @@ -55,7 +55,7 @@ Array [
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,





    diff --git a/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot b/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot index d7dc9a062e3ee..4b53b885aa7a8 100644 --- a/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot +++ b/x-pack/plugins/canvas/public/components/saved_elements_modal/__stories__/__snapshots__/saved_elements_modal.stories.storyshot @@ -88,7 +88,7 @@ exports[`Storyshots components/SavedElementsModal no custom elements 1`] = `
    can navigate Autoplay Settings 2`] = `




    ` - padding: ${theme.eui.ruleMargins.marginSmall} 0; + padding: ${theme.eui.euiSizeM} 0; `} `; diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx index 3bc7eac1a9e69..a5d0e8facf236 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/document_creation/document_creation_flyout.test.tsx @@ -44,7 +44,7 @@ describe('DocumentCreationFlyout', () => { const wrapper = shallow(); expect(wrapper.find(EuiFlyout)).toHaveLength(1); - wrapper.find(EuiFlyout).prop('onClose')(); + wrapper.find(EuiFlyout).prop('onClose')(new MouseEvent('click')); expect(actions.closeDocumentCreation).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx index fad3596bc8122..07ae7e0e46435 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/list_page/components/create_agent_policy.tsx @@ -33,7 +33,7 @@ const FlyoutWithHigherZIndex = styled(EuiFlyout)` z-index: ${(props) => props.theme.eui.euiZLevel5}; `; -interface Props extends EuiFlyoutProps { +interface Props extends Omit { onClose: (createdAgentPolicy?: AgentPolicy) => void; } diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/icon_panel.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/icon_panel.tsx index cc8b61e103be4..cb388ba4b7443 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/icon_panel.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/components/icon_panel.tsx @@ -14,7 +14,7 @@ import { usePackageIconType } from '../../../../../hooks'; import { Loading } from '../../../../../components'; const Panel = styled(EuiPanel)` - padding: ${(props) => props.theme.eui.spacerSizes.xl}; + padding: ${(props) => props.theme.eui.euiSizeXL}; width: ${(props) => parseFloat(props.theme.eui.euiSize) * 6 + parseFloat(props.theme.eui.euiSizeXL) * 2}px; svg, diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx index 9202d89d7c93b..05ff443a7b0e6 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/detail/settings/settings.tsx @@ -48,8 +48,8 @@ import { UpdateButton } from './update_button'; import { UninstallButton } from './uninstall_button'; const SettingsTitleCell = styled.td` - padding-right: ${(props) => props.theme.eui.spacerSizes.xl}; - padding-bottom: ${(props) => props.theme.eui.spacerSizes.m}; + padding-right: ${(props) => props.theme.eui.euiSizeXL}; + padding-bottom: ${(props) => props.theme.eui.euiSizeM}; `; const NoteLabel = () => ( diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap index 802d684a8a261..6b54e1d3f43f5 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap @@ -95,779 +95,258 @@ Array [ `; exports[`extend index management ilm summary extension should return extension when index has lifecycle error 1`] = ` - - -

    - - Index lifecycle management - -

    -
    - , +
    , +
    - - +
    - - - - Index lifecycle error - - + illegal_argument_exception: setting [index.lifecycle.rollover_alias] for index [testy3] is empty or not defined
    - +
    , +
    , +
    +
    +
    - - + testy + + +
    + + Current action + +
    +
    + rollover +
    +
    + + Failed step + +
    +
    + check-rollover-ready +
    +
    - -
    - - -
    - -
    +
    - -
    - -
    - - Lifecycle policy - -
    -
    - -
    - - - testy - - -
    -
    - -
    - - Current action - -
    -
    - -
    - rollover -
    -
    - -
    - - Failed step - -
    -
    - -
    - check-rollover-ready -
    -
    -
    -
    -
    -
    - -
    + Current phase + + +
    + hot +
    +
    + + Current action time + +
    +
    + 2018-12-07 13:02:55 +
    +
    + + Phase definition + +
    +
    - -
    - -
    - - Current phase - -
    -
    - -
    - hot -
    -
    - -
    - - Current action time - -
    -
    - -
    - 2018-12-07 13:02:55 -
    -
    - -
    - - - Phase definition - - -
    -
    - -
    - - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="phaseExecutionPopover" - isOpen={false} - key="phaseExecutionPopover" - ownFocus={true} - panelPaddingSize="m" - > -
    -
    - - - -
    -
    -
    -
    -
    -
    -
    -
    -
    + Show definition + +
    +
    + +
    - - +
    , +] `; exports[`extend index management ilm summary extension should return extension when index has lifecycle policy 1`] = ` - - -

    - - Index lifecycle management - -

    -
    - , +
    , +
    - - -
    - -
    +
    - -
    - -
    - - Lifecycle policy - -
    -
    - -
    - - - testy - - -
    -
    - -
    - - Current action - -
    -
    - -
    - complete -
    -
    - -
    - - Failed step - -
    -
    - -
    - - -
    -
    -
    -
    -
    -
    - -
    + Lifecycle policy + + +
    - -
    - -
    - - Current phase - -
    -
    - -
    - new -
    -
    - -
    - - Current action time - -
    -
    - -
    - 2018-12-07 13:02:55 -
    -
    -
    -
    -
    -
    + testy + + +
    + + Current action + +
    +
    + complete +
    +
    + + Failed step + +
    +
    + - +
    +
    -
    - +
    +
    +
    + + Current phase + +
    +
    + new +
    +
    + + Current action time + +
    +
    + 2018-12-07 13:02:55 +
    +
    +
    +
    , +] `; exports[`extend index management remove lifecycle action extension should return extension when all indices have lifecycle policy 1`] = ` diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap index f4d7fc149a694..8cbb4aa450c7c 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/policy_table.test.tsx.snap @@ -70,7 +70,7 @@ exports[`policy table shows empty state when there are no policies 1`] = ` class="euiTextColor euiTextColor--subdued" >
    -
    -
    -
    - Confirm License Upload -
    -
    -
    -
    -
    -
    -
    - Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. -
    -
    -
      -
    • - Watcher will be disabled -
    • -
    -
    -
    -
    -
    -
    -
    - - -
    -
    -
    -
    -
    - } - > - -
    -
    - - - - - -
    - -
    - -
    - - Confirm License Upload - -
    -
    -
    -
    - -
    -
    - -
    -
    - -
    - Some functionality will be lost if you replace your TRIAL license with a BASIC license. Review the list of features below. -
    -
    - -
    -
      -
    • - Watcher will be disabled -
    • -
    -
    -
    -
    -
    -
    -
    -
    -
    - -
    - - - - - - - - -
    -
    -
    -
    -
    -
    - - - - - -
    -

    - - Your license key is a JSON file with a signature attached. - -

    -

    - - - , - } - } - > - Uploading a license will replace your current - - license. - -

    -
    - - -
    - - -
    - - } - onChange={[Function]} - > - -
    -
    - - - -
    - -
    - - Select or drag your license file - -
    -
    -
    -
    -
    -
    - -
    - - -
    - - -
    - - - - -
    - - - - - -
    -
    -
    -
    -
    - -
    - + Select or drag your license file
    - - - - - - +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    `; exports[`UploadLicense should display an error when ES says license is expired 1`] = ` - - - - +
    +
    +

    + Your license key is a JSON file with a signature attached. +

    +

    + Uploading a license will replace your current + + license. +

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

    - - Upload your license - -

    -
    - -
    - - -
    -

    - - Your license key is a JSON file with a signature attached. - -

    -

    - - - , - } - } - > - Uploading a license will replace your current - - license. - -

    -
    - - -
    - - -
    - - -
    -
    - - Please address the highlighted errors. - -
    - -
    - -
    -
      -
    • - The supplied license has expired. -
    • -
    -
    -
    -
    -
    -
    -
    -
    - - } - onChange={[Function]} - > - -
    -
    - - - -
    - -
    - - Select or drag your license file - -
    -
    -
    -
    -
    -
    - -
    - - -
    - - -
    - - - - -
    - - - - - -
    -
    -
    -
    -
    - -
    - + Select or drag your license file
    - - - - - - +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    `; exports[`UploadLicense should display an error when ES says license is invalid 1`] = ` - - - + Upload your license +
    +
    +
    +

    + Your license key is a JSON file with a signature attached. +

    +

    + Uploading a license will replace your current + + license. +

    +
    +
    +
    - - - + Please address the highlighted errors. + +
    +
    +
    +
      +
    • + The supplied license is not valid for this product. +
    • +
    +
    +
    +
    +
    +
    + +
    +
    - -
    - -

    - - Upload your license - -

    -
    - -
    - - -
    -

    - - Your license key is a JSON file with a signature attached. - -

    -

    - - - , - } - } - > - Uploading a license will replace your current - - license. - -

    -
    - - -
    - - -
    - - -
    -
    - - Please address the highlighted errors. - -
    - -
    - -
    -
      -
    • - The supplied license is not valid for this product. -
    • -
    -
    -
    -
    -
    -
    -
    -
    - - } - onChange={[Function]} - > - -
    -
    - - - -
    - -
    - - Select or drag your license file - -
    -
    -
    -
    -
    -
    - -
    - - -
    - - -
    - - - - -
    - - - - - -
    -
    -
    -
    -
    - -
    - + Select or drag your license file
    - - - - - - +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    `; exports[`UploadLicense should display an error when submitting invalid JSON 1`] = ` - - - - +
    +
    +

    + Your license key is a JSON file with a signature attached. +

    +

    + Uploading a license will replace your current + + license. +

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

    - - Upload your license - -

    -
    - -
    - - -
    -

    - - Your license key is a JSON file with a signature attached. - -

    -

    - - - , - } - } - > - Uploading a license will replace your current - - license. - -

    -
    - - -
    - - -
    - - -
    -
    - - Please address the highlighted errors. - -
    - -
    - -
    -
      -
    • - Error encountered uploading license: Check your license file. -
    • -
    -
    -
    -
    -
    -
    -
    -
    - - } - onChange={[Function]} - > - -
    -
    - - - -
    - -
    - - Select or drag your license file - -
    -
    -
    -
    -
    -
    - -
    - - -
    - - -
    - - - - -
    - - - - - -
    -
    -
    -
    -
    - -
    - + Select or drag your license file
    - - - - - - +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    `; exports[`UploadLicense should display error when ES returns error 1`] = ` - - - + Upload your license +
    +
    +
    +

    + Your license key is a JSON file with a signature attached. +

    +

    + Uploading a license will replace your current + + license. +

    +
    +
    +
    - +
    + + Please address the highlighted errors. + +
    +
    +
    +
      +
    • + Error encountered uploading license: Can not upgrade to a production license unless TLS is configured or security is disabled +
    • +
    +
    +
    +
    +
    - - +
    +
    - -
    - -

    - - Upload your license - -

    -
    - -
    - - -
    -

    - - Your license key is a JSON file with a signature attached. - -

    -

    - - - , - } - } - > - Uploading a license will replace your current - - license. - -

    -
    - - -
    - - -
    - - -
    -
    - - Please address the highlighted errors. - -
    - -
    - -
    -
      -
    • - Error encountered uploading license: Can not upgrade to a production license unless TLS is configured or security is disabled -
    • -
    -
    -
    -
    -
    -
    -
    -
    - - } - onChange={[Function]} - > - -
    -
    - - - -
    - -
    - - Select or drag your license file - -
    -
    -
    -
    -
    -
    - -
    - - -
    - - -
    - - - - -
    - - - - - -
    -
    -
    -
    -
    - -
    - + Select or drag your license file
    - - - - - - +
    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    `; diff --git a/x-pack/plugins/license_management/__jest__/upload_license.test.tsx b/x-pack/plugins/license_management/__jest__/upload_license.test.tsx index eb38aab4470d8..c24c2bf6a9c6b 100644 --- a/x-pack/plugins/license_management/__jest__/upload_license.test.tsx +++ b/x-pack/plugins/license_management/__jest__/upload_license.test.tsx @@ -89,7 +89,7 @@ describe('UploadLicense', () => { const rendered = mountWithIntl(component); store.dispatch(uploadLicense('INVALID', 'trial')); rendered.update(); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); it('should display an error when ES says license is invalid', async () => { @@ -98,7 +98,7 @@ describe('UploadLicense', () => { const invalidLicense = JSON.stringify({ license: { type: 'basic' } }); await uploadLicense(invalidLicense)(store.dispatch, null, thunkServices); rendered.update(); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); it('should display an error when ES says license is expired', async () => { @@ -107,7 +107,7 @@ describe('UploadLicense', () => { const invalidLicense = JSON.stringify({ license: { type: 'basic' } }); await uploadLicense(invalidLicense)(store.dispatch, null, thunkServices); rendered.update(); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); it('should display a modal when license requires acknowledgement', async () => { @@ -117,7 +117,7 @@ describe('UploadLicense', () => { }); await uploadLicense(unacknowledgedLicense, 'trial')(store.dispatch, null, thunkServices); const rendered = mountWithIntl(component); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); it('should refresh xpack info and navigate to BASE_PATH when ES accepts new license', async () => { @@ -134,6 +134,6 @@ describe('UploadLicense', () => { const license = JSON.stringify({ license: { type: 'basic' } }); await uploadLicense(license)(store.dispatch, null, thunkServices); rendered.update(); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); }); diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js index 84908775a14a8..339925d3f16ee 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_distribution.test.js @@ -83,7 +83,8 @@ describe('ExplorerChart', () => { ); // test if the loading indicator is shown - expect(wrapper.find('.ml-loading-indicator .euiLoadingChart')).toHaveLength(1); + // Added span because class appears twice with classNames and Emotion + expect(wrapper.find('.ml-loading-indicator span.euiLoadingChart')).toHaveLength(1); }); // For the following tests the directive needs to be rendered in the actual DOM, diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js index 890feb6efaf18..3748a196e742d 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_charts/explorer_chart_single_metric.test.js @@ -86,7 +86,8 @@ describe('ExplorerChart', () => { ); // test if the loading indicator is shown - expect(wrapper.find('.ml-loading-indicator .euiLoadingChart')).toHaveLength(1); + // Added span because class appears twice with classNames and Emotion + expect(wrapper.find('.ml-loading-indicator span.euiLoadingChart')).toHaveLength(1); }); // For the following tests the directive needs to be rendered in the actual DOM, diff --git a/x-pack/plugins/monitoring/public/components/no_data/__snapshots__/checker_errors.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/__snapshots__/checker_errors.test.js.snap index e3fa9da6639b3..dff498b7f0ccd 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/__snapshots__/checker_errors.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/__snapshots__/checker_errors.test.js.snap @@ -5,7 +5,7 @@ exports[`CheckerErrors should render nothing if errors is empty 1`] = `null`; exports[`CheckerErrors should render typical boom errors from api response 1`] = ` Array [
    ,



    , + -
    - -

    - - -1 - , - "property": - xpack.monitoring.collection.enabled - , - } - } - > - We checked the cluster settings and found that - - - xpack.monitoring.collection.enabled - - - is set to - - - -1 - - - . - -

    -

    - - Would you like to turn it on? - + Monitoring provides insight to your hardware performance and load.

    -
    - -
    - - , +
    , +
    +

    + We checked the cluster settings and found that + + xpack.monitoring.collection.enabled + + is set to + + -1 + + . +

    +

    + Would you like to turn it on? +

    +
    , +
    , +
    - -
    - - - - - -
    -
    + Turn on monitoring + + +
    - - +
    , +] `; diff --git a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.test.js b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.test.js index 95dc62abdf9d2..d7957dcc457ec 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.test.js +++ b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_enabled/collection_enabled.test.js @@ -26,7 +26,7 @@ describe('ExplainCollectionEnabled', () => { test('should explain about xpack.monitoring.collection.enabled setting', () => { const rendered = mountWithIntl(component); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); test('should have a button that triggers ajax action', () => { diff --git a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__snapshots__/collection_interval.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__snapshots__/collection_interval.test.js.snap index dc0253e80fecb..84486fafca89a 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__snapshots__/collection_interval.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__snapshots__/collection_interval.test.js.snap @@ -138,8 +138,91 @@ exports[`ExplainCollectionInterval collection interval setting updates should sh size="half" >
    + css="unknown styles" + > + + + + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jb21wb25lbnRzL2hvcml6b250YWxfcnVsZS9ob3Jpem9udGFsX3J1bGUuc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQTRDUSIsImZpbGUiOiIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9ob3Jpem9udGFsX3J1bGUvaG9yaXpvbnRhbF9ydWxlLnN0eWxlcy50cyIsInNvdXJjZXNDb250ZW50IjpbIi8qXG4gKiBDb3B5cmlnaHQgRWxhc3RpY3NlYXJjaCBCLlYuIGFuZC9vciBsaWNlbnNlZCB0byBFbGFzdGljc2VhcmNoIEIuVi4gdW5kZXIgb25lXG4gKiBvciBtb3JlIGNvbnRyaWJ1dG9yIGxpY2Vuc2UgYWdyZWVtZW50cy4gTGljZW5zZWQgdW5kZXIgdGhlIEVsYXN0aWMgTGljZW5zZVxuICogMi4wIGFuZCB0aGUgU2VydmVyIFNpZGUgUHVibGljIExpY2Vuc2UsIHYgMTsgeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHRcbiAqIGluIGNvbXBsaWFuY2Ugd2l0aCwgYXQgeW91ciBlbGVjdGlvbiwgdGhlIEVsYXN0aWMgTGljZW5zZSAyLjAgb3IgdGhlIFNlcnZlclxuICogU2lkZSBQdWJsaWMgTGljZW5zZSwgdiAxLlxuICovXG5cbmltcG9ydCB7IGNzcyB9IGZyb20gJ0BlbW90aW9uL3JlYWN0JztcbmltcG9ydCB7IFVzZUV1aVRoZW1lIH0gZnJvbSAnLi4vLi4vc2VydmljZXMnO1xuXG5leHBvcnQgY29uc3QgZXVpSG9yaXpvbnRhbFJ1bGVTdHlsZXMgPSAoeyBldWlUaGVtZSB9OiBVc2VFdWlUaGVtZSkgPT4gKHtcbiAgZXVpSG9yaXpvbnRhbFJ1bGU6IGNzc2BcbiAgICBib3JkZXI6IG5vbmU7XG4gICAgaGVpZ2h0OiAke2V1aVRoZW1lLmJvcmRlci53aWR0aC50aGlufTtcbiAgICBiYWNrZ3JvdW5kLWNvbG9yOiAke2V1aVRoZW1lLmJvcmRlci5jb2xvcn07XG4gICAgZmxleC1zaHJpbms6IDA7IC8vIEVuc3VyZSB3aGVuIHVzZWQgaW4gZmxleCBncm91cCwgaXQgcmV0YWlucyBpdHMgc2l6ZVxuICAgIGZsZXgtZ3JvdzogMDsgLy8gRW5zdXJlIHdoZW4gdXNlZCBpbiBmbGV4IGdyb3VwLCBpdCByZXRhaW5zIGl0cyBzaXplXG4gIGAsXG5cbiAgLy8gU2l6ZXNcbiAgZnVsbDogY3NzYFxuICAgIHdpZHRoOiAxMDAlO1xuICBgLFxuICBoYWxmOiBjc3NgXG4gICAgd2lkdGg6IDUwJTtcbiAgICBtYXJnaW4taW5saW5lOiBhdXRvO1xuICBgLFxuICBxdWFydGVyOiBjc3NgXG4gICAgd2lkdGg6IDI1JTtcbiAgICBtYXJnaW4taW5saW5lOiBhdXRvO1xuICBgLFxuXG4gIC8vIE1hcmdpbnNcbiAgbm9uZTogJycsXG4gIHhzOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUuc307XG4gIGAsXG4gIHM6IGNzc2BcbiAgICBtYXJnaW4tYmxvY2s6ICR7ZXVpVGhlbWUuc2l6ZS5tfTtcbiAgYCxcbiAgbTogY3NzYFxuICAgIG1hcmdpbi1ibG9jazogJHtldWlUaGVtZS5zaXplLmJhc2V9O1xuICBgLFxuICBsOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUubH07XG4gIGAsXG4gIHhsOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUueGx9O1xuICBgLFxuICB4eGw6IGNzc2BcbiAgICBtYXJnaW4tYmxvY2s6ICR7ZXVpVGhlbWUuc2l6ZS54eGx9O1xuICBgLFxufSk7XG4iXX0= */", + "name": "ilegow-euiHorizontalRule-half-l", + "next": undefined, + "styles": "border:none;height:1px;background-color:#D3DAE6;flex-shrink:0;flex-grow:0;;label:euiHorizontalRule;;;width:50%;margin-inline:auto;label:half;;;margin-block:24px;;label:l;;;", + "toString": [Function], + } + } + /> +
    +
    + css="unknown styles" + > + + + + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jb21wb25lbnRzL3NwYWNlci9zcGFjZXIuc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQTBCUSIsImZpbGUiOiIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9zcGFjZXIvc3BhY2VyLnN0eWxlcy50cyIsInNvdXJjZXNDb250ZW50IjpbIi8qXG4gKiBDb3B5cmlnaHQgRWxhc3RpY3NlYXJjaCBCLlYuIGFuZC9vciBsaWNlbnNlZCB0byBFbGFzdGljc2VhcmNoIEIuVi4gdW5kZXIgb25lXG4gKiBvciBtb3JlIGNvbnRyaWJ1dG9yIGxpY2Vuc2UgYWdyZWVtZW50cy4gTGljZW5zZWQgdW5kZXIgdGhlIEVsYXN0aWMgTGljZW5zZVxuICogMi4wIGFuZCB0aGUgU2VydmVyIFNpZGUgUHVibGljIExpY2Vuc2UsIHYgMTsgeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHRcbiAqIGluIGNvbXBsaWFuY2Ugd2l0aCwgYXQgeW91ciBlbGVjdGlvbiwgdGhlIEVsYXN0aWMgTGljZW5zZSAyLjAgb3IgdGhlIFNlcnZlclxuICogU2lkZSBQdWJsaWMgTGljZW5zZSwgdiAxLlxuICovXG5cbmltcG9ydCB7IGNzcyB9IGZyb20gJ0BlbW90aW9uL3JlYWN0JztcbmltcG9ydCB7IFVzZUV1aVRoZW1lIH0gZnJvbSAnLi4vLi4vc2VydmljZXMnO1xuXG5leHBvcnQgY29uc3QgZXVpU3BhY2VyU3R5bGVzID0gKHsgZXVpVGhlbWUgfTogVXNlRXVpVGhlbWUpID0+ICh7XG4gIC8vIGJhc2VcbiAgZXVpU3BhY2VyOiBjc3NgXG4gICAgZmxleC1zaHJpbms6IDA7IC8vIGRvbid0IGV2ZXIgbGV0IHRoaXMgc2hyaW5rIGluIGhlaWdodCBpZiBkaXJlY3QgZGVzY2VuZGVudCBvZiBmbGV4O1xuICBgLFxuICAvLyB2YXJpYW50c1xuICB4czogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnhzfTtcbiAgYCxcbiAgczogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnN9O1xuICBgLFxuICBtOiBjc3NgXG4gICAgaGVpZ2h0OiAke2V1aVRoZW1lLnNpemUuYmFzZX07XG4gIGAsXG4gIGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS5sfTtcbiAgYCxcbiAgeGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS54bH07XG4gIGAsXG4gIHh4bDogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnh4bH07XG4gIGAsXG59KTtcbiJdfQ== */", + "name": "jz428s-euiSpacer-l", + "next": undefined, + "styles": "flex-shrink:0;label:euiSpacer;;;height:24px;;label:l;;;", + "toString": [Function], + } + } + /> +
    +

    + css="unknown styles" + > + + + + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jb21wb25lbnRzL2hvcml6b250YWxfcnVsZS9ob3Jpem9udGFsX3J1bGUuc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQTRDUSIsImZpbGUiOiIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9ob3Jpem9udGFsX3J1bGUvaG9yaXpvbnRhbF9ydWxlLnN0eWxlcy50cyIsInNvdXJjZXNDb250ZW50IjpbIi8qXG4gKiBDb3B5cmlnaHQgRWxhc3RpY3NlYXJjaCBCLlYuIGFuZC9vciBsaWNlbnNlZCB0byBFbGFzdGljc2VhcmNoIEIuVi4gdW5kZXIgb25lXG4gKiBvciBtb3JlIGNvbnRyaWJ1dG9yIGxpY2Vuc2UgYWdyZWVtZW50cy4gTGljZW5zZWQgdW5kZXIgdGhlIEVsYXN0aWMgTGljZW5zZVxuICogMi4wIGFuZCB0aGUgU2VydmVyIFNpZGUgUHVibGljIExpY2Vuc2UsIHYgMTsgeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHRcbiAqIGluIGNvbXBsaWFuY2Ugd2l0aCwgYXQgeW91ciBlbGVjdGlvbiwgdGhlIEVsYXN0aWMgTGljZW5zZSAyLjAgb3IgdGhlIFNlcnZlclxuICogU2lkZSBQdWJsaWMgTGljZW5zZSwgdiAxLlxuICovXG5cbmltcG9ydCB7IGNzcyB9IGZyb20gJ0BlbW90aW9uL3JlYWN0JztcbmltcG9ydCB7IFVzZUV1aVRoZW1lIH0gZnJvbSAnLi4vLi4vc2VydmljZXMnO1xuXG5leHBvcnQgY29uc3QgZXVpSG9yaXpvbnRhbFJ1bGVTdHlsZXMgPSAoeyBldWlUaGVtZSB9OiBVc2VFdWlUaGVtZSkgPT4gKHtcbiAgZXVpSG9yaXpvbnRhbFJ1bGU6IGNzc2BcbiAgICBib3JkZXI6IG5vbmU7XG4gICAgaGVpZ2h0OiAke2V1aVRoZW1lLmJvcmRlci53aWR0aC50aGlufTtcbiAgICBiYWNrZ3JvdW5kLWNvbG9yOiAke2V1aVRoZW1lLmJvcmRlci5jb2xvcn07XG4gICAgZmxleC1zaHJpbms6IDA7IC8vIEVuc3VyZSB3aGVuIHVzZWQgaW4gZmxleCBncm91cCwgaXQgcmV0YWlucyBpdHMgc2l6ZVxuICAgIGZsZXgtZ3JvdzogMDsgLy8gRW5zdXJlIHdoZW4gdXNlZCBpbiBmbGV4IGdyb3VwLCBpdCByZXRhaW5zIGl0cyBzaXplXG4gIGAsXG5cbiAgLy8gU2l6ZXNcbiAgZnVsbDogY3NzYFxuICAgIHdpZHRoOiAxMDAlO1xuICBgLFxuICBoYWxmOiBjc3NgXG4gICAgd2lkdGg6IDUwJTtcbiAgICBtYXJnaW4taW5saW5lOiBhdXRvO1xuICBgLFxuICBxdWFydGVyOiBjc3NgXG4gICAgd2lkdGg6IDI1JTtcbiAgICBtYXJnaW4taW5saW5lOiBhdXRvO1xuICBgLFxuXG4gIC8vIE1hcmdpbnNcbiAgbm9uZTogJycsXG4gIHhzOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUuc307XG4gIGAsXG4gIHM6IGNzc2BcbiAgICBtYXJnaW4tYmxvY2s6ICR7ZXVpVGhlbWUuc2l6ZS5tfTtcbiAgYCxcbiAgbTogY3NzYFxuICAgIG1hcmdpbi1ibG9jazogJHtldWlUaGVtZS5zaXplLmJhc2V9O1xuICBgLFxuICBsOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUubH07XG4gIGAsXG4gIHhsOiBjc3NgXG4gICAgbWFyZ2luLWJsb2NrOiAke2V1aVRoZW1lLnNpemUueGx9O1xuICBgLFxuICB4eGw6IGNzc2BcbiAgICBtYXJnaW4tYmxvY2s6ICR7ZXVpVGhlbWUuc2l6ZS54eGx9O1xuICBgLFxufSk7XG4iXX0= */", + "name": "ilegow-euiHorizontalRule-half-l", + "next": undefined, + "styles": "border:none;height:1px;background-color:#D3DAE6;flex-shrink:0;flex-grow:0;;label:euiHorizontalRule;;;width:50%;margin-inline:auto;label:half;;;margin-block:24px;;label:l;;;", + "toString": [Function], + } + } + /> +
    +
    + css="unknown styles" + > + + + + , + , + ], + }, + } + } + isStringTag={true} + serialized={ + Object { + "map": "/*# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9jb21wb25lbnRzL3NwYWNlci9zcGFjZXIuc3R5bGVzLnRzIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQTBCUSIsImZpbGUiOiIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy9zcGFjZXIvc3BhY2VyLnN0eWxlcy50cyIsInNvdXJjZXNDb250ZW50IjpbIi8qXG4gKiBDb3B5cmlnaHQgRWxhc3RpY3NlYXJjaCBCLlYuIGFuZC9vciBsaWNlbnNlZCB0byBFbGFzdGljc2VhcmNoIEIuVi4gdW5kZXIgb25lXG4gKiBvciBtb3JlIGNvbnRyaWJ1dG9yIGxpY2Vuc2UgYWdyZWVtZW50cy4gTGljZW5zZWQgdW5kZXIgdGhlIEVsYXN0aWMgTGljZW5zZVxuICogMi4wIGFuZCB0aGUgU2VydmVyIFNpZGUgUHVibGljIExpY2Vuc2UsIHYgMTsgeW91IG1heSBub3QgdXNlIHRoaXMgZmlsZSBleGNlcHRcbiAqIGluIGNvbXBsaWFuY2Ugd2l0aCwgYXQgeW91ciBlbGVjdGlvbiwgdGhlIEVsYXN0aWMgTGljZW5zZSAyLjAgb3IgdGhlIFNlcnZlclxuICogU2lkZSBQdWJsaWMgTGljZW5zZSwgdiAxLlxuICovXG5cbmltcG9ydCB7IGNzcyB9IGZyb20gJ0BlbW90aW9uL3JlYWN0JztcbmltcG9ydCB7IFVzZUV1aVRoZW1lIH0gZnJvbSAnLi4vLi4vc2VydmljZXMnO1xuXG5leHBvcnQgY29uc3QgZXVpU3BhY2VyU3R5bGVzID0gKHsgZXVpVGhlbWUgfTogVXNlRXVpVGhlbWUpID0+ICh7XG4gIC8vIGJhc2VcbiAgZXVpU3BhY2VyOiBjc3NgXG4gICAgZmxleC1zaHJpbms6IDA7IC8vIGRvbid0IGV2ZXIgbGV0IHRoaXMgc2hyaW5rIGluIGhlaWdodCBpZiBkaXJlY3QgZGVzY2VuZGVudCBvZiBmbGV4O1xuICBgLFxuICAvLyB2YXJpYW50c1xuICB4czogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnhzfTtcbiAgYCxcbiAgczogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnN9O1xuICBgLFxuICBtOiBjc3NgXG4gICAgaGVpZ2h0OiAke2V1aVRoZW1lLnNpemUuYmFzZX07XG4gIGAsXG4gIGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS5sfTtcbiAgYCxcbiAgeGw6IGNzc2BcbiAgICBoZWlnaHQ6ICR7ZXVpVGhlbWUuc2l6ZS54bH07XG4gIGAsXG4gIHh4bDogY3NzYFxuICAgIGhlaWdodDogJHtldWlUaGVtZS5zaXplLnh4bH07XG4gIGAsXG59KTtcbiJdfQ== */", + "name": "jz428s-euiSpacer-l", + "next": undefined, + "styles": "flex-shrink:0;label:euiSpacer;;;height:24px;;label:l;;;", + "toString": [Function], + } + } + /> +
    +
    - - -

    - - Monitoring is currently off - -

    -
    - - - -
    -

    - - Monitoring provides insight to your hardware performance and load. - -

    -
    -
    -
    -
    -
    - + Monitoring is currently off + , + -
    -
    -

    - - -1 - , - "property": - xpack.monitoring.collection.interval - , - } - } - > - We checked the cluster settings and found that - - - xpack.monitoring.collection.interval - - - is set to - - - -1 - - - . - -

    -

    - - The collection interval setting needs to be a positive integer (10s is recommended) in order for the collection agents to be active. - -

    -

    - - Would you like us to change it and enable monitoring? - + Monitoring provides insight to your hardware performance and load.

    -
    - -
    - - , +
    , +
    +

    + We checked the cluster settings and found that + + xpack.monitoring.collection.interval + + is set to + + -1 + + . +

    +

    + The collection interval setting needs to be a positive integer (10s is recommended) in order for the collection agents to be active. +

    +

    + Would you like us to change it and enable monitoring? +

    +
    , +
    , +
    - -
    - - - - - -
    -
    + Turn on monitoring + + +
    - - +
    , +] `; diff --git a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/collection_interval.test.js b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/collection_interval.test.js index 95ffad81b902d..4b7af5e22f1d7 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/collection_interval.test.js +++ b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/collection_interval.test.js @@ -32,7 +32,7 @@ describe('ExplainCollectionInterval', () => { /> ); const rendered = mountWithIntl(component); - expect(rendered).toMatchSnapshot(); + expect(rendered.render()).toMatchSnapshot(); }); test('should have a button that triggers ajax action', () => { diff --git a/x-pack/plugins/monitoring/public/components/no_data/explanations/exporters/__snapshots__/exporters.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/explanations/exporters/__snapshots__/exporters.test.js.snap index 41501a7eedb62..494985be0a6bf 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/explanations/exporters/__snapshots__/exporters.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/explanations/exporters/__snapshots__/exporters.test.js.snap @@ -19,7 +19,7 @@ Array [
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,
    ,

    - -
    -

    - - - Analytical Apps can take a minute or two to generate based upon the size of your test-object-type. - - -

    -
    -
    - -
    - - - } - labelType="label" +

    + + Analytical Apps can take a minute or two to generate based upon the size of your test-object-type. + +

    +
    +
    +
    +
    -
    - - } - onBlur={[Function]} - onChange={[Function]} - onFocus={[Function]} - > -
    - - - - - Full page layout - - - -
    -
    - -
    - - - Remove borders and footer logo - - -
    -
    -
    -
    - - - - - - -
    -
    - + + + Full page layout + + +
    -
    - - - - -
    -
    - -
    -
    - -
    - - -
    -

    - - - Alternatively, copy this POST URL to call generation from outside Kibana or from Watcher. - - -

    -
    -
    - -
    - - - -
    -
    - - - Unsaved work - -
    - -
    - -
    - -
    -

    - - - Save your work before copying this URL. - - -

    -
    -
    - -
    - -
    - -
    - -
    - - -
    -
    - -
    + + Remove borders and footer logo +
    - +
    - -`; - -exports[`ScreenCapturePanelContent properly renders a view with "print" layout option 1`] = ` - -
    - -
    -

    - - - Analytical Apps can take a minute or two to generate based upon the size of your test-object-type. - - -

    -
    -
    - + Generate Analytical App + + + + +
    +
    +
    -
    - -