From 1751cb277447872d40b018408c02d9f3e37ffb89 Mon Sep 17 00:00:00 2001 From: Melissa Burpo Date: Mon, 14 Feb 2022 13:59:31 -0600 Subject: [PATCH 01/43] Update osquery.asciidoc to address doc issue (#125425) This update fixes the issue raised in #125355 Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/osquery/osquery.asciidoc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/osquery/osquery.asciidoc b/docs/osquery/osquery.asciidoc index 70effbc2b3c96..3231d2162f2e1 100644 --- a/docs/osquery/osquery.asciidoc +++ b/docs/osquery/osquery.asciidoc @@ -112,7 +112,7 @@ or <>. When you add a saved query to a pack, . Click a pack name to view the status. + Details include the last time each query ran, how many results were returned, and the number of agents the query ran against. -If there are errors, expand the row to view the details. +If there are errors, expand the row to view the details, including an option to view more information in the Logs. + [role="screenshot"] image::images/scheduled-pack.png[Shows queries in the pack and details about each query, including the last time it ran, how many results were returned, the number of agents it ran against, and if there are errors] From 57aefd0a6bde382b41f77362148f9af7045f286a Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Mon, 14 Feb 2022 15:04:45 -0500 Subject: [PATCH 02/43] [Fleet] Add retries when starting docker registry in integration tests (#125530) --- .../integration_tests/docker_registry_helper.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/server/integration_tests/docker_registry_helper.ts b/x-pack/plugins/fleet/server/integration_tests/docker_registry_helper.ts index 902be3aa35bcd..31b0831d7f3e5 100644 --- a/x-pack/plugins/fleet/server/integration_tests/docker_registry_helper.ts +++ b/x-pack/plugins/fleet/server/integration_tests/docker_registry_helper.ts @@ -8,6 +8,7 @@ import { spawn } from 'child_process'; import type { ChildProcess } from 'child_process'; +import pRetry from 'p-retry'; import fetch from 'node-fetch'; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -49,8 +50,12 @@ export function useDockerRegistry() { await delay(3000); } + if (isExited && dockerProcess.exitCode !== 0) { + throw new Error(`Unable to setup docker registry exit code ${dockerProcess.exitCode}`); + } + dockerProcess.kill(); - throw new Error('Unable to setup docker registry'); + throw new pRetry.AbortError('Unable to setup docker registry after timeout'); } async function cleanupDockerRegistryServer() { @@ -60,8 +65,11 @@ export function useDockerRegistry() { } beforeAll(async () => { - jest.setTimeout(5 * 60 * 1000); // 5 minutes timeout - await startDockerRegistryServer(); + const testTimeout = 5 * 60 * 1000; // 5 minutes timeout + jest.setTimeout(testTimeout); + await pRetry(() => startDockerRegistryServer(), { + retries: 3, + }); }); afterAll(async () => { From f4713f5a26e722f2169483c286ea69a5782f6484 Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Mon, 14 Feb 2022 21:06:29 +0100 Subject: [PATCH 03/43] [ML] Use Discover locator in Data Visualizer instead of URL Generator (#124283) * use discover locator in data_visualizer to generate url * pass in discover plugin into context * format according to linter * add console messages Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../results_links/results_links.tsx | 42 ++++----------- .../file_data_visualizer.tsx | 4 +- .../actions_panel/actions_panel.tsx | 54 ++++++------------- .../index_data_visualizer.tsx | 2 + .../plugins/data_visualizer/public/plugin.ts | 3 ++ 5 files changed, 36 insertions(+), 69 deletions(-) diff --git a/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx b/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx index 3a24364e57c36..80119d8de4b5f 100644 --- a/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx +++ b/x-pack/plugins/data_visualizer/public/application/common/components/results_links/results_links.tsx @@ -9,10 +9,6 @@ import React, { FC, useState, useEffect } from 'react'; import moment from 'moment'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiFlexGroup, EuiFlexItem, EuiCard, EuiIcon } from '@elastic/eui'; -import { - DISCOVER_APP_URL_GENERATOR, - DiscoverUrlGeneratorState, -} from '../../../../../../../../src/plugins/discover/public'; import { TimeRange, RefreshInterval } from '../../../../../../../../src/plugins/data/public'; import { FindFileStructureResponse } from '../../../../../../file_upload/common'; import type { FileUploadPluginStart } from '../../../../../../file_upload/public'; @@ -61,9 +57,7 @@ export const ResultsLinks: FC = ({ services: { fileUpload, application: { getUrlForApp, capabilities }, - share: { - urlGenerators: { getUrlGenerator }, - }, + discover, }, } = useDataVisualizerKibana(); @@ -83,32 +77,18 @@ export const ResultsLinks: FC = ({ const getDiscoverUrl = async (): Promise => { const isDiscoverAvailable = capabilities.discover?.show ?? false; - if (!isDiscoverAvailable) { + if (!isDiscoverAvailable) return; + if (!discover.locator) { + // eslint-disable-next-line no-console + console.error('Discover locator not available'); return; } - - const state: DiscoverUrlGeneratorState = { + const discoverUrl = await discover.locator.getUrl({ indexPatternId, - }; - - if (globalState?.time) { - state.timeRange = globalState.time; - } - - let discoverUrlGenerator; - try { - discoverUrlGenerator = getUrlGenerator(DISCOVER_APP_URL_GENERATOR); - } catch (error) { - // ignore error thrown when url generator is not available - } - - if (!discoverUrlGenerator) { - return; - } - const discoverUrl = await discoverUrlGenerator.createUrl(state); - if (!unmounted) { - setDiscoverLink(discoverUrl); - } + timeRange: globalState?.time ? globalState.time : undefined, + }); + if (unmounted) return; + setDiscoverLink(discoverUrl); }; getDiscoverUrl(); @@ -148,7 +128,7 @@ export const ResultsLinks: FC = ({ unmounted = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [indexPatternId, getUrlGenerator, JSON.stringify(globalState)]); + }, [indexPatternId, discover, JSON.stringify(globalState)]); useEffect(() => { updateTimeValues(); diff --git a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx index 6b2657bf357b8..e378d2a853bfd 100644 --- a/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/file_data_visualizer/file_data_visualizer.tsx @@ -23,11 +23,13 @@ interface Props { export type FileDataVisualizerSpec = typeof FileDataVisualizer; export const FileDataVisualizer: FC = ({ additionalLinks }) => { const coreStart = getCoreStart(); - const { data, maps, embeddable, share, security, fileUpload, cloud } = getPluginsStart(); + const { data, maps, embeddable, discover, share, security, fileUpload, cloud } = + getPluginsStart(); const services = { data, maps, embeddable, + discover, share, security, fileUpload, diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx index 2d086ab5ae700..66522fd3a9735 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/components/actions_panel/actions_panel.tsx @@ -10,10 +10,6 @@ import React, { FC, useState, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; import { EuiSpacer, EuiTitle } from '@elastic/eui'; -import { - DISCOVER_APP_URL_GENERATOR, - DiscoverUrlGeneratorState, -} from '../../../../../../../../src/plugins/discover/public'; import type { IndexPattern } from '../../../../../../../../src/plugins/data/common'; import { useDataVisualizerKibana } from '../../../kibana_context'; import { useUrlState } from '../../../common/util/url_state'; @@ -42,9 +38,7 @@ export const ActionsPanel: FC = ({ services: { data, application: { capabilities }, - share: { - urlGenerators: { getUrlGenerator }, - }, + discover, }, } = useDataVisualizerKibana(); @@ -54,38 +48,24 @@ export const ActionsPanel: FC = ({ const indexPatternId = indexPattern.id; const getDiscoverUrl = async (): Promise => { const isDiscoverAvailable = capabilities.discover?.show ?? false; - if (!isDiscoverAvailable) { + if (!isDiscoverAvailable) return; + if (!discover.locator) { + // eslint-disable-next-line no-console + console.error('Discover locator not available'); return; } - - const state: DiscoverUrlGeneratorState = { + const discoverUrl = await discover.locator.getUrl({ indexPatternId, - }; - - state.filters = data.query.filterManager.getFilters() ?? []; - - if (searchString && searchQueryLanguage !== undefined) { - state.query = { query: searchString, language: searchQueryLanguage }; - } - if (globalState?.time) { - state.timeRange = globalState.time; - } - if (globalState?.refreshInterval) { - state.refreshInterval = globalState.refreshInterval; - } - - let discoverUrlGenerator; - try { - discoverUrlGenerator = getUrlGenerator(DISCOVER_APP_URL_GENERATOR); - } catch (error) { - // ignore error thrown when url generator is not available - return; - } - - const discoverUrl = await discoverUrlGenerator.createUrl(state); - if (!unmounted) { - setDiscoverLink(discoverUrl); - } + filters: data.query.filterManager.getFilters() ?? [], + query: + searchString && searchQueryLanguage !== undefined + ? { query: searchString, language: searchQueryLanguage } + : undefined, + timeRange: globalState?.time ? globalState.time : undefined, + refreshInterval: globalState?.refreshInterval ? globalState.refreshInterval : undefined, + }); + if (unmounted) return; + setDiscoverLink(discoverUrl); }; Promise.all( @@ -115,7 +95,7 @@ export const ActionsPanel: FC = ({ searchQueryLanguage, globalState, capabilities, - getUrlGenerator, + discover, additionalLinks, data.query, ]); diff --git a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx index c0fc46b01cb74..c03bdeb56d069 100644 --- a/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx +++ b/x-pack/plugins/data_visualizer/public/application/index_data_visualizer/index_data_visualizer.tsx @@ -267,6 +267,7 @@ export const IndexDataVisualizer: FC<{ additionalLinks: ResultLink[] }> = ({ add data, maps, embeddable, + discover, share, security, fileUpload, @@ -279,6 +280,7 @@ export const IndexDataVisualizer: FC<{ additionalLinks: ResultLink[] }> = ({ add data, maps, embeddable, + discover, share, security, fileUpload, diff --git a/x-pack/plugins/data_visualizer/public/plugin.ts b/x-pack/plugins/data_visualizer/public/plugin.ts index 265f7e11e3b09..06ec021d28ba8 100644 --- a/x-pack/plugins/data_visualizer/public/plugin.ts +++ b/x-pack/plugins/data_visualizer/public/plugin.ts @@ -10,6 +10,7 @@ import { ChartsPluginStart } from 'src/plugins/charts/public'; import type { CloudStart } from '../../cloud/public'; import type { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public'; import type { SharePluginSetup, SharePluginStart } from '../../../../src/plugins/share/public'; +import type { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public'; import { Plugin } from '../../../../src/core/public'; import { setStartServices } from './kibana_services'; @@ -32,6 +33,7 @@ export interface DataVisualizerSetupDependencies { home?: HomePublicPluginSetup; embeddable: EmbeddableSetup; share: SharePluginSetup; + discover: DiscoverSetup; } export interface DataVisualizerStartDependencies { data: DataPublicPluginStart; @@ -40,6 +42,7 @@ export interface DataVisualizerStartDependencies { embeddable: EmbeddableStart; security?: SecurityPluginSetup; share: SharePluginStart; + discover: DiscoverStart; lens?: LensPublicStart; charts: ChartsPluginStart; dataViewFieldEditor?: IndexPatternFieldEditorStart; From 48e8a84c8c1a2cd48334581d8a84c0b253851982 Mon Sep 17 00:00:00 2001 From: Spencer Date: Mon, 14 Feb 2022 13:25:17 -0800 Subject: [PATCH 04/43] [ci-stats] add Client class for accessing test group stats (#125164) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../src/ci_stats_reporter/ci_stats_client.ts | 89 +++++++++++++++++++ .../ci_stats_reporter/ci_stats_metadata.ts | 16 ++++ .../ci_stats_reporter/ci_stats_reporter.ts | 10 +-- .../ci_stats_test_group_types.ts | 2 +- .../src/ci_stats_reporter/index.ts | 1 + packages/kbn-pm/dist/index.js | 2 +- 6 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_client.ts create mode 100644 packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_metadata.ts diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_client.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_client.ts new file mode 100644 index 0000000000000..77b3769fe62c1 --- /dev/null +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_client.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 Axios from 'axios'; +import { ToolingLog } from '../tooling_log'; + +import { parseConfig, Config } from './ci_stats_config'; +import { CiStatsMetadata } from './ci_stats_metadata'; + +interface LatestTestGroupStatsOptions { + /** The Kibana branch to get stats for, eg "main" */ + branch: string; + /** The CI job names to filter builds by, eg "kibana-hourly" */ + ciJobNames: string[]; + /** Filter test groups by group type */ + testGroupType?: string; +} + +interface CompleteSuccessBuildSource { + jobName: string; + jobRunner: string; + completedAt: string; + commit: string; + startedAt: string; + branch: string; + result: 'SUCCESS'; + jobId: string; + targetBranch: string | null; + fromKibanaCiProduction: boolean; + requiresValidMetrics: boolean | null; + jobUrl: string; + mergeBase: string | null; +} + +interface TestGroupSource { + '@timestamp': string; + buildId: string; + name: string; + type: string; + startTime: string; + durationMs: number; + meta: CiStatsMetadata; +} + +interface LatestTestGroupStatsResp { + build: CompleteSuccessBuildSource & { id: string }; + testGroups: Array; +} + +export class CiStatsClient { + /** + * Create a CiStatsReporter by inspecting the ENV for the necessary config + */ + static fromEnv(log: ToolingLog) { + return new CiStatsClient(parseConfig(log)); + } + + constructor(private readonly config?: Config) {} + + isEnabled() { + return !!this.config?.apiToken; + } + + async getLatestTestGroupStats(options: LatestTestGroupStatsOptions) { + if (!this.config || !this.config.apiToken) { + throw new Error('No ciStats config available, call `isEnabled()` before using the client'); + } + + const resp = await Axios.request({ + baseURL: 'https://ci-stats.kibana.dev', + url: '/v1/test_group_stats', + params: { + branch: options.branch, + ci_job_name: options.ciJobNames.join(','), + test_group_type: options.testGroupType, + }, + headers: { + Authentication: `token ${this.config.apiToken}`, + }, + }); + + return resp.data; + } +} diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_metadata.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_metadata.ts new file mode 100644 index 0000000000000..edf78eed64974 --- /dev/null +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_metadata.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 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. + */ + +/** Container for metadata that can be attached to different ci-stats objects */ +export interface CiStatsMetadata { + /** + * Arbitrary key-value pairs which can be attached to CiStatsTiming and CiStatsMetric + * objects stored in the ci-stats service + */ + [key: string]: string | string[] | number | boolean | undefined; +} diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts index f16cdcc80f286..f710f7ec70843 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_reporter.ts @@ -20,18 +20,10 @@ import httpAdapter from 'axios/lib/adapters/http'; import { ToolingLog } from '../tooling_log'; import { parseConfig, Config } from './ci_stats_config'; import type { CiStatsTestGroupInfo, CiStatsTestRun } from './ci_stats_test_group_types'; +import { CiStatsMetadata } from './ci_stats_metadata'; const BASE_URL = 'https://ci-stats.kibana.dev'; -/** Container for metadata that can be attached to different ci-stats objects */ -export interface CiStatsMetadata { - /** - * Arbitrary key-value pairs which can be attached to CiStatsTiming and CiStatsMetric - * objects stored in the ci-stats service - */ - [key: string]: string | string[] | number | boolean | undefined; -} - /** A ci-stats metric record */ export interface CiStatsMetric { /** Top-level categorization for the metric, e.g. "page load bundle size" */ diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_test_group_types.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_test_group_types.ts index 147d4e19325b2..b786981fb8437 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_test_group_types.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/ci_stats_test_group_types.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import type { CiStatsMetadata } from './ci_stats_reporter'; +import type { CiStatsMetadata } from './ci_stats_metadata'; export type CiStatsTestResult = 'fail' | 'pass' | 'skip'; export type CiStatsTestType = diff --git a/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts b/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts index cf80d06613dbf..fab2e61755a5c 100644 --- a/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts +++ b/packages/kbn-dev-utils/src/ci_stats_reporter/index.ts @@ -11,3 +11,4 @@ export type { Config } from './ci_stats_config'; export * from './ship_ci_stats_cli'; export { getTimeReporter } from './report_time'; export * from './ci_stats_test_group_types'; +export * from './ci_stats_client'; diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index a4b6f4938ddcd..607afa266da83 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -9049,7 +9049,7 @@ var _ci_stats_config = __webpack_require__(218); */ // @ts-expect-error not "public", but necessary to prevent Jest shimming from breaking things const BASE_URL = 'https://ci-stats.kibana.dev'; -/** Container for metadata that can be attached to different ci-stats objects */ +/** A ci-stats metric record */ /** Object that helps report data to the ci-stats service */ class CiStatsReporter { From ff5a4dce9d76fa97f052a7c9c01ff9ca0726e9d1 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Tue, 15 Feb 2022 07:59:08 +0100 Subject: [PATCH 05/43] SavedObjects management: change index patterns labels to data views (#125356) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../__snapshots__/not_found_errors.test.tsx.snap | 8 ++++---- .../object_view/components/not_found_errors.test.tsx | 4 ++-- .../object_view/components/not_found_errors.tsx | 4 ++-- .../components/__snapshots__/flyout.test.tsx.snap | 10 +++++----- .../objects_table/components/flyout.tsx | 12 ++++++------ x-pack/plugins/translations/translations/ja-JP.json | 5 ----- x-pack/plugins/translations/translations/zh-CN.json | 5 ----- 7 files changed, 19 insertions(+), 29 deletions(-) diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap index c55583679f264..42fa495dfb4cc 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/not_found_errors.test.tsx.snap @@ -62,11 +62,11 @@ exports[`NotFoundErrors component renders correctly for index-pattern type 1`] = >
- The index pattern associated with this object no longer exists. + The data view associated with this object no longer exists.
@@ -199,11 +199,11 @@ exports[`NotFoundErrors component renders correctly for index-pattern-field type >
- A field associated with this object no longer exists in the index pattern. + A field associated with this object no longer exists in the data view.
diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx index 9ef69b5cef2d2..dd3d29ead6438 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.test.tsx @@ -34,7 +34,7 @@ describe('NotFoundErrors component', () => { const callOut = mounted.find('EuiCallOut'); expect(callOut).toMatchSnapshot(); expect(mounted.text()).toMatchInlineSnapshot( - `"There is a problem with this saved objectThe index pattern associated with this object no longer exists.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` + `"There is a problem with this saved objectThe data view associated with this object no longer exists.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` ); }); @@ -43,7 +43,7 @@ describe('NotFoundErrors component', () => { const callOut = mounted.find('EuiCallOut'); expect(callOut).toMatchSnapshot(); expect(mounted.text()).toMatchInlineSnapshot( - `"There is a problem with this saved objectA field associated with this object no longer exists in the index pattern.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` + `"There is a problem with this saved objectA field associated with this object no longer exists in the data view.If you know what this error means, you can use the Saved objects APIs(opens in a new tab or window) to fix it — otherwise click the delete button above."` ); }); diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx index ec2f345056d29..56a317b54c4fd 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/not_found_errors.tsx @@ -38,14 +38,14 @@ export const NotFoundErrors = ({ type, docLinks }: NotFoundErrors) => { return ( ); case 'index-pattern-field': return ( ); default: diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index d71c79398acae..dabcee37a8959 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -32,7 +32,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` iconType="help" title={ @@ -40,7 +40,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` >

@@ -63,7 +63,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` columns={ Array [ Object { - "description": "ID of the index pattern", + "description": "ID of the data view", "field": "existingIndexPatternId", "name": "ID", "sortable": true, @@ -82,7 +82,7 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` }, Object { "field": "existingIndexPatternId", - "name": "New index pattern", + "name": "New data view", "render": [Function], }, ] diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index 7b363109a6f3b..5b0408110fd85 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -287,7 +287,7 @@ export class Flyout extends Component { ), description: i18n.translate( 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription', - { defaultMessage: 'ID of the index pattern' } + { defaultMessage: 'ID of the data view' } ), sortable: true, }, @@ -329,7 +329,7 @@ export class Flyout extends Component { field: 'existingIndexPatternId', name: i18n.translate( 'savedObjectsManagement.objectsTable.flyout.renderConflicts.columnNewIndexPatternName', - { defaultMessage: 'New index pattern' } + { defaultMessage: 'New data view' } ), render: (id: string) => { const options = [ @@ -573,7 +573,7 @@ export class Flyout extends Component { title={ } color="warning" @@ -582,15 +582,15 @@ export class Flyout extends Component {

), diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 125c9ff096507..a991ead4705c9 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4662,14 +4662,9 @@ "savedObjectsManagement.objectsTable.flyout.importSavedObjectTitle": "保存されたオブジェクトのインポート", "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmAllChangesButtonLabel": "すべての変更を確定", "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmButtonLabel": "完了", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsCalloutLinkText": "新規インデックスパターンを作成", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsDescription": "次の保存されたオブジェクトは、存在しないインデックスパターンを使用しています。関連付け直す別のインデックスパターンを選択してください。必要に応じて、{indexPatternLink}できます。", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsTitle": "インデックスパターンの矛盾", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "影響されるオブジェクトの数です", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "カウント", - "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "インデックスパターンのIDです", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdName": "ID", - "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnNewIndexPatternName": "新規インデックスパターン", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription": "影響されるオブジェクトのサンプル", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName": "影響されるオブジェクトのサンプル", "savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel": "インポートするファイルを選択してください", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 69e9f293f845d..cabdbb00bf7c0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4459,14 +4459,9 @@ "savedObjectsManagement.objectsTable.flyout.importSavedObjectTitle": "导入已保存对象", "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmAllChangesButtonLabel": "确认所有更改", "savedObjectsManagement.objectsTable.flyout.importSuccessful.confirmButtonLabel": "完成", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsCalloutLinkText": "创建新的索引模式", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsDescription": "以下已保存对象使用不存在的索引模式。请选择要重新关联的索引模式。必要时可以{indexPatternLink}。", - "savedObjectsManagement.objectsTable.flyout.indexPatternConflictsTitle": "索引模式冲突", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountDescription": "受影响对象数目", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnCountName": "计数", - "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdDescription": "索引模式的 ID", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnIdName": "ID", - "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnNewIndexPatternName": "新建索引模式", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsDescription": "受影响对象样例", "savedObjectsManagement.objectsTable.flyout.renderConflicts.columnSampleOfAffectedObjectsName": "受影响对象样例", "savedObjectsManagement.objectsTable.flyout.selectFileToImportFormRowLabel": "选择要导入的文件", From f5041b43acef99b3629a8a6e013a97d5c5a4888e Mon Sep 17 00:00:00 2001 From: Milton Hultgren Date: Tue, 15 Feb 2022 09:38:23 +0100 Subject: [PATCH 06/43] [Logs UI] Support switching between log source modes (#124929) * [Logs UI] Enable switching between log source modes (#120082) * [Logs UI] Improve Data view selection section (#124251) * Update translation files * Apply review requests Apply useCallback optimization Add telemetry tracking for users opting out of data view usage Remove extra visual space at the bottom of the card Improve initial render state of data view panel Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../index_names_configuration_panel.tsx | 45 +------ .../index_pattern_configuration_panel.tsx | 13 +- .../logs/settings/index_pattern_selector.tsx | 2 +- .../settings/indices_configuration_panel.tsx | 125 ++++++++++++++---- .../translations/translations/ja-JP.json | 3 - .../translations/translations/zh-CN.json | 3 - 6 files changed, 103 insertions(+), 88 deletions(-) diff --git a/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx index 49e847e944694..5da03d9cb22c1 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/index_names_configuration_panel.tsx @@ -5,17 +5,7 @@ * 2.0. */ -import { - EuiButton, - EuiCallOut, - EuiCode, - EuiDescribedFormGroup, - EuiFieldText, - EuiFormRow, - EuiSpacer, - EuiTitle, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; +import { EuiCode, EuiDescribedFormGroup, EuiFieldText, EuiFormRow } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; @@ -28,8 +18,7 @@ export const IndexNamesConfigurationPanel: React.FC<{ isLoading: boolean; isReadOnly: boolean; indexNamesFormElement: FormElement; - onSwitchToIndexPatternReference: () => void; -}> = ({ isLoading, isReadOnly, indexNamesFormElement, onSwitchToIndexPatternReference }) => { +}> = ({ isLoading, isReadOnly, indexNamesFormElement }) => { useTrackPageview({ app: 'infra_logs', path: 'log_source_configuration_index_name' }); useTrackPageview({ app: 'infra_logs', @@ -39,29 +28,6 @@ export const IndexNamesConfigurationPanel: React.FC<{ return ( <> - -

- -

- - - - - - - - - @@ -118,10 +84,3 @@ const getIndexNamesInputFieldProps = getInputFieldProps( }), ({ indexName }) => indexName ); - -const indexPatternInformationCalloutTitle = i18n.translate( - 'xpack.infra.logSourceConfiguration.indexPatternInformationCalloutTitle', - { - defaultMessage: 'New configuration option', - } -); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx index 17d537101e5d2..2d1c407595f61 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_configuration_panel.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { EuiDescribedFormGroup, EuiFormRow, EuiLink, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiDescribedFormGroup, EuiFormRow, EuiLink, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import React, { useCallback, useMemo } from 'react'; import { useTrackPageview } from '../../../../../observability/public'; @@ -44,15 +44,6 @@ export const IndexPatternConfigurationPanel: React.FC<{ return ( <> - -

- -

-
- { return ( diff --git a/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx index cbc9bc477829d..c63b27f6d0ce1 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/index_pattern_selector.tsx @@ -76,7 +76,7 @@ export const IndexPatternSelector: React.FC<{ options={availableOptions} placeholder={indexPatternSelectorPlaceholder} selectedOptions={selectedOptions} - singleSelection={true} + singleSelection={{ asPlainText: true }} onChange={changeSelectedIndexPatterns} /> ); diff --git a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx index 064d5f7907037..46af94989f259 100644 --- a/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/pages/logs/settings/indices_configuration_panel.tsx @@ -5,6 +5,8 @@ * 2.0. */ +import { EuiCheckableCard, EuiFormFieldset, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; import React, { useCallback } from 'react'; import { useUiTracker } from '../../../../../observability/public'; import { @@ -23,37 +25,106 @@ export const IndicesConfigurationPanel = React.memo<{ isReadOnly: boolean; indicesFormElement: FormElement; }>(({ isLoading, isReadOnly, indicesFormElement }) => { - const trackSwitchToIndexPatternReference = useUiTracker({ app: 'infra_logs' }); + const trackChangeIndexSourceType = useUiTracker({ app: 'infra_logs' }); - const switchToIndexPatternReference = useCallback(() => { - indicesFormElement.updateValue(() => undefined); - trackSwitchToIndexPatternReference({ + const changeToIndexPatternType = useCallback(() => { + if (indicesFormElement.initialValue?.type === 'index_pattern') { + indicesFormElement.updateValue(() => indicesFormElement.initialValue); + } else { + indicesFormElement.updateValue(() => undefined); + } + + trackChangeIndexSourceType({ metric: 'configuration_switch_to_index_pattern_reference', }); - }, [indicesFormElement, trackSwitchToIndexPatternReference]); + }, [indicesFormElement, trackChangeIndexSourceType]); + + const changeToIndexNameType = useCallback(() => { + if (indicesFormElement.initialValue?.type === 'index_name') { + indicesFormElement.updateValue(() => indicesFormElement.initialValue); + } else { + indicesFormElement.updateValue(() => ({ + type: 'index_name', + indexName: '', + })); + } + + trackChangeIndexSourceType({ + metric: 'configuration_switch_to_index_names_reference', + }); + }, [indicesFormElement, trackChangeIndexSourceType]); + + return ( + +

+ +

+ + ), + }} + > + +

+ +

+ + } + name="dataView" + value="dataView" + checked={isIndexPatternFormElement(indicesFormElement)} + onChange={changeToIndexPatternType} + disabled={isReadOnly} + > + {isIndexPatternFormElement(indicesFormElement) && ( + + )} +
+ - if (isIndexPatternFormElement(indicesFormElement)) { - return ( - - ); - } else if (isIndexNamesFormElement(indicesFormElement)) { - return ( - <> - - - ); - } else { - return null; - } + +

+ +

+ + } + name="indexNames" + value="indexNames" + checked={isIndexNamesFormElement(indicesFormElement)} + onChange={changeToIndexNameType} + disabled={isReadOnly} + > + {isIndexNamesFormElement(indicesFormElement) && ( + + )} +
+
+ ); }); const isIndexPatternFormElement = isFormElementForType( diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a991ead4705c9..9c38543d56fbc 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -13772,8 +13772,6 @@ "xpack.infra.logSourceConfiguration.dataViewTitle": "ログデータビュー", "xpack.infra.logSourceConfiguration.emptyColumnListErrorMessage": "列リストは未入力のままにできません。", "xpack.infra.logSourceConfiguration.emptyFieldErrorMessage": "フィールド'{fieldName}'は未入力のままにできません。", - "xpack.infra.logSourceConfiguration.indexPatternInformationCalloutDescription": "ログUIはデータビューと統合し、使用されているインデックスを構成します。", - "xpack.infra.logSourceConfiguration.indexPatternInformationCalloutTitle": "新しい構成オプション", "xpack.infra.logSourceConfiguration.invalidMessageFieldTypeErrorMessage": "{messageField}フィールドはテキストフィールドでなければなりません。", "xpack.infra.logSourceConfiguration.logSourceConfigurationFormErrorsCalloutTitle": "一貫しないソース構成", "xpack.infra.logSourceConfiguration.missingDataViewErrorMessage": "データビュー{dataViewId}が存在する必要があります。", @@ -13781,7 +13779,6 @@ "xpack.infra.logSourceConfiguration.missingMessageFieldErrorMessage": "データビューには{messageField}フィールドが必要です。", "xpack.infra.logSourceConfiguration.missingTimestampFieldErrorMessage": "データビューは時間に基づく必要があります。", "xpack.infra.logSourceConfiguration.rollupIndexPatternErrorMessage": "データビューがロールアップインデックスパターンであってはなりません。", - "xpack.infra.logSourceConfiguration.switchToDataViewReferenceButtonLabel": "データビューを使用", "xpack.infra.logSourceConfiguration.unsavedFormPromptMessage": "終了してよろしいですか?変更内容は失われます", "xpack.infra.logSourceErrorPage.failedToLoadSourceMessage": "構成の読み込み試行中にエラーが発生しました。再試行するか、構成を変更して問題を修正してください。", "xpack.infra.logSourceErrorPage.failedToLoadSourceTitle": "構成を読み込めませんでした", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index cabdbb00bf7c0..d94df37a7c260 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -13723,8 +13723,6 @@ "xpack.infra.logSourceConfiguration.dataViewTitle": "日志数据视图", "xpack.infra.logSourceConfiguration.emptyColumnListErrorMessage": "列列表不得为空。", "xpack.infra.logSourceConfiguration.emptyFieldErrorMessage": "字段“{fieldName}”不得为空。", - "xpack.infra.logSourceConfiguration.indexPatternInformationCalloutDescription": "现在,Logs UI 可以与数据视图集成以配置使用的索引。", - "xpack.infra.logSourceConfiguration.indexPatternInformationCalloutTitle": "新配置选项", "xpack.infra.logSourceConfiguration.invalidMessageFieldTypeErrorMessage": "{messageField} 字段必须是文本字段。", "xpack.infra.logSourceConfiguration.logDataViewHelpText": "数据视图在 Kibana 工作区中的应用间共享,并可以通过 {dataViewsManagementLink} 进行管理。", "xpack.infra.logSourceConfiguration.logSourceConfigurationFormErrorsCalloutTitle": "内容配置不一致", @@ -13733,7 +13731,6 @@ "xpack.infra.logSourceConfiguration.missingMessageFieldErrorMessage": "数据视图必须包含 {messageField} 字段。", "xpack.infra.logSourceConfiguration.missingTimestampFieldErrorMessage": "数据视图必须基于时间。", "xpack.infra.logSourceConfiguration.rollupIndexPatternErrorMessage": "数据视图不得为汇总/打包索引模式。", - "xpack.infra.logSourceConfiguration.switchToDataViewReferenceButtonLabel": "使用数据视图", "xpack.infra.logSourceConfiguration.unsavedFormPromptMessage": "是否确定要离开?更改将丢失", "xpack.infra.logSourceErrorPage.failedToLoadSourceMessage": "尝试加载配置时出错。请重试或更改配置以解决问题。", "xpack.infra.logSourceErrorPage.failedToLoadSourceTitle": "无法加载配置", From 75ac8e515bf699c5e78336e1bcc16439843e42ba Mon Sep 17 00:00:00 2001 From: Muhammad Ibragimov <53621505+mibragimov@users.noreply.github.com> Date: Tue, 15 Feb 2022 13:58:41 +0500 Subject: [PATCH 07/43] [Console] Support suggesting index templates v2 (#124655) * Add autocomplete suggestions for index template api * Add support for component templates * Add unit tests Co-authored-by: Muhammad Ibragimov Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...mponent_template_autocomplete_component.js | 20 ++++ .../lib/autocomplete/components/index.js | 4 +- ... index_template_autocomplete_component.js} | 9 +- .../autocomplete/components/legacy/index.js | 9 ++ .../legacy_template_autocomplete_component.js | 19 +++ src/plugins/console/public/lib/kb/kb.js | 12 +- .../public/lib/mappings/mapping.test.js | 26 ++++ .../console/public/lib/mappings/mappings.js | 112 +++++++++++++----- .../cluster.delete_component_template.json | 2 +- .../cluster.exists_component_template.json | 2 +- .../cluster.get_component_template.json | 2 +- .../cluster.put_component_template.json | 2 +- .../indices.delete_index_template.json | 2 +- .../indices.exists_index_template.json | 2 +- .../generated/indices.get_index_template.json | 2 +- .../generated/indices.put_index_template.json | 2 +- 16 files changed, 182 insertions(+), 45 deletions(-) create mode 100644 src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js rename src/plugins/console/public/lib/autocomplete/components/{template_autocomplete_component.js => index_template_autocomplete_component.js} (68%) create mode 100644 src/plugins/console/public/lib/autocomplete/components/legacy/index.js create mode 100644 src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js diff --git a/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js new file mode 100644 index 0000000000000..ca59e077116e4 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete/components/component_template_autocomplete_component.js @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 { getComponentTemplates } from '../../mappings/mappings'; +import { ListComponent } from './list_component'; + +export class ComponentTemplateAutocompleteComponent extends ListComponent { + constructor(name, parent) { + super(name, getComponentTemplates, parent, true, true); + } + + getContextKey() { + return 'component_template'; + } +} diff --git a/src/plugins/console/public/lib/autocomplete/components/index.js b/src/plugins/console/public/lib/autocomplete/components/index.js index 0e651aefa1678..32078ee2c1519 100644 --- a/src/plugins/console/public/lib/autocomplete/components/index.js +++ b/src/plugins/console/public/lib/autocomplete/components/index.js @@ -20,5 +20,7 @@ export { IndexAutocompleteComponent } from './index_autocomplete_component'; export { FieldAutocompleteComponent } from './field_autocomplete_component'; export { TypeAutocompleteComponent } from './type_autocomplete_component'; export { IdAutocompleteComponent } from './id_autocomplete_component'; -export { TemplateAutocompleteComponent } from './template_autocomplete_component'; export { UsernameAutocompleteComponent } from './username_autocomplete_component'; +export { IndexTemplateAutocompleteComponent } from './index_template_autocomplete_component'; +export { ComponentTemplateAutocompleteComponent } from './component_template_autocomplete_component'; +export * from './legacy'; diff --git a/src/plugins/console/public/lib/autocomplete/components/template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js similarity index 68% rename from src/plugins/console/public/lib/autocomplete/components/template_autocomplete_component.js rename to src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js index 40ebd6b4c55fb..444e40e756f7b 100644 --- a/src/plugins/console/public/lib/autocomplete/components/template_autocomplete_component.js +++ b/src/plugins/console/public/lib/autocomplete/components/index_template_autocomplete_component.js @@ -6,14 +6,15 @@ * Side Public License, v 1. */ -import { getTemplates } from '../../mappings/mappings'; +import { getIndexTemplates } from '../../mappings/mappings'; import { ListComponent } from './list_component'; -export class TemplateAutocompleteComponent extends ListComponent { +export class IndexTemplateAutocompleteComponent extends ListComponent { constructor(name, parent) { - super(name, getTemplates, parent, true, true); + super(name, getIndexTemplates, parent, true, true); } + getContextKey() { - return 'template'; + return 'index_template'; } } diff --git a/src/plugins/console/public/lib/autocomplete/components/legacy/index.js b/src/plugins/console/public/lib/autocomplete/components/legacy/index.js new file mode 100644 index 0000000000000..1e84cb05f5b80 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete/components/legacy/index.js @@ -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 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. + */ + +export { LegacyTemplateAutocompleteComponent } from './legacy_template_autocomplete_component'; diff --git a/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js b/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js new file mode 100644 index 0000000000000..b68ae952702f5 --- /dev/null +++ b/src/plugins/console/public/lib/autocomplete/components/legacy/legacy_template_autocomplete_component.js @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { getLegacyTemplates } from '../../../mappings/mappings'; +import { ListComponent } from '../list_component'; + +export class LegacyTemplateAutocompleteComponent extends ListComponent { + constructor(name, parent) { + super(name, getLegacyTemplates, parent, true, true); + } + getContextKey() { + return 'template'; + } +} diff --git a/src/plugins/console/public/lib/kb/kb.js b/src/plugins/console/public/lib/kb/kb.js index 199440bf6197a..5f02365a48fdf 100644 --- a/src/plugins/console/public/lib/kb/kb.js +++ b/src/plugins/console/public/lib/kb/kb.js @@ -12,8 +12,10 @@ import { IndexAutocompleteComponent, FieldAutocompleteComponent, ListComponent, - TemplateAutocompleteComponent, + LegacyTemplateAutocompleteComponent, UsernameAutocompleteComponent, + IndexTemplateAutocompleteComponent, + ComponentTemplateAutocompleteComponent, } from '../autocomplete/components'; import $ from 'jquery'; @@ -62,7 +64,7 @@ const parametrizedComponentFactories = { return new UsernameAutocompleteComponent(name, parent); }, template: function (name, parent) { - return new TemplateAutocompleteComponent(name, parent); + return new LegacyTemplateAutocompleteComponent(name, parent); }, task_id: function (name, parent) { return idAutocompleteComponentFactory(name, parent); @@ -86,6 +88,12 @@ const parametrizedComponentFactories = { node: function (name, parent) { return new ListComponent(name, [], parent, false); }, + index_template: function (name, parent) { + return new IndexTemplateAutocompleteComponent(name, parent); + }, + component_template: function (name, parent) { + return new ComponentTemplateAutocompleteComponent(name, parent); + }, }; export function getUnmatchedEndpointComponents() { diff --git a/src/plugins/console/public/lib/mappings/mapping.test.js b/src/plugins/console/public/lib/mappings/mapping.test.js index b694b8c3936fc..9191eb736be3c 100644 --- a/src/plugins/console/public/lib/mappings/mapping.test.js +++ b/src/plugins/console/public/lib/mappings/mapping.test.js @@ -240,4 +240,30 @@ describe('Mappings', () => { ]); expect(mappings.expandAliases('alias2')).toEqual('test_index2'); }); + + test('Templates', function () { + mappings.loadLegacyTemplates({ + test_index1: { order: 0 }, + test_index2: { order: 0 }, + test_index3: { order: 0 }, + }); + + mappings.loadIndexTemplates({ + index_templates: [{ name: 'test_index1' }, { name: 'test_index2' }, { name: 'test_index3' }], + }); + + mappings.loadComponentTemplates({ + component_templates: [ + { name: 'test_index1' }, + { name: 'test_index2' }, + { name: 'test_index3' }, + ], + }); + + const expectedResult = ['test_index1', 'test_index2', 'test_index3']; + + expect(mappings.getLegacyTemplates()).toEqual(expectedResult); + expect(mappings.getIndexTemplates()).toEqual(expectedResult); + expect(mappings.getComponentTemplates()).toEqual(expectedResult); + }); }); diff --git a/src/plugins/console/public/lib/mappings/mappings.js b/src/plugins/console/public/lib/mappings/mappings.js index 84e818f177d63..75b8a263e8690 100644 --- a/src/plugins/console/public/lib/mappings/mappings.js +++ b/src/plugins/console/public/lib/mappings/mappings.js @@ -14,7 +14,9 @@ let pollTimeoutId; let perIndexTypes = {}; let perAliasIndexes = []; -let templates = []; +let legacyTemplates = []; +let indexTemplates = []; +let componentTemplates = []; const mappingObj = {}; @@ -46,8 +48,16 @@ export function expandAliases(indicesOrAliases) { return ret.length > 1 ? ret : ret[0]; } -export function getTemplates() { - return [...templates]; +export function getLegacyTemplates() { + return [...legacyTemplates]; +} + +export function getIndexTemplates() { + return [...indexTemplates]; +} + +export function getComponentTemplates() { + return [...componentTemplates]; } export function getFields(indices, types) { @@ -182,8 +192,16 @@ function getFieldNamesFromProperties(properties = {}) { }); } -function loadTemplates(templatesObject = {}) { - templates = Object.keys(templatesObject); +export function loadLegacyTemplates(templatesObject = {}) { + legacyTemplates = Object.keys(templatesObject); +} + +export function loadIndexTemplates(data) { + indexTemplates = (data.index_templates ?? []).map(({ name }) => name); +} + +export function loadComponentTemplates(data) { + componentTemplates = (data.component_templates ?? []).map(({ name }) => name); } export function loadMappings(mappings) { @@ -235,14 +253,18 @@ export function loadAliases(aliases) { export function clear() { perIndexTypes = {}; perAliasIndexes = {}; - templates = []; + legacyTemplates = []; + indexTemplates = []; + componentTemplates = []; } function retrieveSettings(settingsKey, settingsToRetrieve) { const settingKeyToPathMap = { fields: '_mapping', indices: '_aliases', - templates: '_template', + legacyTemplates: '_template', + indexTemplates: '_index_template', + componentTemplates: '_component_template', }; // Fetch autocomplete info if setting is set to true, and if user has made changes. @@ -289,36 +311,66 @@ export function clearSubscriptions() { export function retrieveAutoCompleteInfo(settings, settingsToRetrieve) { clearSubscriptions(); + const templatesSettingToRetrieve = { + ...settingsToRetrieve, + legacyTemplates: settingsToRetrieve.templates, + indexTemplates: settingsToRetrieve.templates, + componentTemplates: settingsToRetrieve.templates, + }; + const mappingPromise = retrieveSettings('fields', settingsToRetrieve); const aliasesPromise = retrieveSettings('indices', settingsToRetrieve); - const templatesPromise = retrieveSettings('templates', settingsToRetrieve); - - $.when(mappingPromise, aliasesPromise, templatesPromise).done((mappings, aliases, templates) => { + const legacyTemplatesPromise = retrieveSettings('legacyTemplates', templatesSettingToRetrieve); + const indexTemplatesPromise = retrieveSettings('indexTemplates', templatesSettingToRetrieve); + const componentTemplatesPromise = retrieveSettings( + 'componentTemplates', + templatesSettingToRetrieve + ); + + $.when( + mappingPromise, + aliasesPromise, + legacyTemplatesPromise, + indexTemplatesPromise, + componentTemplatesPromise + ).done((mappings, aliases, legacyTemplates, indexTemplates, componentTemplates) => { let mappingsResponse; - if (mappings) { - const maxMappingSize = mappings[0].length > 10 * 1024 * 1024; - if (maxMappingSize) { - console.warn( - `Mapping size is larger than 10MB (${mappings[0].length / 1024 / 1024} MB). Ignoring...` - ); - mappingsResponse = '[{}]'; - } else { - mappingsResponse = mappings[0]; + try { + if (mappings && mappings.length) { + const maxMappingSize = mappings[0].length > 10 * 1024 * 1024; + if (maxMappingSize) { + console.warn( + `Mapping size is larger than 10MB (${mappings[0].length / 1024 / 1024} MB). Ignoring...` + ); + mappingsResponse = '[{}]'; + } else { + mappingsResponse = mappings[0]; + } + loadMappings(JSON.parse(mappingsResponse)); } - loadMappings(JSON.parse(mappingsResponse)); - } - if (aliases) { - loadAliases(JSON.parse(aliases[0])); - } + if (aliases) { + loadAliases(JSON.parse(aliases[0])); + } - if (templates) { - loadTemplates(JSON.parse(templates[0])); - } + if (legacyTemplates) { + loadLegacyTemplates(JSON.parse(legacyTemplates[0])); + } - if (mappings && aliases) { - // Trigger an update event with the mappings, aliases - $(mappingObj).trigger('update', [mappingsResponse, aliases[0]]); + if (indexTemplates) { + loadIndexTemplates(JSON.parse(indexTemplates[0])); + } + + if (componentTemplates) { + loadComponentTemplates(JSON.parse(componentTemplates[0])); + } + + if (mappings && aliases) { + // Trigger an update event with the mappings, aliases + $(mappingObj).trigger('update', [mappingsResponse, aliases[0]]); + } + } catch (error) { + console.error(error); } // Schedule next request. diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json index 24255f7231892..400e064c3de9c 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.delete_component_template.json @@ -8,7 +8,7 @@ "DELETE" ], "patterns": [ - "_component_template/{name}" + "_component_template/{component_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-component-template.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.exists_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.exists_component_template.json index 24dcbeb006e6f..3157e1b8ccc7a 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.exists_component_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.exists_component_template.json @@ -8,7 +8,7 @@ "HEAD" ], "patterns": [ - "_component_template/{name}" + "_component_template/{component_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-component-template.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json index cbfed6741f8a4..e491ccf94bb64 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.get_component_template.json @@ -10,7 +10,7 @@ ], "patterns": [ "_component_template", - "_component_template/{name}" + "_component_template/{component_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/getting-component-templates.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json index 999ff0c149fe8..31a94d098f604 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/cluster.put_component_template.json @@ -9,7 +9,7 @@ "POST" ], "patterns": [ - "_component_template/{name}" + "_component_template/{component_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-component-template.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json index ef3f836207f17..5d6d53e1d1d6f 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.delete_index_template.json @@ -8,7 +8,7 @@ "DELETE" ], "patterns": [ - "_index_template/{name}" + "_index_template/{index_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json index 97fa8cf55576f..d1c5d9d617f8f 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.exists_index_template.json @@ -9,7 +9,7 @@ "HEAD" ], "patterns": [ - "_index_template/{name}" + "_index_template/{index_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json index 142b75f22c2a6..3d91424f4ce3b 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.get_index_template.json @@ -10,7 +10,7 @@ ], "patterns": [ "_index_template", - "_index_template/{name}" + "_index_template/{index_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html" } diff --git a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json index 0ce27c1d9d21e..fcae7af55b4ee 100644 --- a/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json +++ b/src/plugins/console/server/lib/spec_definitions/json/generated/indices.put_index_template.json @@ -10,7 +10,7 @@ "POST" ], "patterns": [ - "_index_template/{name}" + "_index_template/{index_template}" ], "documentation": "https://www.elastic.co/guide/en/elasticsearch/reference/master/indices-templates.html" } From 7c2437f5a8b8411488a8b6f13cdc1ad7b588f850 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Tue, 15 Feb 2022 10:06:19 +0100 Subject: [PATCH 08/43] [Uptime monitor management] Make index template installation retry (#125537) --- .../hydrate_saved_object.ts | 62 ++++++++++--------- .../synthetics_service/synthetics_service.ts | 49 ++++++++++----- 2 files changed, 67 insertions(+), 44 deletions(-) diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts index 2e98b62ddee66..08e2934a4ac08 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/hydrate_saved_object.ts @@ -18,39 +18,43 @@ export const hydrateSavedObjects = async ({ monitors: SyntheticsMonitorSavedObject[]; server: UptimeServerSetup; }) => { - const missingUrlInfoIds: string[] = []; + try { + const missingUrlInfoIds: string[] = []; - monitors - .filter((monitor) => monitor.attributes.type === 'browser') - .forEach(({ attributes, id }) => { - const monitor = attributes as MonitorFields; - if (!monitor || !monitor.urls) { - missingUrlInfoIds.push(id); - } - }); + monitors + .filter((monitor) => monitor.attributes.type === 'browser') + .forEach(({ attributes, id }) => { + const monitor = attributes as MonitorFields; + if (!monitor || !monitor.urls) { + missingUrlInfoIds.push(id); + } + }); - if (missingUrlInfoIds.length > 0 && server.uptimeEsClient) { - const esDocs: Ping[] = await fetchSampleMonitorDocuments( - server.uptimeEsClient, - missingUrlInfoIds - ); - const updatedObjects = monitors - .filter((monitor) => missingUrlInfoIds.includes(monitor.id)) - .map((monitor) => { - let url = ''; - esDocs.forEach((doc) => { - // to make sure the document is ingested after the latest update of the monitor - const diff = moment(monitor.updated_at).diff(moment(doc.timestamp), 'minutes'); - if (doc.config_id === monitor.id && doc.url?.full && diff > 1) { - url = doc.url?.full; + if (missingUrlInfoIds.length > 0 && server.uptimeEsClient) { + const esDocs: Ping[] = await fetchSampleMonitorDocuments( + server.uptimeEsClient, + missingUrlInfoIds + ); + const updatedObjects = monitors + .filter((monitor) => missingUrlInfoIds.includes(monitor.id)) + .map((monitor) => { + let url = ''; + esDocs.forEach((doc) => { + // to make sure the document is ingested after the latest update of the monitor + const diff = moment(monitor.updated_at).diff(moment(doc.timestamp), 'minutes'); + if (doc.config_id === monitor.id && doc.url?.full && diff > 1) { + url = doc.url?.full; + } + }); + if (url) { + return { ...monitor, attributes: { ...monitor.attributes, urls: url } }; } + return monitor; }); - if (url) { - return { ...monitor, attributes: { ...monitor.attributes, urls: url } }; - } - return monitor; - }); - await server.authSavedObjectsClient?.bulkUpdate(updatedObjects); + await server.authSavedObjectsClient?.bulkUpdate(updatedObjects); + } + } catch (e) { + server.logger.error(e); } }; diff --git a/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts b/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts index 12e13cd22f748..acf3c0df49164 100644 --- a/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts +++ b/x-pack/plugins/uptime/server/lib/synthetics_service/synthetics_service.ts @@ -51,6 +51,9 @@ export class SyntheticsService { public locations: ServiceLocations; + private indexTemplateExists?: boolean; + private indexTemplateInstalling?: boolean; + constructor(logger: Logger, server: UptimeServerSetup, config: ServiceConfig) { this.logger = logger; this.server = server; @@ -70,23 +73,34 @@ export class SyntheticsService { // this.apiKey = apiKey; // } // }); - this.setupIndexTemplates(); } private setupIndexTemplates() { - installSyntheticsIndexTemplates(this.server).then( - (result) => { - if (result.name === 'synthetics' && result.install_status === 'installed') { - this.logger.info('Installed synthetics index templates'); - } else if (result.name === 'synthetics' && result.install_status === 'install_failed') { + if (this.indexTemplateExists) { + // if already installed, don't need to reinstall + return; + } + + if (!this.indexTemplateInstalling) { + installSyntheticsIndexTemplates(this.server).then( + (result) => { + this.indexTemplateInstalling = false; + if (result.name === 'synthetics' && result.install_status === 'installed') { + this.logger.info('Installed synthetics index templates'); + this.indexTemplateExists = true; + } else if (result.name === 'synthetics' && result.install_status === 'install_failed') { + this.logger.warn(new IndexTemplateInstallationError()); + this.indexTemplateExists = false; + } + }, + () => { + this.indexTemplateInstalling = false; this.logger.warn(new IndexTemplateInstallationError()); } - }, - () => { - this.logger.warn(new IndexTemplateInstallationError()); - } - ); + ); + this.indexTemplateInstalling = true; + } } public registerSyncTask(taskManager: TaskManagerSetupContract) { @@ -106,6 +120,8 @@ export class SyntheticsService { async run() { const { state } = taskInstance; + service.setupIndexTemplates(); + getServiceLocations(service.server).then((result) => { service.locations = result.locations; service.apiClient.locations = result.locations; @@ -283,10 +299,13 @@ export class SyntheticsService { perPage: 10000, }); - hydrateSavedObjects({ - monitors: findResult.saved_objects as unknown as SyntheticsMonitorSavedObject[], - server: this.server, - }); + if (this.indexTemplateExists) { + // without mapping, querying won't make sense + hydrateSavedObjects({ + monitors: findResult.saved_objects as unknown as SyntheticsMonitorSavedObject[], + server: this.server, + }); + } return (findResult.saved_objects ?? []).map(({ attributes, id }) => ({ ...attributes, From d720235f959b17335c338252fe0cc76dd1332d23 Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Tue, 15 Feb 2022 10:14:36 +0100 Subject: [PATCH 09/43] [SecuritySolution] Enrich threshold data from correct fields (#125376) * fix: enrich threshold data from fields data * test: add tests for field edge-cases * test: test cases where value fields are missing Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../event_details/alert_summary_view.test.tsx | 145 +++++++++++++++++- .../event_details/get_alert_summary_rows.tsx | 120 ++++++++++----- 2 files changed, 223 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index 76ca459fbbe1d..25de792731d44 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -254,9 +254,27 @@ describe('AlertSummaryView', () => { }, { category: 'kibana', - field: 'kibana.alert.threshold_result.terms', - values: ['{"field":"host.name","value":"Host-i120rdnmnw"}'], - originalValue: ['{"field":"host.name","value":"Host-i120rdnmnw"}'], + field: 'kibana.alert.threshold_result.terms.value', + values: ['host-23084y2', '3084hf3n84p8934r8h'], + originalValue: ['host-23084y2', '3084hf3n84p8934r8h'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.terms.field', + values: ['host.name', 'host.id'], + originalValue: ['host.name', 'host.id'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.cardinality.field', + values: ['host.name'], + originalValue: ['host.name'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.cardinality.value', + values: [9001], + originalValue: [9001], }, ] as TimelineEventsDetailsItem[]; const renderProps = { @@ -269,11 +287,130 @@ describe('AlertSummaryView', () => { ); - ['Threshold Count', 'host.name [threshold]'].forEach((fieldId) => { + [ + 'Threshold Count', + 'host.name [threshold]', + 'host.id [threshold]', + 'Threshold Cardinality', + 'count(host.name) >= 9001', + ].forEach((fieldId) => { expect(getByText(fieldId)); }); }); + test('Threshold fields are not shown when data is malformated', () => { + const enhancedData = [ + ...mockAlertDetailsData.map((item) => { + if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') { + return { + ...item, + values: ['threshold'], + originalValue: ['threshold'], + }; + } + return item; + }), + { + category: 'kibana', + field: 'kibana.alert.threshold_result.count', + values: [9001], + originalValue: [9001], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.terms.field', + // This would be expected to have two entries + values: ['host.id'], + originalValue: ['host.id'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.terms.value', + values: ['host-23084y2', '3084hf3n84p8934r8h'], + originalValue: ['host-23084y2', '3084hf3n84p8934r8h'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.cardinality.field', + values: ['host.name'], + originalValue: ['host.name'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.cardinality.value', + // This would be expected to have one entry + values: [], + originalValue: [], + }, + ] as TimelineEventsDetailsItem[]; + const renderProps = { + ...props, + data: enhancedData, + }; + const { getByText } = render( + + + + ); + + ['Threshold Count'].forEach((fieldId) => { + expect(getByText(fieldId)); + }); + + [ + 'host.name [threshold]', + 'host.id [threshold]', + 'Threshold Cardinality', + 'count(host.name) >= 9001', + ].forEach((fieldText) => { + expect(() => getByText(fieldText)).toThrow(); + }); + }); + + test('Threshold fields are not shown when data is partially missing', () => { + const enhancedData = [ + ...mockAlertDetailsData.map((item) => { + if (item.category === 'kibana' && item.field === 'kibana.alert.rule.type') { + return { + ...item, + values: ['threshold'], + originalValue: ['threshold'], + }; + } + return item; + }), + { + category: 'kibana', + field: 'kibana.alert.threshold_result.terms.field', + // This would be expected to have two entries + values: ['host.id'], + originalValue: ['host.id'], + }, + { + category: 'kibana', + field: 'kibana.alert.threshold_result.cardinality.field', + values: ['host.name'], + originalValue: ['host.name'], + }, + ] as TimelineEventsDetailsItem[]; + const renderProps = { + ...props, + data: enhancedData, + }; + const { getByText } = render( + + + + ); + + // The `value` fields are missing here, so the enriched field info cannot be calculated correctly + ['host.id [threshold]', 'Threshold Cardinality', 'count(host.name) >= 9001'].forEach( + (fieldText) => { + expect(() => getByText(fieldText)).toThrow(); + } + ); + }); + test("doesn't render empty fields", () => { const renderProps = { ...props, diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx index 441bd5028cb95..3da4ecab77992 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { getOr, find, isEmpty, uniqBy } from 'lodash/fp'; +import { find, isEmpty, uniqBy } from 'lodash/fp'; import { ALERT_RULE_NAMESPACE, ALERT_RULE_TYPE, @@ -24,12 +24,18 @@ import { import { ALERT_THRESHOLD_RESULT } from '../../../../common/field_maps/field_names'; import { AGENT_STATUS_FIELD_NAME } from '../../../timelines/components/timeline/body/renderers/constants'; import { getEnrichedFieldInfo, SummaryRow } from './helpers'; -import { EventSummaryField } from './types'; +import { EventSummaryField, EnrichedFieldInfo } from './types'; import { TimelineEventsDetailsItem } from '../../../../common/search_strategy/timeline'; import { isAlertFromEndpointEvent } from '../../utils/endpoint_alert_check'; import { EventCode, EventCategory } from '../../../../common/ecs/event'; +const THRESHOLD_TERMS_FIELD = `${ALERT_THRESHOLD_RESULT}.terms.field`; +const THRESHOLD_TERMS_VALUE = `${ALERT_THRESHOLD_RESULT}.terms.value`; +const THRESHOLD_CARDINALITY_FIELD = `${ALERT_THRESHOLD_RESULT}.cardinality.field`; +const THRESHOLD_CARDINALITY_VALUE = `${ALERT_THRESHOLD_RESULT}.cardinality.value`; +const THRESHOLD_COUNT = `${ALERT_THRESHOLD_RESULT}.count`; + /** Always show these fields */ const alwaysDisplayedFields: EventSummaryField[] = [ { id: 'host.name' }, @@ -132,10 +138,10 @@ function getFieldsByRuleType(ruleType?: string): EventSummaryField[] { switch (ruleType) { case 'threshold': return [ - { id: `${ALERT_THRESHOLD_RESULT}.count`, label: ALERTS_HEADERS_THRESHOLD_COUNT }, - { id: `${ALERT_THRESHOLD_RESULT}.terms`, label: ALERTS_HEADERS_THRESHOLD_TERMS }, + { id: THRESHOLD_COUNT, label: ALERTS_HEADERS_THRESHOLD_COUNT }, + { id: THRESHOLD_TERMS_FIELD, label: ALERTS_HEADERS_THRESHOLD_TERMS }, { - id: `${ALERT_THRESHOLD_RESULT}.cardinality`, + id: THRESHOLD_CARDINALITY_FIELD, label: ALERTS_HEADERS_THRESHOLD_CARDINALITY, }, ]; @@ -272,42 +278,20 @@ export const getSummaryRows = ({ return acc; } - if (field.id === `${ALERT_THRESHOLD_RESULT}.terms`) { - try { - const terms = getOr(null, 'originalValue', item); - const parsedValue = terms.map((term: string) => JSON.parse(term)); - const thresholdTerms = (parsedValue ?? []).map( - (entry: { field: string; value: string }) => { - return { - title: `${entry.field} [threshold]`, - description: { - ...description, - values: [entry.value], - }, - }; - } - ); - return [...acc, ...thresholdTerms]; - } catch (err) { - return [...acc]; + if (field.id === THRESHOLD_TERMS_FIELD) { + const enrichedInfo = enrichThresholdTerms(item, data, description); + if (enrichedInfo) { + return [...acc, ...enrichedInfo]; + } else { + return acc; } } - if (field.id === `${ALERT_THRESHOLD_RESULT}.cardinality`) { - try { - const value = getOr(null, 'originalValue.0', field); - const parsedValue = JSON.parse(value); - return [ - ...acc, - { - title: ALERTS_HEADERS_THRESHOLD_CARDINALITY, - description: { - ...description, - values: [`count(${parsedValue.field}) == ${parsedValue.value}`], - }, - }, - ]; - } catch (err) { + if (field.id === THRESHOLD_CARDINALITY_FIELD) { + const enrichedInfo = enrichThresholdCardinality(item, data, description); + if (enrichedInfo) { + return [...acc, enrichedInfo]; + } else { return acc; } } @@ -322,3 +306,63 @@ export const getSummaryRows = ({ }, []) : []; }; + +/** + * Enriches the summary data for threshold terms. + * For any given threshold term, it generates a row with the term's name and the associated value. + */ +function enrichThresholdTerms( + { values: termsFieldArr }: TimelineEventsDetailsItem, + data: TimelineEventsDetailsItem[], + description: EnrichedFieldInfo +) { + const termsValueItem = data.find((d) => d.field === THRESHOLD_TERMS_VALUE); + const termsValueArray = termsValueItem && termsValueItem.values; + + // Make sure both `fields` and `values` are an array and that they have the same length + if ( + Array.isArray(termsFieldArr) && + termsFieldArr.length > 0 && + Array.isArray(termsValueArray) && + termsFieldArr.length === termsValueArray.length + ) { + return termsFieldArr.map((field, index) => { + return { + title: `${field} [threshold]`, + description: { + ...description, + values: [termsValueArray[index]], + }, + }; + }); + } +} + +/** + * Enriches the summary data for threshold cardinality. + * Reads out the cardinality field and the value and interpolates them into a combined string value. + */ +function enrichThresholdCardinality( + { values: cardinalityFieldArr }: TimelineEventsDetailsItem, + data: TimelineEventsDetailsItem[], + description: EnrichedFieldInfo +) { + const cardinalityValueItem = data.find((d) => d.field === THRESHOLD_CARDINALITY_VALUE); + const cardinalityValueArray = cardinalityValueItem && cardinalityValueItem.values; + + // Only return a summary row if we actually have the correct field and value + if ( + Array.isArray(cardinalityFieldArr) && + cardinalityFieldArr.length === 1 && + Array.isArray(cardinalityValueArray) && + cardinalityFieldArr.length === cardinalityValueArray.length + ) { + return { + title: ALERTS_HEADERS_THRESHOLD_CARDINALITY, + description: { + ...description, + values: [`count(${cardinalityFieldArr[0]}) >= ${cardinalityValueArray[0]}`], + }, + }; + } +} From 02021641537ae93db746f0143c8b2315da458c38 Mon Sep 17 00:00:00 2001 From: Dario Gieselaar Date: Tue, 15 Feb 2022 10:38:07 +0100 Subject: [PATCH 10/43] [APM] Lint rule for explicit return types (#124771) --- .eslintrc.js | 12 +++ .../apm/server/routes/backends/route.ts | 33 +++++--- .../apm/server/routes/correlations/route.ts | 26 +++++-- .../apm/server/routes/data_view/route.ts | 6 +- .../plugins/apm/server/routes/fleet/route.ts | 29 ++++--- .../routes/observability_overview/route.ts | 49 +++++++----- .../apm/server/routes/rum_client/route.ts | 1 + .../apm/server/routes/services/route.ts | 76 +++++++++++-------- .../settings/anomaly_detection/route.ts | 8 +- 9 files changed, 160 insertions(+), 80 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index ce7e2dea0a14f..6c98a016469f7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -886,6 +886,18 @@ module.exports = { ], }, }, + { + // require explicit return types in route handlers for performance reasons + files: ['x-pack/plugins/apm/server/**/route.ts'], + rules: { + '@typescript-eslint/explicit-function-return-type': [ + 'error', + { + allowTypedFunctionExpressions: false, + }, + ], + }, + }, /** * Fleet overrides diff --git a/x-pack/plugins/apm/server/routes/backends/route.ts b/x-pack/plugins/apm/server/routes/backends/route.ts index 02dac877715a9..730ad672a26b7 100644 --- a/x-pack/plugins/apm/server/routes/backends/route.ts +++ b/x-pack/plugins/apm/server/routes/backends/route.ts @@ -21,6 +21,7 @@ import { getTopBackends } from './get_top_backends'; import { getUpstreamServicesForBackend } from './get_upstream_services_for_backend'; import { getThroughputChartsForBackend } from './get_throughput_charts_for_backend'; import { getErrorRateChartsForBackend } from './get_error_rate_charts_for_backend'; +import { ConnectionStatsItemWithImpact } from './../../../common/connections'; const topBackendsRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/backends/top_backends', @@ -105,10 +106,11 @@ const topBackendsRoute = createApmServerRoute({ ]); return { + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type backends: currentBackends.map((backend) => { const { stats, ...rest } = backend; const prev = previousBackends.find( - (item) => item.location.id === backend.location.id + (item): boolean => item.location.id === backend.location.id ); return { ...rest, @@ -221,17 +223,24 @@ const upstreamServicesForBackendRoute = createApmServerRoute({ ]); return { - services: currentServices.map((service) => { - const { stats, ...rest } = service; - const prev = previousServices.find( - (item) => item.location.id === service.location.id - ); - return { - ...rest, - currentStats: stats, - previousStats: prev?.stats ?? null, - }; - }), + services: currentServices.map( + ( + service + ): Omit & { + currentStats: ConnectionStatsItemWithImpact['stats']; + previousStats: ConnectionStatsItemWithImpact['stats'] | null; + } => { + const { stats, ...rest } = service; + const prev = previousServices.find( + (item): boolean => item.location.id === service.location.id + ); + return { + ...rest, + currentStats: stats, + previousStats: prev?.stats ?? null, + }; + } + ), }; }, }); diff --git a/x-pack/plugins/apm/server/routes/correlations/route.ts b/x-pack/plugins/apm/server/routes/correlations/route.ts index 0e1707cc55222..fd0bce7a62ff8 100644 --- a/x-pack/plugins/apm/server/routes/correlations/route.ts +++ b/x-pack/plugins/apm/server/routes/correlations/route.ts @@ -27,6 +27,13 @@ import { withApmSpan } from '../../utils/with_apm_span'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; import { environmentRt, kueryRt, rangeRt } from '../default_api_types'; +import { LatencyCorrelation } from './../../../common/correlations/latency_correlations/types'; +import { + FieldStats, + TopValuesStats, +} from './../../../common/correlations/field_stats_types'; +import { FieldValuePair } from './../../../common/correlations/types'; +import { FailedTransactionsCorrelation } from './../../../common/correlations/failed_transactions_correlations/types'; const INVALID_LICENSE = i18n.translate('xpack.apm.correlations.license.text', { defaultMessage: @@ -59,7 +66,7 @@ const fieldCandidatesRoute = createApmServerRoute({ return withApmSpan( 'get_correlations_field_candidates', - async () => + async (): Promise<{ fieldCandidates: string[] }> => await fetchTransactionDurationFieldCandidates(esClient, { ...resources.params.query, index: indices.transaction, @@ -106,7 +113,7 @@ const fieldStatsRoute = createApmServerRoute({ return withApmSpan( 'get_correlations_field_stats', - async () => + async (): Promise<{ stats: FieldStats[]; errors: any[] }> => await fetchFieldsStats( esClient, { @@ -155,7 +162,7 @@ const fieldValueStatsRoute = createApmServerRoute({ return withApmSpan( 'get_correlations_field_value_stats', - async () => + async (): Promise => await fetchFieldValueFieldStats( esClient, { @@ -206,7 +213,7 @@ const fieldValuePairsRoute = createApmServerRoute({ return withApmSpan( 'get_correlations_field_value_pairs', - async () => + async (): Promise<{ errors: any[]; fieldValuePairs: FieldValuePair[] }> => await fetchTransactionDurationFieldValuePairs( esClient, { @@ -268,7 +275,11 @@ const significantCorrelationsRoute = createApmServerRoute({ return withApmSpan( 'get_significant_correlations', - async () => + async (): Promise<{ + latencyCorrelations: LatencyCorrelation[]; + ccsWarning: boolean; + totalDocCount: number; + }> => await fetchSignificantCorrelations( esClient, paramsWithIndex, @@ -321,7 +332,10 @@ const pValuesRoute = createApmServerRoute({ return withApmSpan( 'get_p_values', - async () => await fetchPValues(esClient, paramsWithIndex, fieldCandidates) + async (): Promise<{ + failedTransactionsCorrelations: FailedTransactionsCorrelation[]; + ccsWarning: boolean; + }> => await fetchPValues(esClient, paramsWithIndex, fieldCandidates) ); }, }); diff --git a/x-pack/plugins/apm/server/routes/data_view/route.ts b/x-pack/plugins/apm/server/routes/data_view/route.ts index b918e687bd7cd..01d835b149d2e 100644 --- a/x-pack/plugins/apm/server/routes/data_view/route.ts +++ b/x-pack/plugins/apm/server/routes/data_view/route.ts @@ -9,6 +9,7 @@ import { createStaticDataView } from './create_static_data_view'; import { setupRequest } from '../../lib/helpers/setup_request'; import { getDynamicDataView } from './get_dynamic_data_view'; import { createApmServerRoute } from '../apm_routes/create_apm_server_route'; +import { ISavedObjectsRepository } from '../../../../../../src/core/server'; const staticDataViewRoute = createApmServerRoute({ endpoint: 'POST /internal/apm/data_view/static', @@ -24,7 +25,10 @@ const staticDataViewRoute = createApmServerRoute({ const setupPromise = setupRequest(resources); const clientPromise = core .start() - .then((coreStart) => coreStart.savedObjects.createInternalRepository()); + .then( + (coreStart): ISavedObjectsRepository => + coreStart.savedObjects.createInternalRepository() + ); const setup = await setupPromise; const savedObjectsClient = await clientPromise; diff --git a/x-pack/plugins/apm/server/routes/fleet/route.ts b/x-pack/plugins/apm/server/routes/fleet/route.ts index 668d4e207208c..11753ab3ef12c 100644 --- a/x-pack/plugins/apm/server/routes/fleet/route.ts +++ b/x-pack/plugins/apm/server/routes/fleet/route.ts @@ -105,16 +105,25 @@ const fleetAgentsRoute = createApmServerRoute({ return { cloudStandaloneSetup, isFleetEnabled: true, - fleetAgents: fleetAgents.map((agent) => { - const packagePolicy = policiesGroupedById[agent.id]; - const packagePolicyVars = packagePolicy.inputs[0]?.vars; - return { - id: agent.id, - name: agent.name, - apmServerUrl: packagePolicyVars?.url?.value, - secretToken: packagePolicyVars?.secret_token?.value, - }; - }), + fleetAgents: fleetAgents.map( + ( + agent + ): { + id: string; + name: string; + apmServerUrl: string | undefined; + secretToken: string | undefined; + } => { + const packagePolicy = policiesGroupedById[agent.id]; + const packagePolicyVars = packagePolicy.inputs[0]?.vars; + return { + id: agent.id, + name: agent.name, + apmServerUrl: packagePolicyVars?.url?.value, + secretToken: packagePolicyVars?.secret_token?.value, + }; + } + ), }; }, }); diff --git a/x-pack/plugins/apm/server/routes/observability_overview/route.ts b/x-pack/plugins/apm/server/routes/observability_overview/route.ts index faccd5eb29602..e32c04b849664 100644 --- a/x-pack/plugins/apm/server/routes/observability_overview/route.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview/route.ts @@ -58,25 +58,36 @@ const observabilityOverviewRoute = createApmServerRoute({ kuery: '', }); - return withApmSpan('observability_overview', async () => { - const [serviceCount, transactionPerMinute] = await Promise.all([ - getServiceCount({ - setup, - searchAggregatedTransactions, - start, - end, - }), - getTransactionsPerMinute({ - setup, - bucketSize, - searchAggregatedTransactions, - start, - end, - intervalString, - }), - ]); - return { serviceCount, transactionPerMinute }; - }); + return withApmSpan( + 'observability_overview', + async (): Promise<{ + serviceCount: number; + transactionPerMinute: + | { value: undefined; timeseries: never[] } + | { + value: number; + timeseries: Array<{ x: number; y: number | null }>; + }; + }> => { + const [serviceCount, transactionPerMinute] = await Promise.all([ + getServiceCount({ + setup, + searchAggregatedTransactions, + start, + end, + }), + getTransactionsPerMinute({ + setup, + bucketSize, + searchAggregatedTransactions, + start, + end, + intervalString, + }), + ]); + return { serviceCount, transactionPerMinute }; + } + ); }, }); diff --git a/x-pack/plugins/apm/server/routes/rum_client/route.ts b/x-pack/plugins/apm/server/routes/rum_client/route.ts index e3bee6da6722c..d7d1e2837c53e 100644 --- a/x-pack/plugins/apm/server/routes/rum_client/route.ts +++ b/x-pack/plugins/apm/server/routes/rum_client/route.ts @@ -407,6 +407,7 @@ function decodeUiFilters( } } +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type async function setupUXRequest( resources: APMRouteHandlerResources & { params: TParams } ) { diff --git a/x-pack/plugins/apm/server/routes/services/route.ts b/x-pack/plugins/apm/server/routes/services/route.ts index db7793568676b..949105807b0f2 100644 --- a/x-pack/plugins/apm/server/routes/services/route.ts +++ b/x-pack/plugins/apm/server/routes/services/route.ts @@ -49,6 +49,9 @@ import { } from '../../../../ml/server'; import { getServiceInstancesDetailedStatisticsPeriods } from './get_service_instances/detailed_statistics'; import { ML_ERRORS } from '../../../common/anomaly_detection'; +import { ScopedAnnotationsClient } from '../../../../observability/server'; +import { Annotation } from './../../../../observability/common/annotations'; +import { ConnectionStatsItemWithImpact } from './../../../common/connections'; const servicesRoute = createApmServerRoute({ endpoint: 'GET /internal/apm/services', @@ -373,8 +376,10 @@ const serviceAnnotationsRoute = createApmServerRoute({ const [annotationsClient, searchAggregatedTransactions] = await Promise.all( [ observability - ? withApmSpan('get_scoped_annotations_client', () => - observability.setup.getScopedAnnotationsClient(context, request) + ? withApmSpan( + 'get_scoped_annotations_client', + (): Promise => + observability.setup.getScopedAnnotationsClient(context, request) ) : undefined, getSearchAggregatedTransactions({ @@ -443,8 +448,10 @@ const serviceAnnotationsCreateRoute = createApmServerRoute({ } = resources; const annotationsClient = observability - ? await withApmSpan('get_scoped_annotations_client', () => - observability.setup.getScopedAnnotationsClient(context, request) + ? await withApmSpan( + 'get_scoped_annotations_client', + (): Promise => + observability.setup.getScopedAnnotationsClient(context, request) ) : undefined; @@ -454,20 +461,22 @@ const serviceAnnotationsCreateRoute = createApmServerRoute({ const { body, path } = params; - return withApmSpan('create_annotation', () => - annotationsClient.create({ - message: body.service.version, - ...body, - '@timestamp': new Date(body['@timestamp']).toISOString(), - annotation: { - type: 'deployment', - }, - service: { - ...body.service, - name: path.serviceName, - }, - tags: uniq(['apm'].concat(body.tags ?? [])), - }) + return withApmSpan( + 'create_annotation', + (): Promise<{ _id: string; _index: string; _source: Annotation }> => + annotationsClient.create({ + message: body.service.version, + ...body, + '@timestamp': new Date(body['@timestamp']).toISOString(), + annotation: { + type: 'deployment', + }, + service: { + ...body.service, + name: path.serviceName, + }, + tags: uniq(['apm'].concat(body.tags ?? [])), + }) ); }, }); @@ -925,18 +934,25 @@ export const serviceDependenciesRoute = createApmServerRoute({ ]); return { - serviceDependencies: currentPeriod.map((item) => { - const { stats, ...rest } = item; - const previousPeriodItem = previousPeriod.find( - (prevItem) => item.location.id === prevItem.location.id - ); - - return { - ...rest, - currentStats: stats, - previousStats: previousPeriodItem?.stats || null, - }; - }), + serviceDependencies: currentPeriod.map( + ( + item + ): Omit & { + currentStats: ConnectionStatsItemWithImpact['stats']; + previousStats: ConnectionStatsItemWithImpact['stats'] | null; + } => { + const { stats, ...rest } = item; + const previousPeriodItem = previousPeriod.find( + (prevItem): boolean => item.location.id === prevItem.location.id + ); + + return { + ...rest, + currentStats: stats, + previousStats: previousPeriodItem?.stats || null, + }; + } + ), }; }, }); diff --git a/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts b/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts index 44dac0d9bc4a0..974b7f57289db 100644 --- a/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts +++ b/x-pack/plugins/apm/server/routes/settings/anomaly_detection/route.ts @@ -19,6 +19,7 @@ import { notifyFeatureUsage } from '../../../feature'; import { updateToV3 } from './update_to_v3'; import { environmentStringRt } from '../../../../common/environment_rt'; import { getMlJobsWithAPMGroup } from '../../../lib/anomaly_detection/get_ml_jobs_with_apm_group'; +import { ElasticsearchClient } from '../../../../../../../src/core/server'; // get ML anomaly detection jobs for each environment const anomalyDetectionJobsRoute = createApmServerRoute({ @@ -49,7 +50,7 @@ const anomalyDetectionJobsRoute = createApmServerRoute({ return { jobs, - hasLegacyJobs: jobs.some((job) => job.version === 1), + hasLegacyJobs: jobs.some((job): boolean => job.version === 1), }; }, }); @@ -128,7 +129,10 @@ const anomalyDetectionUpdateToV3Route = createApmServerRoute({ setupRequest(resources), resources.core .start() - .then((start) => start.elasticsearch.client.asInternalUser), + .then( + (start): ElasticsearchClient => + start.elasticsearch.client.asInternalUser + ), ]); const { logger } = resources; From 556b629691415b7771eabf317cd84196cb9acb04 Mon Sep 17 00:00:00 2001 From: Michael Dokolin Date: Tue, 15 Feb 2022 10:38:49 +0100 Subject: [PATCH 11/43] [Reporting] Add additional PNG and PDF metrics (#125450) * Update browser driver to return metrics along with the results * Add metrics to the reporting job * Add metrics to the event logging * Add screenshot metrics to the report info panel --- .../plugins/reporting/common/types/index.ts | 27 +++++++++++++++-- .../__snapshots__/stream_handler.test.ts.snap | 3 -- x-pack/plugins/reporting/public/lib/job.tsx | 4 +-- .../public/lib/stream_handler.test.ts | 2 +- .../reporting/public/lib/stream_handler.ts | 1 - .../components/report_info_flyout_content.tsx | 28 +++++++++++++++-- .../export_types/common/generate_png.ts | 22 +++++++++----- .../pdf/integration_tests/pdfmaker.test.ts | 25 ++++++++++++++++ .../export_types/common/pdf/pdfmaker.ts | 10 +++++++ .../generate_csv/generate_csv.ts | 4 ++- .../export_types/png/execute_job/index.ts | 3 +- .../server/export_types/png_v2/execute_job.ts | 3 +- .../printable_pdf/execute_job/index.ts | 3 +- .../printable_pdf/lib/generate_pdf.ts | 28 ++++++++++++----- .../printable_pdf_v2/execute_job.ts | 5 ++-- .../printable_pdf_v2/lib/generate_pdf.ts | 29 ++++++++++++------ .../server/lib/event_logger/logger.test.ts | 17 +++++++++-- .../server/lib/event_logger/logger.ts | 21 ++++++++++--- .../server/lib/event_logger/types.ts | 4 +-- .../reporting/server/lib/store/mapping.ts | 30 +++++++++++++++++++ .../reporting/server/lib/store/report.ts | 4 +++ .../reporting/server/lib/store/store.test.ts | 16 ++++++++++ .../reporting/server/lib/store/store.ts | 1 + .../server/lib/tasks/execute_report.ts | 4 +-- .../generate/csv_searchsource_immediate.ts | 2 +- .../routes/lib/get_document_payload.test.ts | 1 - .../server/routes/lib/request_handler.test.ts | 2 ++ .../browsers/chromium/driver_factory/index.ts | 25 +++++++++------- .../screenshotting/server/browsers/mock.ts | 3 +- .../server/browsers/safe_child_process.ts | 2 +- .../server/screenshots/index.test.ts | 5 ++-- .../server/screenshots/index.ts | 25 +++++++++------- .../screenshotting/server/screenshots/mock.ts | 3 +- 33 files changed, 281 insertions(+), 81 deletions(-) diff --git a/x-pack/plugins/reporting/common/types/index.ts b/x-pack/plugins/reporting/common/types/index.ts index 55da2cc366390..37c33dd0ecc7c 100644 --- a/x-pack/plugins/reporting/common/types/index.ts +++ b/x-pack/plugins/reporting/common/types/index.ts @@ -5,6 +5,8 @@ * 2.0. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { ScreenshotResult } from '../../../screenshotting/server'; import type { BaseParams, BaseParamsV2, BasePayload, BasePayloadV2, JobId } from './base'; export type { JobParamsPNGDeprecated } from './export_types/png'; @@ -33,12 +35,33 @@ export interface ReportOutput extends TaskRunResult { size: number; } +type ScreenshotMetrics = Required['metrics']; + +export interface CsvMetrics { + rows: number; +} + +export type PngMetrics = ScreenshotMetrics; + +export interface PdfMetrics extends Partial { + /** + * A number of emitted pages in the generated PDF report. + */ + pages: number; +} + +export interface TaskRunMetrics { + csv?: CsvMetrics; + png?: PngMetrics; + pdf?: PdfMetrics; +} + export interface TaskRunResult { content_type: string | null; csv_contains_formulas?: boolean; - csv_rows?: number; max_size_reached?: boolean; warnings?: string[]; + metrics?: TaskRunMetrics; } export interface ReportSource { @@ -76,6 +99,7 @@ export interface ReportSource { started_at?: string; // timestamp in UTC completed_at?: string; // timestamp in UTC process_expiration?: string | null; // timestamp in UTC - is overwritten with `null` when the job needs a retry + metrics?: TaskRunMetrics; } /* @@ -131,7 +155,6 @@ export interface JobSummary { title: ReportSource['payload']['title']; maxSizeReached: TaskRunResult['max_size_reached']; csvContainsFormulas: TaskRunResult['csv_contains_formulas']; - csvRows: TaskRunResult['csv_rows']; } export interface JobSummarySet { diff --git a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap index 3d326844fedf7..50c8672733168 100644 --- a/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap +++ b/x-pack/plugins/reporting/public/lib/__snapshots__/stream_handler.test.ts.snap @@ -5,7 +5,6 @@ Object { "completed": Array [ Object { "csvContainsFormulas": false, - "csvRows": undefined, "id": "job-source-mock1", "jobtype": undefined, "maxSizeReached": false, @@ -14,7 +13,6 @@ Object { }, Object { "csvContainsFormulas": true, - "csvRows": 42000000, "id": "job-source-mock4", "jobtype": undefined, "maxSizeReached": false, @@ -25,7 +23,6 @@ Object { "failed": Array [ Object { "csvContainsFormulas": false, - "csvRows": undefined, "id": "job-source-mock2", "jobtype": undefined, "maxSizeReached": false, diff --git a/x-pack/plugins/reporting/public/lib/job.tsx b/x-pack/plugins/reporting/public/lib/job.tsx index d9f501ecd1418..e875d00cabab8 100644 --- a/x-pack/plugins/reporting/public/lib/job.tsx +++ b/x-pack/plugins/reporting/public/lib/job.tsx @@ -55,8 +55,8 @@ export class Job { public size?: ReportOutput['size']; public content_type?: TaskRunResult['content_type']; public csv_contains_formulas?: TaskRunResult['csv_contains_formulas']; - public csv_rows?: TaskRunResult['csv_rows']; public max_size_reached?: TaskRunResult['max_size_reached']; + public metrics?: ReportSource['metrics']; public warnings?: TaskRunResult['warnings']; public locatorParams?: BaseParamsV2['locatorParams']; @@ -88,10 +88,10 @@ export class Job { this.isDeprecated = report.payload.isDeprecated || false; this.spaceId = report.payload.spaceId; this.csv_contains_formulas = report.output?.csv_contains_formulas; - this.csv_rows = report.output?.csv_rows; this.max_size_reached = report.output?.max_size_reached; this.warnings = report.output?.warnings; this.locatorParams = (report.payload as BaseParamsV2).locatorParams; + this.metrics = report.metrics; } getStatusMessage() { diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 2caa1b70fe162..4863a9f7e1e36 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -24,7 +24,7 @@ const mockJobsFound: Job[] = [ { id: 'job-source-mock1', status: 'completed', output: { csv_contains_formulas: false, max_size_reached: false }, payload: { title: 'specimen' } }, { id: 'job-source-mock2', status: 'failed', output: { csv_contains_formulas: false, max_size_reached: false }, payload: { title: 'specimen' } }, { id: 'job-source-mock3', status: 'pending', output: { csv_contains_formulas: false, max_size_reached: false }, payload: { title: 'specimen' } }, - { id: 'job-source-mock4', status: 'completed', output: { csv_contains_formulas: true, csv_rows: 42000000, max_size_reached: false }, payload: { title: 'specimen' } }, + { id: 'job-source-mock4', status: 'completed', output: { csv_contains_formulas: true, max_size_reached: false }, payload: { title: 'specimen' } }, ].map((j) => new Job(j as ReportApiJSON)); // prettier-ignore const coreSetup = coreMock.createSetup(); diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.ts b/x-pack/plugins/reporting/public/lib/stream_handler.ts index 03f4fcd30a618..e9645f3bb8735 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.ts @@ -33,7 +33,6 @@ function getReportStatus(src: Job): JobSummary { jobtype: src.prettyJobTypeName ?? src.jobtype, maxSizeReached: src.max_size_reached, csvContainsFormulas: src.csv_contains_formulas, - csvRows: src.csv_rows, }; } diff --git a/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx b/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx index e73fc5ab54e33..92b38d99cedd1 100644 --- a/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx +++ b/x-pack/plugins/reporting/public/management/components/report_info_flyout_content.tsx @@ -50,8 +50,12 @@ export const ReportInfoFlyoutContent: FunctionComponent = ({ info }) => { const formatDate = createDateFormatter(uiSettings.get('dateFormat'), timezone); - const hasCsvRows = info.csv_rows != null; + const cpuInPercentage = info.metrics?.pdf?.cpuInPercentage ?? info.metrics?.png?.cpuInPercentage; + const memoryInMegabytes = + info.metrics?.pdf?.memoryInMegabytes ?? info.metrics?.png?.memoryInMegabytes; + const hasCsvRows = info.metrics?.csv?.rows != null; const hasScreenshot = USES_HEADLESS_JOB_TYPES.includes(info.jobtype); + const hasPdfPagesMetric = info.metrics?.pdf?.pages != null; const outputInfo = [ { @@ -99,7 +103,7 @@ export const ReportInfoFlyoutContent: FunctionComponent = ({ info }) => { title: i18n.translate('xpack.reporting.listing.infoPanel.csvRows', { defaultMessage: 'CSV rows', }), - description: info.csv_rows?.toString() || NA, + description: info.metrics?.csv?.rows?.toString() || NA, }, hasScreenshot && { @@ -118,6 +122,12 @@ export const ReportInfoFlyoutContent: FunctionComponent = ({ info }) => { description: info.layout?.dimensions?.width != null ? Math.ceil(info.layout.dimensions.width) : UNKNOWN, }, + hasPdfPagesMetric && { + title: i18n.translate('xpack.reporting.listing.infoPanel.pdfPagesInfo', { + defaultMessage: 'Pages count', + }), + description: info.metrics?.pdf?.pages, + }, { title: i18n.translate('xpack.reporting.listing.infoPanel.processedByInfo', { @@ -132,6 +142,20 @@ export const ReportInfoFlyoutContent: FunctionComponent = ({ info }) => { }), description: info.prettyTimeout, }, + + cpuInPercentage != null && { + title: i18n.translate('xpack.reporting.listing.infoPanel.cpuInfo', { + defaultMessage: 'CPU usage', + }), + description: `${cpuInPercentage}%`, + }, + + memoryInMegabytes != null && { + title: i18n.translate('xpack.reporting.listing.infoPanel.memoryInfo', { + defaultMessage: 'RAM usage', + }), + description: `${memoryInMegabytes}MB`, + }, ].filter(Boolean) as EuiDescriptionListProps['listItems']; const timestampsInfo = [ diff --git a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts index 8c83e0ae73527..caa0b7fb91b3f 100644 --- a/x-pack/plugins/reporting/server/export_types/common/generate_png.ts +++ b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts @@ -10,15 +10,22 @@ import * as Rx from 'rxjs'; import { finalize, map, tap } from 'rxjs/operators'; import { LayoutTypes } from '../../../../screenshotting/common'; import { REPORTING_TRANSACTION_TYPE } from '../../../common/constants'; +import type { PngMetrics } from '../../../common/types'; import { ReportingCore } from '../../'; import { ScreenshotOptions } from '../../types'; import { LevelLogger } from '../../lib'; +interface PngResult { + buffer: Buffer; + metrics?: PngMetrics; + warnings: string[]; +} + export function generatePngObservable( reporting: ReportingCore, logger: LevelLogger, options: ScreenshotOptions -): Rx.Observable<{ buffer: Buffer; warnings: string[] }> { +): Rx.Observable { const apmTrans = apm.startTransaction('generate-png', REPORTING_TRANSACTION_TYPE); const apmLayout = apmTrans?.startSpan('create-layout', 'setup'); if (!options.layout.dimensions) { @@ -35,15 +42,16 @@ export function generatePngObservable( let apmBuffer: typeof apm.currentSpan; return reporting.getScreenshots({ ...options, layout }).pipe( - tap(({ metrics$ }) => { - metrics$.subscribe(({ cpu, memory }) => { - apmTrans?.setLabel('cpu', cpu, false); - apmTrans?.setLabel('memory', memory, false); - }); + tap(({ metrics }) => { + if (metrics) { + apmTrans?.setLabel('cpu', metrics.cpu, false); + apmTrans?.setLabel('memory', metrics.memory, false); + } apmScreenshots?.end(); apmBuffer = apmTrans?.startSpan('get-buffer', 'output') ?? null; }), - map(({ results }) => ({ + map(({ metrics, results }) => ({ + metrics, buffer: results[0].screenshots[0].data, warnings: results.reduce((found, current) => { if (current.error) { diff --git a/x-pack/plugins/reporting/server/export_types/common/pdf/integration_tests/pdfmaker.test.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/integration_tests/pdfmaker.test.ts index afad91faa4bde..4258349726ccf 100644 --- a/x-pack/plugins/reporting/server/export_types/common/pdf/integration_tests/pdfmaker.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/integration_tests/pdfmaker.test.ts @@ -32,4 +32,29 @@ describe('PdfMaker', () => { await expect(pdf.getBuffer()).resolves.toBeInstanceOf(Buffer); }); }); + + describe('getPageCount', () => { + it('should return zero pages on no content', () => { + expect(pdf.getPageCount()).toBe(0); + }); + + it('should return a number of generated pages', () => { + for (let i = 0; i < 100; i++) { + pdf.addImage(imageBase64, { title: `${i} viz`, description: '☃️' }); + } + pdf.generate(); + + expect(pdf.getPageCount()).toBe(100); + }); + + it('should return a number of already flushed pages', async () => { + for (let i = 0; i < 100; i++) { + pdf.addImage(imageBase64, { title: `${i} viz`, description: '☃️' }); + } + pdf.generate(); + await pdf.getBuffer(); + + expect(pdf.getPageCount()).toBe(100); + }); + }); }); diff --git a/x-pack/plugins/reporting/server/export_types/common/pdf/pdfmaker.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/pdfmaker.ts index 96dcd480a454c..4d462a429607a 100644 --- a/x-pack/plugins/reporting/server/export_types/common/pdf/pdfmaker.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/pdfmaker.ts @@ -157,4 +157,14 @@ export class PdfMaker { this._pdfDoc.end(); }); } + + getPageCount(): number { + const pageRange = this._pdfDoc?.bufferedPageRange(); + if (!pageRange) { + return 0; + } + const { count, start } = pageRange; + + return start + count; + } } diff --git a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts index 7b1f82f226e5e..0feaab90975d8 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_searchsource/generate_csv/generate_csv.ts @@ -395,8 +395,10 @@ export class CsvGenerator { return { content_type: CONTENT_TYPE_CSV, csv_contains_formulas: this.csvContainsFormulas && !escapeFormulaValues, - csv_rows: this.csvRowCount, max_size_reached: this.maxSizeReached, + metrics: { + csv: { rows: this.csvRowCount }, + }, warnings, }; } diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts index e6cbfb45eb095..20a2ea98e06d4 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts @@ -49,8 +49,9 @@ export const runTaskFnFactory: RunTaskFnFactory> = }); }), tap(({ buffer }) => stream.write(buffer)), - map(({ warnings }) => ({ + map(({ metrics, warnings }) => ({ content_type: 'image/png', + metrics: { png: metrics }, warnings, })), tap({ error: (error) => jobLogger.error(error) }), diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts index a8ab6c4355000..1acce6e475630 100644 --- a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts @@ -50,8 +50,9 @@ export const runTaskFnFactory: RunTaskFnFactory> = }); }), tap(({ buffer }) => stream.write(buffer)), - map(({ warnings }) => ({ + map(({ metrics, warnings }) => ({ content_type: 'image/png', + metrics: { png: metrics }, warnings, })), tap({ error: (error) => jobLogger.error(error) }), diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts index f301b3e1e6ef2..02ba917ce329d 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts @@ -65,8 +65,9 @@ export const runTaskFnFactory: RunTaskFnFactory> = stream.write(buffer); } }), - map(({ warnings }) => ({ + map(({ metrics, warnings }) => ({ content_type: 'application/pdf', + metrics: { pdf: metrics }, warnings, })), catchError((err) => { diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts index 032552be3978f..149f4fc3aee52 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts @@ -7,8 +7,9 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; -import { mergeMap } from 'rxjs/operators'; +import { mergeMap, tap } from 'rxjs/operators'; import { ScreenshotResult } from '../../../../../screenshotting/server'; +import type { PdfMetrics } from '../../../../common/types'; import { ReportingCore } from '../../../'; import { LevelLogger } from '../../../lib'; import { ScreenshotOptions } from '../../../types'; @@ -25,25 +26,32 @@ const getTimeRange = (urlScreenshots: ScreenshotResult['results']) => { return null; }; +interface PdfResult { + buffer: Buffer | null; + metrics?: PdfMetrics; + warnings: string[]; +} + export function generatePdfObservable( reporting: ReportingCore, logger: LevelLogger, title: string, options: ScreenshotOptions, logo?: string -): Rx.Observable<{ buffer: Buffer | null; warnings: string[] }> { +): Rx.Observable { const tracker = getTracker(); tracker.startScreenshots(); return reporting.getScreenshots(options).pipe( - mergeMap(async ({ layout, metrics$, results }) => { - metrics$.subscribe(({ cpu, memory }) => { - tracker.setCpuUsage(cpu); - tracker.setMemoryUsage(memory); - }); + tap(({ metrics }) => { + if (metrics) { + tracker.setCpuUsage(metrics.cpu); + tracker.setMemoryUsage(metrics.memory); + } tracker.endScreenshots(); tracker.startSetup(); - + }), + mergeMap(async ({ layout, metrics, results }) => { const pdfOutput = new PdfMaker(layout, logo); if (title) { const timeRange = getTimeRange(results); @@ -89,6 +97,10 @@ export function generatePdfObservable( return { buffer, + metrics: { + ...metrics, + pages: pdfOutput.getPageCount(), + }, warnings: results.reduce((found, current) => { if (current.error) { found.push(current.error.message); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts index 890c0c9cde731..de6f2ae70a756 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts @@ -57,15 +57,16 @@ export const runTaskFnFactory: RunTaskFnFactory> = logo ); }), - tap(({ buffer, warnings }) => { + tap(({ buffer }) => { apmGeneratePdf?.end(); if (buffer) { stream.write(buffer); } }), - map(({ warnings }) => ({ + map(({ metrics, warnings }) => ({ content_type: 'application/pdf', + metrics: { pdf: metrics }, warnings, })), catchError((err) => { diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts index 424715f10fb79..08e73371f74b7 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts @@ -7,9 +7,9 @@ import { groupBy } from 'lodash'; import * as Rx from 'rxjs'; -import { mergeMap } from 'rxjs/operators'; +import { mergeMap, tap } from 'rxjs/operators'; import { ReportingCore } from '../../../'; -import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../../common/types'; +import { LocatorParams, PdfMetrics, UrlOrUrlLocatorTuple } from '../../../../common/types'; import { LevelLogger } from '../../../lib'; import { ScreenshotResult } from '../../../../../screenshotting/server'; import { ScreenshotOptions } from '../../../types'; @@ -28,6 +28,12 @@ const getTimeRange = (urlScreenshots: ScreenshotResult['results']) => { return null; }; +interface PdfResult { + buffer: Buffer | null; + metrics?: PdfMetrics; + warnings: string[]; +} + export function generatePdfObservable( reporting: ReportingCore, logger: LevelLogger, @@ -36,7 +42,7 @@ export function generatePdfObservable( locatorParams: LocatorParams[], options: Omit, logo?: string -): Rx.Observable<{ buffer: Buffer | null; warnings: string[] }> { +): Rx.Observable { const tracker = getTracker(); tracker.startScreenshots(); @@ -49,14 +55,15 @@ export function generatePdfObservable( ]) as UrlOrUrlLocatorTuple[]; const screenshots$ = reporting.getScreenshots({ ...options, urls }).pipe( - mergeMap(async ({ layout, metrics$, results }) => { - metrics$.subscribe(({ cpu, memory }) => { - tracker.setCpuUsage(cpu); - tracker.setMemoryUsage(memory); - }); + tap(({ metrics }) => { + if (metrics) { + tracker.setCpuUsage(metrics.cpu); + tracker.setMemoryUsage(metrics.memory); + } tracker.endScreenshots(); tracker.startSetup(); - + }), + mergeMap(async ({ layout, metrics, results }) => { const pdfOutput = new PdfMaker(layout, logo); if (title) { const timeRange = getTimeRange(results); @@ -102,6 +109,10 @@ export function generatePdfObservable( return { buffer, + metrics: { + ...metrics, + pages: pdfOutput.getPageCount(), + }, warnings: results.reduce((found, current) => { if (current.error) { found.push(current.error.message); diff --git a/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts b/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts index 10b7d1278183f..fa45a8d04176c 100644 --- a/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts +++ b/x-pack/plugins/reporting/server/lib/event_logger/logger.test.ts @@ -116,7 +116,14 @@ describe('Event Logger', () => { jest.spyOn(logger.completionLogger, 'stopTiming'); logger.logExecutionStart(); - const result = logger.logExecutionComplete({ byteSize: 444, csvRows: 440000 }); + const result = logger.logExecutionComplete({ + byteSize: 444, + pdf: { + cpu: 0.1, + memory: 1024, + pages: 5, + }, + }); expect([result.event, result.kibana.reporting, result.message]).toMatchInlineSnapshot(` Array [ Object { @@ -125,9 +132,15 @@ describe('Event Logger', () => { Object { "actionType": "execute-complete", "byteSize": 444, - "csvRows": 440000, + "csv": undefined, "id": "12348", "jobType": "csv", + "pdf": Object { + "cpu": 0.1, + "memory": 1024, + "pages": 5, + }, + "png": undefined, }, "completed csv execution", ] diff --git a/x-pack/plugins/reporting/server/lib/event_logger/logger.ts b/x-pack/plugins/reporting/server/lib/event_logger/logger.ts index a54f69eff3582..6a7feea0c335d 100644 --- a/x-pack/plugins/reporting/server/lib/event_logger/logger.ts +++ b/x-pack/plugins/reporting/server/lib/event_logger/logger.ts @@ -9,6 +9,7 @@ import deepMerge from 'deepmerge'; import { LogMeta } from 'src/core/server'; import { LevelLogger } from '../'; import { PLUGIN_ID } from '../../../common/constants'; +import type { TaskRunMetrics } from '../../../common/types'; import { IReport } from '../store'; import { ActionType } from './'; import { EcsLogAdapter } from './adapter'; @@ -25,9 +26,8 @@ import { } from './types'; /** @internal */ -export interface ExecutionCompleteMetrics { +export interface ExecutionCompleteMetrics extends TaskRunMetrics { byteSize: number; - csvRows?: number; } export interface IReportingEventLogger { @@ -102,13 +102,26 @@ export function reportingEventLoggerFactory(logger: LevelLogger) { return event; } - logExecutionComplete({ byteSize, csvRows }: ExecutionCompleteMetrics): CompletedExecution { + logExecutionComplete({ + byteSize, + csv, + pdf, + png, + }: ExecutionCompleteMetrics): CompletedExecution { const message = `completed ${this.report.jobtype} execution`; this.completionLogger.stopTiming(); const event = deepMerge( { message, - kibana: { reporting: { actionType: ActionType.EXECUTE_COMPLETE, byteSize, csvRows } }, + kibana: { + reporting: { + actionType: ActionType.EXECUTE_COMPLETE, + byteSize, + csv, + pdf, + png, + }, + }, } as Partial, this.eventObj ); diff --git a/x-pack/plugins/reporting/server/lib/event_logger/types.ts b/x-pack/plugins/reporting/server/lib/event_logger/types.ts index cc3ee25813128..3094919da278d 100644 --- a/x-pack/plugins/reporting/server/lib/event_logger/types.ts +++ b/x-pack/plugins/reporting/server/lib/event_logger/types.ts @@ -6,6 +6,7 @@ */ import { LogMeta } from 'src/core/server'; +import type { TaskRunMetrics } from '../../../common/types'; import { ActionType } from './'; export interface ReportingAction extends LogMeta { @@ -19,8 +20,7 @@ export interface ReportingAction extends LogMeta { id?: string; // "immediate download" exports have no ID jobType: string; byteSize?: number; - csvRows?: number; - }; + } & TaskRunMetrics; task?: { id?: string }; }; user?: { name: string }; diff --git a/x-pack/plugins/reporting/server/lib/store/mapping.ts b/x-pack/plugins/reporting/server/lib/store/mapping.ts index 667648d3372c5..f860493dfc3fa 100644 --- a/x-pack/plugins/reporting/server/lib/store/mapping.ts +++ b/x-pack/plugins/reporting/server/lib/store/mapping.ts @@ -59,4 +59,34 @@ export const mapping = { content: { type: 'object', enabled: false }, }, }, + metrics: { + type: 'object', + properties: { + csv: { + type: 'object', + properties: { + rows: { type: 'long' }, + }, + }, + pdf: { + type: 'object', + properties: { + pages: { type: 'long' }, + cpu: { type: 'double' }, + cpuInPercentage: { type: 'double' }, + memory: { type: 'long' }, + memoryInMegabytes: { type: 'double' }, + }, + }, + png: { + type: 'object', + properties: { + cpu: { type: 'double' }, + cpuInPercentage: { type: 'double' }, + memory: { type: 'long' }, + memoryInMegabytes: { type: 'double' }, + }, + }, + }, + }, } as const; diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts index 67f1ccdea5db8..6b2b6f997233b 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -50,6 +50,7 @@ export class Report implements Partial { public readonly completed_at: ReportSource['completed_at']; public readonly timeout: ReportSource['timeout']; public readonly max_attempts: ReportSource['max_attempts']; + public readonly metrics?: ReportSource['metrics']; public process_expiration?: ReportSource['process_expiration']; public migration_version: string; @@ -88,6 +89,7 @@ export class Report implements Partial { this.created_at = opts.created_at || moment.utc().toISOString(); this.created_by = opts.created_by || false; this.meta = opts.meta || { objectType: 'unknown' }; + this.metrics = opts.metrics; this.status = opts.status || JOB_STATUSES.PENDING; this.output = opts.output || null; @@ -129,6 +131,7 @@ export class Report implements Partial { completed_at: this.completed_at, process_expiration: this.process_expiration, output: this.output || null, + metrics: this.metrics, }; } @@ -174,6 +177,7 @@ export class Report implements Partial { migration_version: this.migration_version, payload: omit(this.payload, 'headers'), output: omit(this.output, 'content'), + metrics: this.metrics, }; } } diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index 81ba2454124c0..3e8942be1ffa0 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -193,6 +193,14 @@ describe('ReportingStore', () => { max_attempts: 1, timeout: 30000, output: null, + metrics: { + png: { + cpu: 0.02, + cpuInPercentage: 2, + memory: 1024 * 1024, + memoryInMegabytes: 1, + }, + }, }, }; mockEsClient.get.mockResponse(mockReport as any); @@ -219,6 +227,14 @@ describe('ReportingStore', () => { "meta": Object { "testMeta": "meta", }, + "metrics": Object { + "png": Object { + "cpu": 0.02, + "cpuInPercentage": 2, + "memory": 1048576, + "memoryInMegabytes": 1, + }, + }, "migration_version": "7.14.0", "output": null, "payload": Object { diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 492838d61ca74..41fdd9580c996 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -253,6 +253,7 @@ export class ReportingStore { created_by: document._source?.created_by, max_attempts: document._source?.max_attempts, meta: document._source?.meta, + metrics: document._source?.metrics, payload: document._source?.payload, process_expiration: document._source?.process_expiration, status: document._source?.status, diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts index c566a07c3e6b2..8cc4139da3f1f 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts @@ -221,7 +221,6 @@ export class ExecuteReportTask implements ReportingTask { docOutput.content_type = output.content_type || unknownMime; docOutput.max_size_reached = output.max_size_reached; docOutput.csv_contains_formulas = output.csv_contains_formulas; - docOutput.csv_rows = output.csv_rows; docOutput.size = output.size; docOutput.warnings = output.warnings && output.warnings.length > 0 ? output.warnings : undefined; @@ -271,6 +270,7 @@ export class ExecuteReportTask implements ReportingTask { const store = await this.getStore(); const doc = { completed_at: completedTime, + metrics: output.metrics, output: docOutput, }; docId = `/${report._index}/_doc/${report._id}`; @@ -365,8 +365,8 @@ export class ExecuteReportTask implements ReportingTask { report._primary_term = stream.getPrimaryTerm()!; eventLog.logExecutionComplete({ + ...(report.metrics ?? {}), byteSize: stream.bytesWritten, - csvRows: output?.csv_rows, }); if (output) { diff --git a/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts b/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts index 364ceea3fa001..b6ada00ba55ab 100644 --- a/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/generate/csv_searchsource_immediate.ts @@ -89,8 +89,8 @@ export function registerGenerateCsvFromSavedObjectImmediate( } eventLog.logExecutionComplete({ + ...(output.metrics ?? {}), byteSize: stream.bytesWritten, - csvRows: output.csv_rows, }); }) .finally(() => stream.end()); diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.test.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.test.ts index 7904946892905..d2ed0b86e2cce 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.test.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.test.ts @@ -76,7 +76,6 @@ describe('getDocumentPayload', () => { output: { content_type: 'text/csv', csv_contains_formulas: true, - csv_rows: 42000000, max_size_reached: true, size: 1024, }, diff --git a/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts b/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts index 0028073290f20..d1c1dddb3c302 100644 --- a/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts +++ b/x-pack/plugins/reporting/server/routes/lib/request_handler.test.ts @@ -114,6 +114,7 @@ describe('Handle request to generate', () => { "layout": "preserve_layout", "objectType": "cool_object_type", }, + "metrics": undefined, "migration_version": "7.14.0", "output": null, "process_expiration": undefined, @@ -195,6 +196,7 @@ describe('Handle request to generate', () => { "layout": "preserve_layout", "objectType": "cool_object_type", }, + "metrics": undefined, "migration_version": "7.14.0", "output": Object {}, "payload": Object { diff --git a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts index d26d948beee16..2f4c59707430e 100644 --- a/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/screenshotting/server/browsers/chromium/driver_factory/index.ts @@ -49,7 +49,6 @@ interface CreatePageOptions { interface CreatePageResult { driver: HeadlessChromiumDriver; unexpectedExit$: Rx.Observable; - metrics$: Rx.Observable; /** * Close the page and the browser. * @@ -57,7 +56,11 @@ interface CreatePageResult { * have concluded. This ensures the browser is closed and gives the OS a chance * to reclaim resources like memory. */ - close: () => Rx.Observable; + close: () => Rx.Observable; +} + +interface ClosePageResult { + metrics?: PerformanceMetrics; } export const DEFAULT_VIEWPORT = { @@ -167,7 +170,6 @@ export class HeadlessChromiumDriverFactory { await devTools.send('Performance.enable', { timeDomain: 'timeTicks' }); const startMetrics = await devTools.send('Performance.getMetrics'); - const metrics$ = new Rx.Subject(); // Log version info for debugging / maintenance const versionInfo = await devTools.send('Browser.getVersion'); @@ -182,23 +184,25 @@ export class HeadlessChromiumDriverFactory { logger.debug(`Browser page driver created`); const childProcess = { - async kill(): Promise { - if (page.isClosed()) return; + async kill(): Promise { + if (page.isClosed()) { + return {}; + } + + let metrics: PerformanceMetrics | undefined; + try { if (devTools && startMetrics) { const endMetrics = await devTools.send('Performance.getMetrics'); - const metrics = getMetrics(startMetrics, endMetrics); + metrics = getMetrics(startMetrics, endMetrics); const { cpuInPercentage, memoryInMegabytes } = metrics; - metrics$.next(metrics); logger.debug( `Chromium consumed CPU ${cpuInPercentage}% Memory ${memoryInMegabytes}MB` ); } } catch (error) { logger.error(error); - } finally { - metrics$.complete(); } try { @@ -209,6 +213,8 @@ export class HeadlessChromiumDriverFactory { // do not throw logger.error(err); } + + return { metrics }; }, }; const { terminate$ } = safeChildProcess(logger, childProcess); @@ -245,7 +251,6 @@ export class HeadlessChromiumDriverFactory { observer.next({ driver, unexpectedExit$, - metrics$: metrics$.asObservable(), close: () => Rx.from(childProcess.kill()), }); diff --git a/x-pack/plugins/screenshotting/server/browsers/mock.ts b/x-pack/plugins/screenshotting/server/browsers/mock.ts index 1958f5e6b0396..9904f3e396830 100644 --- a/x-pack/plugins/screenshotting/server/browsers/mock.ts +++ b/x-pack/plugins/screenshotting/server/browsers/mock.ts @@ -91,8 +91,7 @@ export function createMockBrowserDriverFactory( of({ driver: driver ?? createMockBrowserDriver(), unexpectedExit$: NEVER, - metrics$: NEVER, - close: () => of(undefined), + close: () => of({}), }) ), diagnose: jest.fn(() => of('message')), diff --git a/x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts b/x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts index 4bc378a4c8c86..8447e56324a25 100644 --- a/x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts +++ b/x-pack/plugins/screenshotting/server/browsers/safe_child_process.ts @@ -10,7 +10,7 @@ import { take, share, mapTo, delay, tap } from 'rxjs/operators'; import type { Logger } from 'src/core/server'; interface IChild { - kill: (signal: string) => Promise; + kill(signal: string): Promise; } // Our process can get sent various signals, and when these occur we wish to diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.test.ts b/x-pack/plugins/screenshotting/server/screenshots/index.test.ts index eae7a6a5bc031..ff5c910e9cc3e 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/index.test.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/index.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { of, throwError, NEVER } from 'rxjs'; +import { of, throwError } from 'rxjs'; import type { Logger } from 'src/core/server'; import type { ConfigType } from '../config'; import { createMockBrowserDriver, createMockBrowserDriverFactory } from '../browsers/mock'; @@ -356,8 +356,7 @@ describe('Screenshot Observable Pipeline', () => { of({ driver, unexpectedExit$: throwError('Instant timeout has fired!'), - metrics$: NEVER, - close: () => of(undefined), + close: () => of({}), }) ); diff --git a/x-pack/plugins/screenshotting/server/screenshots/index.ts b/x-pack/plugins/screenshotting/server/screenshots/index.ts index a43fd4549e482..e8a90145f77e6 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/index.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/index.ts @@ -11,10 +11,11 @@ import { catchError, concatMap, first, - mapTo, + map, mergeMap, take, takeUntil, + tap, toArray, } from 'rxjs/operators'; import type { Logger } from 'src/core/server'; @@ -40,7 +41,7 @@ export interface ScreenshotResult { /** * Collected performance metrics during the screenshotting session. */ - metrics$: Observable; + metrics?: PerformanceMetrics; /** * Screenshotting results. @@ -88,12 +89,8 @@ export class Screenshots { ) .pipe( this.semaphore.acquire(), - mergeMap(({ driver, unexpectedExit$, metrics$, close }) => { + mergeMap(({ driver, unexpectedExit$, close }) => { apmCreatePage?.end(); - metrics$.subscribe(({ cpu, memory }) => { - apmTrans?.setLabel('cpu', cpu, false); - apmTrans?.setLabel('memory', memory, false); - }); unexpectedExit$.subscribe({ error: () => apmTrans?.end() }); const screen = new ScreenshotObservableHandler(driver, this.logger, layout, options); @@ -113,10 +110,18 @@ export class Screenshots { ), take(options.urls.length), toArray(), - mergeMap((results) => { + mergeMap((results) => // At this point we no longer need the page, close it. - return close().pipe(mapTo({ layout, metrics$, results })); - }) + close().pipe( + tap(({ metrics }) => { + if (metrics) { + apmTrans?.setLabel('cpu', metrics.cpu, false); + apmTrans?.setLabel('memory', metrics.memory, false); + } + }), + map(({ metrics }) => ({ layout, metrics, results })) + ) + ) ); }), first() diff --git a/x-pack/plugins/screenshotting/server/screenshots/mock.ts b/x-pack/plugins/screenshotting/server/screenshots/mock.ts index c4b5707243136..302407864ffbe 100644 --- a/x-pack/plugins/screenshotting/server/screenshots/mock.ts +++ b/x-pack/plugins/screenshotting/server/screenshots/mock.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { of, NEVER } from 'rxjs'; +import { of } from 'rxjs'; import { createMockLayout } from '../layouts/mock'; import type { Screenshots, ScreenshotResult } from '.'; @@ -14,7 +14,6 @@ export function createMockScreenshots(): jest.Mocked { getScreenshots: jest.fn((options) => of({ layout: createMockLayout(), - metrics$: NEVER, results: options.urls.map(() => ({ timeRange: null, screenshots: [ From 57ef9e56a52b1b641e616da78dbd0c0b20fe1f7c Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 15 Feb 2022 11:38:13 +0100 Subject: [PATCH 12/43] [Graph] Make graph edges easier to click (#124053) * :bug: Make edge easier to click on graph visualization * :white_check_mark: Fix tests for new implementation * :bug: Fix functional test * :bug: Fix more functional tests * :bug: Fix tests * :bug: Fix hover behaviour * :camera_flash: Update snapshot * :white_check_mark: Adjust fucntional objects for new changes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../graph_visualization.test.tsx.snap | 84 +++++++++++++------ .../_graph_visualization.scss | 15 +++- .../graph_visualization.test.tsx | 2 +- .../graph_visualization.tsx | 43 ++++++---- x-pack/test/functional/apps/graph/graph.ts | 2 +- .../functional/page_objects/graph_page.ts | 4 +- 6 files changed, 103 insertions(+), 47 deletions(-) diff --git a/x-pack/plugins/graph/public/components/graph_visualization/__snapshots__/graph_visualization.test.tsx.snap b/x-pack/plugins/graph/public/components/graph_visualization/__snapshots__/graph_visualization.test.tsx.snap index 0339bfc8a9be5..a66ebc7bc1f1e 100644 --- a/x-pack/plugins/graph/public/components/graph_visualization/__snapshots__/graph_visualization.test.tsx.snap +++ b/x-pack/plugins/graph/public/components/graph_visualization/__snapshots__/graph_visualization.test.tsx.snap @@ -11,36 +11,68 @@ exports[`graph_visualization should render to svg elements 1`] = ` > - + - + + + + + x1={7} + x2={12} + y1={9} + y2={2} + /> + + { /> ); - instance.find('.gphEdge').first().simulate('click'); + instance.find('.gphEdge').at(1).simulate('click'); expect(workspace.getAllIntersections).toHaveBeenCalled(); expect(edges[0].topSrc).toEqual(workspace.getAllIntersections.mock.calls[0][1][0]); diff --git a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx index 26359101a9a5b..4859daa16488e 100644 --- a/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx +++ b/x-pack/plugins/graph/public/components/graph_visualization/graph_visualization.tsx @@ -90,24 +90,39 @@ export function GraphVisualization({ {workspace.edges && workspace.edges.map((edge) => ( - { - edgeClick(edge); - }} - className={classNames('gphEdge', { - 'gphEdge--selected': edge.isSelected, - })} - style={{ strokeWidth: edge.width }} - strokeLinecap="round" - /> + className="gphEdge--wrapper" + > + {/* Draw two edges: a thicker one for better click handling and the one to show the user */} + + { + edgeClick(edge); + }} + className="gphEdge gphEdge--clickable" + style={{ + strokeWidth: Math.max(edge.width, 15), + }} + /> + ))} {workspace.nodes && diff --git a/x-pack/test/functional/apps/graph/graph.ts b/x-pack/test/functional/apps/graph/graph.ts index 172686692110e..6410a0b0272f8 100644 --- a/x-pack/test/functional/apps/graph/graph.ts +++ b/x-pack/test/functional/apps/graph/graph.ts @@ -138,7 +138,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await browser.execute(() => { const event = document.createEvent('SVGEvents'); event.initEvent('click', true, true); - return document.getElementsByClassName('gphEdge')[0].dispatchEvent(event); + return document.getElementsByClassName('gphEdge--clickable')[0].dispatchEvent(event); }); await PageObjects.common.sleep(1000); await PageObjects.graph.startLayout(); diff --git a/x-pack/test/functional/page_objects/graph_page.ts b/x-pack/test/functional/page_objects/graph_page.ts index b0389510e5ef5..bc6890246f444 100644 --- a/x-pack/test/functional/page_objects/graph_page.ts +++ b/x-pack/test/functional/page_objects/graph_page.ts @@ -131,7 +131,9 @@ export class GraphPageObject extends FtrService { const elements = document.querySelectorAll('#graphSvg text.gphNode__label'); return [...elements].map(element => element.innerHTML); `); - const graphElements = await this.find.allByCssSelector('#graphSvg line, #graphSvg circle'); + const graphElements = await this.find.allByCssSelector( + '#graphSvg line:not(.gphEdge--clickable), #graphSvg circle' + ); const nodes: Node[] = []; const nodePositionMap: Record = {}; const edges: Edge[] = []; From 0e87ffccd03050fe486005b92571ca302956f13d Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Tue, 15 Feb 2022 11:06:58 +0000 Subject: [PATCH 13/43] Service overview e2e test (#125359) * Add e2e test for service overview * add test for comparison type change * PR review comments addressed * improve time range test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../service_overview/service_overview.spec.ts | 272 ++++++++++++++---- .../service_overview_errors_table/index.tsx | 6 +- .../index.tsx | 6 +- .../index.tsx | 9 +- .../shared/dependencies_table/index.tsx | 6 +- .../shared/transactions_table/index.tsx | 6 +- 6 files changed, 246 insertions(+), 59 deletions(-) diff --git a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts index 5601ade671908..31586651cbb84 100644 --- a/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts +++ b/x-pack/plugins/apm/ftr_e2e/cypress/integration/read_only_user/service_overview/service_overview.spec.ts @@ -18,6 +18,71 @@ const baseUrl = url.format({ query: { rangeFrom: start, rangeTo: end }, }); +const apiRequestsToIntercept = [ + { + endpoint: + '/internal/apm/services/opbeans-node/transactions/groups/main_statistics?*', + aliasName: 'transactionsGroupsMainStadisticsRequest', + }, + { + endpoint: + '/internal/apm/services/opbeans-node/errors/groups/main_statistics?*', + aliasName: 'errorsGroupsMainStadisticsRequest', + }, + { + endpoint: + '/internal/apm/services/opbeans-node/transaction/charts/breakdown?*', + aliasName: 'transactionsBreakdownRequest', + }, + { + endpoint: '/internal/apm/services/opbeans-node/dependencies?*', + aliasName: 'dependenciesRequest', + }, +]; + +const apiRequestsToInterceptWithComparison = [ + { + endpoint: + '/internal/apm/services/opbeans-node/transactions/charts/latency?*', + aliasName: 'latencyRequest', + }, + { + endpoint: '/internal/apm/services/opbeans-node/throughput?*', + aliasName: 'throughputRequest', + }, + { + endpoint: + '/internal/apm/services/opbeans-node/transactions/charts/error_rate?*', + aliasName: 'errorRateRequest', + }, + { + endpoint: + '/internal/apm/services/opbeans-node/transactions/groups/detailed_statistics?*', + aliasName: 'transactionsGroupsDetailedStadisticsRequest', + }, + { + endpoint: + '/internal/apm/services/opbeans-node/service_overview_instances/main_statistics?*', + aliasName: 'instancesMainStadisticsRequest', + }, + + { + endpoint: + '/internal/apm/services/opbeans-node/service_overview_instances/detailed_statistics?*', + aliasName: 'instancesDetailedStadisticsRequest', + }, +]; + +const aliasNamesNoComparison = apiRequestsToIntercept.map( + ({ aliasName }) => `@${aliasName}` +); + +const aliasNamesWithComparison = apiRequestsToInterceptWithComparison.map( + ({ aliasName }) => `@${aliasName}` +); + +const aliasNames = [...aliasNamesNoComparison, ...aliasNamesWithComparison]; + describe('Service Overview', () => { before(async () => { await synthtrace.index( @@ -32,66 +97,167 @@ describe('Service Overview', () => { await synthtrace.clean(); }); - beforeEach(() => { - cy.loginAsReadOnlyUser(); + describe('renders', () => { + before(() => { + cy.loginAsReadOnlyUser(); + cy.visit(baseUrl); + }); + it('transaction latency chart', () => { + cy.get('[data-test-subj="latencyChart"]'); + }); + + it('throughput chart', () => { + cy.get('[data-test-subj="throughput"]'); + }); + + it('transactions group table', () => { + cy.get('[data-test-subj="transactionsGroupTable"]'); + }); + + it('error table', () => { + cy.get('[data-test-subj="serviceOverviewErrorsTable"]'); + }); + + it('dependencies table', () => { + cy.get('[data-test-subj="dependenciesTable"]'); + }); + + it('instances latency distribution chart', () => { + cy.get('[data-test-subj="instancesLatencyDistribution"]'); + }); + + it('instances table', () => { + cy.get('[data-test-subj="serviceOverviewInstancesTable"]'); + }); }); - it('persists transaction type selected when clicking on Transactions tab', () => { - cy.visit(baseUrl); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'request' - ); - cy.get('[data-test-subj="headerFilterTransactionType"]').select('Worker'); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'Worker' - ); - cy.contains('Transactions').click(); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'Worker' - ); + describe('transactions', () => { + beforeEach(() => { + cy.loginAsReadOnlyUser(); + cy.visit(baseUrl); + }); + + it('persists transaction type selected when clicking on Transactions tab', () => { + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'request' + ); + cy.get('[data-test-subj="headerFilterTransactionType"]').select('Worker'); + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'Worker' + ); + cy.contains('Transactions').click(); + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'Worker' + ); + }); + + it('persists transaction type selected when clicking on View Transactions link', () => { + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'request' + ); + cy.get('[data-test-subj="headerFilterTransactionType"]').select('Worker'); + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'Worker' + ); + + cy.contains('View transactions').click(); + cy.get('[data-test-subj="headerFilterTransactionType"]').should( + 'have.value', + 'Worker' + ); + }); }); - it('persists transaction type selected when clicking on View Transactions link', () => { - cy.visit(baseUrl); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'request' - ); - cy.get('[data-test-subj="headerFilterTransactionType"]').select('Worker'); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'Worker' - ); + describe('when RUM service', () => { + before(() => { + cy.loginAsReadOnlyUser(); + cy.visit( + url.format({ + pathname: '/app/apm/services/opbeans-rum/overview', + query: { rangeFrom: start, rangeTo: end }, + }) + ); + }); - cy.contains('View transactions').click(); - cy.get('[data-test-subj="headerFilterTransactionType"]').should( - 'have.value', - 'Worker' - ); + it('hides dependency tab when RUM service', () => { + cy.intercept('GET', '/internal/apm/services/opbeans-rum/agent?*').as( + 'agentRequest' + ); + cy.contains('Overview'); + cy.contains('Transactions'); + cy.contains('Error'); + cy.contains('Service Map'); + // Waits until the agent request is finished to check the tab. + cy.wait('@agentRequest'); + cy.get('.euiTabs .euiTab__content').then((elements) => { + elements.map((index, element) => { + expect(element.innerText).to.not.equal('Dependencies'); + }); + }); + }); }); - it('hides dependency tab when RUM service', () => { - cy.intercept('GET', '/internal/apm/services/opbeans-rum/agent?*').as( - 'agentRequest' - ); - cy.visit( - url.format({ - pathname: '/app/apm/services/opbeans-rum/overview', - query: { rangeFrom: start, rangeTo: end }, - }) - ); - cy.contains('Overview'); - cy.contains('Transactions'); - cy.contains('Error'); - cy.contains('Service Map'); - // Waits until the agent request is finished to check the tab. - cy.wait('@agentRequest'); - cy.get('.euiTabs .euiTab__content').then((elements) => { - elements.map((index, element) => { - expect(element.innerText).to.not.equal('Dependencies'); + describe('Calls APIs', () => { + beforeEach(() => { + cy.loginAsReadOnlyUser(); + cy.visit(baseUrl); + apiRequestsToIntercept.map(({ endpoint, aliasName }) => { + cy.intercept('GET', endpoint).as(aliasName); + }); + apiRequestsToInterceptWithComparison.map(({ endpoint, aliasName }) => { + cy.intercept('GET', endpoint).as(aliasName); + }); + }); + + it('with the correct environment when changing the environment', () => { + cy.wait(aliasNames, { requestTimeout: 10000 }); + + cy.get('[data-test-subj="environmentFilter"]').select('production'); + + cy.expectAPIsToHaveBeenCalledWith({ + apisIntercepted: aliasNames, + value: 'environment=production', + }); + }); + + it('when clicking the refresh button', () => { + cy.contains('Refresh').click(); + cy.wait(aliasNames, { requestTimeout: 10000 }); + }); + + it('when selecting a different time range and clicking the update button', () => { + cy.wait(aliasNames, { requestTimeout: 10000 }); + + cy.selectAbsoluteTimeRange( + 'Oct 10, 2021 @ 01:00:00.000', + 'Oct 10, 2021 @ 01:30:00.000' + ); + cy.contains('Update').click(); + + cy.expectAPIsToHaveBeenCalledWith({ + apisIntercepted: aliasNames, + value: + 'start=2021-10-10T00%3A00%3A00.000Z&end=2021-10-10T00%3A30%3A00.000Z', + }); + }); + + it('when selecting a different comparison window', () => { + cy.get('[data-test-subj="comparisonSelect"]').should('have.value', 'day'); + + // selects another comparison type + cy.get('[data-test-subj="comparisonSelect"]').select('week'); + cy.get('[data-test-subj="comparisonSelect"]').should( + 'have.value', + 'week' + ); + cy.expectAPIsToHaveBeenCalledWith({ + apisIntercepted: aliasNamesWithComparison, + value: 'comparisonStart', }); }); }); diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx index cffc5563d75cd..0b7d3c32957e2 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_errors_table/index.tsx @@ -180,7 +180,11 @@ export function ServiceOverviewErrorsTable({ serviceName }: Props) { }); return ( - + diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx index b698a0672213d..c41ad329ea863 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_instances_table/index.tsx @@ -145,7 +145,11 @@ export function ServiceOverviewInstancesTable({ }; return ( - +

diff --git a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx index 0146b9e8dd44d..4c93d2b513818 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/instances_latency_distribution_chart/index.tsx @@ -114,8 +114,13 @@ export function InstancesLatencyDistributionChart({ })}

- - + + + diff --git a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx index 856fa4139963e..4c1063173d929 100644 --- a/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/transactions_table/index.tsx @@ -229,7 +229,11 @@ export function TransactionsTable({ const isNotInitiated = status === FETCH_STATUS.NOT_INITIATED; return ( - + From f8df852712e9b9bdad6eddd5660ee35723becbf5 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 15 Feb 2022 12:59:18 +0100 Subject: [PATCH 14/43] Update dependency broadcast-channel to ^4.10.0 (#124740) Co-authored-by: Renovate Bot Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 27368b2f4e4f6..d8e2226ba944c 100644 --- a/package.json +++ b/package.json @@ -203,7 +203,7 @@ "base64-js": "^1.3.1", "bitmap-sdf": "^1.0.3", "brace": "0.11.1", - "broadcast-channel": "^4.9.0", + "broadcast-channel": "^4.10.0", "canvg": "^3.0.9", "chalk": "^4.1.0", "cheerio": "^1.0.0-rc.10", diff --git a/yarn.lock b/yarn.lock index cb5038e3f3d72..252b2a8cc6775 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9912,10 +9912,10 @@ broadcast-channel@^3.4.1: rimraf "3.0.2" unload "2.2.0" -broadcast-channel@^4.9.0: - version "4.9.0" - resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-4.9.0.tgz#8af337d4ea19aeb6b819ec2eb3dda942b28c724c" - integrity sha512-xWzFb3wrOZGJF2kOSs2D3KvHXdLDMVb+WypEIoNvwblcHgUBydVy65pDJ9RS4WN9Kyvs0UVQuCCzfKme0G6Qjw== +broadcast-channel@^4.10.0: + version "4.10.0" + resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-4.10.0.tgz#d19fb902df227df40b1b580351713d30c302d198" + integrity sha512-hOUh312XyHk6JTVyX9cyXaH1UYs+2gHVtnW16oQAu9FL7ALcXGXc/YoJWqlkV8vUn14URQPMmRi4A9q4UrwVEQ== dependencies: "@babel/runtime" "^7.16.0" detect-node "^2.1.0" From 8facea9e1dba68141ec9dbb4bf01ae5a941d5a4e Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 15 Feb 2022 13:30:25 +0100 Subject: [PATCH 15/43] [Lens] Do not allow rarity in some cases (#125523) * do not allow rarity in some cases * use new params to build label --- .../droppable/droppable.test.ts | 35 +++++++++++++++++++ .../public/indexpattern_datasource/mocks.ts | 1 + .../operations/definitions/terms/index.tsx | 26 ++++++++++++-- .../definitions/terms/terms.test.tsx | 24 +++++++++++++ .../public/indexpattern_datasource/utils.tsx | 10 ++++-- 5 files changed, 91 insertions(+), 5 deletions(-) diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts index 002fec786d7e6..d676523609bcc 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable/droppable.test.ts @@ -771,6 +771,41 @@ describe('IndexPatternDimensionEditorPanel', () => { }); }); + it('returns no combine_compatible drop type if the target column uses rarity ordering', () => { + state = getStateWithMultiFieldColumn(); + state.layers.first = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2'], + columns: { + col1: state.layers.first.columns.col1, + + col2: { + ...state.layers.first.columns.col1, + sourceField: 'bytes', + params: { + ...(state.layers.first.columns.col1 as TermsIndexPatternColumn).params, + orderBy: { type: 'rare' }, + }, + } as TermsIndexPatternColumn, + }, + }; + + expect( + getDropProps({ + ...defaultProps, + state, + groupId, + dragging: { + ...draggingCol1, + groupId: 'c', + }, + columnId: 'col2', + }) + ).toEqual({ + dropTypes: ['replace_compatible', 'replace_duplicate_compatible'], + }); + }); + it('returns no combine drop type if the dragged column is compatible, the target one supports multiple fields but there are too many fields', () => { state = getStateWithMultiFieldColumn(); state.layers.first = { diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts index b8b5b9a4e6293..079a866676f04 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/mocks.ts @@ -50,6 +50,7 @@ export const createMockedIndexPattern = (): IndexPattern => { type: 'number', aggregatable: true, searchable: true, + esTypes: ['float'], }, { name: 'source', diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx index d574f9f6c5d35..78129cc8c1233 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/terms/index.tsx @@ -40,6 +40,13 @@ import { isSortableByColumn, } from './helpers'; +export function supportsRarityRanking(field?: IndexPatternField) { + // these es field types can't be sorted by rarity + return !field?.esTypes?.some((esType) => + ['double', 'float', 'half_float', 'scaled_float'].includes(esType) + ); +} + export type { TermsIndexPatternColumn } from './types'; const missingFieldLabel = i18n.translate('xpack.lens.indexPattern.missingFieldLabel', { @@ -144,7 +151,10 @@ export const termsOperation: OperationDefinition { - // first step: collect the fields from the targetColumn + if (targetColumn.params.orderBy.type === 'rare') { + return false; + } + // collect the fields from the targetColumn const originalTerms = new Set([ targetColumn.sourceField, ...(targetColumn.params?.secondaryFields ?? []), @@ -306,6 +316,9 @@ export const termsOperation: OperationDefinition { ]); }); + it('should disable rare ordering for floating point types', () => { + const updateLayerSpy = jest.fn(); + const instance = shallow( + + ); + + const select = instance.find('[data-test-subj="indexPattern-terms-orderBy"]').find(EuiSelect); + + expect(select.prop('value')).toEqual('alphabetical'); + + expect(select.prop('options')!.map(({ value }) => value)).toEqual([ + 'column$$$col2', + 'alphabetical', + ]); + }); + it('should update state with the order by value', () => { const updateLayerSpy = jest.fn(); const instance = shallow( diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx index cc8a5c322782d..d3536c7d0ae29 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.tsx @@ -30,7 +30,7 @@ import { isQueryValid } from './operations/definitions/filters'; import { checkColumnForPrecisionError } from '../../../../../src/plugins/data/common'; import { hasField } from './pure_utils'; import { mergeLayer } from './state_helpers'; -import { DEFAULT_MAX_DOC_COUNT } from './operations/definitions/terms'; +import { DEFAULT_MAX_DOC_COUNT, supportsRarityRanking } from './operations/definitions/terms'; export function isColumnInvalid( layer: IndexPatternLayer, @@ -117,7 +117,13 @@ export function getPrecisionErrorWarningMessages( 'count', currentLayer.columns[currentColumn.params.orderBy.columnId] ); - if (!isAscendingCountSorting) { + const usesFloatingPointField = + isColumnOfType('terms', currentColumn) && + !supportsRarityRanking(indexPattern.getFieldByName(currentColumn.sourceField)); + const usesMultipleFields = + isColumnOfType('terms', currentColumn) && + (currentColumn.params.secondaryFields || []).length > 0; + if (!isAscendingCountSorting || usesFloatingPointField || usesMultipleFields) { warningMessages.push( Date: Tue, 15 Feb 2022 08:15:49 -0500 Subject: [PATCH 16/43] [Fleet] Update copy for integration server (#125592) --- .../components/fleet_server_cloud_instructions.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx index 7585bd31d57d1..00487d41d25ab 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_requirements_page/components/fleet_server_cloud_instructions.tsx @@ -43,14 +43,14 @@ export const CloudInstructions: React.FC<{ deploymentUrl: string }> = ({ deploym

} body={ Date: Tue, 15 Feb 2022 13:22:29 +0000 Subject: [PATCH 17/43] [Fleet] Do not mutate package policy update object (#125622) * do not modify object property * add unit test --- .../server/services/package_policy.test.ts | 52 ++++++++++++++++++- .../fleet/server/services/package_policy.ts | 4 +- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/package_policy.test.ts b/x-pack/plugins/fleet/server/services/package_policy.test.ts index 735f41f499868..92a3e9ac99d2b 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.test.ts @@ -10,7 +10,7 @@ import { savedObjectsClientMock, httpServerMock, } from 'src/core/server/mocks'; - +import { produce } from 'immer'; import type { SavedObjectsClient, SavedObjectsClientContract, @@ -25,6 +25,7 @@ import type { PostPackagePolicyDeleteCallback, RegistryDataStream, PackagePolicyInputStream, + PackagePolicy, } from '../types'; import { createPackagePolicyMock } from '../../common/mocks'; @@ -949,6 +950,55 @@ describe('Package policy service', () => { expect(result.elasticsearch).toMatchObject({ privileges: { cluster: ['monitor'] } }); }); + + it('should not mutate packagePolicyUpdate object when trimming whitespace', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const mockPackagePolicy = createPackagePolicyMock(); + + const attributes = { + ...mockPackagePolicy, + inputs: [], + }; + + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes, + }); + + savedObjectsClient.update.mockImplementation( + async ( + type: string, + id: string, + attrs: any + ): Promise> => { + savedObjectsClient.get.mockResolvedValue({ + id: 'test', + type: 'abcd', + references: [], + version: 'test', + attributes: attrs, + }); + return attrs; + } + ); + const elasticsearchClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + + const result = await packagePolicyService.update( + savedObjectsClient, + elasticsearchClient, + 'the-package-policy-id', + // this mimics the way that OSQuery plugin create immutable objects + produce( + { ...mockPackagePolicy, name: ' test ', inputs: [] }, + (draft) => draft + ) + ); + + expect(result.name).toEqual('test'); + }); }); describe('runDeleteExternalCallbacks', () => { diff --git a/x-pack/plugins/fleet/server/services/package_policy.ts b/x-pack/plugins/fleet/server/services/package_policy.ts index cb93933bb0d05..641136b89fb30 100644 --- a/x-pack/plugins/fleet/server/services/package_policy.ts +++ b/x-pack/plugins/fleet/server/services/package_policy.ts @@ -363,11 +363,11 @@ class PackagePolicyService { soClient: SavedObjectsClientContract, esClient: ElasticsearchClient, id: string, - packagePolicy: UpdatePackagePolicy, + packagePolicyUpdate: UpdatePackagePolicy, options?: { user?: AuthenticatedUser }, currentVersion?: string ): Promise { - packagePolicy.name = packagePolicy.name.trim(); + const packagePolicy = { ...packagePolicyUpdate, name: packagePolicyUpdate.name.trim() }; const oldPackagePolicy = await this.get(soClient, id); const { version, ...restOfPackagePolicy } = packagePolicy; From 724e3b2ebf813aa4da2d9d7496f9a5dfe0361e8b Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Tue, 15 Feb 2022 16:21:37 +0200 Subject: [PATCH 18/43] Reverse parent child context relationship (#125486) * reverse parent child context relationship * bad merge * ts * ts * fix jest * try unblocking flaky test * doc --- ...ugin-core-public.kibanaexecutioncontext.md | 4 +- ...ugin-core-server.kibanaexecutioncontext.md | 4 +- .../user/troubleshooting/trace-query.asciidoc | 20 +- .../execution_context_container.test.ts | 60 +++--- src/core/public/public.api.md | 4 +- .../execution_context_container.test.ts | 77 ++++--- .../execution_context_container.ts | 6 +- .../execution_context_service.test.ts | 44 ++-- .../integration_tests/tracing.test.ts | 4 +- src/core/server/server.api.md | 4 +- src/core/types/execution_context.ts | 6 +- .../embeddable/saved_search_embeddable.tsx | 12 +- .../embeddable/visualize_embeddable.tsx | 12 +- .../lens/public/embeddable/embeddable.tsx | 16 +- .../tests/browser.ts | 201 +++++++++--------- .../tests/server.ts | 20 +- 16 files changed, 266 insertions(+), 228 deletions(-) diff --git a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md index 8b758715a1975..6266639b63976 100644 --- a/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.kibanaexecutioncontext.md @@ -13,8 +13,8 @@ export declare type KibanaExecutionContext = { readonly type: string; readonly name: string; readonly id: string; - readonly description: string; + readonly description?: string; readonly url?: string; - parent?: KibanaExecutionContext; + child?: KibanaExecutionContext; }; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md index db06f9b13f9f6..0d65a3662da6f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.kibanaexecutioncontext.md @@ -13,8 +13,8 @@ export declare type KibanaExecutionContext = { readonly type: string; readonly name: string; readonly id: string; - readonly description: string; + readonly description?: string; readonly url?: string; - parent?: KibanaExecutionContext; + child?: KibanaExecutionContext; }; ``` diff --git a/docs/user/troubleshooting/trace-query.asciidoc b/docs/user/troubleshooting/trace-query.asciidoc index f037b26ade630..24f8cc487bf75 100644 --- a/docs/user/troubleshooting/trace-query.asciidoc +++ b/docs/user/troubleshooting/trace-query.asciidoc @@ -29,16 +29,16 @@ Now, you can see the request to {es} has been initiated by the `[Logs] Unique Vi [source,text] ---- [DEBUG][execution_context] stored the execution context: { - "parent": { - "type": "application", - "name": "dashboard", - "id": "edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b", - "description": "[Logs] Web Traffic","url":"/view/edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b" + "type": "application", + "name": "dashboard", + "id": "edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b", + "description": "[Logs] Web Traffic","url":"/view/edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b" + "child": { + "type": "visualization", + "name": "Vega", + "id": "cb099a20-ea66-11eb-9425-113343a037e3", + "description": "[Logs] Unique Visitor Heatmap", + "url": "/app/visualize#/edit/cb099a20-ea66-11eb-9425-113343a037e3" }, - "type": "visualization", - "name": "Vega", - "id": "cb099a20-ea66-11eb-9425-113343a037e3", - "description": "[Logs] Unique Visitor Heatmap", - "url": "/app/visualize#/edit/cb099a20-ea66-11eb-9425-113343a037e3" } ---- diff --git a/src/core/public/execution_context/execution_context_container.test.ts b/src/core/public/execution_context/execution_context_container.test.ts index 5e4e34d102e5b..189dc35e9d730 100644 --- a/src/core/public/execution_context/execution_context_container.test.ts +++ b/src/core/public/execution_context/execution_context_container.test.ts @@ -29,26 +29,26 @@ describe('KibanaExecutionContext', () => { `); }); - it('includes a parent context to string representation', () => { - const parentContext: KibanaExecutionContext = { - type: 'parent-type', - name: 'parent-name', - id: '41', - description: 'parent-descripton', + it('includes a child context to string representation', () => { + const childContext: KibanaExecutionContext = { + type: 'child-test-type', + name: 'child-test-name', + id: '42', + description: 'child-test-descripton', }; const context: KibanaExecutionContext = { - type: 'test-type', - name: 'test-name', - id: '42', - description: 'test-descripton', - parent: parentContext, + type: 'type', + name: 'name', + id: '41', + description: 'descripton', + child: childContext, }; const value = new ExecutionContextContainer(context).toHeader(); expect(value).toMatchInlineSnapshot(` Object { - "x-kbn-context": "%7B%22type%22%3A%22test-type%22%2C%22name%22%3A%22test-name%22%2C%22id%22%3A%2242%22%2C%22description%22%3A%22test-descripton%22%2C%22parent%22%3A%7B%22type%22%3A%22parent-type%22%2C%22name%22%3A%22parent-name%22%2C%22id%22%3A%2241%22%2C%22description%22%3A%22parent-descripton%22%7D%7D", + "x-kbn-context": "%7B%22type%22%3A%22type%22%2C%22name%22%3A%22name%22%2C%22id%22%3A%2241%22%2C%22description%22%3A%22descripton%22%2C%22child%22%3A%7B%22type%22%3A%22child-test-type%22%2C%22name%22%3A%22child-test-name%22%2C%22id%22%3A%2242%22%2C%22description%22%3A%22child-test-descripton%22%7D%7D", } `); }); @@ -103,35 +103,35 @@ describe('KibanaExecutionContext', () => { }); it('returns JSON representation when the parent context if provided', () => { - const parentAContext: KibanaExecutionContext = { - type: 'parent-a-type', - name: 'parent-a-name', - id: '40', - description: 'parent-a-descripton', + const childBContext: KibanaExecutionContext = { + type: 'child-b-type', + name: 'child-b-name', + id: '42', + description: 'child-b-descripton', }; - const parentBContext: KibanaExecutionContext = { - type: 'parent-b-type', - name: 'parent-b-name', + const childAContext: KibanaExecutionContext = { + type: 'child-a-type', + name: 'child-a-name', id: '41', - description: 'parent-b-descripton', - parent: parentAContext, + description: 'child-a-descripton', + child: childBContext, }; const context: KibanaExecutionContext = { - type: 'test-type', - name: 'test-name', - id: '42', - description: 'test-descripton', - parent: parentBContext, + type: 'type', + name: 'name', + id: '40', + description: 'descripton', + child: childAContext, }; const value = new ExecutionContextContainer(context).toJSON(); expect(value).toEqual({ ...context, - parent: { - ...parentBContext, - parent: parentAContext, + child: { + ...childAContext, + child: childBContext, }, }); }); diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index c610c98c53646..4cf845de4617d 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -754,9 +754,9 @@ export type KibanaExecutionContext = { readonly type: string; readonly name: string; readonly id: string; - readonly description: string; + readonly description?: string; readonly url?: string; - parent?: KibanaExecutionContext; + child?: KibanaExecutionContext; }; // @public diff --git a/src/core/server/execution_context/execution_context_container.test.ts b/src/core/server/execution_context/execution_context_container.test.ts index c332913b2f401..8e9f46ee78a68 100644 --- a/src/core/server/execution_context/execution_context_container.test.ts +++ b/src/core/server/execution_context/execution_context_container.test.ts @@ -16,11 +16,24 @@ import { describe('KibanaExecutionContext', () => { describe('constructor', () => { - it('allows context to define parent explicitly', () => { + it('allows context be defined without a parent', () => { const parentContext: KibanaExecutionContext = { - type: 'parent-type', - name: 'parent-name', - id: '44', + type: 'test-type', + name: 'test-name', + id: '42', + description: 'parent-descripton', + }; + const container = new ExecutionContextContainer(parentContext); + + const value = container.toJSON(); + expect(value.child).toBeUndefined(); + }); + + it('allows context to be called with parent explicitly', () => { + const parentContext: KibanaExecutionContext = { + type: 'test-type', + name: 'test-name', + id: '42', description: 'parent-descripton', }; const parentContainer = new ExecutionContextContainer(parentContext); @@ -30,16 +43,17 @@ describe('KibanaExecutionContext', () => { name: 'test-name', id: '42', description: 'test-descripton', - parent: { - type: 'custom-parent-type', - name: 'custom-parent-name', + child: { + type: 'custom-child-type', + name: 'custom-child-name', id: '41', - description: 'custom-parent-descripton', + description: 'custom-child-descripton', }, }; const value = new ExecutionContextContainer(context, parentContainer).toJSON(); - expect(value).toEqual(context); + expect(value.id).toEqual(parentContext.id); + expect(value.child).toEqual(context); }); }); @@ -56,24 +70,25 @@ describe('KibanaExecutionContext', () => { expect(value).toBe('test-type:test-name:42'); }); - it('includes a parent context to string representation', () => { - const parentContext: KibanaExecutionContext = { - type: 'parent-type', - name: 'parent-name', + it('includes a child context to string representation', () => { + const context: KibanaExecutionContext = { + type: 'type', + name: 'name', id: '41', - description: 'parent-descripton', + description: 'descripton', }; - const parentContainer = new ExecutionContextContainer(parentContext); - const context: KibanaExecutionContext = { - type: 'test-type', - name: 'test-name', + const childContext: KibanaExecutionContext = { + type: 'child-test-type', + name: 'child-test-name', id: '42', description: 'test-descripton', }; - const value = new ExecutionContextContainer(context, parentContainer).toString(); - expect(value).toBe('parent-type:parent-name:41;test-type:test-name:42'); + const contextContainer = new ExecutionContextContainer(context); + + const value = new ExecutionContextContainer(childContext, contextContainer).toString(); + expect(value).toBe('type:name:41;child-test-type:child-test-name:42'); }); it('returns an escaped string representation of provided execution contextStringified', () => { @@ -115,24 +130,24 @@ describe('KibanaExecutionContext', () => { expect(value).toEqual(context); }); - it('returns a context object with registered parent object', () => { - const parentContext: KibanaExecutionContext = { - type: 'parent-type', - name: 'parent-name', + it('returns a context object with registered context object', () => { + const context: KibanaExecutionContext = { + type: 'type', + name: 'name', id: '41', - description: 'parent-descripton', + description: 'descripton', }; - const parentContainer = new ExecutionContextContainer(parentContext); - const context: KibanaExecutionContext = { - type: 'test-type', - name: 'test-name', + const childContext: KibanaExecutionContext = { + type: 'child-test-type', + name: 'child-test-name', id: '42', description: 'test-descripton', }; + const contextContainer = new ExecutionContextContainer(context); - const value = new ExecutionContextContainer(context, parentContainer).toJSON(); - expect(value).toEqual({ ...context, parent: parentContext }); + const value = new ExecutionContextContainer(childContext, contextContainer).toJSON(); + expect(value).toEqual({ child: childContext, ...context }); }); }); }); diff --git a/src/core/server/execution_context/execution_context_container.ts b/src/core/server/execution_context/execution_context_container.ts index a81c409ab3e9e..066248a26ad7b 100644 --- a/src/core/server/execution_context/execution_context_container.ts +++ b/src/core/server/execution_context/execution_context_container.ts @@ -50,14 +50,14 @@ export interface IExecutionContextContainer { } function stringify(ctx: KibanaExecutionContext): string { - const stringifiedCtx = `${ctx.type}:${ctx.name}:${encodeURIComponent(ctx.id)}`; - return ctx.parent ? `${stringify(ctx.parent)};${stringifiedCtx}` : stringifiedCtx; + const stringifiedCtx = `${ctx.type}:${ctx.name}:${encodeURIComponent(ctx.id!)}`; + return ctx.child ? `${stringifiedCtx};${stringify(ctx.child)}` : stringifiedCtx; } export class ExecutionContextContainer implements IExecutionContextContainer { readonly #context: Readonly; constructor(context: KibanaExecutionContext, parent?: IExecutionContextContainer) { - this.#context = { parent: parent?.toJSON(), ...context }; + this.#context = parent ? { ...parent.toJSON(), child: context } : context; } toString(): string { return enforceMaxLength(stringify(this.#context)); diff --git a/src/core/server/execution_context/execution_context_service.test.ts b/src/core/server/execution_context/execution_context_service.test.ts index 9bb76ad78c49a..c39769133cede 100644 --- a/src/core/server/execution_context/execution_context_service.test.ts +++ b/src/core/server/execution_context/execution_context_service.test.ts @@ -59,7 +59,7 @@ describe('ExecutionContextService', () => { name: 'name-a', id: 'id-a', description: 'description-a', - parent: undefined, + child: undefined, }, { @@ -67,7 +67,7 @@ describe('ExecutionContextService', () => { name: 'name-b', id: 'id-b', description: 'description-b', - parent: undefined, + child: undefined, }, ]); }); @@ -271,17 +271,17 @@ describe('ExecutionContextService', () => { ); expect(result?.toJSON()).toEqual({ - type: 'type-b', - name: 'name-b', - id: 'id-b', - description: 'description-b', - parent: { - type: 'type-a', - name: 'name-a', - id: 'id-a', - description: 'description-a', - parent: undefined, + child: { + child: undefined, + type: 'type-b', + name: 'name-b', + id: 'id-b', + description: 'description-b', }, + type: 'type-a', + name: 'name-a', + id: 'id-a', + description: 'description-a', }); }); @@ -306,16 +306,16 @@ describe('ExecutionContextService', () => { ); expect(result?.toJSON()).toEqual({ - type: 'type-b', - name: 'name-b', - id: 'id-b', - description: 'description-b', - parent: { - type: 'type-a', - name: 'name-a', - id: 'id-a', - description: 'description-a', - parent: undefined, + type: 'type-a', + name: 'name-a', + id: 'id-a', + description: 'description-a', + child: { + child: undefined, + type: 'type-b', + name: 'name-b', + id: 'id-b', + description: 'description-b', }, }); }); diff --git a/src/core/server/execution_context/integration_tests/tracing.test.ts b/src/core/server/execution_context/integration_tests/tracing.test.ts index 4aef2e815fa30..c4fc88dd04dc9 100644 --- a/src/core/server/execution_context/integration_tests/tracing.test.ts +++ b/src/core/server/execution_context/integration_tests/tracing.test.ts @@ -589,7 +589,7 @@ describe('trace', () => { expect(response.body).toEqual(parentContext); }); - it('set execution context inerits a parent if presented', async () => { + it('set execution context becomes child if parent context is presented', async () => { const { executionContext, http } = await root.setup(); const { createRouter } = http; @@ -612,7 +612,7 @@ describe('trace', () => { await root.start(); const response = await kbnTestServer.request.get(root, '/execution-context').expect(200); - expect(response.body).toEqual({ ...nestedContext, parent: parentContext }); + expect(response.body).toEqual({ child: nestedContext, ...parentContext }); }); it('extends the execution context passed from the client-side', async () => { diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 111f2ed0001fc..d7ed4928e1cf5 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1322,9 +1322,9 @@ export type KibanaExecutionContext = { readonly type: string; readonly name: string; readonly id: string; - readonly description: string; + readonly description?: string; readonly url?: string; - parent?: KibanaExecutionContext; + child?: KibanaExecutionContext; }; // @public diff --git a/src/core/types/execution_context.ts b/src/core/types/execution_context.ts index 8a2d657812da8..1b985a73f410b 100644 --- a/src/core/types/execution_context.ts +++ b/src/core/types/execution_context.ts @@ -22,9 +22,9 @@ export type KibanaExecutionContext = { /** unique value to identify the source */ readonly id: string; /** human readable description. For example, a vis title, action name */ - readonly description: string; + readonly description?: string; /** in browser - url to navigate to a current page, on server - endpoint path, for task: task SO url */ readonly url?: string; - /** a context that spawned the current context. */ - parent?: KibanaExecutionContext; + /** an inner context spawned from the current context. */ + child?: KibanaExecutionContext; }; diff --git a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx index b950e42fb5f22..921ed32c0f159 100644 --- a/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/saved_search_embeddable.tsx @@ -12,6 +12,7 @@ import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { isEqual } from 'lodash'; import { I18nProvider } from '@kbn/i18n-react'; +import type { KibanaExecutionContext } from 'kibana/public'; import { Container, Embeddable } from '../../../embeddable/public'; import { ISearchEmbeddable, SearchInput, SearchOutput } from './types'; import { SavedSearch } from '../services/saved_searches'; @@ -168,14 +169,21 @@ export class SavedSearchEmbeddable this.searchProps!.isLoading = true; this.updateOutput({ loading: true, error: undefined }); - const executionContext = { + + const parentContext = this.input.executionContext; + const child: KibanaExecutionContext = { type: this.type, name: 'discover', id: this.savedSearch.id!, description: this.output.title || this.output.defaultTitle || '', url: this.output.editUrl, - parent: this.input.executionContext, }; + const executionContext = parentContext + ? { + ...parentContext, + child, + } + : child; try { // Make the request diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx index a12195e34a81e..24b451533532f 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx @@ -13,6 +13,7 @@ import React from 'react'; import { render } from 'react-dom'; import { EuiLoadingChart } from '@elastic/eui'; import { Filter, onlyDisabledFiltersChanged } from '@kbn/es-query'; +import type { SavedObjectAttributes, KibanaExecutionContext } from 'kibana/public'; import { KibanaThemeProvider } from '../../../kibana_react/public'; import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; import { @@ -41,7 +42,6 @@ import { Vis, SerializedVis } from '../vis'; import { getExpressions, getTheme, getUiActions } from '../services'; import { VIS_EVENT_TO_TRIGGER } from './events'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; -import { SavedObjectAttributes } from '../../../../core/types'; import { getSavedVisualization } from '../utils/saved_visualize_utils'; import { VisSavedObject } from '../types'; import { toExpressionAst } from './to_ast'; @@ -398,14 +398,20 @@ export class VisualizeEmbeddable }; private async updateHandler() { - const context = { + const parentContext = this.parent?.getInput().executionContext; + const child: KibanaExecutionContext = { type: 'visualization', name: this.vis.type.title, id: this.vis.id ?? 'an_unsaved_vis', description: this.vis.title || this.input.title || this.vis.type.name, url: this.output.editUrl, - parent: this.parent?.getInput().executionContext, }; + const context = parentContext + ? { + ...parentContext, + child, + } + : child; const expressionParams: IExpressionLoaderParams = { searchContext: { diff --git a/x-pack/plugins/lens/public/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/embeddable/embeddable.tsx index ca37580ad682f..712e9f9f7f476 100644 --- a/x-pack/plugins/lens/public/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/embeddable/embeddable.tsx @@ -60,7 +60,11 @@ import { import { IndexPatternsContract } from '../../../../../src/plugins/data/public'; import { getEditPath, DOC_TYPE, PLUGIN_ID } from '../../common'; -import { IBasePath, ThemeServiceStart } from '../../../../../src/core/public'; +import type { + IBasePath, + KibanaExecutionContext, + ThemeServiceStart, +} from '../../../../../src/core/public'; import { LensAttributeService } from '../lens_attribute_service'; import type { ErrorMessage } from '../editor_frame_service/types'; import { getLensInspectorService, LensInspector } from '../lens_inspector_service'; @@ -413,14 +417,20 @@ export class Embeddable this.renderComplete.dispatchInProgress(); - const executionContext = { + const parentContext = this.input.executionContext; + const child: KibanaExecutionContext = { type: 'lens', name: this.savedVis.visualizationType ?? '', id: this.id, description: this.savedVis.title || this.input.title || '', url: this.output.editUrl, - parent: this.input.executionContext, }; + const executionContext = parentContext + ? { + ...parentContext, + child, + } + : child; const input = this.getInput(); diff --git a/x-pack/test/functional_execution_context/tests/browser.ts b/x-pack/test/functional_execution_context/tests/browser.ts index f6e46a6bc2280..ca777e1d4acf3 100644 --- a/x-pack/test/functional_execution_context/tests/browser.ts +++ b/x-pack/test/functional_execution_context/tests/browser.ts @@ -12,8 +12,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'home']); const retry = getService('retry'); - // Failing: See https://github.com/elastic/kibana/issues/112102 - describe.skip('Browser apps', () => { + describe('Browser apps', () => { before(async () => { await PageObjects.common.navigateToUrl('home', '/tutorial_directory/sampleData', { useActualUrl: true, @@ -98,18 +97,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'lens', + name: 'lnsXY', + id: '086ac2e9-dd16-4b45-92b8-1e43ff7e3f65', + description: '[Flights] Flight count', + url: '/app/lens#/edit_by_value', }, - type: 'lens', - name: 'lnsXY', - id: '086ac2e9-dd16-4b45-92b8-1e43ff7e3f65', - description: '[Flights] Flight count', - url: '/app/lens#/edit_by_value', }), retry, }); @@ -131,18 +130,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'lens', + name: 'lnsMetric', + id: '2e33ade5-96e5-40b4-b460-493e5d4fa834', + description: '', + url: '/app/lens#/edit_by_value', }, - type: 'lens', - name: 'lnsMetric', - id: '2e33ade5-96e5-40b4-b460-493e5d4fa834', - description: '', - url: '/app/lens#/edit_by_value', }), retry, }); @@ -164,18 +163,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'lens', + name: 'lnsDatatable', + id: 'fb86b32f-fb7a-45cf-9511-f366fef51bbd', + description: 'Cities by delay, cancellation', + url: '/app/lens#/edit_by_value', }, - type: 'lens', - name: 'lnsDatatable', - id: 'fb86b32f-fb7a-45cf-9511-f366fef51bbd', - description: 'Cities by delay, cancellation', - url: '/app/lens#/edit_by_value', }), retry, }); @@ -196,18 +195,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'lens', + name: 'lnsPie', + id: '5d53db36-2d5a-4adc-af7b-cec4c1a294e0', + description: '[Flights] Delay Type', + url: '/app/lens#/edit_by_value', }, - type: 'lens', - name: 'lnsPie', - id: '5d53db36-2d5a-4adc-af7b-cec4c1a294e0', - description: '[Flights] Delay Type', - url: '/app/lens#/edit_by_value', }), retry, }); @@ -229,18 +228,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'search', + name: 'discover', + id: '571aaf70-4c88-11e8-b3d7-01146121b73d', + description: '[Flights] Flight Log', + url: '/app/discover#/view/571aaf70-4c88-11e8-b3d7-01146121b73d', }, - type: 'search', - name: 'discover', - id: '571aaf70-4c88-11e8-b3d7-01146121b73d', - description: '[Flights] Flight Log', - url: '/app/discover#/view/571aaf70-4c88-11e8-b3d7-01146121b73d', }), retry, }); @@ -262,18 +261,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'visualization', + name: 'TSVB', + id: 'bcb63b50-4c89-11e8-b3d7-01146121b73d', + description: '[Flights] Delays & Cancellations', + url: '/app/visualize#/edit/bcb63b50-4c89-11e8-b3d7-01146121b73d', }, - type: 'visualization', - name: 'TSVB', - id: 'bcb63b50-4c89-11e8-b3d7-01146121b73d', - description: '[Flights] Delays & Cancellations', - url: '/app/visualize#/edit/bcb63b50-4c89-11e8-b3d7-01146121b73d', }), retry, }); @@ -295,18 +294,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'visualization', + name: 'Vega', + id: 'ed78a660-53a0-11e8-acbd-0be0ad9d822b', + description: '[Flights] Airport Connections (Hover Over Airport)', + url: '/app/visualize#/edit/ed78a660-53a0-11e8-acbd-0be0ad9d822b', }, - type: 'visualization', - name: 'Vega', - id: 'ed78a660-53a0-11e8-acbd-0be0ad9d822b', - description: '[Flights] Airport Connections (Hover Over Airport)', - url: '/app/visualize#/edit/ed78a660-53a0-11e8-acbd-0be0ad9d822b', }), retry, }); @@ -328,18 +327,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'visualization', + name: 'Tag cloud', + id: '293b5a30-4c8f-11e8-b3d7-01146121b73d', + description: '[Flights] Destination Weather', + url: '/app/visualize#/edit/293b5a30-4c8f-11e8-b3d7-01146121b73d', }, - type: 'visualization', - name: 'Tag cloud', - id: '293b5a30-4c8f-11e8-b3d7-01146121b73d', - description: '[Flights] Destination Weather', - url: '/app/visualize#/edit/293b5a30-4c8f-11e8-b3d7-01146121b73d', }), retry, }); @@ -361,18 +360,18 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'application', - name: 'dashboard', - id: '7adfa750-4c81-11e8-b3d7-01146121b73d', - description: '[Flights] Global Flight Dashboard', - url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'application', + name: 'dashboard', + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + description: '[Flights] Global Flight Dashboard', + url: '/view/7adfa750-4c81-11e8-b3d7-01146121b73d', + child: { + type: 'visualization', + name: 'Vertical bar', + id: '9886b410-4c8b-11e8-b3d7-01146121b73d', + description: '[Flights] Delay Buckets', + url: '/app/visualize#/edit/9886b410-4c8b-11e8-b3d7-01146121b73d', }, - type: 'visualization', - name: 'Vertical bar', - id: '9886b410-4c8b-11e8-b3d7-01146121b73d', - description: '[Flights] Delay Buckets', - url: '/app/visualize#/edit/9886b410-4c8b-11e8-b3d7-01146121b73d', }), retry, }); diff --git a/x-pack/test/functional_execution_context/tests/server.ts b/x-pack/test/functional_execution_context/tests/server.ts index 8997c83f4f696..fd10118a03627 100644 --- a/x-pack/test/functional_execution_context/tests/server.ts +++ b/x-pack/test/functional_execution_context/tests/server.ts @@ -93,17 +93,17 @@ export default function ({ getService }: FtrProviderContext) { description: 'execution context propagates to Kibana logs', predicate: (record) => isExecutionContextLog(record?.message, { - parent: { - type: 'task manager', - name: 'run alerting:test.executionContext', - // @ts-expect-error. it accepts strings only - id: ANY, - description: 'run task', + type: 'task manager', + name: 'run alerting:test.executionContext', + // @ts-expect-error. it accepts strings only + id: ANY, + description: 'run task', + child: { + type: 'alert', + name: 'execute test.executionContext', + id: alertId, + description: 'execute [test.executionContext] with name [abc] in [default] namespace', }, - type: 'alert', - name: 'execute test.executionContext', - id: alertId, - description: 'execute [test.executionContext] with name [abc] in [default] namespace', }), retry, }); From 87802301ca104592e6db3698922ccc4ee1ff3103 Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Tue, 15 Feb 2022 15:25:36 +0100 Subject: [PATCH 19/43] [Fleet] extracted hook, clean up unused prop (#125610) * extracted hook, clean up unused prop * fixed tests * added tests for hook --- .../components/search_and_filter_bar.tsx | 5 +- .../sections/agents/agent_list_page/index.tsx | 1 - .../fleet/sections/agents/index.tsx | 24 +------- .../agent_enrollment_flyout.test.mocks.ts | 7 +++ .../agent_enrollment_flyout.test.tsx | 13 ++--- .../agent_policy_selection.tsx | 4 +- .../agent_enrollment_flyout/index.tsx | 22 +------ .../managed_instructions.tsx | 3 +- .../agent_enrollment_flyout/steps.tsx | 4 +- .../agent_enrollment_flyout/types.ts | 8 +-- x-pack/plugins/fleet/public/hooks/index.ts | 27 +++++---- .../use_agent_enrollment_flyout.data.test.ts | 57 +++++++++++++++++++ .../hooks/use_agent_enrollment_flyout_data.ts | 38 +++++++++++++ 13 files changed, 131 insertions(+), 82 deletions(-) create mode 100644 x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout.data.test.ts create mode 100644 x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout_data.ts diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx index 46aafb8a31877..a23bfc8bfbd1a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/components/search_and_filter_bar.tsx @@ -103,10 +103,7 @@ export const SearchAndFilterBar: React.FunctionComponent<{ <> {isEnrollmentFlyoutOpen ? ( - setIsEnrollmentFlyoutOpen(false)} - /> + setIsEnrollmentFlyoutOpen(false)} /> ) : null} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx index ed4f435f284b3..8e8091d13a794 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/agent_list_page/index.tsx @@ -525,7 +525,6 @@ export const AgentListPage: React.FunctionComponent<{}> = () => { {enrollmentFlyout.isOpen ? ( p.id === enrollmentFlyout.selectedPolicyId)} onClose={() => setEnrollmentFlyoutState({ isOpen: false })} /> diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx index f900191930ef3..faaadc5f8eb10 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agents/index.tsx @@ -5,21 +5,14 @@ * 2.0. */ -import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { Router, Route, Switch, useHistory } from 'react-router-dom'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiPortal } from '@elastic/eui'; import { FLEET_ROUTING_PATHS } from '../../constants'; import { Loading, Error, AgentEnrollmentFlyout } from '../../components'; -import { - useConfig, - useFleetStatus, - useBreadcrumbs, - useAuthz, - useGetSettings, - useGetAgentPolicies, -} from '../../hooks'; +import { useConfig, useFleetStatus, useBreadcrumbs, useAuthz, useGetSettings } from '../../hooks'; import { DefaultLayout, WithoutHeaderLayout } from '../../layouts'; import { AgentListPage } from './agent_list_page'; @@ -33,18 +26,6 @@ export const AgentsApp: React.FunctionComponent = () => { const history = useHistory(); const { agents } = useConfig(); const hasFleetAllPrivileges = useAuthz().fleet.all; - - const agentPoliciesRequest = useGetAgentPolicies({ - page: 1, - perPage: 1000, - full: true, - }); - - const agentPolicies = useMemo( - () => agentPoliciesRequest.data?.items || [], - [agentPoliciesRequest.data] - ); - const fleetStatus = useFleetStatus(); const settings = useGetSettings(); @@ -104,7 +85,6 @@ export const AgentsApp: React.FunctionComponent = () => { setIsEnrollmentFlyoutOpen(false)} /> diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts index acb9b198fdcba..15f6437485925 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.mocks.ts @@ -5,6 +5,13 @@ * 2.0. */ +jest.mock('../../hooks', () => { + return { + ...jest.requireActual('../../hooks'), + useAgentEnrollmentFlyoutData: jest.fn(), + }; +}); + jest.mock('../../hooks/use_request', () => { const module = jest.requireActual('../../hooks/use_request'); return { diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx index b46996ef164bd..b0c9fac454c28 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_enrollment_flyout.test.tsx @@ -21,9 +21,8 @@ import { sendGetFleetStatus, sendGetOneAgentPolicy, useGetAgents, - useGetAgentPolicies, } from '../../hooks/use_request'; -import { FleetStatusProvider, ConfigContext } from '../../hooks'; +import { FleetStatusProvider, ConfigContext, useAgentEnrollmentFlyoutData } from '../../hooks'; import { useFleetServerInstructions } from '../../applications/fleet/sections/agents/agent_requirements_page/components'; @@ -102,13 +101,13 @@ describe('', () => { data: { items: [{ policy_id: 'fleet-server-policy' }] }, }); - (useGetAgentPolicies as jest.Mock).mockReturnValue?.({ - data: { items: [{ id: 'fleet-server-policy' }] }, + (useAgentEnrollmentFlyoutData as jest.Mock).mockReturnValue?.({ + agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], + refreshAgentPolicies: jest.fn(), }); await act(async () => { testBed = await setup({ - agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], onClose: jest.fn(), }); testBed.component.update(); @@ -132,7 +131,6 @@ describe('', () => { jest.clearAllMocks(); await act(async () => { testBed = await setup({ - agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], agentPolicy: testAgentPolicy, onClose: jest.fn(), }); @@ -173,7 +171,6 @@ describe('', () => { jest.clearAllMocks(); await act(async () => { testBed = await setup({ - agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], onClose: jest.fn(), viewDataStep: { title: 'View Data', children:
}, }); @@ -193,7 +190,6 @@ describe('', () => { jest.clearAllMocks(); await act(async () => { testBed = await setup({ - agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], onClose: jest.fn(), viewDataStep: undefined, }); @@ -224,7 +220,6 @@ describe('', () => { jest.clearAllMocks(); await act(async () => { testBed = await setup({ - agentPolicies: [{ id: 'fleet-server-policy' } as AgentPolicy], agentPolicy: testAgentPolicy, onClose: jest.fn(), }); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx index f8ae02fb5a664..8260692616106 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/agent_policy_selection.tsx @@ -51,10 +51,10 @@ type Props = { ); const resolveAgentId = ( - agentPolicies?: AgentPolicy[], + agentPolicies: AgentPolicy[], selectedAgentPolicyId?: string ): undefined | string => { - if (agentPolicies && agentPolicies.length && !selectedAgentPolicyId) { + if (agentPolicies.length && !selectedAgentPolicyId) { if (agentPolicies.length === 1) { return agentPolicies[0].id; } diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx index 9018f508e93ea..960230820e074 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import { EuiFlyout, EuiFlyoutBody, @@ -26,7 +26,7 @@ import { useGetSettings, sendGetOneAgentPolicy, useFleetStatus, - useGetAgentPolicies, + useAgentEnrollmentFlyoutData, } from '../../hooks'; import { FLEET_SERVER_PACKAGE } from '../../constants'; import type { PackagePolicy } from '../../types'; @@ -64,23 +64,7 @@ export const AgentEnrollmentFlyout: React.FunctionComponent = ({ const [policyId, setSelectedPolicyId] = useState(agentPolicy?.id); const [isFleetServerPolicySelected, setIsFleetServerPolicySelected] = useState(false); - // loading the latest agentPolicies for add agent flyout - const { - data: agentPoliciesData, - isLoading: isLoadingAgentPolicies, - resendRequest: refreshAgentPolicies, - } = useGetAgentPolicies({ - page: 1, - perPage: 1000, - full: true, - }); - - const agentPolicies = useMemo(() => { - if (!isLoadingAgentPolicies) { - return agentPoliciesData?.items; - } - return []; - }, [isLoadingAgentPolicies, agentPoliciesData?.items]); + const { agentPolicies, refreshAgentPolicies } = useAgentEnrollmentFlyoutData(); useEffect(() => { async function checkPolicyIsFleetServer() { diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx index 6fac9b889a679..8dd0fccc9adb9 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/managed_instructions.tsx @@ -81,8 +81,7 @@ export const ManagedInstructions = React.memo( }); const fleetServers = useMemo(() => { - const policies = agentPolicies; - const fleetServerAgentPolicies: string[] = (policies ?? []) + const fleetServerAgentPolicies: string[] = agentPolicies .filter((pol) => policyHasFleetServer(pol)) .map((pol) => pol.id); return (agents?.items ?? []).filter((agent) => diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx index 5e5f26b7317e4..92c71df1b8f0f 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/steps.tsx @@ -83,7 +83,7 @@ export const AgentPolicySelectionStep = ({ excludeFleetServer, refreshAgentPolicies, }: { - agentPolicies?: AgentPolicy[]; + agentPolicies: AgentPolicy[]; setSelectedPolicyId?: (policyId?: string) => void; selectedApiKeyId?: string; setSelectedAPIKeyId?: (key?: string) => void; @@ -93,7 +93,7 @@ export const AgentPolicySelectionStep = ({ // storing the created agent policy id as the child component is being recreated const [policyId, setPolicyId] = useState(undefined); const regularAgentPolicies = useMemo(() => { - return (agentPolicies ?? []).filter( + return agentPolicies.filter( (policy) => policy && !policy.is_managed && (!excludeFleetServer || !policyHasFleetServer(policy)) ); diff --git a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts index e5a3d345dba32..d66c1006c4654 100644 --- a/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts +++ b/x-pack/plugins/fleet/public/components/agent_enrollment_flyout/types.ts @@ -15,13 +15,6 @@ export interface BaseProps { */ agentPolicy?: AgentPolicy; - /** - * A selection of policies for the user to choose from, will be ignored if `agentPolicy` has been provided. - * - * If this value is `undefined` a value must be provided for `agentPolicy`. - */ - agentPolicies?: AgentPolicy[]; - /** * There is a step in the agent enrollment process that allows users to see the data from an integration represented in the UI * in some way. This is an area for consumers to render a button and text explaining how data can be viewed. @@ -36,5 +29,6 @@ export interface BaseProps { } export interface InstructionProps extends BaseProps { + agentPolicies: AgentPolicy[]; refreshAgentPolicies: () => void; } diff --git a/x-pack/plugins/fleet/public/hooks/index.ts b/x-pack/plugins/fleet/public/hooks/index.ts index 08befa46adae9..5c995131396b4 100644 --- a/x-pack/plugins/fleet/public/hooks/index.ts +++ b/x-pack/plugins/fleet/public/hooks/index.ts @@ -5,20 +5,18 @@ * 2.0. */ -export { useAuthz } from './use_authz'; -export { useStartServices } from './use_core'; -export { useConfig, ConfigContext } from './use_config'; -export { useKibanaVersion, KibanaVersionContext } from './use_kibana_version'; -export { licenseService, useLicense } from './use_license'; -export { useLink } from './use_link'; -export { useKibanaLink, getHrefToObjectInKibanaApp } from './use_kibana_link'; -export type { UsePackageIconType } from './use_package_icon_type'; -export { usePackageIconType } from './use_package_icon_type'; -export type { Pagination } from './use_pagination'; -export { usePagination, PAGE_SIZE_OPTIONS } from './use_pagination'; -export { useUrlPagination } from './use_url_pagination'; -export { useSorting } from './use_sorting'; -export { useDebounce } from './use_debounce'; +export * from './use_authz'; +export * from './use_core'; +export * from './use_config'; +export * from './use_kibana_version'; +export * from './use_license'; +export * from './use_link'; +export * from './use_kibana_link'; +export * from './use_package_icon_type'; +export * from './use_pagination'; +export * from './use_url_pagination'; +export * from './use_sorting'; +export * from './use_debounce'; export * from './use_request'; export * from './use_input'; export * from './use_url_params'; @@ -28,3 +26,4 @@ export * from './use_intra_app_state'; export * from './use_platform'; export * from './use_agent_policy_refresh'; export * from './use_package_installations'; +export * from './use_agent_enrollment_flyout_data'; diff --git a/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout.data.test.ts b/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout.data.test.ts new file mode 100644 index 0000000000000..a7b4137b5be29 --- /dev/null +++ b/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout.data.test.ts @@ -0,0 +1,57 @@ +/* + * 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 { createFleetTestRendererMock } from '../mock'; + +import { useGetAgentPolicies, useAgentEnrollmentFlyoutData } from '.'; + +jest.mock('./use_request', () => { + return { + ...jest.requireActual('./use_request'), + useGetAgentPolicies: jest.fn(), + }; +}); + +describe('useAgentEnrollmentFlyoutData', () => { + const testRenderer = createFleetTestRendererMock(); + + it('should return empty agentPolicies when http loading', () => { + (useGetAgentPolicies as jest.Mock).mockReturnValue({ data: undefined, isLoading: true }); + const { result } = testRenderer.renderHook(() => useAgentEnrollmentFlyoutData()); + expect(result.current.agentPolicies).toEqual([]); + }); + + it('should return empty agentPolicies when http not loading and no data', () => { + (useGetAgentPolicies as jest.Mock).mockReturnValue({ data: undefined }); + const { result } = testRenderer.renderHook(() => useAgentEnrollmentFlyoutData()); + expect(result.current.agentPolicies).toEqual([]); + }); + + it('should return empty agentPolicies when http not loading and no items', () => { + (useGetAgentPolicies as jest.Mock).mockReturnValue({ data: { items: undefined } }); + const { result } = testRenderer.renderHook(() => useAgentEnrollmentFlyoutData()); + expect(result.current.agentPolicies).toEqual([]); + }); + + it('should return agentPolicies when http not loading', () => { + (useGetAgentPolicies as jest.Mock).mockReturnValue({ data: { items: [{ id: 'policy1' }] } }); + const { result } = testRenderer.renderHook(() => useAgentEnrollmentFlyoutData()); + expect(result.current.agentPolicies).toEqual([{ id: 'policy1' }]); + }); + + it('should resend request when refresh agent policies called', () => { + const resendRequestMock = jest.fn(); + (useGetAgentPolicies as jest.Mock).mockReturnValue({ + data: { items: [{ id: 'policy1' }] }, + isLoading: false, + resendRequest: resendRequestMock, + }); + const { result } = testRenderer.renderHook(() => useAgentEnrollmentFlyoutData()); + result.current.refreshAgentPolicies(); + expect(resendRequestMock).toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout_data.ts b/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout_data.ts new file mode 100644 index 0000000000000..d93afd9ac1349 --- /dev/null +++ b/x-pack/plugins/fleet/public/hooks/use_agent_enrollment_flyout_data.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; + +import type { AgentPolicy } from '../types'; + +import { useGetAgentPolicies } from './use_request'; + +interface AgentEnrollmentFlyoutData { + agentPolicies: AgentPolicy[]; + refreshAgentPolicies: () => void; +} + +export function useAgentEnrollmentFlyoutData(): AgentEnrollmentFlyoutData { + const { + data: agentPoliciesData, + isLoading: isLoadingAgentPolicies, + resendRequest: refreshAgentPolicies, + } = useGetAgentPolicies({ + page: 1, + perPage: 1000, + full: true, + }); + + const agentPolicies = useMemo(() => { + if (!isLoadingAgentPolicies) { + return agentPoliciesData?.items ?? []; + } + return []; + }, [isLoadingAgentPolicies, agentPoliciesData?.items]); + + return { agentPolicies, refreshAgentPolicies }; +} From 472fe62cbeb556eb58bdc9673c16918ff6ed3cae Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 15 Feb 2022 14:59:04 +0000 Subject: [PATCH 20/43] skip flaky suite (#123253) --- .../test/security_solution_endpoint_api_int/apis/metadata.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index b0aaf71ef3257..a4c83b649af65 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -38,7 +38,8 @@ export default function ({ getService }: FtrProviderContext) { describe('test metadata apis', () => { describe('list endpoints GET route', () => { - describe('with .metrics-endpoint.metadata_united_default index', () => { + // FLAKY: https://github.com/elastic/kibana/issues/123253 + describe.skip('with .metrics-endpoint.metadata_united_default index', () => { const numberOfHostsInFixture = 2; before(async () => { From 928638e395f4d645b36a9ae4ab6afe1995db34ab Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Tue, 15 Feb 2022 16:20:30 +0100 Subject: [PATCH 21/43] [Fleet] Avoid breaking setup when compatible package is not available in registry (#125525) --- .../plugins/fleet/common/openapi/bundled.json | 3 ++ .../plugins/fleet/common/openapi/bundled.yaml | 2 + ...epm@packages@{pkg_name}@{pkg_version}.yaml | 2 + .../fleet/server/routes/epm/handlers.ts | 1 + .../server/services/epm/packages/get.test.ts | 42 +++++++++++++++++++ .../fleet/server/services/epm/packages/get.ts | 18 ++++---- .../server/services/epm/packages/install.ts | 7 +++- .../server/services/epm/registry/index.ts | 23 ++++++++-- .../fleet/server/types/rest_spec/epm.ts | 3 +- .../fleet_api_integration/apis/epm/setup.ts | 34 +++++++++++++++ .../0.1.0/data_stream/test/fields/fields.yml | 16 +++++++ .../0.1.0/data_stream/test/manifest.yml | 9 ++++ .../deprecated/0.1.0/docs/README.md | 3 ++ .../deprecated/0.1.0/manifest.yml | 16 +++++++ 14 files changed, 163 insertions(+), 16 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/fields/fields.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/manifest.yml create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/docs/README.md create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/manifest.yml diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 0be8b335ed549..432e72db05e8c 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -646,6 +646,9 @@ "properties": { "force": { "type": "boolean" + }, + "ignore_constraints": { + "type": "boolean" } } } diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index 0659352deb1d9..439f56da63e5e 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -396,6 +396,8 @@ paths: properties: force: type: boolean + ignore_constraints: + type: boolean put: summary: Packages - Update tags: [] diff --git a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml index 401237008626b..ef0964b66e045 100644 --- a/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml +++ b/x-pack/plugins/fleet/common/openapi/paths/epm@packages@{pkg_name}@{pkg_version}.yaml @@ -78,6 +78,8 @@ post: properties: force: type: boolean + ignore_constraints: + type: boolean put: summary: Packages - Update tags: [] diff --git a/x-pack/plugins/fleet/server/routes/epm/handlers.ts b/x-pack/plugins/fleet/server/routes/epm/handlers.ts index 16f2d2e13e18c..9bfcffa04bf35 100644 --- a/x-pack/plugins/fleet/server/routes/epm/handlers.ts +++ b/x-pack/plugins/fleet/server/routes/epm/handlers.ts @@ -265,6 +265,7 @@ export const installPackageFromRegistryHandler: FleetRequestHandler< esClient, spaceId, force: request.body?.force, + ignoreConstraints: request.body?.ignore_constraints, }); if (!res.error) { const body: InstallPackageResponse = { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts index 76e01ed8b2f27..53b4d341beec2 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.test.ts @@ -19,6 +19,8 @@ import * as Registry from '../registry'; import { createAppContextStartContractMock } from '../../../mocks'; import { appContextService } from '../../app_context'; +import { PackageNotFoundError } from '../../../errors'; + import { getPackageInfo, getPackageUsageStats } from './get'; const MockRegistry = Registry as jest.Mocked; @@ -279,5 +281,45 @@ describe('When using EPM `get` services', () => { }); }); }); + + describe('registry fetch errors', () => { + it('throws when a package that is not installed is not available in the registry', async () => { + MockRegistry.fetchFindLatestPackage.mockResolvedValue(undefined); + const soClient = savedObjectsClientMock.create(); + soClient.get.mockRejectedValue(SavedObjectsErrorHelpers.createGenericNotFoundError()); + + await expect( + getPackageInfo({ + savedObjectsClient: soClient, + pkgName: 'my-package', + pkgVersion: '1.0.0', + }) + ).rejects.toThrowError(PackageNotFoundError); + }); + + it('sets the latestVersion to installed version when an installed package is not available in the registry', async () => { + MockRegistry.fetchFindLatestPackage.mockResolvedValue(undefined); + const soClient = savedObjectsClientMock.create(); + soClient.get.mockResolvedValue({ + id: 'my-package', + type: PACKAGES_SAVED_OBJECT_TYPE, + references: [], + attributes: { + install_status: 'installed', + }, + }); + + await expect( + getPackageInfo({ + savedObjectsClient: soClient, + pkgName: 'my-package', + pkgVersion: '1.0.0', + }) + ).resolves.toMatchObject({ + latestVersion: '1.0.0', + status: 'installed', + }); + }); + }); }); }); diff --git a/x-pack/plugins/fleet/server/services/epm/packages/get.ts b/x-pack/plugins/fleet/server/services/epm/packages/get.ts index a7cbea4d6462a..c78f107cce715 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/get.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/get.ts @@ -21,7 +21,7 @@ import type { GetCategoriesRequest, } from '../../../../common/types'; import type { Installation, PackageInfo } from '../../../types'; -import { IngestManagerError } from '../../../errors'; +import { IngestManagerError, PackageNotFoundError } from '../../../errors'; import { appContextService } from '../../'; import * as Registry from '../registry'; import { getEsPackage } from '../archive/storage'; @@ -145,17 +145,17 @@ export async function getPackageInfo(options: { const { savedObjectsClient, pkgName, pkgVersion } = options; const [savedObject, latestPackage] = await Promise.all([ getInstallationObject({ savedObjectsClient, pkgName }), - Registry.fetchFindLatestPackage(pkgName), + Registry.fetchFindLatestPackage(pkgName, { throwIfNotFound: false }), ]); - // If no package version is provided, use the installed version in the response - let responsePkgVersion = pkgVersion || savedObject?.attributes.install_version; - - // If no installed version of the given package exists, default to the latest version of the package - if (!responsePkgVersion) { - responsePkgVersion = latestPackage.version; + if (!savedObject && !latestPackage) { + throw new PackageNotFoundError(`[${pkgName}] package not installed or found in registry`); } + // If no package version is provided, use the installed version in the response, fallback to package from registry + const responsePkgVersion = + pkgVersion ?? savedObject?.attributes.install_version ?? latestPackage!.version; + const getPackageRes = await getPackageFromSource({ pkgName, pkgVersion: responsePkgVersion, @@ -166,7 +166,7 @@ export async function getPackageInfo(options: { // add properties that aren't (or aren't yet) on the package const additions: EpmPackageAdditions = { - latestVersion: latestPackage.version, + latestVersion: latestPackage?.version ?? responsePkgVersion, title: packageInfo.title || nameAsTitle(packageInfo.name), assets: Registry.groupPathsByService(paths || []), removable: true, diff --git a/x-pack/plugins/fleet/server/services/epm/packages/install.ts b/x-pack/plugins/fleet/server/services/epm/packages/install.ts index 21f0ae25d6faf..9ffae48cb02d8 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/install.ts @@ -205,6 +205,7 @@ interface InstallRegistryPackageParams { esClient: ElasticsearchClient; spaceId: string; force?: boolean; + ignoreConstraints?: boolean; } function getTelemetryEvent(pkgName: string, pkgVersion: string): PackageUpdateEvent { @@ -233,6 +234,7 @@ async function installPackageFromRegistry({ esClient, spaceId, force = false, + ignoreConstraints = false, }: InstallRegistryPackageParams): Promise { const logger = appContextService.getLogger(); // TODO: change epm API to /packageName/version so we don't need to do this @@ -249,7 +251,7 @@ async function installPackageFromRegistry({ installType = getInstallType({ pkgVersion, installedPkg }); // get latest package version - const latestPackage = await Registry.fetchFindLatestPackage(pkgName); + const latestPackage = await Registry.fetchFindLatestPackage(pkgName, { ignoreConstraints }); // let the user install if using the force flag or needing to reinstall or install a previous version due to failed update const installOutOfDateVersionOk = @@ -469,7 +471,7 @@ export async function installPackage(args: InstallPackageParams) { const { savedObjectsClient, esClient } = args; if (args.installSource === 'registry') { - const { pkgkey, force, spaceId } = args; + const { pkgkey, force, ignoreConstraints, spaceId } = args; logger.debug(`kicking off install of ${pkgkey} from registry`); const response = installPackageFromRegistry({ savedObjectsClient, @@ -477,6 +479,7 @@ export async function installPackage(args: InstallPackageParams) { esClient, spaceId, force, + ignoreConstraints, }); return response; } else if (args.installSource === 'upload') { diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts index 5996ce5404b70..12712905b1d36 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -65,18 +65,33 @@ export async function fetchList(params?: SearchParams): Promise { +// When `throwIfNotFound` is true or undefined, return type will never be undefined. +export async function fetchFindLatestPackage( + packageName: string, + options?: { ignoreConstraints?: boolean; throwIfNotFound?: true } +): Promise; +export async function fetchFindLatestPackage( + packageName: string, + options: { ignoreConstraints?: boolean; throwIfNotFound: false } +): Promise; +export async function fetchFindLatestPackage( + packageName: string, + options?: { ignoreConstraints?: boolean; throwIfNotFound?: boolean } +): Promise { + const { ignoreConstraints = false, throwIfNotFound = true } = options ?? {}; const registryUrl = getRegistryUrl(); const url = new URL(`${registryUrl}/search?package=${packageName}&experimental=true`); - setKibanaVersion(url); + if (!ignoreConstraints) { + setKibanaVersion(url); + } const res = await fetchUrl(url.toString()); const searchResults = JSON.parse(res); if (searchResults.length) { return searchResults[0]; - } else { - throw new PackageNotFoundError(`${packageName} not found`); + } else if (throwIfNotFound) { + throw new PackageNotFoundError(`[${packageName}] package not found in registry`); } } diff --git a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts index 390d5dea792cb..c51a0127c2e29 100644 --- a/x-pack/plugins/fleet/server/types/rest_spec/epm.ts +++ b/x-pack/plugins/fleet/server/types/rest_spec/epm.ts @@ -74,7 +74,8 @@ export const InstallPackageFromRegistryRequestSchema = { }), body: schema.nullable( schema.object({ - force: schema.boolean(), + force: schema.boolean({ defaultValue: false }), + ignore_constraints: schema.boolean({ defaultValue: false }), }) ), }; diff --git a/x-pack/test/fleet_api_integration/apis/epm/setup.ts b/x-pack/test/fleet_api_integration/apis/epm/setup.ts index 44e582b445f96..eb29920b83036 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/setup.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/setup.ts @@ -52,6 +52,40 @@ export default function (providerContext: FtrProviderContext) { }); }); + it('does not fail when package is no longer compatible in registry', async () => { + await supertest + .post(`/api/fleet/epm/packages/deprecated/0.1.0`) + .set('kbn-xsrf', 'xxxx') + .send({ force: true, ignore_constraints: true }) + .expect(200); + + const agentPolicyResponse = await supertest + .post(`/api/fleet/agent_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'deprecated-ap-1', + namespace: 'default', + monitoring_enabled: [], + }) + .expect(200); + + await supertest + .post(`/api/fleet/package_policies`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'deprecated-1', + policy_id: agentPolicyResponse.body.item.id, + package: { + name: 'deprecated', + version: '0.1.0', + }, + inputs: [], + }) + .expect(200); + + await supertest.post('/api/fleet/setup').set('kbn-xsrf', 'xxxx').expect(200); + }); + it('allows elastic/fleet-server user to call required APIs', async () => { const { token, diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/fields/fields.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/fields/fields.yml new file mode 100644 index 0000000000000..6e003ed0ad147 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/fields/fields.yml @@ -0,0 +1,16 @@ +- name: data_stream.type + type: constant_keyword + description: > + Data stream type. +- name: data_stream.dataset + type: constant_keyword + description: > + Data stream dataset. +- name: data_stream.namespace + type: constant_keyword + description: > + Data stream namespace. +- name: '@timestamp' + type: date + description: > + Event timestamp. diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/manifest.yml new file mode 100644 index 0000000000000..9ac3c68a0be9e --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/data_stream/test/manifest.yml @@ -0,0 +1,9 @@ +title: Test Dataset + +type: logs + +elasticsearch: + index_template.mappings: + dynamic: false + index_template.settings: + index.lifecycle.name: reference diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/docs/README.md b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/docs/README.md new file mode 100644 index 0000000000000..13ef3f4fa9152 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/docs/README.md @@ -0,0 +1,3 @@ +# Test package + +This is a test package for testing installing or updating to an out-of-date package \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/manifest.yml new file mode 100644 index 0000000000000..755c49e1af388 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/deprecated/0.1.0/manifest.yml @@ -0,0 +1,16 @@ +format_version: 1.0.0 +name: deprecated +title: Package install/update test +description: This is a package for testing deprecated packages +version: 0.1.0 +categories: [] +release: beta +type: integration +license: basic + +conditions: + # Version number is not compatible with current version + elasticsearch: + version: '^1.0.0' + kibana: + version: '^1.0.0' From 21cef490f3ed3b91ae5f41e4cc38070cd0dd0d58 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Tue, 15 Feb 2022 09:25:20 -0600 Subject: [PATCH 22/43] [kibana_react] Enable Storybook for all of kibana_react (#125589) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../steps/storybooks/build_and_upload.js | 3 +-- src/dev/storybook/aliases.ts | 3 +-- .../.storybook/main.js => .storybook/main.ts} | 5 +++-- .../kibana_react/.storybook/manager.ts | 21 +++++++++++++++++++ .../kibana_react/public/code_editor/README.md | 2 +- .../url_template_editor/.storybook/main.js | 10 --------- src/plugins/kibana_react/tsconfig.json | 2 +- test/scripts/jenkins_storybook.sh | 10 ++++----- 8 files changed, 33 insertions(+), 23 deletions(-) rename src/plugins/kibana_react/{public/code_editor/.storybook/main.js => .storybook/main.ts} (77%) create mode 100644 src/plugins/kibana_react/.storybook/manager.ts delete mode 100644 src/plugins/kibana_react/public/url_template_editor/.storybook/main.js diff --git a/.buildkite/scripts/steps/storybooks/build_and_upload.js b/.buildkite/scripts/steps/storybooks/build_and_upload.js index 0af75e72de78a..9d40edc905763 100644 --- a/.buildkite/scripts/steps/storybooks/build_and_upload.js +++ b/.buildkite/scripts/steps/storybooks/build_and_upload.js @@ -16,7 +16,6 @@ const STORYBOOKS = [ 'canvas', 'ci_composite', 'cloud', - 'codeeditor', 'custom_integrations', 'dashboard_enhanced', 'dashboard', @@ -31,13 +30,13 @@ const STORYBOOKS = [ 'expression_tagcloud', 'fleet', 'infra', + 'kibana_react', 'lists', 'observability', 'presentation', 'security_solution', 'shared_ux', 'ui_actions_enhanced', - 'url_template_editor', ]; const GITHUB_CONTEXT = 'Build and Publish Storybooks'; diff --git a/src/dev/storybook/aliases.ts b/src/dev/storybook/aliases.ts index 542acf7b0fa8f..db0791f41b0a7 100644 --- a/src/dev/storybook/aliases.ts +++ b/src/dev/storybook/aliases.ts @@ -12,7 +12,6 @@ export const storybookAliases = { canvas: 'x-pack/plugins/canvas/storybook', ci_composite: '.ci/.storybook', cloud: 'x-pack/plugins/cloud/.storybook', - codeeditor: 'src/plugins/kibana_react/public/code_editor/.storybook', controls: 'src/plugins/controls/storybook', custom_integrations: 'src/plugins/custom_integrations/storybook', dashboard_enhanced: 'x-pack/plugins/dashboard_enhanced/.storybook', @@ -31,11 +30,11 @@ export const storybookAliases = { expression_tagcloud: 'src/plugins/chart_expressions/expression_tagcloud/.storybook', fleet: 'x-pack/plugins/fleet/.storybook', infra: 'x-pack/plugins/infra/.storybook', + kibana_react: 'src/plugins/kibana_react/.storybook', lists: 'x-pack/plugins/lists/.storybook', observability: 'x-pack/plugins/observability/.storybook', presentation: 'src/plugins/presentation_util/storybook', security_solution: 'x-pack/plugins/security_solution/.storybook', shared_ux: 'src/plugins/shared_ux/.storybook', ui_actions_enhanced: 'x-pack/plugins/ui_actions_enhanced/.storybook', - url_template_editor: 'src/plugins/kibana_react/public/url_template_editor/.storybook', }; diff --git a/src/plugins/kibana_react/public/code_editor/.storybook/main.js b/src/plugins/kibana_react/.storybook/main.ts similarity index 77% rename from src/plugins/kibana_react/public/code_editor/.storybook/main.js rename to src/plugins/kibana_react/.storybook/main.ts index 742239e638b8a..1261fe5a06f69 100644 --- a/src/plugins/kibana_react/public/code_editor/.storybook/main.js +++ b/src/plugins/kibana_react/.storybook/main.ts @@ -6,5 +6,6 @@ * Side Public License, v 1. */ -// eslint-disable-next-line import/no-commonjs -module.exports = require('@kbn/storybook').defaultConfig; +import { defaultConfig } from '@kbn/storybook'; + +module.exports = defaultConfig; diff --git a/src/plugins/kibana_react/.storybook/manager.ts b/src/plugins/kibana_react/.storybook/manager.ts new file mode 100644 index 0000000000000..27eaef2b2be0e --- /dev/null +++ b/src/plugins/kibana_react/.storybook/manager.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 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 { addons } from '@storybook/addons'; +import { create } from '@storybook/theming'; +import { PANEL_ID } from '@storybook/addon-actions'; + +addons.setConfig({ + theme: create({ + base: 'light', + brandTitle: 'Kibana React Storybook', + brandUrl: 'https://github.com/elastic/kibana/tree/main/src/plugins/kibana_react', + }), + showPanel: true.valueOf, + selectedPanel: PANEL_ID, +}); diff --git a/src/plugins/kibana_react/public/code_editor/README.md b/src/plugins/kibana_react/public/code_editor/README.md index 811038b58c828..df8913fb32f96 100644 --- a/src/plugins/kibana_react/public/code_editor/README.md +++ b/src/plugins/kibana_react/public/code_editor/README.md @@ -11,6 +11,6 @@ This editor component allows easy access to: The Monaco editor doesn't automatically resize the editor area on window or container resize so this component includes a [resize detector](https://github.com/maslianok/react-resize-detector) to cause the Monaco editor to re-layout and adjust its size when the window or container size changes ## Storybook Examples -To run the CodeEditor storybook, from the root kibana directory, run `yarn storybook codeeditor` +To run the `CodeEditor` Storybook, from the root kibana directory, run `yarn storybook kibana_react` All stories for the component live in `code_editor.examples.tsx` \ No newline at end of file diff --git a/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js b/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js deleted file mode 100644 index 742239e638b8a..0000000000000 --- a/src/plugins/kibana_react/public/url_template_editor/.storybook/main.js +++ /dev/null @@ -1,10 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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. - */ - -// eslint-disable-next-line import/no-commonjs -module.exports = require('@kbn/storybook').defaultConfig; diff --git a/src/plugins/kibana_react/tsconfig.json b/src/plugins/kibana_react/tsconfig.json index 3f6dd8fd280b6..43b51a45e08c4 100644 --- a/src/plugins/kibana_react/tsconfig.json +++ b/src/plugins/kibana_react/tsconfig.json @@ -6,6 +6,6 @@ "declaration": true, "declarationMap": true }, - "include": ["common/**/*", "public/**/*", "../../../typings/**/*"], + "include": [".storybook/**/*", "common/**/*", "public/**/*", "../../../typings/**/*"], "references": [{ "path": "../kibana_utils/tsconfig.json" }] } diff --git a/test/scripts/jenkins_storybook.sh b/test/scripts/jenkins_storybook.sh index bf8b881a91ecd..e03494e13677d 100755 --- a/test/scripts/jenkins_storybook.sh +++ b/test/scripts/jenkins_storybook.sh @@ -6,10 +6,8 @@ cd "$KIBANA_DIR" yarn storybook --site apm yarn storybook --site canvas -yarn storybook --site codeeditor yarn storybook --site ci_composite yarn storybook --site custom_integrations -yarn storybook --site url_template_editor yarn storybook --site dashboard yarn storybook --site dashboard_enhanced yarn storybook --site data_enhanced @@ -23,8 +21,10 @@ yarn storybook --site expression_shape yarn storybook --site expression_tagcloud yarn storybook --site fleet yarn storybook --site infra -yarn storybook --site security_solution -yarn storybook --site ui_actions_enhanced +yarn storybook --site kibana_react +yarn storybook --site lists yarn storybook --site observability yarn storybook --site presentation -yarn storybook --site lists +yarn storybook --site security_solution +yarn storybook --site shared_ux +yarn storybook --site ui_actions_enhanced From c2a010367dabb7923c094291d94d040aa74cdbf6 Mon Sep 17 00:00:00 2001 From: Ying Mao Date: Tue, 15 Feb 2022 10:31:48 -0500 Subject: [PATCH 23/43] [Task Manager] Adding list of explicitly de-registered task types (#123963) * Adding REMOVED_TYPES to task manager and only marking those types as unrecognized * Adding unit tests * Fixing functional test * Throwing error when registering a removed task type * Adding migration * Adding functional tests * Cleanup * Adding disabled siem signals rule type Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/plugins/task_manager/server/plugin.ts | 3 +- .../server/polling_lifecycle.test.ts | 1 + .../task_manager/server/polling_lifecycle.ts | 3 + .../mark_available_tasks_as_claimed.test.ts | 47 ++++--- .../mark_available_tasks_as_claimed.ts | 27 ++-- .../server/queries/task_claiming.test.ts | 125 ++++++++++++++++++ .../server/queries/task_claiming.ts | 22 +-- .../server/saved_objects/migrations.test.ts | 56 ++++++++ .../server/saved_objects/migrations.ts | 26 +++- .../server/task_type_dictionary.test.ts | 58 +++++++- .../server/task_type_dictionary.ts | 16 +++ .../task_manager_removed_types/data.json | 31 +++++ .../es_archives/task_manager_tasks/data.json | 62 +++++++++ .../test_suites/task_manager/migrations.ts | 32 +++++ .../task_management_removed_types.ts | 12 +- 15 files changed, 483 insertions(+), 38 deletions(-) diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts index bb4c461758f96..b58b0665c10c0 100644 --- a/x-pack/plugins/task_manager/server/plugin.ts +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -22,7 +22,7 @@ import { TaskManagerConfig } from './config'; import { createInitialMiddleware, addMiddlewareToChain, Middleware } from './lib/middleware'; import { removeIfExists } from './lib/remove_if_exists'; import { setupSavedObjects } from './saved_objects'; -import { TaskDefinitionRegistry, TaskTypeDictionary } from './task_type_dictionary'; +import { TaskDefinitionRegistry, TaskTypeDictionary, REMOVED_TYPES } from './task_type_dictionary'; import { FetchResult, SearchOpts, TaskStore } from './task_store'; import { createManagedConfiguration } from './lib/create_managed_configuration'; import { TaskScheduling } from './task_scheduling'; @@ -189,6 +189,7 @@ export class TaskManagerPlugin this.taskPollingLifecycle = new TaskPollingLifecycle({ config: this.config!, definitions: this.definitions, + unusedTypes: REMOVED_TYPES, logger: this.logger, executionContext, taskStore, diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts index b6a93b14f578b..cf29d1f475c6c 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.test.ts @@ -70,6 +70,7 @@ describe('TaskPollingLifecycle', () => { }, taskStore: mockTaskStore, logger: taskManagerLogger, + unusedTypes: [], definitions: new TaskTypeDictionary(taskManagerLogger), middleware: createInitialMiddleware(), maxWorkersConfiguration$: of(100), diff --git a/x-pack/plugins/task_manager/server/polling_lifecycle.ts b/x-pack/plugins/task_manager/server/polling_lifecycle.ts index b61891d732f5e..a452c8a3f82fb 100644 --- a/x-pack/plugins/task_manager/server/polling_lifecycle.ts +++ b/x-pack/plugins/task_manager/server/polling_lifecycle.ts @@ -50,6 +50,7 @@ import { TaskClaiming, ClaimOwnershipResult } from './queries/task_claiming'; export type TaskPollingLifecycleOpts = { logger: Logger; definitions: TaskTypeDictionary; + unusedTypes: string[]; taskStore: TaskStore; config: TaskManagerConfig; middleware: Middleware; @@ -106,6 +107,7 @@ export class TaskPollingLifecycle { config, taskStore, definitions, + unusedTypes, executionContext, usageCounter, }: TaskPollingLifecycleOpts) { @@ -134,6 +136,7 @@ export class TaskPollingLifecycle { maxAttempts: config.max_attempts, excludedTaskTypes: config.unsafe.exclude_task_types, definitions, + unusedTypes, logger: this.logger, getCapacity: (taskType?: string) => taskType && this.definitions.get(taskType)?.maxConcurrency diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts index 9e31ab9f0cb4e..18ed1a5802538 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts @@ -47,15 +47,16 @@ describe('mark_available_tasks_as_claimed', () => { // status running or claiming with a retryAt <= now. shouldBeOneOf(IdleTaskWithExpiredRunAt, RunningOrClaimingTaskWithExpiredRetryAt) ), - script: updateFieldsAndMarkAsFailed( + script: updateFieldsAndMarkAsFailed({ fieldUpdates, - claimTasksById || [], - definitions.getAllTypes(), - [], - Array.from(definitions).reduce((accumulator, [type, { maxAttempts }]) => { + claimTasksById: claimTasksById || [], + claimableTaskTypes: definitions.getAllTypes(), + skippedTaskTypes: [], + unusedTaskTypes: [], + taskMaxAttempts: Array.from(definitions).reduce((accumulator, [type, { maxAttempts }]) => { return { ...accumulator, [type]: maxAttempts || defaultMaxAttempts }; - }, {}) - ), + }, {}), + }), sort: SortByRunAtAndRetryAt, }).toEqual({ query: { @@ -126,7 +127,7 @@ if (doc['task.runAt'].size()!=0) { ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) .join(' ')} - } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + } else if (params.unusedTaskTypes.contains(ctx._source.task.taskType)) { ctx._source.task.status = "unrecognized"; } else { ctx.op = "noop"; @@ -140,6 +141,7 @@ if (doc['task.runAt'].size()!=0) { claimTasksById: [], claimableTaskTypes: ['sampleTask', 'otherTask'], skippedTaskTypes: [], + unusedTaskTypes: [], taskMaxAttempts: { sampleTask: 5, otherTask: 1, @@ -164,9 +166,16 @@ if (doc['task.runAt'].size()!=0) { ]; expect( - updateFieldsAndMarkAsFailed(fieldUpdates, claimTasksById, ['foo', 'bar'], [], { - foo: 5, - bar: 2, + updateFieldsAndMarkAsFailed({ + fieldUpdates, + claimTasksById, + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: [], + unusedTaskTypes: [], + taskMaxAttempts: { + foo: 5, + bar: 2, + }, }) ).toMatchObject({ source: ` @@ -182,7 +191,7 @@ if (doc['task.runAt'].size()!=0) { ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) .join(' ')} - } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + } else if (params.unusedTaskTypes.contains(ctx._source.task.taskType)) { ctx._source.task.status = "unrecognized"; } else { ctx.op = "noop"; @@ -196,6 +205,7 @@ if (doc['task.runAt'].size()!=0) { ], claimableTaskTypes: ['foo', 'bar'], skippedTaskTypes: [], + unusedTaskTypes: [], taskMaxAttempts: { foo: 5, bar: 2, @@ -213,9 +223,16 @@ if (doc['task.runAt'].size()!=0) { }; expect( - updateFieldsAndMarkAsFailed(fieldUpdates, [], ['foo', 'bar'], [], { - foo: 5, - bar: 2, + updateFieldsAndMarkAsFailed({ + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: [], + unusedTaskTypes: [], + taskMaxAttempts: { + foo: 5, + bar: 2, + }, }).source ).toMatch(/ctx.op = "noop"/); }); diff --git a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts index b1ccb191bdce0..5f2aa25253b0c 100644 --- a/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts +++ b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts @@ -104,15 +104,25 @@ if (doc['task.runAt'].size()!=0) { }; export const SortByRunAtAndRetryAt = SortByRunAtAndRetryAtScript as estypes.SortCombinations; -export const updateFieldsAndMarkAsFailed = ( +export interface UpdateFieldsAndMarkAsFailedOpts { fieldUpdates: { [field: string]: string | number | Date; - }, - claimTasksById: string[], - claimableTaskTypes: string[], - skippedTaskTypes: string[], - taskMaxAttempts: { [field: string]: number } -): ScriptClause => { + }; + claimTasksById: string[]; + claimableTaskTypes: string[]; + skippedTaskTypes: string[]; + unusedTaskTypes: string[]; + taskMaxAttempts: { [field: string]: number }; +} + +export const updateFieldsAndMarkAsFailed = ({ + fieldUpdates, + claimTasksById, + claimableTaskTypes, + skippedTaskTypes, + unusedTaskTypes, + taskMaxAttempts, +}: UpdateFieldsAndMarkAsFailedOpts): ScriptClause => { const markAsClaimingScript = `ctx._source.task.status = "claiming"; ${Object.keys(fieldUpdates) .map((field) => `ctx._source.task.${field}=params.fieldUpdates.${field};`) .join(' ')}`; @@ -126,7 +136,7 @@ export const updateFieldsAndMarkAsFailed = ( } } else if (params.skippedTaskTypes.contains(ctx._source.task.taskType) && params.claimTasksById.contains(ctx._id)) { ${markAsClaimingScript} - } else if (!params.skippedTaskTypes.contains(ctx._source.task.taskType)) { + } else if (params.unusedTaskTypes.contains(ctx._source.task.taskType)) { ctx._source.task.status = "unrecognized"; } else { ctx.op = "noop"; @@ -137,6 +147,7 @@ export const updateFieldsAndMarkAsFailed = ( claimTasksById, claimableTaskTypes, skippedTaskTypes, + unusedTaskTypes, taskMaxAttempts, }, }; diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts index ed656b5144956..7b46f10adaabc 100644 --- a/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.test.ts @@ -109,6 +109,7 @@ describe('TaskClaiming', () => { logger: taskManagerLogger, definitions, excludedTaskTypes: [], + unusedTypes: [], taskStore: taskStoreMock.create({ taskManagerId: '' }), maxAttempts: 2, getCapacity: () => 10, @@ -127,12 +128,14 @@ describe('TaskClaiming', () => { hits = [generateFakeTasks(1)], versionConflicts = 2, excludedTaskTypes = [], + unusedTaskTypes = [], }: { storeOpts: Partial; taskClaimingOpts: Partial; hits?: ConcreteTaskInstance[][]; versionConflicts?: number; excludedTaskTypes?: string[]; + unusedTaskTypes?: string[]; }) { const definitions = storeOpts.definitions ?? taskDefinitions; const store = taskStoreMock.create({ taskManagerId: storeOpts.taskManagerId }); @@ -161,6 +164,7 @@ describe('TaskClaiming', () => { definitions, taskStore: store, excludedTaskTypes, + unusedTypes: unusedTaskTypes, maxAttempts: taskClaimingOpts.maxAttempts ?? 2, getCapacity: taskClaimingOpts.getCapacity ?? (() => 10), ...taskClaimingOpts, @@ -176,6 +180,7 @@ describe('TaskClaiming', () => { hits = [generateFakeTasks(1)], versionConflicts = 2, excludedTaskTypes = [], + unusedTaskTypes = [], }: { storeOpts: Partial; taskClaimingOpts: Partial; @@ -183,12 +188,14 @@ describe('TaskClaiming', () => { hits?: ConcreteTaskInstance[][]; versionConflicts?: number; excludedTaskTypes?: string[]; + unusedTaskTypes?: string[]; }) { const getCapacity = taskClaimingOpts.getCapacity ?? (() => 10); const { taskClaiming, store } = initialiseTestClaiming({ storeOpts, taskClaimingOpts, excludedTaskTypes, + unusedTaskTypes, hits, versionConflicts, }); @@ -496,6 +503,7 @@ if (doc['task.runAt'].size()!=0) { ], claimableTaskTypes: ['foo', 'bar'], skippedTaskTypes: [], + unusedTaskTypes: [], taskMaxAttempts: { bar: customMaxAttempts, foo: maxAttempts, @@ -614,6 +622,7 @@ if (doc['task.runAt'].size()!=0) { 'anotherLimitedToOne', 'limitedToTwo', ], + unusedTaskTypes: [], taskMaxAttempts: { unlimited: maxAttempts, }, @@ -871,6 +880,121 @@ if (doc['task.runAt'].size()!=0) { expect(firstCycle).not.toMatchObject(secondCycle); }); + test('it passes any unusedTaskTypes to script', async () => { + const maxAttempts = _.random(2, 43); + const customMaxAttempts = _.random(44, 100); + const taskManagerId = uuid.v1(); + const fieldUpdates = { + ownerId: taskManagerId, + retryAt: new Date(Date.now()), + }; + const definitions = new TaskTypeDictionary(mockLogger()); + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + createTaskRunner: jest.fn(), + }, + bar: { + title: 'bar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + foobar: { + title: 'foobar', + maxAttempts: customMaxAttempts, + createTaskRunner: jest.fn(), + }, + }); + + const [ + { + args: { + updateByQuery: [{ query, script }], + }, + }, + ] = await testClaimAvailableTasks({ + storeOpts: { + definitions, + taskManagerId, + }, + taskClaimingOpts: { + maxAttempts, + }, + claimingOpts: { + claimOwnershipUntil: new Date(), + }, + excludedTaskTypes: ['foobar'], + unusedTaskTypes: ['barfoo'], + }); + expect(query).toMatchObject({ + bool: { + must: [ + { + bool: { + should: [ + { + bool: { + must: [ + { term: { 'task.status': 'idle' } }, + { range: { 'task.runAt': { lte: 'now' } } }, + ], + }, + }, + { + bool: { + must: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + }, + }, + { range: { 'task.retryAt': { lte: 'now' } } }, + ], + }, + }, + ], + }, + }, + ], + filter: [ + { + bool: { + must_not: [ + { + bool: { + should: [ + { term: { 'task.status': 'running' } }, + { term: { 'task.status': 'claiming' } }, + ], + must: { range: { 'task.retryAt': { gt: 'now' } } }, + }, + }, + ], + }, + }, + ], + }, + }); + expect(script).toMatchObject({ + source: expect.any(String), + lang: 'painless', + params: { + fieldUpdates, + claimTasksById: [], + claimableTaskTypes: ['foo', 'bar'], + skippedTaskTypes: ['foobar'], + unusedTaskTypes: ['barfoo'], + taskMaxAttempts: { + bar: customMaxAttempts, + foo: maxAttempts, + }, + }, + }); + }); + test('it claims tasks by setting their ownerId, status and retryAt', async () => { const taskManagerId = uuid.v1(); const claimOwnershipUntil = new Date(Date.now()); @@ -1263,6 +1387,7 @@ if (doc['task.runAt'].size()!=0) { logger: taskManagerLogger, definitions, excludedTaskTypes: [], + unusedTypes: [], taskStore, maxAttempts: 2, getCapacity, diff --git a/x-pack/plugins/task_manager/server/queries/task_claiming.ts b/x-pack/plugins/task_manager/server/queries/task_claiming.ts index b45591a233e19..1b4f0fdb73683 100644 --- a/x-pack/plugins/task_manager/server/queries/task_claiming.ts +++ b/x-pack/plugins/task_manager/server/queries/task_claiming.ts @@ -57,6 +57,7 @@ import { TASK_MANAGER_TRANSACTION_TYPE } from '../task_running'; export interface TaskClaimingOpts { logger: Logger; definitions: TaskTypeDictionary; + unusedTypes: string[]; taskStore: TaskStore; maxAttempts: number; excludedTaskTypes: string[]; @@ -121,6 +122,7 @@ export class TaskClaiming { private readonly taskClaimingBatchesByType: TaskClaimingBatches; private readonly taskMaxAttempts: Record; private readonly excludedTaskTypes: string[]; + private readonly unusedTypes: string[]; /** * Constructs a new TaskStore. @@ -137,6 +139,7 @@ export class TaskClaiming { this.taskClaimingBatchesByType = this.partitionIntoClaimingBatches(this.definitions); this.taskMaxAttempts = Object.fromEntries(this.normalizeMaxAttempts(this.definitions)); this.excludedTaskTypes = opts.excludedTaskTypes; + this.unusedTypes = opts.unusedTypes; this.events$ = new Subject(); } @@ -225,7 +228,7 @@ export class TaskClaiming { return of(accumulatedResult); } return from( - this.executClaimAvailableTasks({ + this.executeClaimAvailableTasks({ claimOwnershipUntil, claimTasksById: claimTasksById.splice(0, capacity), size: capacity, @@ -249,7 +252,7 @@ export class TaskClaiming { ); } - private executClaimAvailableTasks = async ({ + private executeClaimAvailableTasks = async ({ claimOwnershipUntil, claimTasksById = [], size, @@ -403,16 +406,17 @@ export class TaskClaiming { : queryForScheduledTasks, filterDownBy(InactiveTasks) ); - const script = updateFieldsAndMarkAsFailed( - { + const script = updateFieldsAndMarkAsFailed({ + fieldUpdates: { ownerId: this.taskStore.taskManagerId, retryAt: claimOwnershipUntil, }, - claimTasksById || [], - taskTypesToClaim, - taskTypesToSkip, - pick(this.taskMaxAttempts, taskTypesToClaim) - ); + claimTasksById: claimTasksById || [], + claimableTaskTypes: taskTypesToClaim, + skippedTaskTypes: taskTypesToSkip, + unusedTaskTypes: this.unusedTypes, + taskMaxAttempts: pick(this.taskMaxAttempts, taskTypesToClaim), + }); const apmTrans = apm.startTransaction( TASK_MANAGER_MARK_AS_CLAIMED, diff --git a/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts b/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts index e912eda258090..cfd0f874f58ff 100644 --- a/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts +++ b/x-pack/plugins/task_manager/server/saved_objects/migrations.test.ts @@ -169,6 +169,62 @@ describe('successful migrations', () => { expect(migration820(taskInstance, migrationContext)).toEqual(taskInstance); }); + + test('resets "unrecognized" status to "idle" when task type is not in REMOVED_TYPES list', () => { + const migration820 = getMigrations()['8.2.0']; + const taskInstance = getMockData({ + taskType: 'someValidTask', + status: 'unrecognized', + }); + + expect(migration820(taskInstance, migrationContext)).toEqual({ + ...taskInstance, + attributes: { + ...taskInstance.attributes, + status: 'idle', + }, + }); + }); + + test('does not modify "unrecognized" status when task type is in REMOVED_TYPES list', () => { + const migration820 = getMigrations()['8.2.0']; + const taskInstance = getMockData({ + taskType: 'sampleTaskRemovedType', + status: 'unrecognized', + }); + + expect(migration820(taskInstance, migrationContext)).toEqual(taskInstance); + }); + + test('does not modify document when status is "running"', () => { + const migration820 = getMigrations()['8.2.0']; + const taskInstance = getMockData({ + taskType: 'someTask', + status: 'running', + }); + + expect(migration820(taskInstance, migrationContext)).toEqual(taskInstance); + }); + + test('does not modify document when status is "idle"', () => { + const migration820 = getMigrations()['8.2.0']; + const taskInstance = getMockData({ + taskType: 'someTask', + status: 'idle', + }); + + expect(migration820(taskInstance, migrationContext)).toEqual(taskInstance); + }); + + test('does not modify document when status is "failed"', () => { + const migration820 = getMigrations()['8.2.0']; + const taskInstance = getMockData({ + taskType: 'someTask', + status: 'failed', + }); + + expect(migration820(taskInstance, migrationContext)).toEqual(taskInstance); + }); }); }); diff --git a/x-pack/plugins/task_manager/server/saved_objects/migrations.ts b/x-pack/plugins/task_manager/server/saved_objects/migrations.ts index f50b3d6a927ad..6e527918f2a7e 100644 --- a/x-pack/plugins/task_manager/server/saved_objects/migrations.ts +++ b/x-pack/plugins/task_manager/server/saved_objects/migrations.ts @@ -13,6 +13,7 @@ import { SavedObjectsUtils, SavedObjectUnsanitizedDoc, } from '../../../../../src/core/server'; +import { REMOVED_TYPES } from '../task_type_dictionary'; import { ConcreteTaskInstance, TaskStatus } from '../task'; interface TaskInstanceLogMeta extends LogMeta { @@ -38,7 +39,7 @@ export function getMigrations(): SavedObjectMigrationMap { '8.0.0' ), '8.2.0': executeMigrationWithErrorHandling( - pipeMigrations(resetAttemptsAndStatusForTheTasksWithoutSchedule), + pipeMigrations(resetAttemptsAndStatusForTheTasksWithoutSchedule, resetUnrecognizedStatus), '8.2.0' ), }; @@ -143,6 +144,29 @@ function moveIntervalIntoSchedule({ }; } +function resetUnrecognizedStatus( + doc: SavedObjectUnsanitizedDoc +): SavedObjectUnsanitizedDoc { + const status = doc?.attributes?.status; + if (status && status === 'unrecognized') { + const taskType = doc.attributes.taskType; + // If task type is in the REMOVED_TYPES list, maintain "unrecognized" status + if (REMOVED_TYPES.indexOf(taskType) >= 0) { + return doc; + } + + return { + ...doc, + attributes: { + ...doc.attributes, + status: 'idle', + }, + } as SavedObjectUnsanitizedDoc; + } + + return doc; +} + function pipeMigrations(...migrations: TaskInstanceMigration[]): TaskInstanceMigration { return (doc: SavedObjectUnsanitizedDoc) => migrations.reduce((migratedDoc, nextMigration) => nextMigration(migratedDoc), doc); diff --git a/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts b/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts index d682d40a1d811..cb2f436fa8676 100644 --- a/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts +++ b/x-pack/plugins/task_manager/server/task_type_dictionary.test.ts @@ -7,7 +7,12 @@ import { get } from 'lodash'; import { RunContext, TaskDefinition } from './task'; -import { sanitizeTaskDefinitions, TaskDefinitionRegistry } from './task_type_dictionary'; +import { mockLogger } from './test_utils'; +import { + sanitizeTaskDefinitions, + TaskDefinitionRegistry, + TaskTypeDictionary, +} from './task_type_dictionary'; interface Opts { numTasks: number; @@ -40,6 +45,12 @@ const getMockTaskDefinitions = (opts: Opts) => { }; describe('taskTypeDictionary', () => { + let definitions: TaskTypeDictionary; + + beforeEach(() => { + definitions = new TaskTypeDictionary(mockLogger()); + }); + describe('sanitizeTaskDefinitions', () => {}); it('provides tasks with defaults', () => { const taskDefinitions = getMockTaskDefinitions({ numTasks: 3 }); @@ -154,4 +165,49 @@ describe('taskTypeDictionary', () => { `"Invalid timeout \\"1.5h\\". Timeout must be of the form \\"{number}{cadance}\\" where number is an integer. Example: 5m."` ); }); + + describe('registerTaskDefinitions', () => { + it('registers a valid task', () => { + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + expect(definitions.has('foo')).toBe(true); + }); + + it('throws error when registering duplicate task type', () => { + definitions.registerTaskDefinitions({ + foo: { + title: 'foo', + maxConcurrency: 2, + createTaskRunner: jest.fn(), + }, + }); + + expect(() => { + definitions.registerTaskDefinitions({ + foo: { + title: 'foo2', + createTaskRunner: jest.fn(), + }, + }); + }).toThrowErrorMatchingInlineSnapshot(`"Task foo is already defined!"`); + }); + + it('throws error when registering removed task type', () => { + expect(() => { + definitions.registerTaskDefinitions({ + sampleTaskRemovedType: { + title: 'removed', + createTaskRunner: jest.fn(), + }, + }); + }).toThrowErrorMatchingInlineSnapshot( + `"Task sampleTaskRemovedType has been removed from registration!"` + ); + }); + }); }); diff --git a/x-pack/plugins/task_manager/server/task_type_dictionary.ts b/x-pack/plugins/task_manager/server/task_type_dictionary.ts index 3bc60284efc8f..a2ea46122acf8 100644 --- a/x-pack/plugins/task_manager/server/task_type_dictionary.ts +++ b/x-pack/plugins/task_manager/server/task_type_dictionary.ts @@ -8,6 +8,17 @@ import { TaskDefinition, taskDefinitionSchema, TaskRunCreatorFunction } from './task'; import { Logger } from '../../../../src/core/server'; +/** + * Types that are no longer registered and will be marked as unregistered + */ +export const REMOVED_TYPES: string[] = [ + // for testing + 'sampleTaskRemovedType', + + // deprecated in https://github.com/elastic/kibana/pull/121442 + 'alerting:siem.signals', +]; + /** * Defines a task which can be scheduled and run by the Kibana * task manager. @@ -109,6 +120,11 @@ export class TaskTypeDictionary { throw new Error(`Task ${duplicate} is already defined!`); } + const removed = Object.keys(taskDefinitions).find((type) => REMOVED_TYPES.indexOf(type) >= 0); + if (removed) { + throw new Error(`Task ${removed} has been removed from registration!`); + } + try { for (const definition of sanitizeTaskDefinitions(taskDefinitions)) { this.definitions.set(definition.type, definition); diff --git a/x-pack/test/functional/es_archives/task_manager_removed_types/data.json b/x-pack/test/functional/es_archives/task_manager_removed_types/data.json index 8594e9d567b8a..3fc1a2cad2d28 100644 --- a/x-pack/test/functional/es_archives/task_manager_removed_types/data.json +++ b/x-pack/test/functional/es_archives/task_manager_removed_types/data.json @@ -1,3 +1,34 @@ +{ + "type": "doc", + "value": { + "id": "task:ce7e1250-3322-11eb-94c1-db6995e83f6b", + "index": ".kibana_task_manager_1", + "source": { + "migrationVersion": { + "task": "7.6.0" + }, + "references": [ + ], + "task": { + "attempts": 0, + "params": "{\"originalParams\":{},\"superFly\":\"My middleware param!\"}", + "retryAt": "2020-11-30T15:43:39.626Z", + "runAt": "2020-11-30T15:43:08.277Z", + "scheduledAt": "2020-11-30T15:43:08.277Z", + "scope": [ + "testing" + ], + "startedAt": null, + "state": "{}", + "status": "idle", + "taskType": "sampleTaskNotRegisteredType" + }, + "type": "task", + "updated_at": "2020-11-30T15:43:08.277Z" + } + } +} + { "type": "doc", "value": { diff --git a/x-pack/test/functional/es_archives/task_manager_tasks/data.json b/x-pack/test/functional/es_archives/task_manager_tasks/data.json index 3431419dda17e..2b92c18dcd47b 100644 --- a/x-pack/test/functional/es_archives/task_manager_tasks/data.json +++ b/x-pack/test/functional/es_archives/task_manager_tasks/data.json @@ -90,3 +90,65 @@ } } } + +{ + "type": "doc", + "value": { + "id": "task:ce7e1250-3322-11eb-94c1-db6995e84f6d", + "index": ".kibana_task_manager_1", + "source": { + "migrationVersion": { + "task": "7.16.0" + }, + "references": [ + ], + "task": { + "attempts": 0, + "params": "{\"spaceId\":\"user1\",\"alertId\":\"0359d7fcc04da9878ee9aadbda38ba55\"}", + "retryAt": "2020-11-30T15:43:39.626Z", + "runAt": "2020-11-30T15:43:08.277Z", + "scheduledAt": "2020-11-30T15:43:08.277Z", + "scope": [ + "testing" + ], + "startedAt": null, + "state": "{}", + "status": "unrecognized", + "taskType": "alerting:0359d7fcc04da9878ee9aadbda38ba55" + }, + "type": "task", + "updated_at": "2020-11-30T15:43:08.277Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "task:fe7e1250-3322-11eb-94c1-db6395e84f6e", + "index": ".kibana_task_manager_1", + "source": { + "migrationVersion": { + "task": "7.16.0" + }, + "references": [ + ], + "task": { + "attempts": 0, + "params": "{\"spaceId\":\"user1\",\"alertId\":\"0359d7fcc04da9878ee9aadbda38ba55\"}", + "retryAt": "2020-11-30T15:43:39.626Z", + "runAt": "2020-11-30T15:43:08.277Z", + "scheduledAt": "2020-11-30T15:43:08.277Z", + "scope": [ + "testing" + ], + "startedAt": null, + "state": "{}", + "status": "unrecognized", + "taskType": "sampleTaskRemovedType" + }, + "type": "task", + "updated_at": "2020-11-30T15:43:08.277Z" + } + } +} \ No newline at end of file diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts index 1e6bb11c13583..1b0ffdedb0077 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/migrations.ts @@ -104,5 +104,37 @@ export default function createGetTests({ getService }: FtrProviderContext) { expect(hit!._source!.task.attempts).to.be(0); expect(hit!._source!.task.status).to.be(TaskStatus.Idle); }); + + it('8.2.0 migrates tasks with unrecognized status to idle if task type is removed', async () => { + const response = await es.get<{ task: ConcreteTaskInstance }>( + { + index: '.kibana_task_manager', + id: 'task:ce7e1250-3322-11eb-94c1-db6995e84f6d', + }, + { + meta: true, + } + ); + expect(response.statusCode).to.eql(200); + expect(response.body._source?.task.taskType).to.eql( + `alerting:0359d7fcc04da9878ee9aadbda38ba55` + ); + expect(response.body._source?.task.status).to.eql(`idle`); + }); + + it('8.2.0 does not migrate tasks with unrecognized status if task type is valid', async () => { + const response = await es.get<{ task: ConcreteTaskInstance }>( + { + index: '.kibana_task_manager', + id: 'task:fe7e1250-3322-11eb-94c1-db6395e84f6e', + }, + { + meta: true, + } + ); + expect(response.statusCode).to.eql(200); + expect(response.body._source?.task.taskType).to.eql(`sampleTaskRemovedType`); + expect(response.body._source?.task.status).to.eql(`unrecognized`); + }); }); } diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management_removed_types.ts b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management_removed_types.ts index 61223b8b67e64..90590f1e3e572 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management_removed_types.ts +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_management_removed_types.ts @@ -45,9 +45,10 @@ export default function ({ getService }: FtrProviderContext) { const config = getService('config'); const request = supertest(url.format(config.get('servers.kibana'))); + const UNREGISTERED_TASK_TYPE_ID = 'ce7e1250-3322-11eb-94c1-db6995e83f6b'; const REMOVED_TASK_TYPE_ID = 'be7e1250-3322-11eb-94c1-db6995e83f6a'; - describe('removed task types', () => { + describe('not registered task types', () => { before(async () => { await esArchiver.load('x-pack/test/functional/es_archives/task_manager_removed_types'); }); @@ -76,7 +77,7 @@ export default function ({ getService }: FtrProviderContext) { .then((response) => response.body); } - it('should successfully schedule registered tasks and mark unregistered tasks as unrecognized', async () => { + it('should successfully schedule registered tasks, not claim unregistered tasks and mark removed task types as unrecognized', async () => { const scheduledTask = await scheduleTask({ taskType: 'sampleTask', schedule: { interval: `1s` }, @@ -85,16 +86,21 @@ export default function ({ getService }: FtrProviderContext) { await retry.try(async () => { const tasks = (await currentTasks()).docs; - expect(tasks.length).to.eql(2); + expect(tasks.length).to.eql(3); const taskIds = tasks.map((task) => task.id); expect(taskIds).to.contain(scheduledTask.id); + expect(taskIds).to.contain(UNREGISTERED_TASK_TYPE_ID); expect(taskIds).to.contain(REMOVED_TASK_TYPE_ID); const scheduledTaskInstance = tasks.find((task) => task.id === scheduledTask.id); + const unregisteredTaskInstance = tasks.find( + (task) => task.id === UNREGISTERED_TASK_TYPE_ID + ); const removedTaskInstance = tasks.find((task) => task.id === REMOVED_TASK_TYPE_ID); expect(scheduledTaskInstance?.status).to.eql('claiming'); + expect(unregisteredTaskInstance?.status).to.eql('idle'); expect(removedTaskInstance?.status).to.eql('unrecognized'); }); }); From dde4d6e9daef0954ce997c97a1b2970d97cdcb77 Mon Sep 17 00:00:00 2001 From: Abdul Wahab Zahid Date: Tue, 15 Feb 2022 16:33:43 +0100 Subject: [PATCH 24/43] [Uptime][Monitor Management] Use push flyout to show Test Run Results (#125017) (uptime/issues/445) * Make run-once test results appear in a push flyout. Fixup tooltip. Fixup action buttons order. * Wrapping Monitor Fields form rows when Test Run flyout is open. * Only show step duration trend if it's an already saved monitor. Stop showing "Failed to run steps" until Test Run steps are done loading. uptime/issues/445 Co-authored-by: shahzad31 --- .../fleet_package/browser/advanced_fields.tsx | 17 ++-- .../browser/throttling_fields.tsx | 18 ++-- .../components/fleet_package/code_editor.tsx | 15 +++- .../common/described_form_group_with_wrap.tsx | 23 +++++ .../fleet_package/custom_fields.tsx | 29 ++++-- .../fleet_package/http/advanced_fields.tsx | 20 +++-- .../fleet_package/tcp/advanced_fields.tsx | 23 +++-- .../action_bar/action_bar.test.tsx | 12 +-- .../action_bar/action_bar.tsx | 69 +++++++++----- .../action_bar/action_bar_errors.test.tsx | 4 +- .../edit_monitor_config.tsx | 2 +- .../monitor_advanced_fields.tsx | 15 ++-- .../monitor_config/monitor_config.tsx | 90 +++++++++++++------ .../monitor_config/monitor_fields.tsx | 10 ++- .../monitor_config/monitor_name_location.tsx | 2 +- .../browser/browser_test_results.test.tsx | 18 +++- .../browser/browser_test_results.tsx | 24 ++++- .../use_browser_run_once_monitors.test.tsx | 1 + .../browser/use_browser_run_once_monitors.ts | 3 +- .../simple/simple_test_results.test.tsx | 17 +++- .../simple/simple_test_results.tsx | 6 +- .../test_now_mode/test_now_mode.test.tsx | 12 ++- .../test_now_mode/test_now_mode.tsx | 33 +++++-- .../test_now_mode/test_run_results.tsx | 8 +- .../synthetics/check_steps/step_duration.tsx | 26 ++++-- .../synthetics/check_steps/steps_list.tsx | 10 ++- .../pages/monitor_management/add_monitor.tsx | 2 +- 27 files changed, 362 insertions(+), 147 deletions(-) create mode 100644 x-pack/plugins/uptime/public/components/fleet_package/common/described_form_group_with_wrap.tsx diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx index cf72c7562d390..f838474f5219b 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/advanced_fields.tsx @@ -13,10 +13,10 @@ import { EuiFieldText, EuiCheckbox, EuiFormRow, - EuiDescribedFormGroup, EuiSpacer, } from '@elastic/eui'; import { ComboBox } from '../combo_box'; +import { DescribedFormGroupWithWrap } from '../common/described_form_group_with_wrap'; import { useBrowserAdvancedFieldsContext, useBrowserSimpleFieldsContext } from '../contexts'; @@ -28,9 +28,10 @@ import { ThrottlingFields } from './throttling_fields'; interface Props { validate: Validation; children?: React.ReactNode; + minColumnWidth?: string; } -export const BrowserAdvancedFields = memo(({ validate, children }) => { +export const BrowserAdvancedFields = memo(({ validate, children, minColumnWidth }) => { const { fields, setFields } = useBrowserAdvancedFieldsContext(); const { fields: simpleFields } = useBrowserSimpleFieldsContext(); @@ -49,7 +50,8 @@ export const BrowserAdvancedFields = memo(({ validate, children }) => { > {simpleFields[ConfigKey.SOURCE_ZIP_URL] && ( - (({ validate, children }) => { data-test-subj="syntheticsBrowserJourneyFiltersTags" /> - + )} - (({ validate, children }) => { data-test-subj="syntheticsBrowserSyntheticsArgs" /> - + - + {children} ); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx index 6d52ef755d0a2..d5ec96ebb5a6f 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/browser/throttling_fields.tsx @@ -7,14 +7,8 @@ import React, { memo, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiDescribedFormGroup, - EuiSwitch, - EuiSpacer, - EuiFormRow, - EuiFieldNumber, - EuiText, -} from '@elastic/eui'; +import { EuiSwitch, EuiSpacer, EuiFormRow, EuiFieldNumber, EuiText } from '@elastic/eui'; +import { DescribedFormGroupWithWrap } from '../common/described_form_group_with_wrap'; import { OptionalLabel } from '../optional_label'; import { useBrowserAdvancedFieldsContext } from '../contexts'; @@ -22,6 +16,7 @@ import { Validation, ConfigKey } from '../types'; interface Props { validate: Validation; + minColumnWidth?: string; } type ThrottlingConfigs = @@ -30,7 +25,7 @@ type ThrottlingConfigs = | ConfigKey.UPLOAD_SPEED | ConfigKey.LATENCY; -export const ThrottlingFields = memo(({ validate }) => { +export const ThrottlingFields = memo(({ validate, minColumnWidth }) => { const { fields, setFields } = useBrowserAdvancedFieldsContext(); const handleInputChange = useCallback( @@ -148,7 +143,8 @@ export const ThrottlingFields = memo(({ validate }) => { ) : null; return ( - (({ validate }) => { } /> {throttlingInputs} - + ); }); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx b/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx index 3f80b5f9f365e..ee3ede16582f9 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/code_editor.tsx @@ -9,6 +9,7 @@ import React from 'react'; import styled from 'styled-components'; import { EuiPanel } from '@elastic/eui'; +import { euiStyled } from '../../../../../../src/plugins/kibana_react/common'; import { CodeEditor as MonacoCodeEditor } from '../../../../../../src/plugins/kibana_react/public'; import { MonacoEditorLangId } from './types'; @@ -28,7 +29,11 @@ interface Props { export const CodeEditor = ({ ariaLabel, id, languageId, onChange, value }: Props) => { return ( -
+ -
+
); }; + +const MonacoCodeContainer = euiStyled.div` + & > .kibanaCodeEditor { + z-index: 0; + } +`; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/common/described_form_group_with_wrap.tsx b/x-pack/plugins/uptime/public/components/fleet_package/common/described_form_group_with_wrap.tsx new file mode 100644 index 0000000000000..5668b6f1121c8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/fleet_package/common/described_form_group_with_wrap.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { EuiDescribedFormGroup } from '@elastic/eui'; +import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; + +/** + * EuiForm group doesn't expose props to control the flex wrapping on flex groups defining form rows. + * This override allows to define a minimum column width to which the Described Form's flex rows should wrap. + */ +export const DescribedFormGroupWithWrap = euiStyled(EuiDescribedFormGroup)<{ + minColumnWidth?: string; +}>` + > .euiFlexGroup { + ${({ minColumnWidth }) => (minColumnWidth ? `flex-wrap: wrap;` : '')} + > .euiFlexItem { + ${({ minColumnWidth }) => (minColumnWidth ? `min-width: ${minColumnWidth};` : '')} + } + } +`; diff --git a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx index 10aa01ba9361d..638f91fb32d21 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/custom_fields.tsx @@ -14,11 +14,11 @@ import { EuiFormRow, EuiSelect, EuiSpacer, - EuiDescribedFormGroup, EuiSwitch, EuiCallOut, EuiLink, } from '@elastic/eui'; +import { DescribedFormGroupWithWrap } from './common/described_form_group_with_wrap'; import { ConfigKey, DataStream, Validation } from './types'; import { usePolicyConfigContext } from './contexts'; import { TLSFields } from './tls_fields'; @@ -36,6 +36,7 @@ interface Props { dataStreams?: DataStream[]; children?: React.ReactNode; appendAdvancedFields?: React.ReactNode; + minColumnWidth?: string; } const dataStreamToString = [ @@ -54,7 +55,7 @@ const dataStreamToString = [ ]; export const CustomFields = memo( - ({ validate, dataStreams = [], children, appendAdvancedFields }) => { + ({ validate, dataStreams = [], children, appendAdvancedFields, minColumnWidth }) => { const { monitorType, setMonitorType, isTLSEnabled, setIsTLSEnabled, isEditable } = usePolicyConfigContext(); @@ -86,7 +87,8 @@ export const CustomFields = memo( return ( - ( {renderSimpleFields(monitorType)} - + {(isHTTP || isTCP) && ( - ( onChange={(event) => setIsTLSEnabled(event.target.checked)} /> - + )} {isHTTP && ( - {appendAdvancedFields} + + {appendAdvancedFields} + + )} + {isTCP && ( + + {appendAdvancedFields} + )} - {isTCP && {appendAdvancedFields}} {isBrowser && ( - {appendAdvancedFields} + + {appendAdvancedFields} + )} {isICMP && {appendAdvancedFields}} diff --git a/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx index 35c6eb6ffa9e3..e4dd68f50f52c 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/http/advanced_fields.tsx @@ -14,11 +14,11 @@ import { EuiFieldText, EuiFormRow, EuiSelect, - EuiDescribedFormGroup, EuiCheckbox, EuiSpacer, EuiFieldPassword, } from '@elastic/eui'; +import { DescribedFormGroupWithWrap } from '../common/described_form_group_with_wrap'; import { useHTTPAdvancedFieldsContext } from '../contexts'; @@ -33,9 +33,10 @@ import { ComboBox } from '../combo_box'; interface Props { validate: Validation; children?: React.ReactNode; + minColumnWidth?: string; } -export const HTTPAdvancedFields = memo(({ validate, children }) => { +export const HTTPAdvancedFields = memo(({ validate, children, minColumnWidth }) => { const { fields, setFields } = useHTTPAdvancedFieldsContext(); const handleInputChange = useCallback( ({ value, configKey }: { value: unknown; configKey: ConfigKey }) => { @@ -56,7 +57,8 @@ export const HTTPAdvancedFields = memo(({ validate, children }) => { data-test-subj="syntheticsHTTPAdvancedFieldsAccordion" > - (({ validate, children }) => { )} /> - + - (({ validate, children }) => { )} /> - - + (({ validate, children }) => { data-test-subj="syntheticsResponseBodyCheckNegative" /> - + {children} ); diff --git a/x-pack/plugins/uptime/public/components/fleet_package/tcp/advanced_fields.tsx b/x-pack/plugins/uptime/public/components/fleet_package/tcp/advanced_fields.tsx index 46e1a739c57c1..ab185b34085bc 100644 --- a/x-pack/plugins/uptime/public/components/fleet_package/tcp/advanced_fields.tsx +++ b/x-pack/plugins/uptime/public/components/fleet_package/tcp/advanced_fields.tsx @@ -7,14 +7,8 @@ import React, { memo, useCallback } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiAccordion, - EuiCheckbox, - EuiFormRow, - EuiDescribedFormGroup, - EuiFieldText, - EuiSpacer, -} from '@elastic/eui'; +import { EuiAccordion, EuiCheckbox, EuiFormRow, EuiFieldText, EuiSpacer } from '@elastic/eui'; +import { DescribedFormGroupWithWrap } from '../common/described_form_group_with_wrap'; import { useTCPAdvancedFieldsContext } from '../contexts'; @@ -24,9 +18,10 @@ import { OptionalLabel } from '../optional_label'; interface Props { children?: React.ReactNode; + minColumnWidth?: string; } -export const TCPAdvancedFields = memo(({ children }) => { +export const TCPAdvancedFields = memo(({ children, minColumnWidth }) => { const { fields, setFields } = useTCPAdvancedFieldsContext(); const handleInputChange = useCallback( @@ -43,7 +38,8 @@ export const TCPAdvancedFields = memo(({ children }) => { data-test-subj="syntheticsTCPAdvancedFieldsAccordion" > - (({ children }) => { data-test-subj="syntheticsTCPRequestSendCheck" /> - - + (({ children }) => { data-test-subj="syntheticsTCPResponseReceiveCheck" /> - + {children} ); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.test.tsx index adc2a0a8ed344..64b7984b00b40 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.test.tsx @@ -35,7 +35,7 @@ describe('', () => { }); it('only calls setMonitor when valid and after submission', () => { - render(); + render(); act(() => { userEvent.click(screen.getByText('Save monitor')); @@ -45,7 +45,7 @@ describe('', () => { }); it('does not call setMonitor until submission', () => { - render(); + render(); expect(setMonitor).not.toBeCalled(); @@ -57,7 +57,7 @@ describe('', () => { }); it('does not call setMonitor if invalid', () => { - render(); + render(); expect(setMonitor).not.toBeCalled(); @@ -69,7 +69,7 @@ describe('', () => { }); it('disables button and displays help text when form is invalid after first submission', async () => { - render(); + render(); expect( screen.queryByText('Your monitor has errors. Please fix them before saving.') @@ -90,7 +90,9 @@ describe('', () => { it('calls option onSave when saving monitor', () => { const onSave = jest.fn(); - render(); + render( + + ); act(() => { userEvent.click(screen.getByText('Save monitor')); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx index 4d0d20d548673..f54031766be8e 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/action_bar/action_bar.tsx @@ -13,7 +13,7 @@ import { EuiButton, EuiButtonEmpty, EuiText, - EuiToolTip, + EuiPopover, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -37,11 +37,19 @@ export interface ActionBarProps { monitor: SyntheticsMonitor; isValid: boolean; testRun?: TestRun; + isTestRunInProgress: boolean; onSave?: () => void; onTestNow?: () => void; } -export const ActionBar = ({ monitor, isValid, onSave, onTestNow, testRun }: ActionBarProps) => { +export const ActionBar = ({ + monitor, + isValid, + onSave, + onTestNow, + testRun, + isTestRunInProgress, +}: ActionBarProps) => { const { monitorId } = useParams<{ monitorId: string }>(); const { basePath } = useContext(UptimeSettingsContext); const { locations } = useSelector(monitorManagementListSelector); @@ -49,6 +57,7 @@ export const ActionBar = ({ monitor, isValid, onSave, onTestNow, testRun }: Acti const [hasBeenSubmitted, setHasBeenSubmitted] = useState(false); const [isSaving, setIsSaving] = useState(false); const [isSuccessful, setIsSuccessful] = useState(false); + const [isPopoverOpen, setIsPopoverOpen] = useState(undefined); const { data, status } = useFetcher(() => { if (!isSaving || !isValid) { @@ -94,7 +103,7 @@ export const ActionBar = ({ monitor, isValid, onSave, onTestNow, testRun }: Acti }); setIsSuccessful(true); } else if (hasErrors && !loading) { - Object.values(data).forEach((location) => { + Object.values(data!).forEach((location) => { const { status: responseStatus, reason } = location.error || {}; kibanaService.toasts.addWarning({ title: i18n.translate('xpack.uptime.monitorManagement.service.error.title', { @@ -144,35 +153,51 @@ export const ActionBar = ({ monitor, isValid, onSave, onTestNow, testRun }: Acti - {onTestNow && ( - - - onTestNow()} - disabled={!isValid} - data-test-subj={'monitorTestNowRunBtn'} - > - {testRun ? RE_RUN_TEST_LABEL : RUN_TEST_LABEL} - - - - )} - {DISCARD_LABEL} + {onTestNow && ( + + {/* Popover is used instead of EuiTooltip until the resolution of https://github.com/elastic/eui/issues/5604 */} + onTestNow()} + onMouseEnter={() => { + setIsPopoverOpen(true); + }} + onMouseLeave={() => { + setIsPopoverOpen(false); + }} + > + {testRun ? RE_RUN_TEST_LABEL : RUN_TEST_LABEL} + + } + isOpen={isPopoverOpen} + > + +

{TEST_NOW_DESCRIPTION}

+
+
+
+ )} + Service Errors', () => { status: FETCH_STATUS.SUCCESS, refetch: () => {}, }); - render(, { state: mockLocationsState }); + render(, { + state: mockLocationsState, + }); userEvent.click(screen.getByText('Save monitor')); await waitFor(() => { diff --git a/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx b/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx index 015d9c2f9dfdf..2f2014f405bd2 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/edit_monitor_config.tsx @@ -88,7 +88,7 @@ export const EditMonitorConfig = ({ monitor }: Props) => { browserDefaultValues={fullDefaultConfig[DataStream.BROWSER]} tlsDefaultValues={defaultTLSConfig} > - + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_advanced_fields.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_advanced_fields.tsx index 5cecdf9a385bd..21ef7d12dcd59 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_advanced_fields.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_advanced_fields.tsx @@ -6,17 +6,19 @@ */ import React, { memo } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { EuiFormRow, EuiSpacer, EuiDescribedFormGroup, EuiLink, EuiFieldText } from '@elastic/eui'; -import type { Validation } from '../../../../common/types/index'; -import { ConfigKey } from '../../../../common/runtime_types/monitor_management'; +import { EuiFormRow, EuiSpacer, EuiLink, EuiFieldText } from '@elastic/eui'; +import type { Validation } from '../../../../common/types'; +import { ConfigKey } from '../../../../common/runtime_types'; +import { DescribedFormGroupWithWrap } from '../../fleet_package/common/described_form_group_with_wrap'; import { usePolicyConfigContext } from '../../fleet_package/contexts'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; interface Props { validate: Validation; + minColumnWidth?: string; } -export const MonitorManagementAdvancedFields = memo(({ validate }) => { +export const MonitorManagementAdvancedFields = memo(({ validate, minColumnWidth }) => { const { namespace, setNamespace } = usePolicyConfigContext(); const namespaceErrorMsg = validate[ConfigKey.NAMESPACE]?.({ @@ -26,7 +28,8 @@ export const MonitorManagementAdvancedFields = memo(({ validate }) => { const { services } = useKibana(); return ( - (({ validate }) => { name="namespace" /> - + ); }); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx index bcade36929805..c12e3a3f49939 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_config.tsx @@ -5,9 +5,17 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; -import { EuiResizableContainer } from '@elastic/eui'; +import { + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyout, + EuiSpacer, + EuiFlyoutFooter, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { v4 as uuidv4 } from 'uuid'; import { defaultConfig, usePolicyConfigContext } from '../../fleet_package/contexts'; @@ -19,7 +27,7 @@ import { MonitorFields } from './monitor_fields'; import { TestNowMode, TestRun } from '../test_now_mode/test_now_mode'; import { MonitorFields as MonitorFieldsType } from '../../../../common/runtime_types'; -export const MonitorConfig = () => { +export const MonitorConfig = ({ isEdit = false }: { isEdit: boolean }) => { const { monitorType } = usePolicyConfigContext(); /* raw policy config compatible with the UI. Save this to saved objects */ @@ -37,46 +45,70 @@ export const MonitorConfig = () => { }); const [testRun, setTestRun] = useState(); + const [isTestRunInProgress, setIsTestRunInProgress] = useState(false); + const [isFlyoutOpen, setIsFlyoutOpen] = useState(false); - const onTestNow = () => { + const handleTestNow = () => { if (config) { setTestRun({ id: uuidv4(), monitor: config as MonitorFieldsType }); + setIsTestRunInProgress(true); + setIsFlyoutOpen(true); } }; + const handleTestDone = useCallback(() => { + setIsTestRunInProgress(false); + }, [setIsTestRunInProgress]); + + const handleFlyoutClose = useCallback(() => { + handleTestDone(); + setIsFlyoutOpen(false); + }, [handleTestDone, setIsFlyoutOpen]); + + const flyout = isFlyoutOpen && config && ( + + + + + + + + + + {CLOSE_LABEL} + + + + ); + return ( <> - - {(EuiResizablePanel, EuiResizableButton) => ( - <> - - - - - - - - {config && } - - - )} - + + + {flyout} ); }; + +const TEST_RESULT = i18n.translate('xpack.uptime.monitorManagement.testResult', { + defaultMessage: 'Test result', +}); + +const CLOSE_LABEL = i18n.translate('xpack.uptime.monitorManagement.closeButtonLabel', { + defaultMessage: 'Close', +}); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_fields.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_fields.tsx index 9e72a810f821c..32783460aed09 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_fields.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_fields.tsx @@ -15,14 +15,22 @@ import { validate } from '../validation'; import { MonitorNameAndLocation } from './monitor_name_location'; import { MonitorManagementAdvancedFields } from './monitor_advanced_fields'; +const MIN_COLUMN_WRAP_WIDTH = '360px'; + export const MonitorFields = () => { const { monitorType } = usePolicyConfigContext(); return ( } + appendAdvancedFields={ + + } > diff --git a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_name_location.tsx b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_name_location.tsx index b5a4c7c4f7b0f..7ba80f411c6f1 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_name_location.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/monitor_config/monitor_name_location.tsx @@ -43,7 +43,7 @@ export const MonitorNameAndLocation = ({ validate }: Props) => { defaultMessage="Monitor name" /> } - fullWidth={true} + fullWidth={false} isInvalid={isNameInvalid || nameAlreadyExists} error={ nameAlreadyExists ? ( diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx index 727dfa4b9ec31..d164e19705838 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.test.tsx @@ -14,8 +14,16 @@ import { BrowserTestRunResult } from './browser_test_results'; import { fireEvent } from '@testing-library/dom'; describe('BrowserTestRunResult', function () { + const onDone = jest.fn(); + let testId: string; + + beforeEach(() => { + testId = 'test-id'; + jest.resetAllMocks(); + }); + it('should render properly', async function () { - render(); + render(); expect(await screen.findByText('Test result')).toBeInTheDocument(); expect(await screen.findByText('0 steps completed')).toBeInTheDocument(); const dataApi = (kibanaService.core as any).data.search; @@ -28,7 +36,7 @@ describe('BrowserTestRunResult', function () { query: { bool: { filter: [ - { term: { config_id: 'test-id' } }, + { term: { config_id: testId } }, { terms: { 'synthetics.type': ['heartbeat/summary', 'journey/start'], @@ -52,12 +60,13 @@ describe('BrowserTestRunResult', function () { data, stepListData: { steps: [stepEndDoc._source] } as any, loading: false, + stepsLoading: false, journeyStarted: true, summaryDoc: summaryDoc._source, stepEnds: [stepEndDoc._source], }); - render(); + render(); expect(await screen.findByText('Test result')).toBeInTheDocument(); @@ -69,6 +78,9 @@ describe('BrowserTestRunResult', function () { expect(await screen.findByText('Go to https://www.elastic.co/')).toBeInTheDocument(); expect(await screen.findByText('21.8 seconds')).toBeInTheDocument(); + + // Calls onDone on completion + expect(onDone).toHaveBeenCalled(); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx index 5dc893356b214..c6074626bad1e 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/browser_test_results.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import { useEffect } from 'react'; import * as React from 'react'; import { EuiAccordion, EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; @@ -16,13 +17,21 @@ import { TestResultHeader } from '../test_result_header'; interface Props { monitorId: string; + isMonitorSaved: boolean; + onDone: () => void; } -export const BrowserTestRunResult = ({ monitorId }: Props) => { - const { data, loading, stepEnds, journeyStarted, summaryDoc, stepListData } = +export const BrowserTestRunResult = ({ monitorId, isMonitorSaved, onDone }: Props) => { + const { data, loading, stepsLoading, stepEnds, journeyStarted, summaryDoc, stepListData } = useBrowserRunOnceMonitors({ configId: monitorId, }); + useEffect(() => { + if (Boolean(summaryDoc)) { + onDone(); + } + }, [summaryDoc, onDone]); + const hits = data?.hits.hits; const doc = hits?.[0]?._source as JourneyStep; @@ -50,6 +59,10 @@ export const BrowserTestRunResult = ({ monitorId }: Props) => {
); + const isStepsLoading = + journeyStarted && stepEnds.length === 0 && (!summaryDoc || (summaryDoc && stepsLoading)); + const isStepsLoadingFailed = summaryDoc && stepEnds.length === 0 && !isStepsLoading; + return ( { buttonContent={buttonContent} paddingSize="s" data-test-subj="expandResults" + initialIsOpen={true} > - {summaryDoc && stepEnds.length === 0 && {FAILED_TO_RUN}} - {!summaryDoc && journeyStarted && stepEnds.length === 0 && {LOADING_STEPS}} + {isStepsLoading && {LOADING_STEPS}} + {isStepsLoadingFailed && {FAILED_TO_RUN}} + {stepEnds.length > 0 && stepListData?.steps && ( diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx index f467bb642a13e..285a4a4140c27 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.test.tsx @@ -34,6 +34,7 @@ describe('useBrowserRunOnceMonitors', function () { data: undefined, journeyStarted: false, loading: true, + stepsLoading: true, stepEnds: [], stepListData: undefined, summaryDoc: undefined, diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.ts b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.ts index d051eaebe392e..04605373f369e 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.ts +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/browser/use_browser_run_once_monitors.ts @@ -86,7 +86,7 @@ export const useBrowserRunOnceMonitors = ({ const { data, loading } = useBrowserEsResults({ configId, testRunId, lastRefresh }); - const { data: stepListData } = useFetcher(() => { + const { data: stepListData, loading: stepsLoading } = useFetcher(() => { if (checkGroupId && !skipDetails) { return fetchJourneySteps({ checkGroup: checkGroupId, @@ -122,6 +122,7 @@ export const useBrowserRunOnceMonitors = ({ data, stepEnds, loading, + stepsLoading, stepListData, summaryDoc: summary, journeyStarted: Boolean(checkGroupId), diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx index 99ed9ac43db1b..1d5dfef8a67e7 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.test.tsx @@ -14,8 +14,16 @@ import * as runOnceHooks from './use_simple_run_once_monitors'; import { Ping } from '../../../../../common/runtime_types'; describe('SimpleTestResults', function () { + const onDone = jest.fn(); + let testId: string; + + beforeEach(() => { + testId = 'test-id'; + jest.resetAllMocks(); + }); + it('should render properly', async function () { - render(); + render(); expect(await screen.findByText('Test result')).toBeInTheDocument(); const dataApi = (kibanaService.core as any).data.search; @@ -26,7 +34,7 @@ describe('SimpleTestResults', function () { body: { query: { bool: { - filter: [{ term: { config_id: 'test-id' } }, { exists: { field: 'summary' } }], + filter: [{ term: { config_id: testId } }, { exists: { field: 'summary' } }], }, }, sort: [{ '@timestamp': 'desc' }], @@ -51,7 +59,7 @@ describe('SimpleTestResults', function () { loading: false, }); - render(); + render(); expect(await screen.findByText('Test result')).toBeInTheDocument(); @@ -61,6 +69,9 @@ describe('SimpleTestResults', function () { expect(await screen.findByText('Checked Jan 12, 2022 11:54:27 AM')).toBeInTheDocument(); expect(await screen.findByText('Took 191 ms')).toBeInTheDocument(); + // Calls onDone on completion + expect(onDone).toHaveBeenCalled(); + screen.debug(); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx index 507082c7fefb1..4fb27fb83d560 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/simple/simple_test_results.tsx @@ -12,16 +12,18 @@ import { TestResultHeader } from '../test_result_header'; interface Props { monitorId: string; + onDone: () => void; } -export function SimpleTestResults({ monitorId }: Props) { +export function SimpleTestResults({ monitorId, onDone }: Props) { const [summaryDocs, setSummaryDocs] = useState([]); const { summaryDoc, loading } = useSimpleRunOnceMonitors({ configId: monitorId }); useEffect(() => { if (summaryDoc) { setSummaryDocs((prevState) => [summaryDoc, ...prevState]); + onDone(); } - }, [summaryDoc]); + }, [summaryDoc, onDone]); return ( <> diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx index 849f1215614d0..4a3f155a18813 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.test.tsx @@ -13,9 +13,19 @@ import { kibanaService } from '../../../state/kibana_service'; import { MonitorFields } from '../../../../common/runtime_types'; describe('TestNowMode', function () { + const onDone = jest.fn(); + + afterEach(() => { + jest.resetAllMocks(); + }); + it('should render properly', async function () { render( - + ); expect(await screen.findByText('Test result')).toBeInTheDocument(); expect(await screen.findByText('PENDING')).toBeInTheDocument(); diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx index 43d4e0e6e9d2a..a4f04e04ddc14 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_now_mode.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiCallOut, @@ -26,13 +26,25 @@ export interface TestRun { monitor: MonitorFields; } -export function TestNowMode({ testRun }: { testRun?: TestRun }) { +export function TestNowMode({ + testRun, + isMonitorSaved, + onDone, +}: { + testRun?: TestRun; + isMonitorSaved: boolean; + onDone: () => void; +}) { + const [serviceError, setServiceError] = useState(null); + const { data, loading: isPushing } = useFetcher(() => { if (testRun) { return runOnceMonitor({ monitor: testRun.monitor, id: testRun.id, - }); + }) + .then(() => setServiceError(null)) + .catch((error) => setServiceError(error)); } return new Promise((resolve) => resolve(null)); }, [testRun]); @@ -49,7 +61,13 @@ export function TestNowMode({ testRun }: { testRun?: TestRun }) { const errors = (data as { errors?: Array<{ error: Error }> })?.errors; - const hasErrors = errors && errors?.length > 0; + const hasErrors = serviceError || (errors && errors?.length > 0); + + useEffect(() => { + if (!isPushing && (!testRun || hasErrors)) { + onDone(); + } + }, [testRun, hasErrors, isPushing, onDone]); if (!testRun) { return null; @@ -68,7 +86,12 @@ export function TestNowMode({ testRun }: { testRun?: TestRun }) { {testRun && !hasErrors && !isPushing && ( - + )} diff --git a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx index 4b261815e9949..27c9eb8426a31 100644 --- a/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx +++ b/x-pack/plugins/uptime/public/components/monitor_management/test_now_mode/test_run_results.tsx @@ -13,11 +13,13 @@ import { SimpleTestResults } from './simple/simple_test_results'; interface Props { monitorId: string; monitor: SyntheticsMonitor; + isMonitorSaved: boolean; + onDone: () => void; } -export const TestRunResult = ({ monitorId, monitor }: Props) => { +export const TestRunResult = ({ monitorId, monitor, isMonitorSaved, onDone }: Props) => { return monitor.type === 'browser' ? ( - + ) : ( - + ); }; diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx index a9697a8969f65..d9c8eee59ecc1 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/step_duration.tsx @@ -8,7 +8,7 @@ import type { MouseEvent } from 'react'; import * as React from 'react'; -import { EuiButtonEmpty, EuiPopover } from '@elastic/eui'; +import { EuiButtonEmpty, EuiPopover, EuiText } from '@elastic/eui'; import { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { JourneyStep } from '../../../../common/runtime_types'; @@ -16,6 +16,7 @@ import { StepFieldTrend } from './step_field_trend'; import { microToSec } from '../../../lib/formatting'; interface Props { + showStepDurationTrend?: boolean; compactView?: boolean; step: JourneyStep; durationPopoverOpenIndex: number | null; @@ -26,8 +27,20 @@ export const StepDuration = ({ step, durationPopoverOpenIndex, setDurationPopoverOpenIndex, + showStepDurationTrend = true, compactView = false, }: Props) => { + const stepDurationText = useMemo( + () => + i18n.translate('xpack.uptime.synthetics.step.duration', { + defaultMessage: '{value} seconds', + values: { + value: microToSec(step.synthetics.step?.duration.us!, 1), + }, + }), + [step.synthetics.step?.duration.us] + ); + const component = useMemo( () => ( --; } + if (!showStepDurationTrend) { + return {stepDurationText}; + } + const button = ( setDurationPopoverOpenIndex(step.synthetics.step?.index ?? null)} iconType={compactView ? undefined : 'visArea'} > - {i18n.translate('xpack.uptime.synthetics.step.duration', { - defaultMessage: '{value} seconds', - values: { - value: microToSec(step.synthetics.step?.duration.us!, 1), - }, - })} + {stepDurationText} ); diff --git a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx index d635d76fc3f89..40362df3df5fc 100644 --- a/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx +++ b/x-pack/plugins/uptime/public/components/synthetics/check_steps/steps_list.tsx @@ -37,6 +37,7 @@ interface Props { error?: Error; loading: boolean; compactView?: boolean; + showStepDurationTrend?: boolean; } interface StepStatusCount { @@ -85,7 +86,13 @@ function reduceStepStatus(prev: StepStatusCount, cur: JourneyStep): StepStatusCo return prev; } -export const StepsList = ({ data, error, loading, compactView = false }: Props) => { +export const StepsList = ({ + data, + error, + loading, + showStepDurationTrend = true, + compactView = false, +}: Props) => { const steps: JourneyStep[] = data.filter(isStepEnd); const { expandedRows, toggleExpand } = useExpandedRow({ steps, allSteps: data, loading }); @@ -140,6 +147,7 @@ export const StepsList = ({ data, error, loading, compactView = false }: Props) step={item} durationPopoverOpenIndex={durationPopoverOpenIndex} setDurationPopoverOpenIndex={setDurationPopoverOpenIndex} + showStepDurationTrend={showStepDurationTrend} compactView={compactView} /> ); diff --git a/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx b/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx index bc8737ccd4b35..dbf1c1214abd0 100644 --- a/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor_management/add_monitor.tsx @@ -37,7 +37,7 @@ export const AddMonitorPage: React.FC = () => { allowedScheduleUnits: [ScheduleUnit.MINUTES], }} > - + ); From 35f4ba536262d37946a48922d30997e98ff74620 Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Tue, 15 Feb 2022 16:46:05 +0100 Subject: [PATCH 25/43] revert package policy validation that caused issue with input groups (#125657) --- .../services/validate_package_policy.test.ts | 3 ++- .../common/services/validate_package_policy.ts | 14 +++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts b/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts index afb6a2f806f9a..975d45fd01c64 100644 --- a/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/validate_package_policy.test.ts @@ -607,7 +607,8 @@ describe('Fleet - validatePackagePolicy()', () => { }); }); - it('returns package policy validation error if input var does not exist', () => { + // TODO enable when https://github.com/elastic/kibana/issues/125655 is fixed + it.skip('returns package policy validation error if input var does not exist', () => { expect( validatePackagePolicy( { diff --git a/x-pack/plugins/fleet/common/services/validate_package_policy.ts b/x-pack/plugins/fleet/common/services/validate_package_policy.ts index f1e28bfbe4e55..2a8c187d71629 100644 --- a/x-pack/plugins/fleet/common/services/validate_package_policy.ts +++ b/x-pack/plugins/fleet/common/services/validate_package_policy.ts @@ -210,15 +210,11 @@ export const validatePackagePolicyConfig = ( } if (varDef === undefined) { - errors.push( - i18n.translate('xpack.fleet.packagePolicyValidation.nonExistentVarMessage', { - defaultMessage: '{varName} var definition does not exist', - values: { - varName, - }, - }) - ); - return errors; + // TODO return validation error here once https://github.com/elastic/kibana/issues/125655 is fixed + // eslint-disable-next-line no-console + console.debug(`No variable definition for ${varName} found`); + + return null; } if (varDef.required) { From 181d04c2e19f4391c553a0cd4790e5f10858321b Mon Sep 17 00:00:00 2001 From: Nicolas Chaulet Date: Tue, 15 Feb 2022 10:53:50 -0500 Subject: [PATCH 26/43] [Fleet] Fix output form validation for trusted fingerprint (#125662) --- .../output_form_validators.test.tsx | 23 ++++++++++++++++++- .../output_form_validators.tsx | 2 +- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx index 1ad49dc091412..4f8b147e80448 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.test.tsx @@ -5,7 +5,11 @@ * 2.0. */ -import { validateHosts, validateYamlConfig } from './output_form_validators'; +import { + validateHosts, + validateYamlConfig, + validateCATrustedFingerPrint, +} from './output_form_validators'; describe('Output form validation', () => { describe('validateHosts', () => { @@ -72,4 +76,21 @@ describe('Output form validation', () => { } }); }); + describe('validate', () => { + it('should work with a valid fingerprint', () => { + const res = validateCATrustedFingerPrint( + '9f0a10411457adde3982ef01df20d2e7aa53a8ef29c50bcbfa3f3e93aebf631b' + ); + + expect(res).toBeUndefined(); + }); + + it('should return an error with a invalid formatted fingerprint', () => { + const res = validateCATrustedFingerPrint( + '9F:0A:10:41:14:57:AD:DE:39:82:EF:01:DF:20:D2:E7:AA:53:A8:EF:29:C5:0B:CB:FA:3F:3E:93:AE:BF:63:1B' + ); + + expect(res).toEqual(['CA trusted fingerprint should be a base64 CA sha256 fingerprint']); + }); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx index d4a84e4e1bc8c..3a9e42c152cc3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_validators.tsx @@ -73,7 +73,7 @@ export function validateName(value: string) { } export function validateCATrustedFingerPrint(value: string) { - if (value !== '' && !value.match(/^[a-zA-Z0-9]$/)) { + if (value !== '' && !value.match(/^[a-zA-Z0-9]+$/)) { return [ i18n.translate('xpack.fleet.settings.outputForm.caTrusterdFingerprintInvalidErrorMessage', { defaultMessage: 'CA trusted fingerprint should be a base64 CA sha256 fingerprint', From ea059d4a3696abfb7ef6292c215db2fdc73bfddf Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Tue, 15 Feb 2022 16:57:02 +0100 Subject: [PATCH 27/43] Use Discover locator to generate URL (#124282) * use Discover locator to generate URL * improve locator check * do not import discover plugin * allow for share plugin to be missing Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../helpers/setup_environment.tsx | 7 ++--- .../add_docs_accordion/add_docs_accordion.tsx | 31 ++++--------------- .../public/application/index.tsx | 4 +-- .../application/mount_management_section.ts | 2 +- 4 files changed, 11 insertions(+), 33 deletions(-) diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index a2c36e204cbea..8e128692c41c5 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -12,6 +12,7 @@ import { LocationDescriptorObject } from 'history'; import { HttpSetup } from 'kibana/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { sharePluginMock } from '../../../../../../src/plugins/share/public/mocks'; import { notificationServiceMock, docLinksServiceMock, @@ -48,11 +49,7 @@ const appServices = { notifications: notificationServiceMock.createSetupContract(), history, uiSettings: uiSettingsServiceMock.createSetupContract(), - urlGenerators: { - getUrlGenerator: jest.fn().mockReturnValue({ - createUrl: jest.fn(), - }), - }, + url: sharePluginMock.createStartContract().url, fileUpload: { getMaxBytes: jest.fn().mockReturnValue(100), getMaxBytesFormatted: jest.fn().mockReturnValue('100'), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx index ed817498586a6..e6454b207ab35 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/test_pipeline/test_pipeline_tabs/tab_documents/add_docs_accordion/add_docs_accordion.tsx @@ -10,7 +10,6 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiAccordion, EuiText, EuiSpacer, EuiLink } from '@elastic/eui'; -import { UrlGeneratorsDefinition } from 'src/plugins/share/public'; import { useKibana } from '../../../../../../../../shared_imports'; import { useIsMounted } from '../../../../../use_is_mounted'; @@ -18,8 +17,6 @@ import { AddDocumentForm } from '../add_document_form'; import './add_docs_accordion.scss'; -const DISCOVER_URL_GENERATOR_ID = 'DISCOVER_APP_URL_GENERATOR'; - const i18nTexts = { addDocumentsButton: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.addDocumentsAccordion.addDocumentsButtonLabel', @@ -46,34 +43,18 @@ export const AddDocumentsAccordion: FunctionComponent = ({ onAddDocuments useEffect(() => { const getDiscoverUrl = async (): Promise => { - let isDeprecated: UrlGeneratorsDefinition['isDeprecated']; - let createUrl: UrlGeneratorsDefinition['createUrl']; - - // This try/catch may not be necessary once - // https://github.com/elastic/kibana/issues/78344 is addressed - try { - ({ isDeprecated, createUrl } = - services.urlGenerators.getUrlGenerator(DISCOVER_URL_GENERATOR_ID)); - } catch (e) { - // Discover plugin is not enabled + const locator = services.share?.url.locators.get('DISCOVER_APP_LOCATOR'); + if (!locator) { setDiscoverLink(undefined); return; } - - if (isDeprecated) { - setDiscoverLink(undefined); - return; - } - - const discoverUrl = await createUrl({ indexPatternId: undefined }); - - if (isMounted.current) { - setDiscoverLink(discoverUrl); - } + const discoverUrl = await locator.getUrl({ indexPatternId: undefined }); + if (!isMounted.current) return; + setDiscoverLink(discoverUrl); }; getDiscoverUrl(); - }, [isMounted, services.urlGenerators]); + }, [isMounted, services.share]); return ( Date: Tue, 15 Feb 2022 17:07:36 +0100 Subject: [PATCH 28/43] [DOCS] Removes technical preview flag from DFA ML page. (#125497) --- docs/user/ml/index.asciidoc | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/user/ml/index.asciidoc b/docs/user/ml/index.asciidoc index f6a47d7b9d618..e66ca4ee19780 100644 --- a/docs/user/ml/index.asciidoc +++ b/docs/user/ml/index.asciidoc @@ -26,7 +26,8 @@ If {stack-security-features} are enabled, users must have the necessary privileges to use {ml-features}. Refer to {ml-docs}/setup.html#setup-privileges[Set up {ml-features}]. -NOTE: There are limitations in {ml-features} that affect {kib}. For more information, refer to {ml-docs}/ml-limitations.html[Machine learning]. +NOTE: There are limitations in {ml-features} that affect {kib}. For more +information, refer to {ml-docs}/ml-limitations.html[{ml-cap}]. -- @@ -84,8 +85,6 @@ and {ml-docs}/ml-ad-overview.html[{ml-cap} {anomaly-detect}]. [[xpack-ml-dfanalytics]] == {dfanalytics-cap} -experimental[] - The Elastic {ml} {dfanalytics} feature enables you to analyze your data using {classification}, {oldetection}, and {regression} algorithms and generate new indices that contain the results alongside your source data. From 4ee8a02a28dadc9b5c30cf62268a8f9a03b80408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20S=C3=A1nchez?= Date: Tue, 15 Feb 2022 17:19:14 +0100 Subject: [PATCH 29/43] Remove meta field before create/update event filter item (#125624) --- .../pages/event_filters/service/service_actions.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts index 40de6de881431..787d8495a3d45 100644 --- a/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts +++ b/x-pack/plugins/security_solution/public/management/pages/event_filters/service/service_actions.ts @@ -76,6 +76,8 @@ export async function addEventFilters( exception: ExceptionListItemSchema | CreateExceptionListItemSchema ) { await ensureEventFiltersListExists(http); + // Clean meta data before create event flter as the API throws an error with it + delete exception.meta; return http.post(EXCEPTION_LIST_ITEM_URL, { body: JSON.stringify(exception), }); @@ -134,14 +136,13 @@ export function cleanEventFilterToUpdate( const exceptionToUpdateCleaned = { ...exception }; // Clean unnecessary fields for update action [ - 'created_at', - 'created_by', 'created_at', 'created_by', 'list_id', 'tie_breaker_id', 'updated_at', 'updated_by', + 'meta', ].forEach((field) => { delete exceptionToUpdateCleaned[field as keyof UpdateExceptionListItemSchema]; }); From 8fabaf3fae095d0e484083160a5f2cf75ef98a68 Mon Sep 17 00:00:00 2001 From: Jan Monschke Date: Tue, 15 Feb 2022 17:20:08 +0100 Subject: [PATCH 30/43] [SecuritySolution][Threat Hunting] Fix a couple of field ids for highlighted fields (#124941) * fix: use correct DNS field id * fix: for behavior alerts we should display rule.description --- .../event_details/alert_summary_view.test.tsx | 79 ++++++++++++++----- .../event_details/get_alert_summary_rows.tsx | 10 +-- 2 files changed, 64 insertions(+), 25 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx index 25de792731d44..fff723cd31cf4 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/alert_summary_view.test.tsx @@ -116,6 +116,40 @@ describe('AlertSummaryView', () => { expect(getByText(fieldId)); }); }); + + test('DNS event renders the correct summary rows', () => { + const renderProps = { + ...props, + data: [ + ...(mockAlertDetailsData.map((item) => { + if (item.category === 'event' && item.field === 'event.category') { + return { + ...item, + values: ['dns'], + originalValue: ['dns'], + }; + } + return item; + }) as TimelineEventsDetailsItem[]), + { + category: 'dns', + field: 'dns.question.name', + values: ['www.example.com'], + originalValue: ['www.example.com'], + } as TimelineEventsDetailsItem, + ], + }; + const { getByText } = render( + + + + ); + + ['dns.question.name', 'process.name'].forEach((fieldId) => { + expect(getByText(fieldId)); + }); + }); + test('Memory event code renders additional summary rows', () => { const renderProps = { ...props, @@ -140,32 +174,41 @@ describe('AlertSummaryView', () => { }); }); test('Behavior event code renders additional summary rows', () => { + const actualRuleDescription = 'The actual rule description'; const renderProps = { ...props, - data: mockAlertDetailsData.map((item) => { - if (item.category === 'event' && item.field === 'event.code') { - return { - ...item, - values: ['behavior'], - originalValue: ['behavior'], - }; - } - if (item.category === 'event' && item.field === 'event.category') { - return { - ...item, - values: ['malware', 'process', 'file'], - originalValue: ['malware', 'process', 'file'], - }; - } - return item; - }) as TimelineEventsDetailsItem[], + data: [ + ...mockAlertDetailsData.map((item) => { + if (item.category === 'event' && item.field === 'event.code') { + return { + ...item, + values: ['behavior'], + originalValue: ['behavior'], + }; + } + if (item.category === 'event' && item.field === 'event.category') { + return { + ...item, + values: ['malware', 'process', 'file'], + originalValue: ['malware', 'process', 'file'], + }; + } + return item; + }), + { + category: 'rule', + field: 'rule.description', + values: [actualRuleDescription], + originalValue: [actualRuleDescription], + }, + ] as TimelineEventsDetailsItem[], }; const { getByText } = render( ); - ['host.name', 'user.name', 'process.name'].forEach((fieldId) => { + ['host.name', 'user.name', 'process.name', actualRuleDescription].forEach((fieldId) => { expect(getByText(fieldId)); }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx index 3da4ecab77992..35f6b71b1dacf 100644 --- a/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx +++ b/x-pack/plugins/security_solution/public/common/components/event_details/get_alert_summary_rows.tsx @@ -6,11 +6,7 @@ */ import { find, isEmpty, uniqBy } from 'lodash/fp'; -import { - ALERT_RULE_NAMESPACE, - ALERT_RULE_TYPE, - ALERT_RULE_DESCRIPTION, -} from '@kbn/rule-data-utils'; +import { ALERT_RULE_NAMESPACE, ALERT_RULE_TYPE } from '@kbn/rule-data-utils'; import * as i18n from './translations'; import { BrowserFields } from '../../../../common/search_strategy/index_fields'; @@ -69,7 +65,7 @@ function getFieldsByCategory({ { id: 'process.name' }, ]; case EventCategory.DNS: - return [{ id: 'dns.query.name' }, { id: 'process.name' }]; + return [{ id: 'dns.question.name' }, { id: 'process.name' }]; case EventCategory.REGISTRY: return [{ id: 'registry.key' }, { id: 'registry.value' }, { id: 'process.name' }]; case EventCategory.MALWARE: @@ -107,7 +103,7 @@ function getFieldsByEventCode( switch (eventCode) { case EventCode.BEHAVIOR: return [ - { id: ALERT_RULE_DESCRIPTION, label: ALERTS_HEADERS_RULE_DESCRIPTION }, + { id: 'rule.description', label: ALERTS_HEADERS_RULE_DESCRIPTION }, // Resolve more fields based on the source event ...getFieldsByCategory({ ...eventCategories, primaryEventCategory: undefined }), ]; From 8b2a18cca02a976f291ee63db3aba4c64eaf9856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Tue, 15 Feb 2022 11:46:28 -0500 Subject: [PATCH 31/43] [APM] Bug: Service maps popover detail metrics are aggregates over all transaction types (#125580) * fixing failure transaction rate on service maps * addressing PR comments --- .../get_failed_transaction_rate.ts | 6 +- .../get_service_map_service_node_info.ts | 1 + .../get_failed_transaction_rate_periods.ts | 4 +- .../tests/error_rate/service_maps.spec.ts | 149 ++++++++++++++++++ .../tests/latency/service_maps.spec.ts | 127 +++++++++++++++ .../tests/throughput/service_maps.spec.ts | 122 ++++++++++++++ 6 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 x-pack/test/apm_api_integration/tests/error_rate/service_maps.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/latency/service_maps.spec.ts create mode 100644 x-pack/test/apm_api_integration/tests/throughput/service_maps.spec.ts diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts index 4bd49f0db15e1..e3b5c995d0563 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_failed_transaction_rate.ts @@ -35,7 +35,7 @@ export async function getFailedTransactionRate({ environment, kuery, serviceName, - transactionType, + transactionTypes, transactionName, setup, searchAggregatedTransactions, @@ -46,7 +46,7 @@ export async function getFailedTransactionRate({ environment: string; kuery: string; serviceName: string; - transactionType?: string; + transactionTypes: string[]; transactionName?: string; setup: Setup; searchAggregatedTransactions: boolean; @@ -66,8 +66,8 @@ export async function getFailedTransactionRate({ [EVENT_OUTCOME]: [EventOutcome.failure, EventOutcome.success], }, }, + { terms: { [TRANSACTION_TYPE]: transactionTypes } }, ...termQuery(TRANSACTION_NAME, transactionName), - ...termQuery(TRANSACTION_TYPE, transactionType), ...getDocumentTypeFilterForTransactions(searchAggregatedTransactions), ...rangeQuery(start, end), ...environmentQuery(environment), diff --git a/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts b/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts index ec6c13de76fb1..884da3991d731 100644 --- a/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts +++ b/x-pack/plugins/apm/server/routes/service_map/get_service_map_service_node_info.ts @@ -144,6 +144,7 @@ async function getFailedTransactionsRateStats({ end, kuery: '', numBuckets, + transactionTypes: [TRANSACTION_REQUEST, TRANSACTION_PAGE_LOAD], }); return { value: average, diff --git a/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts b/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts index 709c867377aff..96913b9e197a7 100644 --- a/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts +++ b/x-pack/plugins/apm/server/routes/transactions/get_failed_transaction_rate_periods.ts @@ -24,7 +24,7 @@ export async function getFailedTransactionRatePeriods({ environment: string; kuery: string; serviceName: string; - transactionType?: string; + transactionType: string; transactionName?: string; setup: Setup; searchAggregatedTransactions: boolean; @@ -37,7 +37,7 @@ export async function getFailedTransactionRatePeriods({ environment, kuery, serviceName, - transactionType, + transactionTypes: [transactionType], transactionName, setup, searchAggregatedTransactions, diff --git a/x-pack/test/apm_api_integration/tests/error_rate/service_maps.spec.ts b/x-pack/test/apm_api_integration/tests/error_rate/service_maps.spec.ts new file mode 100644 index 0000000000000..4dddff70958ba --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/error_rate/service_maps.spec.ts @@ -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 { apm, timerange } from '@elastic/apm-synthtrace'; +import expect from '@kbn/expect'; +import { meanBy } from 'lodash'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const serviceName = 'synth-go'; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function getErrorRateValues(processorEvent: 'transaction' | 'metric') { + const commonQuery = { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + environment: 'ENVIRONMENT_ALL', + }; + const [serviceInventoryAPIResponse, serviceMapsNodeDetails] = await Promise.all([ + apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services', + params: { + query: { + ...commonQuery, + kuery: `service.name : "${serviceName}" and processor.event : "${processorEvent}"`, + }, + }, + }), + apmApiClient.readUser({ + endpoint: `GET /internal/apm/service-map/service/{serviceName}`, + params: { + path: { serviceName }, + query: commonQuery, + }, + }), + ]); + + const serviceInventoryErrorRate = + serviceInventoryAPIResponse.body.items[0].transactionErrorRate; + + const serviceMapsNodeDetailsErrorRate = meanBy( + serviceMapsNodeDetails.body.currentPeriod.failedTransactionsRate?.timeseries, + 'y' + ); + + return { + serviceInventoryErrorRate, + serviceMapsNodeDetailsErrorRate, + }; + } + + let errorRateMetricValues: Awaited>; + let errorTransactionValues: Awaited>; + registry.when( + 'Service maps APIs', + { config: 'trial', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('when data is loaded ', () => { + const GO_PROD_LIST_RATE = 75; + const GO_PROD_LIST_ERROR_RATE = 25; + const GO_PROD_ID_RATE = 50; + const GO_PROD_ID_ERROR_RATE = 50; + before(async () => { + const serviceGoProdInstance = apm + .service(serviceName, 'production', 'go') + .instance('instance-a'); + + const transactionNameProductList = 'GET /api/product/list'; + const transactionNameProductId = 'GET /api/product/:id'; + + await synthtraceEsClient.index([ + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_LIST_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductList, 'Worker') + .timestamp(timestamp) + .duration(1000) + .success() + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_LIST_ERROR_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductList, 'Worker') + .duration(1000) + .timestamp(timestamp) + .failure() + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_ID_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductId) + .timestamp(timestamp) + .duration(1000) + .success() + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_ID_ERROR_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction(transactionNameProductId) + .duration(1000) + .timestamp(timestamp) + .failure() + .serialize() + ), + ]); + }); + + after(() => synthtraceEsClient.clean()); + + describe('compare latency value between service inventory and service maps', () => { + before(async () => { + [errorTransactionValues, errorRateMetricValues] = await Promise.all([ + getErrorRateValues('transaction'), + getErrorRateValues('metric'), + ]); + }); + + it('returns same avg error rate value for Transaction-based and Metric-based data', () => { + [ + errorTransactionValues.serviceInventoryErrorRate, + errorTransactionValues.serviceMapsNodeDetailsErrorRate, + errorRateMetricValues.serviceInventoryErrorRate, + errorRateMetricValues.serviceMapsNodeDetailsErrorRate, + ].forEach((value) => expect(value).to.be.equal(GO_PROD_ID_ERROR_RATE / 100)); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/latency/service_maps.spec.ts b/x-pack/test/apm_api_integration/tests/latency/service_maps.spec.ts new file mode 100644 index 0000000000000..f977c35bfa54f --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/latency/service_maps.spec.ts @@ -0,0 +1,127 @@ +/* + * 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 { apm, timerange } from '@elastic/apm-synthtrace'; +import expect from '@kbn/expect'; +import { meanBy } from 'lodash'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const serviceName = 'synth-go'; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function getLatencyValues(processorEvent: 'transaction' | 'metric') { + const commonQuery = { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + environment: 'ENVIRONMENT_ALL', + }; + const [serviceInventoryAPIResponse, serviceMapsNodeDetails] = await Promise.all([ + apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services', + params: { + query: { + ...commonQuery, + kuery: `service.name : "${serviceName}" and processor.event : "${processorEvent}"`, + }, + }, + }), + apmApiClient.readUser({ + endpoint: `GET /internal/apm/service-map/service/{serviceName}`, + params: { + path: { serviceName }, + query: commonQuery, + }, + }), + ]); + + const serviceInventoryLatency = serviceInventoryAPIResponse.body.items[0].latency; + + const serviceMapsNodeDetailsLatency = meanBy( + serviceMapsNodeDetails.body.currentPeriod.transactionStats?.latency?.timeseries, + 'y' + ); + + return { + serviceInventoryLatency, + serviceMapsNodeDetailsLatency, + }; + } + + let latencyMetricValues: Awaited>; + let latencyTransactionValues: Awaited>; + registry.when( + 'Service maps APIs', + { config: 'trial', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('when data is loaded ', () => { + const GO_PROD_RATE = 80; + const GO_DEV_RATE = 20; + const GO_PROD_DURATION = 1000; + const GO_DEV_DURATION = 500; + before(async () => { + const serviceGoProdInstance = apm + .service(serviceName, 'production', 'go') + .instance('instance-a'); + const serviceGoDevInstance = apm + .service(serviceName, 'development', 'go') + .instance('instance-b'); + + await synthtraceEsClient.index([ + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction('GET /api/product/list', 'Worker') + .duration(GO_PROD_DURATION) + .timestamp(timestamp) + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_DEV_RATE) + .flatMap((timestamp) => + serviceGoDevInstance + .transaction('GET /api/product/:id') + .duration(GO_DEV_DURATION) + .timestamp(timestamp) + .serialize() + ), + ]); + }); + + after(() => synthtraceEsClient.clean()); + + describe('compare latency value between service inventory and service maps', () => { + before(async () => { + [latencyTransactionValues, latencyMetricValues] = await Promise.all([ + getLatencyValues('transaction'), + getLatencyValues('metric'), + ]); + }); + + it('returns same avg latency value for Transaction-based and Metric-based data', () => { + const expectedLatencyAvgValueMs = + ((GO_DEV_RATE * GO_DEV_DURATION) / GO_DEV_RATE) * 1000; + + [ + latencyTransactionValues.serviceMapsNodeDetailsLatency, + latencyTransactionValues.serviceInventoryLatency, + latencyMetricValues.serviceMapsNodeDetailsLatency, + latencyMetricValues.serviceInventoryLatency, + ].forEach((value) => expect(value).to.be.equal(expectedLatencyAvgValueMs)); + }); + }); + }); + } + ); +} diff --git a/x-pack/test/apm_api_integration/tests/throughput/service_maps.spec.ts b/x-pack/test/apm_api_integration/tests/throughput/service_maps.spec.ts new file mode 100644 index 0000000000000..adbae6dff2096 --- /dev/null +++ b/x-pack/test/apm_api_integration/tests/throughput/service_maps.spec.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { apm, timerange } from '@elastic/apm-synthtrace'; +import expect from '@kbn/expect'; +import { meanBy } from 'lodash'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; +import { roundNumber } from '../../utils'; + +export default function ApiTest({ getService }: FtrProviderContext) { + const registry = getService('registry'); + const apmApiClient = getService('apmApiClient'); + const synthtraceEsClient = getService('synthtraceEsClient'); + + const serviceName = 'synth-go'; + const start = new Date('2021-01-01T00:00:00.000Z').getTime(); + const end = new Date('2021-01-01T00:15:00.000Z').getTime() - 1; + + async function getThroughputValues(processorEvent: 'transaction' | 'metric') { + const commonQuery = { + start: new Date(start).toISOString(), + end: new Date(end).toISOString(), + environment: 'ENVIRONMENT_ALL', + }; + const [serviceInventoryAPIResponse, serviceMapsNodeDetails] = await Promise.all([ + apmApiClient.readUser({ + endpoint: 'GET /internal/apm/services', + params: { + query: { + ...commonQuery, + kuery: `service.name : "${serviceName}" and processor.event : "${processorEvent}"`, + }, + }, + }), + apmApiClient.readUser({ + endpoint: `GET /internal/apm/service-map/service/{serviceName}`, + params: { + path: { serviceName }, + query: commonQuery, + }, + }), + ]); + + const serviceInventoryThroughput = serviceInventoryAPIResponse.body.items[0].throughput; + + const serviceMapsNodeDetailsThroughput = meanBy( + serviceMapsNodeDetails.body.currentPeriod.transactionStats?.throughput?.timeseries, + 'y' + ); + + return { + serviceInventoryThroughput, + serviceMapsNodeDetailsThroughput, + }; + } + + let throughputMetricValues: Awaited>; + let throughputTransactionValues: Awaited>; + + registry.when( + 'Service maps APIs', + { config: 'trial', archives: ['apm_mappings_only_8.0.0'] }, + () => { + describe('when data is loaded ', () => { + const GO_PROD_RATE = 80; + const GO_DEV_RATE = 20; + before(async () => { + const serviceGoProdInstance = apm + .service(serviceName, 'production', 'go') + .instance('instance-a'); + const serviceGoDevInstance = apm + .service(serviceName, 'development', 'go') + .instance('instance-b'); + + await synthtraceEsClient.index([ + ...timerange(start, end) + .interval('1m') + .rate(GO_PROD_RATE) + .flatMap((timestamp) => + serviceGoProdInstance + .transaction('GET /api/product/list', 'Worker') + .duration(1000) + .timestamp(timestamp) + .serialize() + ), + ...timerange(start, end) + .interval('1m') + .rate(GO_DEV_RATE) + .flatMap((timestamp) => + serviceGoDevInstance + .transaction('GET /api/product/:id') + .duration(1000) + .timestamp(timestamp) + .serialize() + ), + ]); + }); + + after(() => synthtraceEsClient.clean()); + + describe('compare throughput value between service inventory and service maps', () => { + before(async () => { + [throughputTransactionValues, throughputMetricValues] = await Promise.all([ + getThroughputValues('transaction'), + getThroughputValues('metric'), + ]); + }); + + it('returns same throughput value for Transaction-based and Metric-based data', () => { + [ + ...Object.values(throughputTransactionValues), + ...Object.values(throughputMetricValues), + ].forEach((value) => expect(roundNumber(value)).to.be.equal(roundNumber(GO_DEV_RATE))); + }); + }); + }); + } + ); +} From 48ce5d18ae7b9ed4ca86d667cccef269f886090c Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Tue, 15 Feb 2022 18:20:01 +0100 Subject: [PATCH 32/43] [Lens][Example] Testing playground for Lens embeddables (#125061) * :alembic: First pass * :sparkles: Add Lens testing playground * :sparkles: Add partition preset * :ok_hand: Addressed feedback * :lipstick: Add thumbnail for example catalog * :wrench: Convert image to png * :fire: Remove extra debug buttons * :bug: Fix error handling * :bug: Fix buttons bug --- .../embedded_lens_example/public/app.tsx | 65 +- .../testing_embedded_lens/.eslintrc.json | 5 + .../examples/testing_embedded_lens/README.md | 7 + .../testing_embedded_lens/kibana.json | 21 + .../testing_embedded_lens/package.json | 14 + .../testing_embedded_lens/public/app.tsx | 730 ++++++++++++++++++ .../testing_embedded_lens/public/image.png | Bin 0 -> 167283 bytes .../testing_embedded_lens/public/index.ts | 10 + .../testing_embedded_lens/public/mount.tsx | 49 ++ .../testing_embedded_lens/public/plugin.ts | 55 ++ .../testing_embedded_lens/tsconfig.json | 21 + 11 files changed, 928 insertions(+), 49 deletions(-) create mode 100644 x-pack/examples/testing_embedded_lens/.eslintrc.json create mode 100644 x-pack/examples/testing_embedded_lens/README.md create mode 100644 x-pack/examples/testing_embedded_lens/kibana.json create mode 100644 x-pack/examples/testing_embedded_lens/package.json create mode 100644 x-pack/examples/testing_embedded_lens/public/app.tsx create mode 100644 x-pack/examples/testing_embedded_lens/public/image.png create mode 100644 x-pack/examples/testing_embedded_lens/public/index.ts create mode 100644 x-pack/examples/testing_embedded_lens/public/mount.tsx create mode 100644 x-pack/examples/testing_embedded_lens/public/plugin.ts create mode 100644 x-pack/examples/testing_embedded_lens/tsconfig.json diff --git a/x-pack/examples/embedded_lens_example/public/app.tsx b/x-pack/examples/embedded_lens_example/public/app.tsx index 2e2e973e7cc6b..950e2f454ad2e 100644 --- a/x-pack/examples/embedded_lens_example/public/app.tsx +++ b/x-pack/examples/embedded_lens_example/public/app.tsx @@ -127,9 +127,6 @@ export const App = (props: { to: 'now', }); - const [enableExtraAction, setEnableExtraAction] = useState(false); - const [enableDefaultAction, setEnableDefaultAction] = useState(false); - const LensComponent = props.plugins.lens.EmbeddableComponent; const LensSaveModalComponent = props.plugins.lens.SaveModalComponent; @@ -242,34 +239,10 @@ export const App = (props: { Change time range
- - { - setEnableExtraAction((prevState) => !prevState); - }} - > - {enableExtraAction ? 'Disable extra action' : 'Enable extra action'} - - - - { - setEnableDefaultAction((prevState) => !prevState); - }} - > - {enableDefaultAction ? 'Disable default action' : 'Enable default action'} - -
'save', - async isCompatible( - context: ActionExecutionContext - ): Promise { - return true; - }, - execute: async (context: ActionExecutionContext) => { - alert('I am an extra action'); - return; - }, - getDisplayName: () => 'Extra action', - }, - ] - : undefined - } + extraActions={[ + { + id: 'testAction', + type: 'link', + getIconType: () => 'save', + async isCompatible(context: ActionExecutionContext): Promise { + return true; + }, + execute: async (context: ActionExecutionContext) => { + alert('I am an extra action'); + return; + }, + getDisplayName: () => 'Extra action', + }, + ]} /> {isSaveModalVisible && ( ; + +function getInitialType(dataView: DataView) { + return dataView.isTimeBased() ? 'date' : 'number'; +} + +function getColumnFor(type: RequiredType, fieldName: string, isBucketed: boolean = true) { + if (type === 'string') { + return { + label: `Top values of ${fieldName}`, + dataType: 'string', + operationType: 'terms', + scale: 'ordinal', + sourceField: fieldName, + isBucketed: true, + params: { + size: 5, + orderBy: { type: 'alphabetical', fallback: true }, + orderDirection: 'desc', + }, + } as TermsIndexPatternColumn; + } + if (type === 'number') { + if (isBucketed) { + return { + label: fieldName, + dataType: 'number', + operationType: 'range', + sourceField: fieldName, + isBucketed: true, + scale: 'interval', + params: { + type: 'histogram', + maxBars: 'auto', + format: undefined, + parentFormat: undefined, + }, + } as RangeIndexPatternColumn; + } + return { + label: `Median of ${fieldName}`, + dataType: 'number', + operationType: 'median', + sourceField: fieldName, + isBucketed: false, + scale: 'ratio', + } as MedianIndexPatternColumn; + } + return { + dataType: 'date', + isBucketed: true, + label: '@timestamp', + operationType: 'date_histogram', + params: { interval: 'auto' }, + scale: 'interval', + sourceField: fieldName, + } as DateHistogramIndexPatternColumn; +} + +function getDataLayer( + type: RequiredType, + field: string, + isBucketed: boolean = true +): PersistedIndexPatternLayer { + return { + columnOrder: ['col1', 'col2'], + columns: { + col2: { + dataType: 'number', + isBucketed: false, + label: 'Count of records', + operationType: 'count', + scale: 'ratio', + sourceField: DOCUMENT_FIELD_NAME, + }, + col1: getColumnFor(type, field, isBucketed), + }, + }; +} + +function getBaseAttributes( + defaultIndexPattern: DataView, + fields: FieldsMap, + type?: RequiredType, + dataLayer?: PersistedIndexPatternLayer +): Omit & { + state: Omit; +} { + const finalType = type ?? getInitialType(defaultIndexPattern); + const finalDataLayer = dataLayer ?? getDataLayer(finalType, fields[finalType]); + return { + title: 'Prefilled from example app', + references: [ + { + id: defaultIndexPattern.id!, + name: 'indexpattern-datasource-current-indexpattern', + type: 'index-pattern', + }, + { + id: defaultIndexPattern.id!, + name: 'indexpattern-datasource-layer-layer1', + type: 'index-pattern', + }, + ], + state: { + datasourceStates: { + indexpattern: { + layers: { + layer1: finalDataLayer, + }, + }, + }, + filters: [], + query: { language: 'kuery', query: '' }, + }, + }; +} + +// Generate a Lens state based on some app-specific input parameters. +// `TypedLensByValueInput` can be used for type-safety - it uses the same interfaces as Lens-internal code. +function getLensAttributes( + defaultIndexPattern: DataView, + fields: FieldsMap, + chartType: 'bar_stacked' | 'line' | 'area', + color: string +): TypedLensByValueInput['attributes'] { + const baseAttributes = getBaseAttributes(defaultIndexPattern, fields); + + const xyConfig: XYState = { + axisTitlesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + fittingFunction: 'None', + gridlinesVisibilitySettings: { x: true, yLeft: true, yRight: true }, + layers: [ + { + accessors: ['col2'], + layerId: 'layer1', + layerType: 'data', + seriesType: chartType, + xAccessor: 'col1', + yConfig: [{ forAccessor: 'col2', color }], + }, + ], + legend: { isVisible: true, position: 'right' }, + preferredSeriesType: chartType, + tickLabelsVisibilitySettings: { x: true, yLeft: true, yRight: true }, + valueLabels: 'hide', + }; + + return { + ...baseAttributes, + visualizationType: 'lnsXY', + state: { + ...baseAttributes.state, + visualization: xyConfig, + }, + }; +} + +function getLensAttributesHeatmap( + defaultIndexPattern: DataView, + fields: FieldsMap +): TypedLensByValueInput['attributes'] { + const initialType = getInitialType(defaultIndexPattern); + const dataLayer = getDataLayer(initialType, fields[initialType]); + const heatmapDataLayer = { + columnOrder: ['col1', 'col3', 'col2'], + columns: { + ...dataLayer.columns, + col3: getColumnFor('string', fields.string) as TermsIndexPatternColumn, + }, + }; + + const baseAttributes = getBaseAttributes( + defaultIndexPattern, + fields, + initialType, + heatmapDataLayer + ); + + const heatmapConfig: HeatmapVisualizationState = { + layerId: 'layer1', + layerType: 'data', + shape: 'heatmap', + xAccessor: 'col1', + yAccessor: 'col3', + valueAccessor: 'col2', + legend: { isVisible: true, position: 'right', type: 'heatmap_legend' }, + gridConfig: { + isCellLabelVisible: true, + isYAxisLabelVisible: true, + isXAxisLabelVisible: true, + isYAxisTitleVisible: true, + isXAxisTitleVisible: true, + type: 'heatmap_grid', + }, + }; + + return { + ...baseAttributes, + visualizationType: 'lnsHeatmap', + state: { + ...baseAttributes.state, + visualization: heatmapConfig, + }, + }; +} + +function getLensAttributesDatatable( + defaultIndexPattern: DataView, + fields: FieldsMap +): TypedLensByValueInput['attributes'] { + const initialType = getInitialType(defaultIndexPattern); + const baseAttributes = getBaseAttributes(defaultIndexPattern, fields, initialType); + + const tableConfig: DatatableVisualizationState = { + layerId: 'layer1', + layerType: 'data', + columns: [{ columnId: 'col1' }, { columnId: 'col2' }], + }; + + return { + ...baseAttributes, + visualizationType: 'lnsDatatable', + state: { + ...baseAttributes.state, + visualization: tableConfig, + }, + }; +} + +function getLensAttributesGauge( + defaultIndexPattern: DataView, + fields: FieldsMap +): TypedLensByValueInput['attributes'] { + const dataLayer = getDataLayer('number', fields.number, false); + const gaugeDataLayer = { + columnOrder: ['col1'], + columns: { + col1: dataLayer.columns.col1, + }, + }; + + const baseAttributes = getBaseAttributes(defaultIndexPattern, fields, 'number', gaugeDataLayer); + const gaugeConfig: GaugeVisualizationState = { + layerId: 'layer1', + layerType: 'data', + shape: 'horizontalBullet', + ticksPosition: 'auto', + labelMajorMode: 'auto', + metricAccessor: 'col1', + }; + return { + ...baseAttributes, + visualizationType: 'lnsGauge', + state: { + ...baseAttributes.state, + visualization: gaugeConfig, + }, + }; +} + +function getLensAttributesPartition( + defaultIndexPattern: DataView, + fields: FieldsMap +): TypedLensByValueInput['attributes'] { + const baseAttributes = getBaseAttributes(defaultIndexPattern, fields, 'number'); + const pieConfig: PieVisualizationState = { + layers: [ + { + groups: ['col1'], + metric: 'col2', + layerId: 'layer1', + layerType: 'data', + numberDisplay: 'percent', + categoryDisplay: 'default', + legendDisplay: 'default', + }, + ], + shape: 'pie', + }; + return { + ...baseAttributes, + visualizationType: 'lnsPie', + state: { + ...baseAttributes.state, + visualization: pieConfig, + }, + }; +} + +function getFieldsByType(dataView: DataView) { + const aggregatableFields = dataView.fields.filter((f) => f.aggregatable); + const fields: Partial = { + string: aggregatableFields.find((f) => f.type === 'string')?.displayName, + number: aggregatableFields.find((f) => f.type === 'number')?.displayName, + }; + if (dataView.isTimeBased()) { + fields.date = dataView.getTimeField().displayName; + } + // remove undefined values + for (const type of ['string', 'number', 'date'] as const) { + if (typeof fields[type] == null) { + delete fields[type]; + } + } + return fields as FieldsMap; +} + +function isXYChart(attributes: TypedLensByValueInput['attributes']) { + return attributes.visualizationType === 'lnsXY'; +} + +function checkAndParseSO(newSO: string) { + try { + return JSON.parse(newSO) as TypedLensByValueInput['attributes']; + } catch (e) { + // do nothing + } +} +let chartCounter = 1; + +export const App = (props: { + core: CoreStart; + plugins: StartDependencies; + defaultDataView: DataView; + stateHelpers: Awaited>; +}) => { + const [isLoading, setIsLoading] = useState(false); + const [isSaveModalVisible, setIsSaveModalVisible] = useState(false); + const [enableExtraAction, setEnableExtraAction] = useState(false); + const [enableDefaultAction, setEnableDefaultAction] = useState(false); + const [enableTriggers, toggleTriggers] = useState(false); + const [loadedCharts, addChartConfiguration] = useState< + Array<{ id: string; attributes: TypedLensByValueInput['attributes'] }> + >([]); + const [hasParsingError, setErrorFlag] = useState(false); + const [hasParsingErrorDebounced, setErrorDebounced] = useState(hasParsingError); + const LensComponent = props.plugins.lens.EmbeddableComponent; + const LensSaveModalComponent = props.plugins.lens.SaveModalComponent; + + const fields = getFieldsByType(props.defaultDataView); + + const [time, setTime] = useState({ + from: 'now-5d', + to: 'now', + }); + + const defaultCharts = [ + { + id: 'bar_stacked', + attributes: getLensAttributes(props.defaultDataView, fields, 'bar_stacked', 'green'), + }, + { + id: 'line', + attributes: getLensAttributes(props.defaultDataView, fields, 'line', 'green'), + }, + { + id: 'area', + attributes: getLensAttributes(props.defaultDataView, fields, 'area', 'green'), + }, + { id: 'pie', attributes: getLensAttributesPartition(props.defaultDataView, fields) }, + { id: 'table', attributes: getLensAttributesDatatable(props.defaultDataView, fields) }, + { id: 'heatmap', attributes: getLensAttributesHeatmap(props.defaultDataView, fields) }, + { id: 'gauge', attributes: getLensAttributesGauge(props.defaultDataView, fields) }, + ]; + // eslint-disable-next-line react-hooks/exhaustive-deps + const charts = useMemo(() => [...defaultCharts, ...loadedCharts], [loadedCharts]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const initialAttributes = useMemo(() => JSON.stringify(charts[0].attributes, null, 2), []); + + const currentSO = useRef(initialAttributes); + const [currentValid, saveValidSO] = useState(initialAttributes); + const switchChartPreset = useCallback( + (newIndex) => { + const newChart = charts[newIndex]; + const newAttributes = JSON.stringify(newChart.attributes, null, 2); + currentSO.current = newAttributes; + saveValidSO(newAttributes); + }, + [charts] + ); + + const currentAttributes = useMemo(() => { + try { + return JSON.parse(currentSO.current); + } catch (e) { + return JSON.parse(currentValid); + } + }, [currentValid, currentSO]); + + const isDisabled = !currentAttributes; + const isColorDisabled = isDisabled || !isXYChart(currentAttributes); + + useDebounce(() => setErrorDebounced(hasParsingError), 500, [hasParsingError]); + + return ( + + + + + + + +

+ This app embeds a Lens visualization by specifying the configuration. Data + fetching and rendering is completely managed by Lens itself. +

+

+ The editor on the right hand side make it possible to paste a Lens attributes + configuration, and have it rendered. Presets are available to have a starting + configuration, and new presets can be saved as well (not persisted). +

+

+ The Open with Lens button will take the current configuration and navigate to a + prefilled editor. +

+ + + + { + const newColor = `rgb(${[1, 2, 3].map(() => + Math.floor(Math.random() * 256) + )})`; + const newAttributes = JSON.stringify( + getLensAttributes( + props.defaultDataView, + fields, + currentAttributes.state.visualization.preferredSeriesType, + newColor + ), + null, + 2 + ); + currentSO.current = newAttributes; + saveValidSO(newAttributes); + }} + isDisabled={isColorDisabled} + > + Change color + + + + { + setIsSaveModalVisible(true); + }} + > + Save Visualization + + + {props.defaultDataView?.isTimeBased() ? ( + + { + setTime( + time.to === 'now' + ? { + from: '2015-09-18T06:31:44.000Z', + to: '2015-09-23T18:31:44.000Z', + } + : { + from: 'now-5d', + to: 'now', + } + ); + }} + > + {time.to === 'now' ? 'Change time range' : 'Reset time range'} + + + ) : null} + + { + props.plugins.lens.navigateToPrefilledEditor( + { + id: '', + timeRange: time, + attributes: currentAttributes, + }, + { + openInNewTab: true, + } + ); + }} + > + Edit in Lens (new tab) + + + + { + toggleTriggers((prevState) => !prevState); + }} + > + {enableTriggers ? 'Disable triggers' : 'Enable triggers'} + + + + { + setEnableExtraAction((prevState) => !prevState); + }} + > + {enableExtraAction ? 'Disable extra action' : 'Enable extra action'} + + + + { + setEnableDefaultAction((prevState) => !prevState); + }} + > + {enableDefaultAction ? 'Disable default action' : 'Enable default action'} + + + +

State: {isLoading ? 'Loading...' : 'Rendered'}

+
+
+ + + { + setIsLoading(val); + }} + onBrushEnd={({ range }) => { + setTime({ + from: new Date(range[0]).toISOString(), + to: new Date(range[1]).toISOString(), + }); + }} + onFilter={(_data) => { + // call back event for on filter event + }} + onTableRowClick={(_data) => { + // call back event for on table row click event + }} + disableTriggers={!enableTriggers} + viewMode={ViewMode.VIEW} + withDefaultActions={enableDefaultAction} + extraActions={ + enableExtraAction + ? [ + { + id: 'testAction', + type: 'link', + getIconType: () => 'save', + async isCompatible( + context: ActionExecutionContext + ): Promise { + return true; + }, + execute: async (context: ActionExecutionContext) => { + alert('I am an extra action'); + return; + }, + getDisplayName: () => 'Extra action', + }, + ] + : undefined + } + /> + + + + {isSaveModalVisible && ( + {}} + onClose={() => setIsSaveModalVisible(false)} + /> + )} + + + + + + +

Paste or edit here your Lens document

+
+
+
+ + + ({ value: i, text: id }))} + value={undefined} + onChange={(e) => switchChartPreset(Number(e.target.value))} + aria-label="Load from a preset" + /> + + + { + const attributes = checkAndParseSO(currentSO.current); + if (attributes) { + const label = `custom-chart-${chartCounter}`; + addChartConfiguration([ + ...loadedCharts, + { + id: label, + attributes, + }, + ]); + chartCounter++; + alert(`The preset has been saved as "${label}"`); + } + }} + > + Save as preset + + + {hasParsingErrorDebounced && currentSO.current !== currentValid && ( + +

Check the spec

+
+ )} +
+ + + { + const isValid = Boolean(checkAndParseSO(newSO)); + setErrorFlag(!isValid); + currentSO.current = newSO; + if (isValid) { + // reset the debounced error + setErrorDebounced(isValid); + saveValidSO(newSO); + } + }} + /> + + +
+
+ + + + + + ); +}; diff --git a/x-pack/examples/testing_embedded_lens/public/image.png b/x-pack/examples/testing_embedded_lens/public/image.png new file mode 100644 index 0000000000000000000000000000000000000000..15563872fd6a0fb93c7dea58f31609b3db249910 GIT binary patch literal 167283 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 Gg-ou9% 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 new TestingEmbeddedLensPlugin(); diff --git a/x-pack/examples/testing_embedded_lens/public/mount.tsx b/x-pack/examples/testing_embedded_lens/public/mount.tsx new file mode 100644 index 0000000000000..50727871a3545 --- /dev/null +++ b/x-pack/examples/testing_embedded_lens/public/mount.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 * as React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { EuiCallOut } from '@elastic/eui'; + +import type { CoreSetup, AppMountParameters } from 'kibana/public'; +import type { StartDependencies } from './plugin'; + +export const mount = + (coreSetup: CoreSetup) => + async ({ element }: AppMountParameters) => { + const [core, plugins] = await coreSetup.getStartServices(); + const { App } = await import('./app'); + + const defaultDataView = await plugins.data.indexPatterns.getDefault(); + const stateHelpers = await plugins.lens.stateHelperApi(); + + const i18nCore = core.i18n; + + const reactElement = ( + + {defaultDataView ? ( + + ) : ( + +

This demo only works if your default index pattern is set and time based

+
+ )} +
+ ); + + render(reactElement, element); + return () => unmountComponentAtNode(element); + }; diff --git a/x-pack/examples/testing_embedded_lens/public/plugin.ts b/x-pack/examples/testing_embedded_lens/public/plugin.ts new file mode 100644 index 0000000000000..374f830d0bb2e --- /dev/null +++ b/x-pack/examples/testing_embedded_lens/public/plugin.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { Plugin, CoreSetup, AppNavLinkStatus } from '../../../../src/core/public'; +import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; +import { LensPublicStart } from '../../../plugins/lens/public'; +import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public'; +import { mount } from './mount'; +import image from './image.png'; + +export interface SetupDependencies { + developerExamples: DeveloperExamplesSetup; +} + +export interface StartDependencies { + data: DataPublicPluginStart; + lens: LensPublicStart; +} + +export class TestingEmbeddedLensPlugin + implements Plugin +{ + public setup(core: CoreSetup, { developerExamples }: SetupDependencies) { + core.application.register({ + id: 'testing_embedded_lens', + title: 'Embedded Lens testing playground', + navLinkStatus: AppNavLinkStatus.hidden, + mount: mount(core), + }); + + developerExamples.register({ + appId: 'testing_embedded_lens', + title: 'Testing Embedded Lens', + description: 'Testing playground used to test Lens embeddable', + links: [ + { + label: 'README', + href: 'https://github.com/elastic/kibana/tree/main/x-pack/examples/testing_embedded_lens', + iconType: 'logoGithub', + size: 's', + target: '_blank', + }, + ], + image, + }); + } + + public start() {} + + public stop() {} +} diff --git a/x-pack/examples/testing_embedded_lens/tsconfig.json b/x-pack/examples/testing_embedded_lens/tsconfig.json new file mode 100644 index 0000000000000..e1016a6c011a1 --- /dev/null +++ b/x-pack/examples/testing_embedded_lens/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./target/types" + }, + "include": [ + "index.ts", + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../../typings/**/*" + ], + "exclude": [], + "references": [ + { "path": "../../../src/core/tsconfig.json" }, + { "path": "../../../src/plugins/data/tsconfig.json" }, + { "path": "../../../src/plugins/embeddable/tsconfig.json" }, + { "path": "../../plugins/lens/tsconfig.json" }, + { "path": "../../../examples/developer_examples/tsconfig.json" }, + ] +} From 1fe2110e31bdde6afc092fea762f8cfdcba706b1 Mon Sep 17 00:00:00 2001 From: Ashokaditya <1849116+ashokaditya@users.noreply.github.com> Date: Tue, 15 Feb 2022 18:30:46 +0100 Subject: [PATCH 33/43] [SecuritySolution][Endpoint] Add a user with kibana_system and superuser role for adding fake data (#125476) * add a user with kibana_system and superuser role for adding fake data fixes elastic/security-team/issues/2908 * Check user exists before adding review changes * Delete endpoint_user afterwards review changes * user API response to simplify adding user * simplify type * allow picking username and password from cli review suggestions * do not add new user by default, require it only when fleet is enabled review changes * use URL review changes * update protocol with URL as well --- .../endpoint/resolver_generator_script.ts | 180 +++++++++++++++--- 1 file changed, 154 insertions(+), 26 deletions(-) diff --git a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts index da0810bead47e..74a51a6e16199 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/resolver_generator_script.ts @@ -11,21 +11,21 @@ import fs from 'fs'; import { Client, errors } from '@elastic/elasticsearch'; import type { ClientOptions } from '@elastic/elasticsearch/lib/client'; import { ToolingLog, CA_CERT_PATH } from '@kbn/dev-utils'; -import { KbnClient } from '@kbn/test'; +import { KbnClient, KbnClientOptions } from '@kbn/test'; import { indexHostsAndAlerts } from '../../common/endpoint/index_data'; import { ANCESTRY_LIMIT, EndpointDocGenerator } from '../../common/endpoint/generate_data'; main(); -async function deleteIndices(indices: string[], client: Client) { - const handleErr = (err: unknown) => { - if (err instanceof errors.ResponseError && err.statusCode !== 404) { - console.log(JSON.stringify(err, null, 2)); - // eslint-disable-next-line no-process-exit - process.exit(1); - } - }; +const handleErr = (err: unknown) => { + if (err instanceof errors.ResponseError && err.statusCode !== 404) { + console.log(JSON.stringify(err, null, 2)); + // eslint-disable-next-line no-process-exit + process.exit(1); + } +}; +async function deleteIndices(indices: string[], client: Client) { for (const index of indices) { try { // The index could be a data stream so let's try deleting that first @@ -37,6 +37,73 @@ async function deleteIndices(indices: string[], client: Client) { } } +interface UserInfo { + username: string; + password: string; +} + +async function addUser( + esClient: Client, + user?: { username: string; password: string } +): Promise { + if (!user) { + return; + } + + const path = `_security/user/${user.username}`; + // add user if doesn't exist already + try { + console.log(`Adding ${user.username}...`); + const addedUser = await esClient.transport.request>({ + method: 'POST', + path, + body: { + password: user.password, + roles: ['superuser', 'kibana_system'], + full_name: user.username, + }, + }); + if (addedUser.created) { + console.log(`User ${user.username} added successfully!`); + } else { + console.log(`User ${user.username} already exists!`); + } + return { + username: user.username, + password: user.password, + }; + } catch (error) { + handleErr(error); + } +} + +async function deleteUser(esClient: Client, username: string): Promise<{ found: boolean }> { + return esClient.transport.request({ + method: 'DELETE', + path: `_security/user/${username}`, + }); +} + +const updateURL = ({ + url, + user, + protocol, +}: { + url: string; + user?: { username: string; password: string }; + protocol?: string; +}): string => { + const urlObject = new URL(url); + if (user) { + urlObject.username = user.username; + urlObject.password = user.password; + } + if (protocol) { + urlObject.protocol = protocol; + } + return urlObject.href; +}; + async function main() { const argv = yargs.help().options({ seed: { @@ -179,35 +246,80 @@ async function main() { type: 'boolean', default: false, }, + withNewUser: { + alias: 'nu', + describe: + 'If the --fleet flag is enabled, using `--withNewUser=username:password` would add a new user with \ + the given username, password and `superuser`, `kibana_system` roles. Adding a new user would also write \ + to indices in the generator as this user with the new roles.', + type: 'string', + default: '', + }, }).argv; let ca: Buffer; - let kbnClient: KbnClient; + let clientOptions: ClientOptions; + let url: string; + let node: string; + const toolingLogOptions = { + log: new ToolingLog({ + level: 'info', + writeTo: process.stdout, + }), + }; + + let kbnClientOptions: KbnClientOptions = { + ...toolingLogOptions, + url: argv.kibana, + }; if (argv.ssl) { ca = fs.readFileSync(CA_CERT_PATH); - const url = argv.kibana.replace('http:', 'https:'); - const node = argv.node.replace('http:', 'https:'); - kbnClient = new KbnClient({ - log: new ToolingLog({ - level: 'info', - writeTo: process.stdout, - }), + url = updateURL({ url: argv.kibana, protocol: 'https:' }); + node = updateURL({ url: argv.node, protocol: 'https:' }); + kbnClientOptions = { + ...kbnClientOptions, url, certificateAuthorities: [ca], - }); + }; + clientOptions = { node, tls: { ca: [ca] } }; } else { - kbnClient = new KbnClient({ - log: new ToolingLog({ - level: 'info', - writeTo: process.stdout, - }), - url: argv.kibana, - }); clientOptions = { node: argv.node }; } - const client = new Client(clientOptions); + let client = new Client(clientOptions); + let user: UserInfo | undefined; + // if fleet flag is used + if (argv.fleet) { + // add endpoint user if --withNewUser flag has values as username:password + const newUserCreds = + argv.withNewUser.indexOf(':') !== -1 ? argv.withNewUser.split(':') : undefined; + user = await addUser( + client, + newUserCreds + ? { + username: newUserCreds[0], + password: newUserCreds[1], + } + : undefined + ); + + // update client and kibana options before instantiating + if (user) { + // use endpoint user for Es and Kibana URLs + + url = updateURL({ url: argv.kibana, user }); + node = updateURL({ url: argv.node, user }); + + kbnClientOptions = { + ...kbnClientOptions, + url, + }; + client = new Client({ ...clientOptions, node }); + } + } + // instantiate kibana client + const kbnClient = new KbnClient({ ...kbnClientOptions }); if (argv.delete) { await deleteIndices( @@ -222,6 +334,14 @@ async function main() { console.log(`No seed supplied, using random seed: ${seed}`); } const startTime = new Date().getTime(); + if (argv.fleet && !argv.withNewUser) { + // warn and exit when using fleet flag + console.log( + 'Please use the --withNewUser=username:password flag to add a custom user with required roles when --fleet is enabled!' + ); + // eslint-disable-next-line no-process-exit + process.exit(0); + } await indexHostsAndAlerts( client, kbnClient, @@ -249,5 +369,13 @@ async function main() { alertsDataStream: EndpointDocGenerator.createDataStreamFromIndex(argv.alertIndex), } ); + // delete endpoint_user after + + if (user) { + const deleted = await deleteUser(client, user.username); + if (deleted.found) { + console.log(`User ${user.username} deleted successfully!`); + } + } console.log(`Creating and indexing documents took: ${new Date().getTime() - startTime}ms`); } From a7743b365147fe3f7064fc02d20d835182d06f62 Mon Sep 17 00:00:00 2001 From: Katerina Patticha Date: Tue, 15 Feb 2022 19:14:51 +0100 Subject: [PATCH 34/43] [APM] Reduce maxNumServices from 500 to 50 (#125646) * [APM] Reduce maxNumServices from 500 to 50 * Fix snapshot --- .../server/routes/services/__snapshots__/queries.test.ts.snap | 4 ++-- .../server/routes/services/get_services/get_services_items.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap index df1b3954cfe29..0f7caab5bf6c0 100644 --- a/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/routes/services/__snapshots__/queries.test.ts.snap @@ -161,7 +161,7 @@ Array [ }, "terms": Object { "field": "service.name", - "size": 500, + "size": 50, }, }, }, @@ -214,7 +214,7 @@ Array [ }, "terms": Object { "field": "service.name", - "size": 500, + "size": 50, }, }, }, diff --git a/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts b/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts index 716fd82aefd46..c158f83ff5560 100644 --- a/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts +++ b/x-pack/plugins/apm/server/routes/services/get_services/get_services_items.ts @@ -15,7 +15,7 @@ import { mergeServiceStats } from './merge_service_stats'; export type ServicesItemsSetup = Setup; -const MAX_NUMBER_OF_SERVICES = 500; +const MAX_NUMBER_OF_SERVICES = 50; export async function getServicesItems({ environment, From 07e645764ca5487bc61726eb1cfbc8f915aedc51 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Tue, 15 Feb 2022 13:51:05 -0500 Subject: [PATCH 35/43] [Security Solution][Timeline] Use first event in threshold set for 'from' value in timeline (#123282) * Use first event in threshold set for 'from' value in timeline * lint * Linting and test fix * Tests * Fix signal generation tests * Update more 'from' dates in threshold tests * More test fixes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../signals/executors/threshold.ts | 4 +++ .../bulk_create_threshold_signals.test.ts | 26 ++++++++++++------- .../bulk_create_threshold_signals.ts | 14 +++------- .../threshold/find_threshold_signals.test.ts | 25 ++++++++++++++++++ .../threshold/find_threshold_signals.ts | 5 ++++ .../lib/detection_engine/signals/types.ts | 1 + .../security_solution/server/lib/types.ts | 3 +++ .../tests/generating_signals.ts | 14 +++++----- .../tests/keyword_family/const_keyword.ts | 2 +- .../tests/keyword_family/keyword.ts | 2 +- .../keyword_mixed_with_const.ts | 2 +- 11 files changed, 68 insertions(+), 30 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts index 34e6e26a30eab..441baa7ee94fd 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.ts @@ -115,11 +115,13 @@ export const thresholdExecutor = async ({ index: ruleParams.index, }); + // Eliminate dupes const bucketFilters = await getThresholdBucketFilters({ signalHistory, timestampOverride: ruleParams.timestampOverride, }); + // Combine dupe filter with other filters const esFilter = await getFilter({ type: ruleParams.type, filters: ruleParams.filters ? ruleParams.filters.concat(bucketFilters) : bucketFilters, @@ -131,6 +133,7 @@ export const thresholdExecutor = async ({ lists: exceptionItems, }); + // Look for new events over threshold const { searchResult: thresholdResults, searchErrors, @@ -147,6 +150,7 @@ export const thresholdExecutor = async ({ buildRuleMessage, }); + // Build and index new alerts const { success, bulkCreateDuration, createdItemsCount, createdItems, errors } = await bulkCreateThresholdSignals({ someResult: thresholdResults, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts index 2d0907b045014..2c14e4bed62a8 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.test.ts @@ -41,7 +41,10 @@ describe('transformThresholdNormalizedResultsToEcs', () => { key: 'garden-gnomes', doc_count: 12, max_timestamp: { - value_as_string: '2020-04-20T21:27:45+0000', + value_as_string: '2020-12-17T16:30:03.000Z', + }, + min_timestamp: { + value_as_string: '2020-12-17T16:28:03.000Z', }, cardinality_count: { value: 7, @@ -92,20 +95,20 @@ describe('transformThresholdNormalizedResultsToEcs', () => { _id, _index: 'test', _source: { - '@timestamp': '2020-04-20T21:27:45+0000', + '@timestamp': '2020-12-17T16:30:03.000Z', 'host.name': 'garden-gnomes', 'source.ip': '127.0.0.1', threshold_result: { - from: new Date('2020-12-17T16:28:00.000Z'), // from threshold signal history + from: new Date('2020-12-17T16:28:03.000Z'), // from min_timestamp terms: [ - { - field: 'host.name', - value: 'garden-gnomes', - }, { field: 'source.ip', value: '127.0.0.1', }, + { + field: 'host.name', + value: 'garden-gnomes', + }, ], cardinality: [ { @@ -207,7 +210,10 @@ describe('transformThresholdNormalizedResultsToEcs', () => { key: '', doc_count: 15, max_timestamp: { - value_as_string: '2020-04-20T21:27:45+0000', + value_as_string: '2020-12-17T16:30:03.000Z', + }, + min_timestamp: { + value_as_string: '2020-12-17T16:28:03.000Z', }, cardinality_count: { value: 7, @@ -250,9 +256,9 @@ describe('transformThresholdNormalizedResultsToEcs', () => { _id, _index: 'test', _source: { - '@timestamp': '2020-04-20T21:27:45+0000', + '@timestamp': '2020-12-17T16:30:03.000Z', threshold_result: { - from: new Date('2020-12-17T16:27:00.000Z'), + from: new Date('2020-12-17T16:28:03.000Z'), terms: [], cardinality: [ { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts index 1c2bdd0d70ced..f098f33b2ffc7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/bulk_create_threshold_signals.ts @@ -22,11 +22,7 @@ import { import { BaseHit } from '../../../../../common/detection_engine/types'; import { TermAggregationBucket } from '../../../types'; import { GenericBulkCreateResponse } from '../bulk_create_factory'; -import { - calculateThresholdSignalUuid, - getThresholdAggregationParts, - getThresholdTermsHash, -} from '../utils'; +import { calculateThresholdSignalUuid, getThresholdAggregationParts } from '../utils'; import { buildReasonMessageForThresholdAlert } from '../reason_formatters'; import type { MultiAggBucket, @@ -102,6 +98,7 @@ const getTransformedHits = ( ].filter((term) => term.field != null), cardinality: val.cardinality, maxTimestamp: val.maxTimestamp, + minTimestamp: val.minTimestamp, docCount: val.docCount, }; acc.push(el as MultiAggBucket); @@ -124,6 +121,7 @@ const getTransformedHits = ( ] : undefined, maxTimestamp: bucket.max_timestamp.value_as_string, + minTimestamp: bucket.min_timestamp.value_as_string, docCount: bucket.doc_count, }; acc.push(el as MultiAggBucket); @@ -138,9 +136,6 @@ const getTransformedHits = ( 0, aggParts.field ).reduce((acc: Array>, bucket) => { - const termsHash = getThresholdTermsHash(bucket.terms); - const signalHit = signalHistory[termsHash]; - const source = { [TIMESTAMP]: bucket.maxTimestamp, ...bucket.terms.reduce((termAcc, term) => { @@ -162,8 +157,7 @@ const getTransformedHits = ( // threshold set in the timeline search. The upper bound will always be // the `original_time` of the signal (the timestamp of the latest event // in the set). - from: - signalHit?.lastSignalTimestamp != null ? new Date(signalHit.lastSignalTimestamp) : from, + from: new Date(bucket.minTimestamp) ?? from, }, }; 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 5374ae53a74e8..3a1149e8c8e99 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 @@ -64,6 +64,11 @@ describe('findThresholdSignals', () => { field: '@timestamp', }, }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, }, }, }, @@ -101,6 +106,11 @@ describe('findThresholdSignals', () => { field: '@timestamp', }, }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, }, }, }, @@ -146,6 +156,11 @@ describe('findThresholdSignals', () => { field: '@timestamp', }, }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, }, }, }, @@ -212,6 +227,11 @@ describe('findThresholdSignals', () => { field: '@timestamp', }, }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, }, }, }, @@ -273,6 +293,11 @@ describe('findThresholdSignals', () => { field: '@timestamp', }, }, + min_timestamp: { + min: { + field: '@timestamp', + }, + }, }, }, }, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts index ad0ff99c019af..52aa429dd64d6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/threshold/find_threshold_signals.ts @@ -56,6 +56,11 @@ export const findThresholdSignals = async ({ field: timestampOverride != null ? timestampOverride : TIMESTAMP, }, }, + min_timestamp: { + min: { + field: timestampOverride != null ? timestampOverride : TIMESTAMP, + }, + }, ...(threshold.cardinality?.length ? { cardinality_count: { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 19e0c36bae052..37ed4a78a61a6 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -361,6 +361,7 @@ export interface MultiAggBucket { }>; docCount: number; maxTimestamp: string; + minTimestamp: string; } export interface ThresholdQueryBucket extends TermAggregationBucket { diff --git a/x-pack/plugins/security_solution/server/lib/types.ts b/x-pack/plugins/security_solution/server/lib/types.ts index 15f40fdbc3019..919e9a7c7b160 100644 --- a/x-pack/plugins/security_solution/server/lib/types.ts +++ b/x-pack/plugins/security_solution/server/lib/types.ts @@ -74,6 +74,9 @@ export type SearchHit = SearchResponse['hits']['hits'][0]; export interface TermAggregationBucket { key: string; doc_count: number; + min_timestamp: { + value_as_string: string; + }; max_timestamp: { value_as_string: string; }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index 6dd569d891fdc..5570eb2813c9b 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -738,7 +738,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], count: 788, - from: '1900-01-01T00:00:00.000Z', + from: '2019-02-19T07:12:05.332Z', }, }); }); @@ -865,7 +865,7 @@ export default ({ getService }: FtrProviderContext) => { }, ], count: 788, - from: '1900-01-01T00:00:00.000Z', + from: '2019-02-19T07:12:05.332Z', }, }); }); @@ -920,10 +920,6 @@ export default ({ getService }: FtrProviderContext) => { [ALERT_DEPTH]: 1, [ALERT_THRESHOLD_RESULT]: { terms: [ - { - field: 'event.module', - value: 'system', - }, { field: 'host.id', value: '2ab45fc1c41e4c84bbd02202a7e5761f', @@ -932,9 +928,13 @@ export default ({ getService }: FtrProviderContext) => { field: 'process.name', value: 'sshd', }, + { + field: 'event.module', + value: 'system', + }, ], count: 21, - from: '1900-01-01T00:00:00.000Z', + from: '2019-02-19T20:22:03.561Z', }, }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts index 17492248f537a..7b3f938ceef2b 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/const_keyword.ts @@ -137,7 +137,7 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([ { count: 4, - from: '1900-01-01T00:00:00.000Z', + from: '2020-10-27T05:00:53.000Z', terms: [ { field: 'event.dataset', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts index 642b65f6a49c3..964bb40add2a1 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword.ts @@ -111,7 +111,7 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([ { count: 4, - from: '1900-01-01T00:00:00.000Z', + from: '2020-10-28T05:00:53.000Z', terms: [ { field: 'event.dataset', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts index df158b239c120..c33354e383809 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/keyword_family/keyword_mixed_with_const.ts @@ -150,7 +150,7 @@ export default ({ getService }: FtrProviderContext) => { expect(hits).to.eql([ { count: 8, - from: '1900-01-01T00:00:00.000Z', + from: '2020-10-27T05:00:53.000Z', terms: [ { field: 'event.dataset', From 7d186f945bd45ef6dfb51834a9a854546ab63ab4 Mon Sep 17 00:00:00 2001 From: Madison Caldwell Date: Tue, 15 Feb 2022 13:51:44 -0500 Subject: [PATCH 36/43] [Security Solution] Upgrade tests for DE rule types - alerts on legacy alerts (#125331) * Add integration tests for alerts-on-legacy-alerts * Remove query rule tests from prior location - they were moved * Remove 'kibana' field from alerts on legacy alerts * Fix tests * Delete alerts before proceeding from compatibility tests Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../factories/utils/filter_source.ts | 2 + .../tests/alerts/alerts_compatibility.ts | 570 ++- .../tests/generating_signals.ts | 43 - .../detection_engine_api_integration/utils.ts | 56 +- .../legacy_cti_signals/mappings.json | 4495 ++++++++++++++++- 5 files changed, 4953 insertions(+), 213 deletions(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/filter_source.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/filter_source.ts index 35c91ba398f6f..473b0da1d58e9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/filter_source.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/filter_source.ts @@ -13,12 +13,14 @@ export const filterSource = (doc: SignalSourceHit): Partial => { const docSource = doc._source ?? {}; const { event, + kibana, signal, threshold_result: siemSignalsThresholdResult, [ALERT_THRESHOLD_RESULT]: alertThresholdResult, ...filteredSource } = docSource || { event: null, + kibana: null, signal: null, threshold_result: null, [ALERT_THRESHOLD_RESULT]: null, diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/alerts/alerts_compatibility.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/alerts/alerts_compatibility.ts index a9942fc86566b..889396c2b6125 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/alerts/alerts_compatibility.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/alerts/alerts_compatibility.ts @@ -13,22 +13,54 @@ import { DETECTION_ENGINE_SIGNALS_MIGRATION_STATUS_URL, } from '../../../../../plugins/security_solution/common/constants'; import { + createRule, createSignalsIndex, + deleteAllAlerts, deleteSignalsIndex, finalizeSignalsMigration, + getEqlRuleForSignalTesting, + getRuleForSignalTesting, + getSavedQueryRuleForSignalTesting, + getSignalsByIds, + getThreatMatchRuleForSignalTesting, + getThresholdRuleForSignalTesting, startSignalsMigration, waitFor, + waitForRuleSuccessOrStatus, + waitForSignalsToBePresent, } from '../../../utils'; import { FtrProviderContext } from '../../../common/ftr_provider_context'; import { ThreatEcs } from '../../../../../plugins/security_solution/common/ecs/threat'; +import { + EqlCreateSchema, + QueryCreateSchema, + SavedQueryCreateSchema, + ThreatMatchCreateSchema, + ThresholdCreateSchema, +} from '../../../../../plugins/security_solution/common/detection_engine/schemas/request'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); - const supertest = getService('supertest'); const log = getService('log'); + const supertest = getService('supertest'); describe('Alerts Compatibility', function () { + beforeEach(async () => { + await esArchiver.load( + 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' + ); + await createSignalsIndex(supertest, log); + }); + + afterEach(async () => { + await esArchiver.unload( + 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' + ); + await deleteSignalsIndex(supertest, log); + await deleteAllAlerts(supertest, log); + }); + describe('CTI', () => { const expectedDomain = 'elastic.local'; const expectedProvider = 'provider1'; @@ -40,20 +72,6 @@ export default ({ getService }: FtrProviderContext) => { type: 'indicator_match_rule', }; - beforeEach(async () => { - await esArchiver.load( - 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' - ); - await createSignalsIndex(supertest, log); - }); - - afterEach(async () => { - await esArchiver.unload( - 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' - ); - await deleteSignalsIndex(supertest, log); - }); - it('allows querying of legacy enriched signals by threat.indicator', async () => { const { body: { @@ -161,6 +179,528 @@ export default ({ getService }: FtrProviderContext) => { ); expect(enrichmentProviders).to.eql([expectedProvider, expectedProvider]); }); + + it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { + const rule: ThreatMatchCreateSchema = getThreatMatchRuleForSignalTesting([ + '.siem-signals-*', + ]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + + it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { + const rule: ThreatMatchCreateSchema = getThreatMatchRuleForSignalTesting([ + `.alerts-security.alerts-default`, + ]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + }); + + describe('Query', () => { + it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { + const rule: QueryCreateSchema = getRuleForSignalTesting([`.siem-signals-*`]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + const { + '@timestamp': timestamp, + 'kibana.version': kibanaVersion, + 'kibana.alert.rule.created_at': createdAt, + 'kibana.alert.rule.updated_at': updatedAt, + 'kibana.alert.rule.execution.uuid': executionUuid, + 'kibana.alert.uuid': alertId, + ...source + } = hit._source!; + expect(source).to.eql({ + 'kibana.alert.rule.category': 'Custom Query Rule', + 'kibana.alert.rule.consumer': 'siem', + 'kibana.alert.rule.name': 'Signal Testing Query', + 'kibana.alert.rule.producer': 'siem', + 'kibana.alert.rule.rule_type_id': 'siem.queryRule', + 'kibana.alert.rule.uuid': id, + 'kibana.space_ids': ['default'], + 'kibana.alert.rule.tags': [], + agent: { + ephemeral_id: '07c24b1e-3663-4372-b982-f2d831e033eb', + hostname: 'elastic.local', + id: 'ce7741d9-3f0a-466d-8ae6-d7d8f883fcec', + name: 'elastic.local', + type: 'auditbeat', + version: '7.14.0', + }, + ecs: { version: '1.10.0' }, + host: { + architecture: 'x86_64', + hostname: 'elastic.local', + id: '1633D595-A115-5BF5-870B-A471B49446C3', + ip: ['192.168.1.1'], + mac: ['aa:bb:cc:dd:ee:ff'], + name: 'elastic.local', + os: { + build: '20G80', + family: 'darwin', + kernel: '20.6.0', + name: 'Mac OS X', + platform: 'darwin', + type: 'macos', + version: '10.16', + }, + }, + message: 'Process mdworker_shared (PID: 32306) by user elastic STARTED', + process: { + args: [ + '/System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Versions/A/Support/mdworker_shared', + '-s', + 'mdworker', + '-c', + 'MDSImporterWorker', + '-m', + 'com.apple.mdworker.shared', + ], + entity_id: 'wfc7zUuEinqxUbZ6', + executable: + '/System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Versions/A/Support/mdworker_shared', + hash: { sha1: '5f3233fd75c14b315731684d59b632df36a731a6' }, + name: 'mdworker_shared', + pid: 32306, + ppid: 1, + start: '2021-08-04T04:14:48.830Z', + working_directory: '/', + }, + service: { type: 'system' }, + threat: { + indicator: [ + { + domain: 'elastic.local', + event: { + category: 'threat', + created: '2021-08-04T03:53:30.761Z', + dataset: 'ti_abusech.malware', + ingested: '2021-08-04T03:53:37.514040Z', + kind: 'enrichment', + module: 'threatintel', + reference: 'https://urlhaus.abuse.ch/url/12345/', + type: 'indicator', + }, + first_seen: '2021-08-03T20:35:17.000Z', + matched: { + atomic: 'elastic.local', + field: 'host.name', + id: '_tdUD3sBcVT20cvWAkpd', + index: 'filebeat-7.14.0-2021.08.04-000001', + type: 'indicator_match_rule', + }, + provider: 'provider1', + type: 'url', + url: { + domain: 'elastic.local', + extension: 'php', + full: 'http://elastic.local/thing', + original: 'http://elastic.local/thing', + path: '/thing', + scheme: 'http', + }, + }, + ], + }, + user: { + effective: { group: { id: '20' }, id: '501' }, + group: { id: '20', name: 'staff' }, + id: '501', + name: 'elastic', + saved: { group: { id: '20' }, id: '501' }, + }, + 'event.action': 'process_started', + 'event.category': ['process'], + 'event.dataset': 'process', + 'event.kind': 'signal', + 'event.module': 'system', + 'event.type': ['start'], + 'kibana.alert.ancestors': [ + { + depth: 0, + id: 'yNdfD3sBcVT20cvWFEs2', + index: 'auditbeat-7.14.0-2021.08.04-000001', + type: 'event', + }, + { + id: '0527411874b23bcea85daf5bf7dcacd144536ba6d92d3230a4a0acfb7de7f512', + type: 'signal', + index: '.siem-signals-default-000001', + depth: 1, + rule: '832f86f0-f4da-11eb-989d-b758d09dbc85', + }, + ], + 'kibana.alert.status': 'active', + 'kibana.alert.workflow_status': 'open', + 'kibana.alert.depth': 2, + 'kibana.alert.reason': + 'process event with process mdworker_shared, by elastic on elastic.local created high alert Signal Testing Query.', + 'kibana.alert.severity': 'high', + 'kibana.alert.risk_score': 1, + 'kibana.alert.rule.parameters': { + description: 'Tests a simple query', + risk_score: 1, + severity: 'high', + author: [], + false_positives: [], + from: '1900-01-01T00:00:00.000Z', + rule_id: 'rule-1', + max_signals: 100, + risk_score_mapping: [], + severity_mapping: [], + threat: [], + to: 'now', + references: [], + version: 1, + exceptions_list: [], + immutable: false, + type: 'query', + language: 'kuery', + index: ['.siem-signals-*'], + query: '*:*', + }, + 'kibana.alert.rule.actions': [], + 'kibana.alert.rule.created_by': 'elastic', + 'kibana.alert.rule.enabled': true, + 'kibana.alert.rule.interval': '5m', + 'kibana.alert.rule.updated_by': 'elastic', + 'kibana.alert.rule.type': 'query', + 'kibana.alert.rule.description': 'Tests a simple query', + 'kibana.alert.rule.risk_score': 1, + 'kibana.alert.rule.severity': 'high', + 'kibana.alert.rule.author': [], + 'kibana.alert.rule.false_positives': [], + 'kibana.alert.rule.from': '1900-01-01T00:00:00.000Z', + 'kibana.alert.rule.rule_id': 'rule-1', + 'kibana.alert.rule.max_signals': 100, + 'kibana.alert.rule.risk_score_mapping': [], + 'kibana.alert.rule.severity_mapping': [], + 'kibana.alert.rule.threat': [], + 'kibana.alert.rule.to': 'now', + 'kibana.alert.rule.references': [], + 'kibana.alert.rule.version': 1, + 'kibana.alert.rule.exceptions_list': [], + 'kibana.alert.rule.immutable': false, + 'kibana.alert.original_time': '2021-08-04T04:14:58.973Z', + 'kibana.alert.original_event.action': 'process_started', + 'kibana.alert.original_event.category': ['process'], + 'kibana.alert.original_event.dataset': 'process', + 'kibana.alert.original_event.kind': 'signal', + 'kibana.alert.original_event.module': 'system', + 'kibana.alert.original_event.type': ['start'], + }); + }); + + it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { + const rule: QueryCreateSchema = getRuleForSignalTesting([ + `.alerts-security.alerts-default`, + ]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + const { + '@timestamp': timestamp, + 'kibana.version': kibanaVersion, + 'kibana.alert.rule.created_at': createdAt, + 'kibana.alert.rule.updated_at': updatedAt, + 'kibana.alert.rule.execution.uuid': executionUuid, + 'kibana.alert.uuid': alertId, + ...source + } = hit._source!; + expect(source).to.eql({ + 'kibana.alert.rule.category': 'Custom Query Rule', + 'kibana.alert.rule.consumer': 'siem', + 'kibana.alert.rule.name': 'Signal Testing Query', + 'kibana.alert.rule.producer': 'siem', + 'kibana.alert.rule.rule_type_id': 'siem.queryRule', + 'kibana.alert.rule.uuid': id, + 'kibana.space_ids': ['default'], + 'kibana.alert.rule.tags': [], + agent: { + ephemeral_id: '07c24b1e-3663-4372-b982-f2d831e033eb', + hostname: 'elastic.local', + id: 'ce7741d9-3f0a-466d-8ae6-d7d8f883fcec', + name: 'elastic.local', + type: 'auditbeat', + version: '7.14.0', + }, + ecs: { version: '1.10.0' }, + host: { + architecture: 'x86_64', + hostname: 'elastic.local', + id: '1633D595-A115-5BF5-870B-A471B49446C3', + ip: ['192.168.1.1'], + mac: ['aa:bb:cc:dd:ee:ff'], + name: 'elastic.local', + os: { + build: '20G80', + family: 'darwin', + kernel: '20.6.0', + name: 'Mac OS X', + platform: 'darwin', + type: 'macos', + version: '10.16', + }, + }, + message: 'Process mdworker_shared (PID: 32306) by user elastic STARTED', + process: { + args: [ + '/System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Versions/A/Support/mdworker_shared', + '-s', + 'mdworker', + '-c', + 'MDSImporterWorker', + '-m', + 'com.apple.mdworker.shared', + ], + entity_id: 'wfc7zUuEinqxUbZ6', + executable: + '/System/Library/Frameworks/CoreServices.framework/Frameworks/Metadata.framework/Versions/A/Support/mdworker_shared', + hash: { sha1: '5f3233fd75c14b315731684d59b632df36a731a6' }, + name: 'mdworker_shared', + pid: 32306, + ppid: 1, + start: '2021-08-04T04:14:48.830Z', + working_directory: '/', + }, + service: { type: 'system' }, + threat: { + indicator: [ + { + domain: 'elastic.local', + event: { + category: 'threat', + created: '2021-08-04T03:53:30.761Z', + dataset: 'ti_abusech.malware', + ingested: '2021-08-04T03:53:37.514040Z', + kind: 'enrichment', + module: 'threatintel', + reference: 'https://urlhaus.abuse.ch/url/12345/', + type: 'indicator', + }, + first_seen: '2021-08-03T20:35:17.000Z', + matched: { + atomic: 'elastic.local', + field: 'host.name', + id: '_tdUD3sBcVT20cvWAkpd', + index: 'filebeat-7.14.0-2021.08.04-000001', + type: 'indicator_match_rule', + }, + provider: 'provider1', + type: 'url', + url: { + domain: 'elastic.local', + extension: 'php', + full: 'http://elastic.local/thing', + original: 'http://elastic.local/thing', + path: '/thing', + scheme: 'http', + }, + }, + ], + }, + user: { + effective: { group: { id: '20' }, id: '501' }, + group: { id: '20', name: 'staff' }, + id: '501', + name: 'elastic', + saved: { group: { id: '20' }, id: '501' }, + }, + 'event.action': 'process_started', + 'event.category': ['process'], + 'event.dataset': 'process', + 'event.kind': 'signal', + 'event.module': 'system', + 'event.type': ['start'], + 'kibana.alert.ancestors': [ + { + depth: 0, + id: 'yNdfD3sBcVT20cvWFEs2', + index: 'auditbeat-7.14.0-2021.08.04-000001', + type: 'event', + }, + { + id: '0527411874b23bcea85daf5bf7dcacd144536ba6d92d3230a4a0acfb7de7f512', + type: 'signal', + index: '.siem-signals-default-000001', + depth: 1, + rule: '832f86f0-f4da-11eb-989d-b758d09dbc85', + }, + ], + 'kibana.alert.status': 'active', + 'kibana.alert.workflow_status': 'open', + 'kibana.alert.depth': 2, + 'kibana.alert.reason': + 'process event with process mdworker_shared, by elastic on elastic.local created high alert Signal Testing Query.', + 'kibana.alert.severity': 'high', + 'kibana.alert.risk_score': 1, + 'kibana.alert.rule.parameters': { + description: 'Tests a simple query', + risk_score: 1, + severity: 'high', + author: [], + false_positives: [], + from: '1900-01-01T00:00:00.000Z', + rule_id: 'rule-1', + max_signals: 100, + risk_score_mapping: [], + severity_mapping: [], + threat: [], + to: 'now', + references: [], + version: 1, + exceptions_list: [], + immutable: false, + type: 'query', + language: 'kuery', + index: ['.alerts-security.alerts-default'], + query: '*:*', + }, + 'kibana.alert.rule.actions': [], + 'kibana.alert.rule.created_by': 'elastic', + 'kibana.alert.rule.enabled': true, + 'kibana.alert.rule.interval': '5m', + 'kibana.alert.rule.updated_by': 'elastic', + 'kibana.alert.rule.type': 'query', + 'kibana.alert.rule.description': 'Tests a simple query', + 'kibana.alert.rule.risk_score': 1, + 'kibana.alert.rule.severity': 'high', + 'kibana.alert.rule.author': [], + 'kibana.alert.rule.false_positives': [], + 'kibana.alert.rule.from': '1900-01-01T00:00:00.000Z', + 'kibana.alert.rule.rule_id': 'rule-1', + 'kibana.alert.rule.max_signals': 100, + 'kibana.alert.rule.risk_score_mapping': [], + 'kibana.alert.rule.severity_mapping': [], + 'kibana.alert.rule.threat': [], + 'kibana.alert.rule.to': 'now', + 'kibana.alert.rule.references': [], + 'kibana.alert.rule.version': 1, + 'kibana.alert.rule.exceptions_list': [], + 'kibana.alert.rule.immutable': false, + 'kibana.alert.original_time': '2021-08-04T04:14:58.973Z', + 'kibana.alert.original_event.action': 'process_started', + 'kibana.alert.original_event.category': ['process'], + 'kibana.alert.original_event.dataset': 'process', + 'kibana.alert.original_event.kind': 'signal', + 'kibana.alert.original_event.module': 'system', + 'kibana.alert.original_event.type': ['start'], + }); + }); + }); + + describe('Saved Query', () => { + it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { + const rule: SavedQueryCreateSchema = getSavedQueryRuleForSignalTesting([`.siem-signals-*`]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + + it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { + const rule: SavedQueryCreateSchema = getSavedQueryRuleForSignalTesting([ + `.alerts-security.alerts-default`, + ]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + }); + + describe('EQL', () => { + it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { + const rule: EqlCreateSchema = getEqlRuleForSignalTesting(['.siem-signals-*']); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + + it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { + const rule: EqlCreateSchema = getEqlRuleForSignalTesting([ + `.alerts-security.alerts-default`, + ]); + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + }); + + describe('Threshold', () => { + it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { + const baseRule: ThresholdCreateSchema = getThresholdRuleForSignalTesting([ + '.siem-signals-*', + ]); + const rule: ThresholdCreateSchema = { + ...baseRule, + threshold: { + ...baseRule.threshold, + value: 1, + }, + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); + + it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { + const baseRule: ThresholdCreateSchema = getThresholdRuleForSignalTesting([ + `.alerts-security.alerts-default`, + ]); + const rule: ThresholdCreateSchema = { + ...baseRule, + threshold: { + ...baseRule.threshold, + value: 1, + }, + }; + const { id } = await createRule(supertest, log, rule); + await waitForRuleSuccessOrStatus(supertest, log, id); + await waitForSignalsToBePresent(supertest, log, 1, [id]); + const signalsOpen = await getSignalsByIds(supertest, log, [id]); + expect(signalsOpen.hits.hits.length).greaterThan(0); + const hit = signalsOpen.hits.hits[0]; + expect(hit._source?.kibana).to.eql(undefined); + }); }); }); }; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts index 5570eb2813c9b..f9c4a1bac9d24 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/generating_signals.ts @@ -941,49 +941,6 @@ export default ({ getService }: FtrProviderContext) => { }); }); - /** - * Here we test that 8.0.x alerts can be generated on legacy (pre-8.x) alerts. - */ - describe('Signals generated from legacy signals', async () => { - beforeEach(async () => { - await deleteSignalsIndex(supertest, log); - await createSignalsIndex(supertest, log); - await esArchiver.load( - 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' - ); - }); - - afterEach(async () => { - await esArchiver.unload( - 'x-pack/test/functional/es_archives/security_solution/legacy_cti_signals' - ); - await deleteSignalsIndex(supertest, log); - await deleteAllAlerts(supertest, log); - }); - - it('should generate a signal-on-legacy-signal with legacy index pattern', async () => { - const rule: QueryCreateSchema = { - ...getRuleForSignalTesting([`.siem-signals-*`]), - }; - const { id } = await createRule(supertest, log, rule); - await waitForRuleSuccessOrStatus(supertest, log, id); - await waitForSignalsToBePresent(supertest, log, 1, [id]); - const signalsOpen = await getSignalsByIds(supertest, log, [id]); - expect(signalsOpen.hits.hits.length).greaterThan(0); - }); - - it('should generate a signal-on-legacy-signal with AAD index pattern', async () => { - const rule: QueryCreateSchema = { - ...getRuleForSignalTesting([`.alerts-security.alerts-default`]), - }; - const { id } = await createRule(supertest, log, rule); - await waitForRuleSuccessOrStatus(supertest, log, id); - await waitForSignalsToBePresent(supertest, log, 1, [id]); - const signalsOpen = await getSignalsByIds(supertest, log, [id]); - expect(signalsOpen.hits.hits.length).greaterThan(0); - }); - }); - /** * Here we test the functionality of Severity and Risk Score overrides (also called "mappings" * in the code). If the rule specifies a mapping, then the final Severity or Risk Score diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 8f05a7fd94487..9cbaef3ad0fe2 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -36,6 +36,7 @@ import { PreviewRulesSchema, ThreatMatchCreateSchema, RulePreviewLogs, + SavedQueryCreateSchema, } from '../../plugins/security_solution/common/detection_engine/schemas/request'; import { signalsMigrationType } from '../../plugins/security_solution/server/lib/detection_engine/migrations/saved_objects'; import { @@ -131,7 +132,7 @@ export const getSimplePreviewRule = ( /** * This is a typical signal testing rule that is easy for most basic testing of output of signals. - * It starts out in an enabled true state. The from is set very far back to test the basics of signal + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal * creation and testing by getting all the signals at once. * @param ruleId The optional ruleId which is rule-1 by default. * @param enabled Enables the rule on creation or not. Defaulted to true. @@ -153,9 +154,26 @@ export const getRuleForSignalTesting = ( from: '1900-01-01T00:00:00.000Z', }); +/** + * This is a typical signal testing rule that is easy for most basic testing of output of Saved Query signals. + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal + * creation for SavedQuery and testing by getting all the signals at once. + * @param ruleId The optional ruleId which is threshold-rule by default. + * @param enabled Enables the rule on creation or not. Defaulted to true. + */ +export const getSavedQueryRuleForSignalTesting = ( + index: string[], + ruleId = 'saved-query-rule', + enabled = true +): SavedQueryCreateSchema => ({ + ...getRuleForSignalTesting(index, ruleId, enabled), + type: 'saved_query', + saved_id: 'abcd', +}); + /** * This is a typical signal testing rule that is easy for most basic testing of output of EQL signals. - * It starts out in an enabled true state. The from is set very far back to test the basics of signal + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal * creation for EQL and testing by getting all the signals at once. * @param ruleId The optional ruleId which is eql-rule by default. * @param enabled Enables the rule on creation or not. Defaulted to true. @@ -171,9 +189,41 @@ export const getEqlRuleForSignalTesting = ( query: 'any where true', }); +/** + * This is a typical signal testing rule that is easy for most basic testing of output of Threat Match signals. + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal + * creation for Threat Match and testing by getting all the signals at once. + * @param ruleId The optional ruleId which is threshold-rule by default. + * @param enabled Enables the rule on creation or not. Defaulted to true. + */ +export const getThreatMatchRuleForSignalTesting = ( + index: string[], + ruleId = 'threat-match-rule', + enabled = true +): ThreatMatchCreateSchema => ({ + ...getRuleForSignalTesting(index, ruleId, enabled), + type: 'threat_match', + language: 'kuery', + query: '*:*', + threat_query: '*:*', + threat_mapping: [ + // We match host.name against host.name + { + entries: [ + { + field: 'host.name', + value: 'host.name', + type: 'mapping', + }, + ], + }, + ], + threat_index: index, // match against same index for simplicity +}); + /** * This is a typical signal testing rule that is easy for most basic testing of output of Threshold signals. - * It starts out in an enabled true state. The from is set very far back to test the basics of signal + * It starts out in an enabled true state. The 'from' is set very far back to test the basics of signal * creation for Threshold and testing by getting all the signals at once. * @param ruleId The optional ruleId which is threshold-rule by default. * @param enabled Enables the rule on creation or not. Defaulted to true. diff --git a/x-pack/test/functional/es_archives/security_solution/legacy_cti_signals/mappings.json b/x-pack/test/functional/es_archives/security_solution/legacy_cti_signals/mappings.json index feda6d3ac85b9..87f3b6d570e80 100644 --- a/x-pack/test/functional/es_archives/security_solution/legacy_cti_signals/mappings.json +++ b/x-pack/test/functional/es_archives/security_solution/legacy_cti_signals/mappings.json @@ -20,13 +20,3383 @@ "@timestamp": { "type": "date" }, + "agent": { + "properties": { + "build": { + "properties": { + "original": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ephemeral_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + } + } + }, + "client": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "availability_zone": { + "type": "keyword", + "ignore_above": 1024 + }, + "instance": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "machine": { + "properties": { + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "project": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "provider": { + "type": "keyword", + "ignore_above": 1024 + }, + "region": { + "type": "keyword", + "ignore_above": 1024 + }, + "service": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "container": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "image": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "tag": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "runtime": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "data_stream": { + "properties": { + "dataset": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "dll": { + "properties": { + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "signing_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "team_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + }, + "ssdeep": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "pe": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "company": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "file_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "imphash": { + "type": "keyword", + "ignore_above": 1024 + }, + "original_file_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "type": "keyword", + "ignore_above": 1024 + }, + "data": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "ttl": { + "type": "long" + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "header_flags": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "op_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "question": { + "properties": { + "class": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ecs": { + "properties": { + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "error": { + "properties": { + "code": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "message": { + "type": "text", + "norms": false + }, + "stack_trace": { + "type": "keyword", + "index": false, + "doc_values": false, + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "event": { + "properties": { + "action": { + "type": "keyword", + "ignore_above": 1024 + }, + "category": { + "type": "keyword", + "ignore_above": 1024 + }, + "code": { + "type": "keyword", + "ignore_above": 1024 + }, + "created": { + "type": "date" + }, + "dataset": { + "type": "keyword", + "ignore_above": 1024 + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "ingested": { + "type": "date" + }, + "kind": { + "type": "keyword", + "ignore_above": 1024 + }, + "module": { + "type": "keyword", + "ignore_above": 1024 + }, + "original": { + "type": "keyword", + "index": false, + "doc_values": false, + "ignore_above": 1024 + }, + "outcome": { + "type": "keyword", + "ignore_above": 1024 + }, + "provider": { + "type": "keyword", + "ignore_above": 1024 + }, + "reason": { + "type": "keyword", + "ignore_above": 1024 + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "url": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "type": "keyword", + "ignore_above": 1024 + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "signing_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "team_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "type": "keyword", + "ignore_above": 1024 + }, + "directory": { + "type": "keyword", + "ignore_above": 1024 + }, + "drive_letter": { + "type": "keyword", + "ignore_above": 1 + }, + "extension": { + "type": "keyword", + "ignore_above": 1024 + }, + "gid": { + "type": "keyword", + "ignore_above": 1024 + }, + "group": { + "type": "keyword", + "ignore_above": 1024 + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + }, + "ssdeep": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "inode": { + "type": "keyword", + "ignore_above": 1024 + }, + "mime_type": { + "type": "keyword", + "ignore_above": 1024 + }, + "mode": { + "type": "keyword", + "ignore_above": 1024 + }, + "mtime": { + "type": "date" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "owner": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "pe": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "company": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "file_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "imphash": { + "type": "keyword", + "ignore_above": 1024 + }, + "original_file_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "size": { + "type": "long" + }, + "target_path": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "uid": { + "type": "keyword", + "ignore_above": 1024 + }, + "x509": { + "properties": { + "alternative_names": { + "type": "keyword", + "ignore_above": 1024 + }, + "issuer": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_curve": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_exponent": { + "type": "long", + "index": false, + "doc_values": false + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "signature_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "version_number": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "host": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "cpu": { + "properties": { + "usage": { + "type": "scaled_float", + "scaling_factor": 1000.0 + } + } + }, + "disk": { + "properties": { + "read": { + "properties": { + "bytes": { + "type": "long" + } + } + }, + "write": { + "properties": { + "bytes": { + "type": "long" + } + } + } + } + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hostname": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "network": { + "properties": { + "egress": { + "properties": { + "bytes": { + "type": "long" + }, + "packets": { + "type": "long" + } + } + }, + "ingress": { + "properties": { + "bytes": { + "type": "long" + }, + "packets": { + "type": "long" + } + } + } + } + }, + "os": { + "properties": { + "family": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "kernel": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "method": { + "type": "keyword", + "ignore_above": 1024 + }, + "mime_type": { + "type": "keyword", + "ignore_above": 1024 + }, + "referrer": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "mime_type": { + "type": "keyword", + "ignore_above": 1024 + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "interface": { + "properties": { + "alias": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "file": { + "properties": { + "path": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "level": { + "type": "keyword", + "ignore_above": 1024 + }, + "logger": { + "type": "keyword", + "ignore_above": 1024 + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "integer" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "function": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "original": { + "type": "keyword", + "index": false, + "doc_values": false, + "ignore_above": 1024 + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "message": { + "type": "text", + "norms": false + }, + "network": { + "properties": { + "application": { + "type": "keyword", + "ignore_above": 1024 + }, + "bytes": { + "type": "long" + }, + "community_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "direction": { + "type": "keyword", + "ignore_above": 1024 + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "inner": { + "properties": { + "vlan": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "packets": { + "type": "long" + }, + "protocol": { + "type": "keyword", + "ignore_above": 1024 + }, + "transport": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "vlan": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "observer": { + "properties": { + "egress": { + "properties": { + "interface": { + "properties": { + "alias": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "vlan": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "zone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hostname": { + "type": "keyword", + "ignore_above": 1024 + }, + "ingress": { + "properties": { + "interface": { + "properties": { + "alias": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "vlan": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "zone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "os": { + "properties": { + "family": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "kernel": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + }, + "serial_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "vendor": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "orchestrator": { + "properties": { + "api_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "cluster": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "url": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "namespace": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "resource": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "organization": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + }, + "os": { + "properties": { + "family": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "kernel": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "package": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "build_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "checksum": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "install_scope": { + "type": "keyword", + "ignore_above": 1024 + }, + "installed": { + "type": "date" + }, + "license": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + }, + "size": { + "type": "long" + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "pe": { + "properties": { + "company": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "file_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "original_file_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "process": { + "properties": { + "args": { + "type": "keyword", + "ignore_above": 1024 + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "signing_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "team_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "entity_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "executable": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + }, + "ssdeep": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "parent": { + "properties": { + "args": { + "type": "keyword", + "ignore_above": 1024 + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "signing_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "team_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "entity_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "executable": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha512": { + "type": "keyword", + "ignore_above": 1024 + }, + "ssdeep": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "pe": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "company": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "file_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "imphash": { + "type": "keyword", + "ignore_above": 1024 + }, + "original_file_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "title": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + }, + "pe": { + "properties": { + "architecture": { + "type": "keyword", + "ignore_above": 1024 + }, + "company": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "file_version": { + "type": "keyword", + "ignore_above": 1024 + }, + "imphash": { + "type": "keyword", + "ignore_above": 1024 + }, + "original_file_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "product": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "title": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "type": "keyword", + "ignore_above": 1024 + }, + "strings": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hive": { + "type": "keyword", + "ignore_above": 1024 + }, + "key": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "value": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "related": { + "properties": { + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "hosts": { + "type": "keyword", + "ignore_above": 1024 + }, + "ip": { + "type": "ip" + }, + "user": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "rule": { + "properties": { + "author": { + "type": "keyword", + "ignore_above": 1024 + }, + "category": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "license": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + }, + "ruleset": { + "type": "keyword", + "ignore_above": 1024 + }, + "uuid": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "server": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "node": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "state": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "signal": { + "properties": { + "_meta": { + "properties": { + "version": { + "type": "long" + } + } + }, + "ancestors": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "depth": { + "type": "integer" + }, + "group": { + "properties": { + "id": { + "type": "keyword" + }, + "index": { + "type": "integer" + } + } + }, + "original_event": { + "properties": { + "action": { + "type": "keyword" + }, + "category": { + "type": "keyword" + }, + "code": { + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + }, + "module": { + "type": "keyword" + }, + "original": { + "type": "keyword", + "index": false, + "doc_values": false + }, + "outcome": { + "type": "keyword" + }, + "provider": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "original_signal": { + "type": "object", + "dynamic": "false", + "enabled": false + }, + "original_time": { + "type": "date" + }, + "parent": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "parents": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "type": "keyword" + }, + "building_block_type": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "enabled": { + "type": "keyword" + }, + "false_positives": { + "type": "keyword" + }, + "filters": { + "type": "object" + }, + "from": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "immutable": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "language": { + "type": "keyword" + }, + "license": { + "type": "keyword" + }, + "max_signals": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "output_index": { + "type": "keyword" + }, + "query": { + "type": "keyword" + }, + "references": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_mapping": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + }, + "rule_id": { + "type": "keyword" + }, + "rule_name_override": { + "type": "keyword" + }, + "saved_id": { + "type": "keyword" + }, + "severity": { + "type": "keyword" + }, + "severity_mapping": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "severity": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + }, + "size": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + }, + "subtechnique": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + } + } + } + } + } + } + }, + "threat_filters": { + "type": "object" + }, + "threat_index": { + "type": "keyword" + }, + "threat_indicator_path": { + "type": "keyword" + }, + "threat_language": { + "type": "keyword" + }, + "threat_mapping": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + } + } + }, + "threat_query": { + "type": "keyword" + }, + "threshold": { + "properties": { + "field": { + "type": "keyword" + }, + "value": { + "type": "float" + } + } + }, + "timeline_id": { + "type": "keyword" + }, + "timeline_title": { + "type": "keyword" + }, + "timestamp_override": { + "type": "keyword" + }, + "to": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "status": { + "type": "keyword" + }, + "threshold_count": { + "type": "float" + }, + "threshold_result": { + "properties": { + "cardinality": { + "properties": { + "field": { + "type": "keyword" + }, + "value": { + "type": "long" + } + } + }, + "count": { + "type": "long" + }, + "from": { + "type": "date" + }, + "terms": { + "properties": { + "field": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + } + } + } + } + }, + "source": { + "properties": { + "address": { + "type": "keyword", + "ignore_above": 1024 + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "postal_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "timezone": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "type": "keyword", + "ignore_above": 1024 + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "user": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "span": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "tags": { + "type": "keyword", + "ignore_above": 1024 + }, "threat": { "properties": { "framework": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "indicator": { + "type": "nested", "properties": { "as": { "properties": { @@ -36,62 +3406,62 @@ "organization": { "properties": { "name": { + "type": "keyword", + "ignore_above": 1024, "fields": { "text": { - "norms": false, - "type": "text" + "type": "text", + "norms": false } - }, - "ignore_above": 1024, - "type": "keyword" + } } } } } }, "confidence": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "dataset": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "description": { "type": "wildcard" }, "domain": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "email": { "properties": { "address": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 } } }, "event": { "properties": { "action": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "category": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "code": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "created": { "type": "date" }, "dataset": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "duration": { "type": "long" @@ -100,45 +3470,45 @@ "type": "date" }, "hash": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "id": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "ingested": { "type": "date" }, "kind": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "module": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "original": { - "doc_values": false, - "ignore_above": 1024, + "type": "keyword", "index": false, - "type": "keyword" + "doc_values": false, + "ignore_above": 1024 }, "outcome": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "provider": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "reason": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "reference": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "risk_score": { "type": "float" @@ -156,170 +3526,991 @@ "type": "date" }, "timezone": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "type": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 }, "url": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "first_seen": { + "type": "date" + }, + "geo": { + "properties": { + "city_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "continent_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "country_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "location": { + "type": "geo_point" + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_iso_code": { + "type": "keyword", + "ignore_above": 1024 + }, + "region_name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "ip": { + "type": "ip" + }, + "last_seen": { + "type": "date" + }, + "marking": { + "properties": { + "tlp": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "matched": { + "properties": { + "atomic": { + "type": "keyword", + "ignore_above": 1024 + }, + "field": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "module": { + "type": "keyword", + "ignore_above": 1024 + }, + "port": { + "type": "long" + }, + "provider": { + "type": "keyword", + "ignore_above": 1024 + }, + "scanner_stats": { + "type": "long" + }, + "sightings": { + "type": "long" + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "tactic": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "technique": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + }, + "subtechnique": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", "ignore_above": 1024, - "type": "keyword" + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + } + } + }, + "tls": { + "properties": { + "cipher": { + "type": "keyword", + "ignore_above": 1024 + }, + "client": { + "properties": { + "certificate": { + "type": "keyword", + "ignore_above": 1024 + }, + "certificate_chain": { + "type": "keyword", + "ignore_above": 1024 + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "issuer": { + "type": "keyword", + "ignore_above": 1024 + }, + "ja3": { + "type": "keyword", + "ignore_above": 1024 + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject": { + "type": "keyword", + "ignore_above": 1024 + }, + "supported_ciphers": { + "type": "keyword", + "ignore_above": 1024 + }, + "x509": { + "properties": { + "alternative_names": { + "type": "keyword", + "ignore_above": 1024 + }, + "issuer": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_curve": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_exponent": { + "type": "long", + "index": false, + "doc_values": false + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "signature_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "version_number": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "curve": { + "type": "keyword", + "ignore_above": 1024 + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "type": "keyword", + "ignore_above": 1024 + }, + "resumed": { + "type": "boolean" + }, + "server": { + "properties": { + "certificate": { + "type": "keyword", + "ignore_above": 1024 + }, + "certificate_chain": { + "type": "keyword", + "ignore_above": 1024 + }, + "hash": { + "properties": { + "md5": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha1": { + "type": "keyword", + "ignore_above": 1024 + }, + "sha256": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "issuer": { + "type": "keyword", + "ignore_above": 1024 + }, + "ja3s": { + "type": "keyword", + "ignore_above": 1024 + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "type": "keyword", + "ignore_above": 1024 + }, + "x509": { + "properties": { + "alternative_names": { + "type": "keyword", + "ignore_above": 1024 + }, + "issuer": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "public_key_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_curve": { + "type": "keyword", + "ignore_above": 1024 + }, + "public_key_exponent": { + "type": "long", + "index": false, + "doc_values": false + }, + "public_key_size": { + "type": "long" + }, + "serial_number": { + "type": "keyword", + "ignore_above": 1024 + }, + "signature_algorithm": { + "type": "keyword", + "ignore_above": 1024 + }, + "subject": { + "properties": { + "common_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "country": { + "type": "keyword", + "ignore_above": 1024 + }, + "distinguished_name": { + "type": "keyword", + "ignore_above": 1024 + }, + "locality": { + "type": "keyword", + "ignore_above": 1024 + }, + "organization": { + "type": "keyword", + "ignore_above": 1024 + }, + "organizational_unit": { + "type": "keyword", + "ignore_above": 1024 + }, + "state_or_province": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "version_number": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + }, + "version_protocol": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "trace": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "transaction": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "url": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "extension": { + "type": "keyword", + "ignore_above": 1024 + }, + "fragment": { + "type": "keyword", + "ignore_above": 1024 + }, + "full": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "original": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "password": { + "type": "keyword", + "ignore_above": 1024 + }, + "path": { + "type": "keyword", + "ignore_above": 1024 + }, + "port": { + "type": "long" + }, + "query": { + "type": "keyword", + "ignore_above": 1024 + }, + "registered_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "scheme": { + "type": "keyword", + "ignore_above": 1024 + }, + "subdomain": { + "type": "keyword", + "ignore_above": 1024 + }, + "top_level_domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "username": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "user": { + "properties": { + "changes": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false } } }, - "first_seen": { - "type": "date" - }, - "geo": { + "group": { "properties": { - "city_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "continent_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "country_iso_code": { - "ignore_above": 1024, - "type": "keyword" + "domain": { + "type": "keyword", + "ignore_above": 1024 }, - "country_name": { - "ignore_above": 1024, - "type": "keyword" - }, - "location": { - "type": "geo_point" + "id": { + "type": "keyword", + "ignore_above": 1024 }, "name": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_iso_code": { - "ignore_above": 1024, - "type": "keyword" - }, - "region_name": { - "ignore_above": 1024, - "type": "keyword" + "type": "keyword", + "ignore_above": 1024 } } }, - "ip": { - "type": "ip" + "hash": { + "type": "keyword", + "ignore_above": 1024 }, - "last_seen": { - "type": "date" + "id": { + "type": "keyword", + "ignore_above": 1024 }, - "marking": { - "properties": { - "tlp": { - "ignore_above": 1024, - "type": "keyword" + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false } } }, - "matched": { + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "effective": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { "properties": { - "atomic": { - "ignore_above": 1024, - "type": "keyword" + "domain": { + "type": "keyword", + "ignore_above": 1024 }, - "field": { - "ignore_above": 1024, - "type": "keyword" + "id": { + "type": "keyword", + "ignore_above": 1024 }, - "type": { - "ignore_above": 1024, - "type": "keyword" + "name": { + "type": "keyword", + "ignore_above": 1024 } } }, - "module": { - "ignore_above": 1024, - "type": "keyword" + "hash": { + "type": "keyword", + "ignore_above": 1024 }, - "port": { - "type": "long" + "id": { + "type": "keyword", + "ignore_above": 1024 }, - "provider": { + "name": { + "type": "keyword", "ignore_above": 1024, - "type": "keyword" + "fields": { + "text": { + "type": "text", + "norms": false + } + } }, - "scanner_stats": { - "type": "long" + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 }, - "sightings": { - "type": "long" + "id": { + "type": "keyword", + "ignore_above": 1024 }, - "type": { - "ignore_above": 1024, - "type": "keyword" + "name": { + "type": "keyword", + "ignore_above": 1024 } - }, - "type": "nested" + } }, - "tactic": { + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "roles": { + "type": "keyword", + "ignore_above": 1024 + }, + "target": { "properties": { - "id": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "email": { + "type": "keyword", + "ignore_above": 1024 + }, + "full_name": { + "type": "keyword", "ignore_above": 1024, - "type": "keyword" + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "group": { + "properties": { + "domain": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "hash": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 }, "name": { + "type": "keyword", "ignore_above": 1024, - "type": "keyword" + "fields": { + "text": { + "type": "text", + "norms": false + } + } }, - "reference": { - "ignore_above": 1024, - "type": "keyword" + "roles": { + "type": "keyword", + "ignore_above": 1024 + } + } + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "type": "keyword", + "ignore_above": 1024 } } }, - "technique": { + "name": { + "type": "keyword", + "ignore_above": 1024 + }, + "original": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false + } + } + }, + "os": { "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" + "family": { + "type": "keyword", + "ignore_above": 1024 }, - "name": { + "full": { + "type": "keyword", + "ignore_above": 1024, "fields": { "text": { - "norms": false, - "type": "text" + "type": "text", + "norms": false } - }, - "ignore_above": 1024, - "type": "keyword" + } }, - "reference": { - "ignore_above": 1024, - "type": "keyword" + "kernel": { + "type": "keyword", + "ignore_above": 1024 }, - "subtechnique": { - "properties": { - "id": { - "ignore_above": 1024, - "type": "keyword" - }, - "name": { - "fields": { - "text": { - "norms": false, - "type": "text" - } - }, - "ignore_above": 1024, - "type": "keyword" - }, - "reference": { - "ignore_above": 1024, - "type": "keyword" + "name": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false } } + }, + "platform": { + "type": "keyword", + "ignore_above": 1024 + }, + "type": { + "type": "keyword", + "ignore_above": 1024 + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "vlan": { + "properties": { + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "name": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "vulnerability": { + "properties": { + "category": { + "type": "keyword", + "ignore_above": 1024 + }, + "classification": { + "type": "keyword", + "ignore_above": 1024 + }, + "description": { + "type": "keyword", + "ignore_above": 1024, + "fields": { + "text": { + "type": "text", + "norms": false } } + }, + "enumeration": { + "type": "keyword", + "ignore_above": 1024 + }, + "id": { + "type": "keyword", + "ignore_above": 1024 + }, + "reference": { + "type": "keyword", + "ignore_above": 1024 + }, + "report_id": { + "type": "keyword", + "ignore_above": 1024 + }, + "scanner": { + "properties": { + "vendor": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, + "severity": { + "type": "keyword", + "ignore_above": 1024 } } } From f0e5e2db052e06d918244367d027432a6aa9aa45 Mon Sep 17 00:00:00 2001 From: Vadim Kibana <82822460+vadimkibana@users.noreply.github.com> Date: Tue, 15 Feb 2022 20:23:11 +0100 Subject: [PATCH 37/43] use locator in osquery plugin (#125698) --- .../osquery/public/common/hooks/use_discover_link.tsx | 8 ++++---- .../osquery/public/packs/pack_queries_status_table.tsx | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/x-pack/plugins/osquery/public/common/hooks/use_discover_link.tsx b/x-pack/plugins/osquery/public/common/hooks/use_discover_link.tsx index dd091d80ce62e..d930d867c7a8b 100644 --- a/x-pack/plugins/osquery/public/common/hooks/use_discover_link.tsx +++ b/x-pack/plugins/osquery/public/common/hooks/use_discover_link.tsx @@ -18,14 +18,14 @@ export const useDiscoverLink = ({ filters }: UseDiscoverLink) => { const { application: { navigateToUrl }, } = useKibana().services; - const urlGenerator = useKibana().services.discover?.urlGenerator; + const locator = useKibana().services.discover?.locator; const [discoverUrl, setDiscoverUrl] = useState(''); useEffect(() => { const getDiscoverUrl = async () => { - if (!urlGenerator?.createUrl) return; + if (!locator) return; - const newUrl = await urlGenerator.createUrl({ + const newUrl = await locator.getUrl({ indexPatternId: 'logs-*', filters: filters.map((filter) => ({ meta: { @@ -44,7 +44,7 @@ export const useDiscoverLink = ({ filters }: UseDiscoverLink) => { setDiscoverUrl(newUrl); }; getDiscoverUrl(); - }, [filters, urlGenerator]); + }, [filters, locator]); const onClick = useCallback( (event: React.MouseEvent) => { diff --git a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx index c982cdd5604d1..836350d12d43e 100644 --- a/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx +++ b/x-pack/plugins/osquery/public/packs/pack_queries_status_table.tsx @@ -264,12 +264,12 @@ const ViewResultsInDiscoverActionComponent: React.FC { - const urlGenerator = useKibana().services.discover?.urlGenerator; + const locator = useKibana().services.discover?.locator; const [discoverUrl, setDiscoverUrl] = useState(''); useEffect(() => { const getDiscoverUrl = async () => { - if (!urlGenerator?.createUrl) return; + if (!locator) return; const agentIdsQuery = agentIds?.length ? { @@ -280,7 +280,7 @@ const ViewResultsInDiscoverActionComponent: React.FC Date: Tue, 15 Feb 2022 13:35:33 -0600 Subject: [PATCH 38/43] [cft] Generate update plan based on current configuration (#125317) When a Cloud deployment is updated, we're reusing the original creation template. In cases where settings are manually overridden. these changes will be lost when a deployment is updated. Instead of using the base template, this queries the cloud API for an update payload as a base. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../scripts/steps/cloud/build_and_deploy.sh | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/.buildkite/scripts/steps/cloud/build_and_deploy.sh b/.buildkite/scripts/steps/cloud/build_and_deploy.sh index 9ea6c4f445328..7227d3a8a5e57 100755 --- a/.buildkite/scripts/steps/cloud/build_and_deploy.sh +++ b/.buildkite/scripts/steps/cloud/build_and_deploy.sh @@ -35,16 +35,16 @@ node scripts/build \ CLOUD_IMAGE=$(docker images --format "{{.Repository}}:{{.Tag}}" docker.elastic.co/kibana-ci/kibana-cloud) CLOUD_DEPLOYMENT_NAME="kibana-pr-$BUILDKITE_PULL_REQUEST" -jq ' - .resources.kibana[0].plan.kibana.docker_image = "'$CLOUD_IMAGE'" | - .name = "'$CLOUD_DEPLOYMENT_NAME'" | - .resources.kibana[0].plan.kibana.version = "'$VERSION'" | - .resources.elasticsearch[0].plan.elasticsearch.version = "'$VERSION'" - ' .buildkite/scripts/steps/cloud/deploy.json > /tmp/deploy.json - CLOUD_DEPLOYMENT_ID=$(ecctl deployment list --output json | jq -r '.deployments[] | select(.name == "'$CLOUD_DEPLOYMENT_NAME'") | .id') JSON_FILE=$(mktemp --suffix ".json") if [ -z "${CLOUD_DEPLOYMENT_ID}" ]; then + jq ' + .resources.kibana[0].plan.kibana.docker_image = "'$CLOUD_IMAGE'" | + .name = "'$CLOUD_DEPLOYMENT_NAME'" | + .resources.kibana[0].plan.kibana.version = "'$VERSION'" | + .resources.elasticsearch[0].plan.elasticsearch.version = "'$VERSION'" + ' .buildkite/scripts/steps/cloud/deploy.json > /tmp/deploy.json + ecctl deployment create --track --output json --file /tmp/deploy.json &> "$JSON_FILE" CLOUD_DEPLOYMENT_USERNAME=$(jq --slurp '.[]|select(.resources).resources[] | select(.credentials).credentials.username' "$JSON_FILE") CLOUD_DEPLOYMENT_PASSWORD=$(jq --slurp '.[]|select(.resources).resources[] | select(.credentials).credentials.password' "$JSON_FILE") @@ -59,6 +59,11 @@ if [ -z "${CLOUD_DEPLOYMENT_ID}" ]; then retry 5 5 vault write "secret/kibana-issues/dev/cloud-deploy/$CLOUD_DEPLOYMENT_NAME" username="$CLOUD_DEPLOYMENT_USERNAME" password="$CLOUD_DEPLOYMENT_PASSWORD" else + ecctl deployment show "$CLOUD_DEPLOYMENT_ID" --generate-update-payload | jq ' + .resources.kibana[0].plan.kibana.docker_image = "'$CLOUD_IMAGE'" | + .resources.kibana[0].plan.kibana.version = "'$VERSION'" | + .resources.elasticsearch[0].plan.elasticsearch.version = "'$VERSION'" + ' > /tmp/deploy.json ecctl deployment update "$CLOUD_DEPLOYMENT_ID" --track --output json --file /tmp/deploy.json &> "$JSON_FILE" fi From 16f3eb352cccd90649fbb6f0a89432193fb66a34 Mon Sep 17 00:00:00 2001 From: Jonathan Budzenski Date: Tue, 15 Feb 2022 13:46:42 -0600 Subject: [PATCH 39/43] [ci] Verify docker contexts (#122897) * [ci] Verify docker contexts * bootstrap * debug * mkdir target * change subdomain if snapshot * move to separate pipeline Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .buildkite/pipelines/docker_context.yml | 11 +++++++++++ .buildkite/scripts/steps/docker_context/build.sh | 16 ++++++++++++++++ .../tasks/os_packages/docker_generator/run.ts | 2 ++ .../docker_generator/template_context.ts | 1 + .../docker_generator/templates/base/Dockerfile | 2 +- 5 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .buildkite/pipelines/docker_context.yml create mode 100644 .buildkite/scripts/steps/docker_context/build.sh diff --git a/.buildkite/pipelines/docker_context.yml b/.buildkite/pipelines/docker_context.yml new file mode 100644 index 0000000000000..f85b895e4780b --- /dev/null +++ b/.buildkite/pipelines/docker_context.yml @@ -0,0 +1,11 @@ + steps: + - command: .buildkite/scripts/steps/docker_context/build.sh + label: 'Docker Build Context' + agents: + queue: n2-4 + timeout_in_minutes: 30 + key: build-docker-context + retry: + automatic: + - exit_status: '*' + limit: 1 \ No newline at end of file diff --git a/.buildkite/scripts/steps/docker_context/build.sh b/.buildkite/scripts/steps/docker_context/build.sh new file mode 100644 index 0000000000000..42152d005ffa9 --- /dev/null +++ b/.buildkite/scripts/steps/docker_context/build.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +set -euo pipefail + +.buildkite/scripts/bootstrap.sh + +echo "--- Create Kibana Docker contexts" +mkdir -p target +node scripts/build --skip-initialize --skip-generic-folders --skip-platform-folders --skip-archives + +echo "--- Build default context" +DOCKER_BUILD_FOLDER=$(mktemp -d) + +tar -xf target/kibana-[0-9]*-docker-build-context.tar.gz -C "$DOCKER_BUILD_FOLDER" +cd $DOCKER_BUILD_FOLDER +docker build . diff --git a/src/dev/build/tasks/os_packages/docker_generator/run.ts b/src/dev/build/tasks/os_packages/docker_generator/run.ts index 657efc8d7bd99..332605e926537 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/run.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/run.ts @@ -76,6 +76,7 @@ export async function runDockerGenerator( const dockerPush = config.getDockerPush(); const dockerTagQualifier = config.getDockerTagQualfiier(); + const publicArtifactSubdomain = config.isRelease ? 'artifacts' : 'snapshots-no-kpi'; const scope: TemplateContext = { artifactPrefix, @@ -100,6 +101,7 @@ export async function runDockerGenerator( ironbank: flags.ironbank, architecture: flags.architecture, revision: config.getBuildSha(), + publicArtifactSubdomain, }; type HostArchitectureToDocker = Record; diff --git a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts index a715bfaa5d50d..524cfcef18284 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/template_context.ts +++ b/src/dev/build/tasks/os_packages/docker_generator/template_context.ts @@ -22,6 +22,7 @@ export interface TemplateContext { baseOSImage: string; dockerBuildDate: string; usePublicArtifact?: boolean; + publicArtifactSubdomain: string; ubi?: boolean; ubuntu?: boolean; cloud?: boolean; diff --git a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile index 54af1c41b2da9..95f6a56ef68cb 100644 --- a/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile +++ b/src/dev/build/tasks/os_packages/docker_generator/templates/base/Dockerfile @@ -22,7 +22,7 @@ RUN {{packageManager}} update && DEBIAN_FRONTEND=noninteractive {{packageManager RUN cd /tmp && \ curl --retry 8 -s -L \ --output kibana.tar.gz \ - https://artifacts.elastic.co/downloads/kibana/{{artifactPrefix}}-$(arch).tar.gz && \ + https://{{publicArtifactSubdomain}}.elastic.co/downloads/kibana/{{artifactPrefix}}-$(arch).tar.gz && \ cd - {{/usePublicArtifact}} From 7c850dd81fc3062ba6ba0242b5498f4482eff540 Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Tue, 15 Feb 2022 12:57:50 -0700 Subject: [PATCH 40/43] [Security Solutions] Updates usage collector telemetry to use PIT (Point in Time) and restructuring of folders (#124912) ## Summary Changes the usage collector telemetry within security solutions to use PIT (Point in Time) and a few other bug fixes and restructuring. * The main goal is to change the full queries for up to 10k items to be instead using 1k batched items at a time and PIT (Point in Time). See [this ticket](https://github.com/elastic/kibana/issues/93770) for more information and [here](https://github.com/elastic/kibana/pull/99031) for an example where they changed there code to use 1k batched items. I use PIT with SO object API, searches, and then composite aggregations which all support the PIT. The PIT timeouts are all set to 5 minutes and all the maximums of 10k to not increase memory more is still there. However, we should be able to increase the 10k limit at this point if we wanted to for usage collector to count beyond the 10k. The initial 10k was an elastic limitation that PIT now avoids. * This also fixes a bug where the aggregations were only returning the top 10 items instead of the full 10k. That is changed to use [composite aggregations](https://www.elastic.co/guide/en/elasticsearch/reference/current/search-aggregations-bucket-composite-aggregation.html). * This restructuring the folder structure to try and do [reductionism](https://en.wikipedia.org/wiki/Reductionism#In_computer_science) best we can. I could not do reductionism with the schema as the tooling does not allow it. But the rest is self-repeating in the way hopefully developers expect it to be. And also make it easier for developers to add new telemetry usage collector counters in the same fashion. * This exchanges the hand spun TypeScript types in favor of using the `caseComments` and the `Sanitized Alerts` and the `ML job types` using Partial and other TypeScript tricks. * This removes the [Cyclomatic Complexity](https://en.wikipedia.org/wiki/Cyclomatic_complexity) warnings coming from the linters by breaking down the functions into smaller units. * This removes the "as casts" in all but 1 area which can lead to subtle TypeScript problems. * This pushes down the logger and uses the logger to report errors and some debug information ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --- .../security_solution/server/plugin.ts | 2 +- .../server/usage/collector.ts | 40 +- .../server/usage/constants.ts | 26 + .../usage/detections/detection_ml_helpers.ts | 175 ------ .../detections/detection_rule_helpers.ts | 503 ------------------ .../usage/detections/get_initial_usage.ts | 25 + ...detections.test.ts => get_metrics.test.ts} | 139 +++-- .../server/usage/detections/get_metrics.ts | 47 ++ .../server/usage/detections/index.ts | 41 -- .../detections/ml_jobs/get_initial_usage.ts | 22 + .../get_metrics.mocks.ts} | 404 ++++++-------- .../usage/detections/ml_jobs/get_metrics.ts | 100 ++++ .../transform_utils/get_job_correlations.ts | 71 +++ .../server/usage/detections/ml_jobs/types.ts | 56 ++ .../update_usage.test.ts} | 18 +- .../usage/detections/ml_jobs/update_usage.ts | 47 ++ .../detections/rules/get_initial_usage.ts | 84 +++ .../detections/rules/get_metrics.mocks.ts | 99 ++++ .../usage/detections/rules/get_metrics.ts | 122 +++++ .../get_alert_id_to_count_map.ts | 14 + .../get_rule_id_to_cases_map.ts | 30 ++ .../get_rule_id_to_enabled_map.ts | 32 ++ .../get_rule_object_correlations.ts | 62 +++ .../server/usage/detections/rules/types.ts | 47 ++ .../update_usage.test.ts} | 35 +- .../usage/detections/rules/update_usage.ts | 85 +++ .../get_notifications_enabled_disabled.ts | 26 + .../rules/usage_utils/update_query_usage.ts | 48 ++ .../rules/usage_utils/update_total_usage.ts | 51 ++ .../server/usage/detections/types.ts | 163 +----- .../get_internal_saved_objects_client.ts | 25 + .../server/usage/queries/get_alerts.ts | 115 ++++ .../server/usage/queries/get_case_comments.ts | 62 +++ .../usage/queries/get_detection_rules.ts | 82 +++ .../usage/queries/legacy_get_rule_actions.ts | 73 +++ .../queries/utils/fetch_hits_with_pit.ts | 79 +++ .../usage/queries/utils/is_elastic_rule.ts | 11 + .../security_solution/server/usage/types.ts | 44 +- .../telemetry/usage_collector/all_types.ts | 4 +- .../usage_collector/detection_rules.ts | 8 +- 40 files changed, 1896 insertions(+), 1221 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/usage/constants.ts delete mode 100644 x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts delete mode 100644 x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/get_initial_usage.ts rename x-pack/plugins/security_solution/server/usage/detections/{detections.test.ts => get_metrics.test.ts} (66%) create mode 100644 x-pack/plugins/security_solution/server/usage/detections/get_metrics.ts delete mode 100644 x-pack/plugins/security_solution/server/usage/detections/index.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_initial_usage.ts rename x-pack/plugins/security_solution/server/usage/detections/{detections.mocks.ts => ml_jobs/get_metrics.mocks.ts} (65%) create mode 100644 x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/ml_jobs/transform_utils/get_job_correlations.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/ml_jobs/types.ts rename x-pack/plugins/security_solution/server/usage/detections/{detection_ml_helpers.test.ts => ml_jobs/update_usage.test.ts} (70%) create mode 100644 x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/get_initial_usage.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.mocks.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_alert_id_to_count_map.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_cases_map.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_enabled_map.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_object_correlations.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/types.ts rename x-pack/plugins/security_solution/server/usage/detections/{detection_rule_helpers.test.ts => rules/update_usage.test.ts} (92%) create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/get_notifications_enabled_disabled.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_query_usage.ts create mode 100644 x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_total_usage.ts create mode 100644 x-pack/plugins/security_solution/server/usage/get_internal_saved_objects_client.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/get_alerts.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/get_case_comments.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/get_detection_rules.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/legacy_get_rule_actions.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/utils/fetch_hits_with_pit.ts create mode 100644 x-pack/plugins/security_solution/server/usage/queries/utils/is_elastic_rule.ts diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index b34a2a4f3a7d6..511679ef71a79 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -168,10 +168,10 @@ export class Plugin implements ISecuritySolutionPlugin { initUsageCollectors({ core, - kibanaIndex: core.savedObjects.getKibanaIndex(), signalsIndex: DEFAULT_ALERTS_INDEX, ml: plugins.ml, usageCollection: plugins.usageCollection, + logger, }); this.telemetryUsageCounter = plugins.usageCollection?.createUsageCounter(APP_ID); diff --git a/x-pack/plugins/security_solution/server/usage/collector.ts b/x-pack/plugins/security_solution/server/usage/collector.ts index 4530dac725c7b..dc98b68f9f186 100644 --- a/x-pack/plugins/security_solution/server/usage/collector.ts +++ b/x-pack/plugins/security_solution/server/usage/collector.ts @@ -5,38 +5,26 @@ * 2.0. */ -import { CoreSetup, SavedObjectsClientContract } from '../../../../../src/core/server'; -import { CollectorFetchContext } from '../../../../../src/plugins/usage_collection/server'; -import { CollectorDependencies } from './types'; -import { fetchDetectionsMetrics } from './detections'; -import { SAVED_OBJECT_TYPES } from '../../../cases/common/constants'; -// eslint-disable-next-line no-restricted-imports -import { legacyRuleActionsSavedObjectType } from '../lib/detection_engine/rule_actions/legacy_saved_object_mappings'; +import type { CollectorFetchContext } from '../../../../../src/plugins/usage_collection/server'; +import type { CollectorDependencies } from './types'; +import { getDetectionsMetrics } from './detections/get_metrics'; +import { getInternalSavedObjectsClient } from './get_internal_saved_objects_client'; export type RegisterCollector = (deps: CollectorDependencies) => void; + export interface UsageData { detectionMetrics: {}; } -export async function getInternalSavedObjectsClient(core: CoreSetup) { - return core.getStartServices().then(async ([coreStart]) => { - // note: we include the "cases" and "alert" hidden types here otherwise we would not be able to query them. If at some point cases and alert is not considered a hidden type this can be removed - return coreStart.savedObjects.createInternalRepository([ - 'alert', - legacyRuleActionsSavedObjectType, - ...SAVED_OBJECT_TYPES, - ]); - }); -} - export const registerCollector: RegisterCollector = ({ core, - kibanaIndex, signalsIndex, ml, usageCollection, + logger, }) => { if (!usageCollection) { + logger.debug('Usage collection is undefined, therefore returning early without registering it'); return; } @@ -525,12 +513,16 @@ export const registerCollector: RegisterCollector = ({ }, isReady: () => true, fetch: async ({ esClient }: CollectorFetchContext): Promise => { - const internalSavedObjectsClient = await getInternalSavedObjectsClient(core); - const soClient = internalSavedObjectsClient as unknown as SavedObjectsClientContract; - + const savedObjectsClient = await getInternalSavedObjectsClient(core); + const detectionMetrics = await getDetectionsMetrics({ + signalsIndex, + esClient, + savedObjectsClient, + logger, + mlClient: ml, + }); return { - detectionMetrics: - (await fetchDetectionsMetrics(kibanaIndex, signalsIndex, esClient, soClient, ml)) || {}, + detectionMetrics: detectionMetrics || {}, }; }, }); diff --git a/x-pack/plugins/security_solution/server/usage/constants.ts b/x-pack/plugins/security_solution/server/usage/constants.ts new file mode 100644 index 0000000000000..d3d526768fcd5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/constants.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * We limit the max results window to prevent in-memory from blowing up when we do correlation. + * This is limiting us to 10,000 cases and 10,000 elastic detection rules to do telemetry and correlation + * and the choice was based on the initial "index.max_result_window" before this turned into a PIT (Point In Time) + * implementation. + * + * This number could be changed, and the implementation details of how we correlate could change as well (maybe) + * to avoid pulling 10,000 worth of cases and elastic rules into memory. + * + * However, for now, we are keeping this maximum as the original and the in-memory implementation + */ +export const MAX_RESULTS_WINDOW = 10_000; + +/** + * We choose our max per page based on 1k as that + * appears to be what others are choosing here in the other sections of telemetry: + * https://github.com/elastic/kibana/pull/99031 + */ +export const MAX_PER_PAGE = 1_000; diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts deleted file mode 100644 index 1aadcfdc5478a..0000000000000 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.ts +++ /dev/null @@ -1,175 +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 { KibanaRequest, SavedObjectsClientContract } from '../../../../../../src/core/server'; -import { MlDatafeedStats, MlJob, MlPluginSetup } from '../../../../ml/server'; -import { isJobStarted } from '../../../common/machine_learning/helpers'; -import { isSecurityJob } from '../../../common/machine_learning/is_security_job'; -import { DetectionsMetric, MlJobMetric, MlJobsUsage, MlJobUsage } from './types'; - -/** - * Default ml job usage count - */ -export const initialMlJobsUsage: MlJobsUsage = { - custom: { - enabled: 0, - disabled: 0, - }, - elastic: { - enabled: 0, - disabled: 0, - }, -}; - -export const updateMlJobsUsage = (jobMetric: DetectionsMetric, usage: MlJobsUsage): MlJobsUsage => { - const { isEnabled, isElastic } = jobMetric; - if (isEnabled && isElastic) { - return { - ...usage, - elastic: { - ...usage.elastic, - enabled: usage.elastic.enabled + 1, - }, - }; - } else if (!isEnabled && isElastic) { - return { - ...usage, - elastic: { - ...usage.elastic, - disabled: usage.elastic.disabled + 1, - }, - }; - } else if (isEnabled && !isElastic) { - return { - ...usage, - custom: { - ...usage.custom, - enabled: usage.custom.enabled + 1, - }, - }; - } else if (!isEnabled && !isElastic) { - return { - ...usage, - custom: { - ...usage.custom, - disabled: usage.custom.disabled + 1, - }, - }; - } else { - return usage; - } -}; - -export const getMlJobMetrics = async ( - ml: MlPluginSetup | undefined, - savedObjectClient: SavedObjectsClientContract -): Promise => { - let jobsUsage: MlJobsUsage = initialMlJobsUsage; - - if (ml) { - try { - const fakeRequest = { headers: {} } as KibanaRequest; - - const modules = await ml.modulesProvider(fakeRequest, savedObjectClient).listModules(); - const moduleJobs = modules.flatMap((module) => module.jobs); - const jobs = await ml.jobServiceProvider(fakeRequest, savedObjectClient).jobsSummary(); - - jobsUsage = jobs.filter(isSecurityJob).reduce((usage, job) => { - const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); - const isEnabled = isJobStarted(job.jobState, job.datafeedState); - - return updateMlJobsUsage({ isElastic, isEnabled }, usage); - }, initialMlJobsUsage); - - const jobsType = 'security'; - const securityJobStats = await ml - .anomalyDetectorsProvider(fakeRequest, savedObjectClient) - .jobStats(jobsType); - - const jobDetails = await ml - .anomalyDetectorsProvider(fakeRequest, savedObjectClient) - .jobs(jobsType); - - const jobDetailsCache = new Map(); - jobDetails.jobs.forEach((detail) => jobDetailsCache.set(detail.job_id, detail)); - - const datafeedStats = await ml - .anomalyDetectorsProvider(fakeRequest, savedObjectClient) - .datafeedStats(); - - const datafeedStatsCache = new Map(); - datafeedStats.datafeeds.forEach((datafeedStat) => - datafeedStatsCache.set(`${datafeedStat.datafeed_id}`, datafeedStat) - ); - - const jobMetrics: MlJobMetric[] = securityJobStats.jobs.map((stat) => { - const jobId = stat.job_id; - const jobDetail = jobDetailsCache.get(stat.job_id); - const datafeed = datafeedStatsCache.get(`datafeed-${jobId}`); - - return { - job_id: jobId, - open_time: stat.open_time, - create_time: jobDetail?.create_time, - finished_time: jobDetail?.finished_time, - state: stat.state, - data_counts: { - bucket_count: stat.data_counts.bucket_count, - empty_bucket_count: stat.data_counts.empty_bucket_count, - input_bytes: stat.data_counts.input_bytes, - input_record_count: stat.data_counts.input_record_count, - last_data_time: stat.data_counts.last_data_time, - processed_record_count: stat.data_counts.processed_record_count, - }, - model_size_stats: { - bucket_allocation_failures_count: - stat.model_size_stats.bucket_allocation_failures_count, - memory_status: stat.model_size_stats.memory_status, - model_bytes: stat.model_size_stats.model_bytes, - model_bytes_exceeded: stat.model_size_stats.model_bytes_exceeded, - model_bytes_memory_limit: stat.model_size_stats.model_bytes_memory_limit, - peak_model_bytes: stat.model_size_stats.peak_model_bytes, - }, - timing_stats: { - average_bucket_processing_time_ms: stat.timing_stats.average_bucket_processing_time_ms, - bucket_count: stat.timing_stats.bucket_count, - exponential_average_bucket_processing_time_ms: - stat.timing_stats.exponential_average_bucket_processing_time_ms, - exponential_average_bucket_processing_time_per_hour_ms: - stat.timing_stats.exponential_average_bucket_processing_time_per_hour_ms, - maximum_bucket_processing_time_ms: stat.timing_stats.maximum_bucket_processing_time_ms, - minimum_bucket_processing_time_ms: stat.timing_stats.minimum_bucket_processing_time_ms, - total_bucket_processing_time_ms: stat.timing_stats.total_bucket_processing_time_ms, - }, - datafeed: { - datafeed_id: datafeed?.datafeed_id, - state: datafeed?.state, - timing_stats: { - bucket_count: datafeed?.timing_stats.bucket_count, - exponential_average_search_time_per_hour_ms: - datafeed?.timing_stats.exponential_average_search_time_per_hour_ms, - search_count: datafeed?.timing_stats.search_count, - total_search_time_ms: datafeed?.timing_stats.total_search_time_ms, - }, - }, - } as MlJobMetric; - }); - - return { - ml_job_usage: jobsUsage, - ml_job_metrics: jobMetrics, - }; - } catch (e) { - // ignore failure, usage will be zeroed - } - } - - return { - ml_job_usage: initialMlJobsUsage, - ml_job_metrics: [], - }; -}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts deleted file mode 100644 index 39c108931e2d7..0000000000000 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.ts +++ /dev/null @@ -1,503 +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 { - SIGNALS_ID, - EQL_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, -} from '@kbn/securitysolution-rules'; -import { ALERT_RULE_UUID } from '@kbn/rule-data-utils'; -import { CASE_COMMENT_SAVED_OBJECT } from '../../../../cases/common/constants'; - -import { ElasticsearchClient, SavedObjectsClientContract } from '../../../../../../src/core/server'; -import { isElasticRule } from './index'; -import type { - AlertsAggregationResponse, - CasesSavedObject, - DetectionRulesTypeUsage, - DetectionRuleMetric, - DetectionRuleAdoption, - RuleSearchParams, - RuleSearchResult, - DetectionMetrics, -} from './types'; -// eslint-disable-next-line no-restricted-imports -import { legacyRuleActionsSavedObjectType } from '../../lib/detection_engine/rule_actions/legacy_saved_object_mappings'; -// eslint-disable-next-line no-restricted-imports -import { LegacyIRuleActionsAttributesSavedObjectAttributes } from '../../lib/detection_engine/rule_actions/legacy_types'; - -/** - * Initial detection metrics initialized. - */ -export const getInitialDetectionMetrics = (): DetectionMetrics => ({ - ml_jobs: { - ml_job_usage: { - custom: { - enabled: 0, - disabled: 0, - }, - elastic: { - enabled: 0, - disabled: 0, - }, - }, - ml_job_metrics: [], - }, - detection_rules: { - detection_rule_detail: [], - detection_rule_usage: initialDetectionRulesUsage, - }, -}); - -/** - * Default detection rule usage count, split by type + elastic/custom - */ -export const initialDetectionRulesUsage: DetectionRulesTypeUsage = { - query: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - threshold: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - eql: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - machine_learning: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - threat_match: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - elastic_total: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, - custom_total: { - enabled: 0, - disabled: 0, - alerts: 0, - cases: 0, - legacy_notifications_enabled: 0, - legacy_notifications_disabled: 0, - notifications_enabled: 0, - notifications_disabled: 0, - }, -}; - -/* eslint-disable complexity */ -export const updateDetectionRuleUsage = ( - detectionRuleMetric: DetectionRuleMetric, - usage: DetectionRulesTypeUsage -): DetectionRulesTypeUsage => { - let updatedUsage = usage; - - const legacyNotificationEnabled = - detectionRuleMetric.has_legacy_notification && detectionRuleMetric.enabled; - - const legacyNotificationDisabled = - detectionRuleMetric.has_legacy_notification && !detectionRuleMetric.enabled; - - const notificationEnabled = detectionRuleMetric.has_notification && detectionRuleMetric.enabled; - - const notificationDisabled = detectionRuleMetric.has_notification && !detectionRuleMetric.enabled; - - if (detectionRuleMetric.rule_type === 'query') { - updatedUsage = { - ...usage, - query: { - ...usage.query, - enabled: detectionRuleMetric.enabled ? usage.query.enabled + 1 : usage.query.enabled, - disabled: !detectionRuleMetric.enabled ? usage.query.disabled + 1 : usage.query.disabled, - alerts: usage.query.alerts + detectionRuleMetric.alert_count_daily, - cases: usage.query.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? usage.query.legacy_notifications_enabled + 1 - : usage.query.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? usage.query.legacy_notifications_disabled + 1 - : usage.query.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? usage.query.notifications_enabled + 1 - : usage.query.notifications_enabled, - notifications_disabled: notificationDisabled - ? usage.query.notifications_disabled + 1 - : usage.query.notifications_disabled, - }, - }; - } else if (detectionRuleMetric.rule_type === 'threshold') { - updatedUsage = { - ...usage, - threshold: { - ...usage.threshold, - enabled: detectionRuleMetric.enabled - ? usage.threshold.enabled + 1 - : usage.threshold.enabled, - disabled: !detectionRuleMetric.enabled - ? usage.threshold.disabled + 1 - : usage.threshold.disabled, - alerts: usage.threshold.alerts + detectionRuleMetric.alert_count_daily, - cases: usage.threshold.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? usage.threshold.legacy_notifications_enabled + 1 - : usage.threshold.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? usage.threshold.legacy_notifications_disabled + 1 - : usage.threshold.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? usage.threshold.notifications_enabled + 1 - : usage.threshold.notifications_enabled, - notifications_disabled: notificationDisabled - ? usage.threshold.notifications_disabled + 1 - : usage.threshold.notifications_disabled, - }, - }; - } else if (detectionRuleMetric.rule_type === 'eql') { - updatedUsage = { - ...usage, - eql: { - ...usage.eql, - enabled: detectionRuleMetric.enabled ? usage.eql.enabled + 1 : usage.eql.enabled, - disabled: !detectionRuleMetric.enabled ? usage.eql.disabled + 1 : usage.eql.disabled, - alerts: usage.eql.alerts + detectionRuleMetric.alert_count_daily, - cases: usage.eql.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? usage.eql.legacy_notifications_enabled + 1 - : usage.eql.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? usage.eql.legacy_notifications_disabled + 1 - : usage.eql.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? usage.eql.notifications_enabled + 1 - : usage.eql.notifications_enabled, - notifications_disabled: notificationDisabled - ? usage.eql.notifications_disabled + 1 - : usage.eql.notifications_disabled, - }, - }; - } else if (detectionRuleMetric.rule_type === 'machine_learning') { - updatedUsage = { - ...usage, - machine_learning: { - ...usage.machine_learning, - enabled: detectionRuleMetric.enabled - ? usage.machine_learning.enabled + 1 - : usage.machine_learning.enabled, - disabled: !detectionRuleMetric.enabled - ? usage.machine_learning.disabled + 1 - : usage.machine_learning.disabled, - alerts: usage.machine_learning.alerts + detectionRuleMetric.alert_count_daily, - cases: usage.machine_learning.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? usage.machine_learning.legacy_notifications_enabled + 1 - : usage.machine_learning.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? usage.machine_learning.legacy_notifications_disabled + 1 - : usage.machine_learning.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? usage.machine_learning.notifications_enabled + 1 - : usage.machine_learning.notifications_enabled, - notifications_disabled: notificationDisabled - ? usage.machine_learning.notifications_disabled + 1 - : usage.machine_learning.notifications_disabled, - }, - }; - } else if (detectionRuleMetric.rule_type === 'threat_match') { - updatedUsage = { - ...usage, - threat_match: { - ...usage.threat_match, - enabled: detectionRuleMetric.enabled - ? usage.threat_match.enabled + 1 - : usage.threat_match.enabled, - disabled: !detectionRuleMetric.enabled - ? usage.threat_match.disabled + 1 - : usage.threat_match.disabled, - alerts: usage.threat_match.alerts + detectionRuleMetric.alert_count_daily, - cases: usage.threat_match.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? usage.threat_match.legacy_notifications_enabled + 1 - : usage.threat_match.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? usage.threat_match.legacy_notifications_disabled + 1 - : usage.threat_match.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? usage.threat_match.notifications_enabled + 1 - : usage.threat_match.notifications_enabled, - notifications_disabled: notificationDisabled - ? usage.threat_match.notifications_disabled + 1 - : usage.threat_match.notifications_disabled, - }, - }; - } - - if (detectionRuleMetric.elastic_rule) { - updatedUsage = { - ...updatedUsage, - elastic_total: { - ...updatedUsage.elastic_total, - enabled: detectionRuleMetric.enabled - ? updatedUsage.elastic_total.enabled + 1 - : updatedUsage.elastic_total.enabled, - disabled: !detectionRuleMetric.enabled - ? updatedUsage.elastic_total.disabled + 1 - : updatedUsage.elastic_total.disabled, - alerts: updatedUsage.elastic_total.alerts + detectionRuleMetric.alert_count_daily, - cases: updatedUsage.elastic_total.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? updatedUsage.elastic_total.legacy_notifications_enabled + 1 - : updatedUsage.elastic_total.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? updatedUsage.elastic_total.legacy_notifications_disabled + 1 - : updatedUsage.elastic_total.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? updatedUsage.elastic_total.notifications_enabled + 1 - : updatedUsage.elastic_total.notifications_enabled, - notifications_disabled: notificationDisabled - ? updatedUsage.elastic_total.notifications_disabled + 1 - : updatedUsage.elastic_total.notifications_disabled, - }, - }; - } else { - updatedUsage = { - ...updatedUsage, - custom_total: { - ...updatedUsage.custom_total, - enabled: detectionRuleMetric.enabled - ? updatedUsage.custom_total.enabled + 1 - : updatedUsage.custom_total.enabled, - disabled: !detectionRuleMetric.enabled - ? updatedUsage.custom_total.disabled + 1 - : updatedUsage.custom_total.disabled, - alerts: updatedUsage.custom_total.alerts + detectionRuleMetric.alert_count_daily, - cases: updatedUsage.custom_total.cases + detectionRuleMetric.cases_count_total, - legacy_notifications_enabled: legacyNotificationEnabled - ? updatedUsage.custom_total.legacy_notifications_enabled + 1 - : updatedUsage.custom_total.legacy_notifications_enabled, - legacy_notifications_disabled: legacyNotificationDisabled - ? updatedUsage.custom_total.legacy_notifications_disabled + 1 - : updatedUsage.custom_total.legacy_notifications_disabled, - notifications_enabled: notificationEnabled - ? updatedUsage.custom_total.notifications_enabled + 1 - : updatedUsage.custom_total.notifications_enabled, - notifications_disabled: notificationDisabled - ? updatedUsage.custom_total.notifications_disabled + 1 - : updatedUsage.custom_total.notifications_disabled, - }, - }; - } - - return updatedUsage; -}; - -const MAX_RESULTS_WINDOW = 10_000; // elasticsearch index.max_result_window default value - -export const getDetectionRuleMetrics = async ( - kibanaIndex: string, - signalsIndex: string, - esClient: ElasticsearchClient, - savedObjectClient: SavedObjectsClientContract -): Promise => { - let rulesUsage: DetectionRulesTypeUsage = initialDetectionRulesUsage; - const ruleSearchOptions: RuleSearchParams = { - body: { - query: { - bool: { - filter: { - terms: { - 'alert.alertTypeId': [ - SIGNALS_ID, - EQL_RULE_TYPE_ID, - ML_RULE_TYPE_ID, - QUERY_RULE_TYPE_ID, - SAVED_QUERY_RULE_TYPE_ID, - INDICATOR_RULE_TYPE_ID, - THRESHOLD_RULE_TYPE_ID, - ], - }, - }, - }, - }, - }, - filter_path: [], - ignore_unavailable: true, - index: kibanaIndex, - size: MAX_RESULTS_WINDOW, - }; - - try { - const ruleResults = await esClient.search(ruleSearchOptions); - const detectionAlertsResp = (await esClient.search({ - index: `${signalsIndex}*`, - size: MAX_RESULTS_WINDOW, - body: { - aggs: { - detectionAlerts: { - terms: { field: ALERT_RULE_UUID }, - }, - }, - query: { - bool: { - filter: [ - { - range: { - '@timestamp': { - gte: 'now-24h', - lte: 'now', - }, - }, - }, - ], - }, - }, - }, - })) as AlertsAggregationResponse; - - const cases = await savedObjectClient.find({ - type: CASE_COMMENT_SAVED_OBJECT, - page: 1, - perPage: MAX_RESULTS_WINDOW, - namespaces: ['*'], - filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: alert`, - }); - - // Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove this function. - const legacyRuleActions = - await savedObjectClient.find({ - type: legacyRuleActionsSavedObjectType, - page: 1, - perPage: MAX_RESULTS_WINDOW, - namespaces: ['*'], - }); - - const legacyNotificationRuleIds = legacyRuleActions.saved_objects.reduce( - (cache, legacyNotificationsObject) => { - const ruleRef = legacyNotificationsObject.references.find( - (reference) => reference.name === 'alert_0' && reference.type === 'alert' - ); - if (ruleRef != null) { - const enabled = legacyNotificationsObject.attributes.ruleThrottle !== 'no_actions'; - cache.set(ruleRef.id, { enabled }); - } - return cache; - }, - new Map() - ); - - const casesCache = cases.saved_objects.reduce((cache, { attributes: casesObject }) => { - const ruleId = casesObject.rule.id; - if (ruleId != null) { - const cacheCount = cache.get(ruleId); - if (cacheCount === undefined) { - cache.set(ruleId, 1); - } else { - cache.set(ruleId, cacheCount + 1); - } - } - return cache; - }, new Map()); - - const alertBuckets = detectionAlertsResp.aggregations?.detectionAlerts?.buckets ?? []; - - const alertsCache = new Map(); - alertBuckets.map((bucket) => alertsCache.set(bucket.key, bucket.doc_count)); - if (ruleResults.hits?.hits?.length > 0) { - const ruleObjects = ruleResults.hits.hits.map((hit) => { - const ruleId = hit._id.split(':')[1]; - const isElastic = isElasticRule(hit._source?.alert.tags); - - // Even if the legacy notification is set to "no_actions" we still count the rule as having a legacy notification that is not migrated yet. - const hasLegacyNotification = legacyNotificationRuleIds.get(ruleId) != null; - - // We only count a rule as having a notification and being "enabled" if it is _not_ set to "no_actions"/"muteAll" and it has at least one action within its array. - const hasNotification = - !hasLegacyNotification && - hit._source?.alert.actions != null && - hit._source?.alert.actions.length > 0 && - hit._source?.alert.muteAll !== true; - - return { - rule_name: hit._source?.alert.name, - rule_id: hit._source?.alert.params.ruleId, - rule_type: hit._source?.alert.params.type, - rule_version: Number(hit._source?.alert.params.version), - enabled: hit._source?.alert.enabled, - elastic_rule: isElastic, - created_on: hit._source?.alert.createdAt, - updated_on: hit._source?.alert.updatedAt, - alert_count_daily: alertsCache.get(ruleId) || 0, - cases_count_total: casesCache.get(ruleId) || 0, - has_legacy_notification: hasLegacyNotification, - has_notification: hasNotification, - } as DetectionRuleMetric; - }); - - // Only bring back rule detail on elastic prepackaged detection rules - const elasticRuleObjects = ruleObjects.filter((hit) => hit.elastic_rule === true); - - rulesUsage = ruleObjects.reduce((usage, rule) => { - return updateDetectionRuleUsage(rule, usage); - }, rulesUsage); - - return { - detection_rule_detail: elasticRuleObjects, - detection_rule_usage: rulesUsage, - }; - } - } catch (e) { - // ignore failure, usage will be zeroed - } - - return { - detection_rule_detail: [], - detection_rule_usage: rulesUsage, - }; -}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/get_initial_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/get_initial_usage.ts new file mode 100644 index 0000000000000..0d885aa3b142c --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/get_initial_usage.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 type { DetectionMetrics } from './types'; + +import { getInitialMlJobUsage } from './ml_jobs/get_initial_usage'; +import { getInitialRulesUsage } from './rules/get_initial_usage'; + +/** + * Initial detection metrics initialized. + */ +export const getInitialDetectionMetrics = (): DetectionMetrics => ({ + ml_jobs: { + ml_job_usage: getInitialMlJobUsage(), + ml_job_metrics: [], + }, + detection_rules: { + detection_rule_detail: [], + detection_rule_usage: getInitialRulesUsage(), + }, +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections/get_metrics.test.ts similarity index 66% rename from x-pack/plugins/security_solution/server/usage/detections/detections.test.ts rename to x-pack/plugins/security_solution/server/usage/detections/get_metrics.test.ts index 866fa226e2ecf..65929039bc104 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/get_metrics.test.ts @@ -5,48 +5,69 @@ * 2.0. */ +import type { DetectionMetrics } from './types'; + import { elasticsearchServiceMock, + loggingSystemMock, savedObjectsClientMock, } from '../../../../../../src/core/server/mocks'; import { mlServicesMock } from '../../lib/machine_learning/mocks'; -import { fetchDetectionsMetrics } from './index'; import { - getMockJobSummaryResponse, + getMockMlJobSummaryResponse, getMockListModulesResponse, getMockMlJobDetailsResponse, getMockMlJobStatsResponse, getMockMlDatafeedStatsResponse, getMockRuleSearchResponse, +} from './ml_jobs/get_metrics.mocks'; +import { getMockRuleAlertsResponse, - getMockAlertCasesResponse, -} from './detections.mocks'; -import { getInitialDetectionMetrics, initialDetectionRulesUsage } from './detection_rule_helpers'; -import { DetectionMetrics } from './types'; + getMockAlertCaseCommentsResponse, + getEmptySavedObjectResponse, +} from './rules/get_metrics.mocks'; +import { getInitialDetectionMetrics } from './get_initial_usage'; +import { getDetectionsMetrics } from './get_metrics'; +import { getInitialRulesUsage } from './rules/get_initial_usage'; describe('Detections Usage and Metrics', () => { - let esClientMock: ReturnType; - let mlMock: ReturnType; + let esClient: ReturnType; + let mlClient: ReturnType; let savedObjectsClient: ReturnType; - describe('getDetectionRuleMetrics()', () => { + describe('getRuleMetrics()', () => { beforeEach(() => { - esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser; - mlMock = mlServicesMock.createSetupContract(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + mlClient = mlServicesMock.createSetupContract(); savedObjectsClient = savedObjectsClientMock.create(); }); it('returns zeroed counts if calls are empty', async () => { - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + const logger = loggingSystemMock.createLogger(); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual(getInitialDetectionMetrics()); }); it('returns information with rule, alerts and cases', async () => { - esClientMock.search - .mockResponseOnce(getMockRuleSearchResponse()) - .mockResponseOnce(getMockRuleAlertsResponse(3400)); - savedObjectsClient.find.mockResolvedValue(getMockAlertCasesResponse()); - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + esClient.search.mockResponseOnce(getMockRuleAlertsResponse(3400)); + savedObjectsClient.find.mockResolvedValueOnce(getMockRuleSearchResponse()); + savedObjectsClient.find.mockResolvedValueOnce(getMockAlertCaseCommentsResponse()); + // Get empty saved object for legacy notification system. + savedObjectsClient.find.mockResolvedValueOnce(getEmptySavedObjectResponse()); + const logger = loggingSystemMock.createLogger(); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual({ ...getInitialDetectionMetrics(), @@ -68,7 +89,7 @@ describe('Detections Usage and Metrics', () => { }, ], detection_rule_usage: { - ...initialDetectionRulesUsage, + ...getInitialRulesUsage(), query: { enabled: 0, disabled: 1, @@ -95,18 +116,26 @@ describe('Detections Usage and Metrics', () => { }); it('returns information with on non elastic prebuilt rule', async () => { - esClientMock.search - .mockResponseOnce(getMockRuleSearchResponse('not_immutable')) - .mockResponseOnce(getMockRuleAlertsResponse(800)); - savedObjectsClient.find.mockResolvedValue(getMockAlertCasesResponse()); - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + esClient.search.mockResponseOnce(getMockRuleAlertsResponse(800)); + savedObjectsClient.find.mockResolvedValueOnce(getMockRuleSearchResponse('not_immutable')); + savedObjectsClient.find.mockResolvedValueOnce(getMockAlertCaseCommentsResponse()); + // Get empty saved object for legacy notification system. + savedObjectsClient.find.mockResolvedValueOnce(getEmptySavedObjectResponse()); + const logger = loggingSystemMock.createLogger(); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual({ ...getInitialDetectionMetrics(), detection_rules: { detection_rule_detail: [], // *should not* contain custom detection rule details detection_rule_usage: { - ...initialDetectionRulesUsage, + ...getInitialRulesUsage(), custom_total: { alerts: 800, cases: 1, @@ -133,11 +162,20 @@ describe('Detections Usage and Metrics', () => { }); it('returns information with rule, no alerts and no cases', async () => { - esClientMock.search - .mockResponseOnce(getMockRuleSearchResponse()) - .mockResponseOnce(getMockRuleAlertsResponse(0)); - savedObjectsClient.find.mockResolvedValue(getMockAlertCasesResponse()); - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + esClient.search.mockResponseOnce(getMockRuleAlertsResponse(0)); + savedObjectsClient.find.mockResolvedValueOnce(getMockRuleSearchResponse()); + savedObjectsClient.find.mockResolvedValueOnce(getMockAlertCaseCommentsResponse()); + // Get empty saved object for legacy notification system. + savedObjectsClient.find.mockResolvedValueOnce(getEmptySavedObjectResponse()); + + const logger = loggingSystemMock.createLogger(); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual({ ...getInitialDetectionMetrics(), @@ -159,7 +197,7 @@ describe('Detections Usage and Metrics', () => { }, ], detection_rule_usage: { - ...initialDetectionRulesUsage, + ...getInitialRulesUsage(), elastic_total: { alerts: 0, cases: 1, @@ -186,29 +224,38 @@ describe('Detections Usage and Metrics', () => { }); }); - describe('fetchDetectionsMetrics()', () => { + describe('getDetectionsMetrics()', () => { beforeEach(() => { - esClientMock = elasticsearchServiceMock.createClusterClient().asInternalUser; - mlMock = mlServicesMock.createSetupContract(); + esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + mlClient = mlServicesMock.createSetupContract(); savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.find.mockResolvedValue(getEmptySavedObjectResponse()); }); it('returns an empty array if there is no data', async () => { - mlMock.anomalyDetectorsProvider.mockReturnValue({ + mlClient.anomalyDetectorsProvider.mockReturnValue({ jobs: null, jobStats: null, - } as unknown as ReturnType); - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + } as unknown as ReturnType); + const logger = loggingSystemMock.createLogger(); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual(getInitialDetectionMetrics()); }); it('returns an ml job telemetry object from anomaly detectors provider', async () => { - const mockJobSummary = jest.fn().mockResolvedValue(getMockJobSummaryResponse()); + const logger = loggingSystemMock.createLogger(); + const mockJobSummary = jest.fn().mockResolvedValue(getMockMlJobSummaryResponse()); const mockListModules = jest.fn().mockResolvedValue(getMockListModulesResponse()); - mlMock.modulesProvider.mockReturnValue({ + mlClient.modulesProvider.mockReturnValue({ listModules: mockListModules, - } as unknown as ReturnType); - mlMock.jobServiceProvider.mockReturnValue({ + } as unknown as ReturnType); + mlClient.jobServiceProvider.mockReturnValue({ jobsSummary: mockJobSummary, }); const mockJobsResponse = jest.fn().mockResolvedValue(getMockMlJobDetailsResponse()); @@ -217,13 +264,19 @@ describe('Detections Usage and Metrics', () => { .fn() .mockResolvedValue(getMockMlDatafeedStatsResponse()); - mlMock.anomalyDetectorsProvider.mockReturnValue({ + mlClient.anomalyDetectorsProvider.mockReturnValue({ jobs: mockJobsResponse, jobStats: mockJobStatsResponse, datafeedStats: mockDatafeedStatsResponse, - } as unknown as ReturnType); + } as unknown as ReturnType); - const result = await fetchDetectionsMetrics('', '', esClientMock, savedObjectsClient, mlMock); + const result = await getDetectionsMetrics({ + signalsIndex: '', + esClient, + savedObjectsClient, + logger, + mlClient, + }); expect(result).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/security_solution/server/usage/detections/get_metrics.ts b/x-pack/plugins/security_solution/server/usage/detections/get_metrics.ts new file mode 100644 index 0000000000000..258945fba662a --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/get_metrics.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'kibana/server'; +import type { MlPluginSetup } from '../../../../ml/server'; +import type { DetectionMetrics } from './types'; + +import { getMlJobMetrics } from './ml_jobs/get_metrics'; +import { getRuleMetrics } from './rules/get_metrics'; +import { getInitialRulesUsage } from './rules/get_initial_usage'; +import { getInitialMlJobUsage } from './ml_jobs/get_initial_usage'; + +export interface GetDetectionsMetricsOptions { + signalsIndex: string; + esClient: ElasticsearchClient; + savedObjectsClient: SavedObjectsClientContract; + logger: Logger; + mlClient: MlPluginSetup | undefined; +} + +export const getDetectionsMetrics = async ({ + signalsIndex, + esClient, + savedObjectsClient, + logger, + mlClient, +}: GetDetectionsMetricsOptions): Promise => { + const [mlJobMetrics, detectionRuleMetrics] = await Promise.allSettled([ + getMlJobMetrics({ mlClient, savedObjectsClient, logger }), + getRuleMetrics({ signalsIndex, esClient, savedObjectsClient, logger }), + ]); + + return { + ml_jobs: + mlJobMetrics.status === 'fulfilled' + ? mlJobMetrics.value + : { ml_job_metrics: [], ml_job_usage: getInitialMlJobUsage() }, + detection_rules: + detectionRuleMetrics.status === 'fulfilled' + ? detectionRuleMetrics.value + : { detection_rule_detail: [], detection_rule_usage: getInitialRulesUsage() }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/index.ts b/x-pack/plugins/security_solution/server/usage/detections/index.ts deleted file mode 100644 index a8d2ead83eec7..0000000000000 --- a/x-pack/plugins/security_solution/server/usage/detections/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { ElasticsearchClient, SavedObjectsClientContract } from '../../../../../../src/core/server'; -import { MlPluginSetup } from '../../../../ml/server'; -import { getDetectionRuleMetrics, initialDetectionRulesUsage } from './detection_rule_helpers'; -import { getMlJobMetrics, initialMlJobsUsage } from './detection_ml_helpers'; -import { DetectionMetrics } from './types'; - -import { INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; - -export const isElasticRule = (tags: string[] = []) => - tags.includes(`${INTERNAL_IMMUTABLE_KEY}:true`); - -export const fetchDetectionsMetrics = async ( - kibanaIndex: string, - signalsIndex: string, - esClient: ElasticsearchClient, - soClient: SavedObjectsClientContract, - mlClient: MlPluginSetup | undefined -): Promise => { - const [mlJobMetrics, detectionRuleMetrics] = await Promise.allSettled([ - getMlJobMetrics(mlClient, soClient), - getDetectionRuleMetrics(kibanaIndex, signalsIndex, esClient, soClient), - ]); - - return { - ml_jobs: - mlJobMetrics.status === 'fulfilled' - ? mlJobMetrics.value - : { ml_job_metrics: [], ml_job_usage: initialMlJobsUsage }, - detection_rules: - detectionRuleMetrics.status === 'fulfilled' - ? detectionRuleMetrics.value - : { detection_rule_detail: [], detection_rule_usage: initialDetectionRulesUsage }, - }; -}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_initial_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_initial_usage.ts new file mode 100644 index 0000000000000..6e3ab3124baf1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_initial_usage.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. + */ + +import type { MlJobUsage } from './types'; + +/** + * Default ml job usage count + */ +export const getInitialMlJobUsage = (): MlJobUsage => ({ + custom: { + enabled: 0, + disabled: 0, + }, + elastic: { + enabled: 0, + disabled: 0, + }, +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.mocks.ts similarity index 65% rename from x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts rename to x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.mocks.ts index e7c1384152c5a..a507a76e0c4f2 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.mocks.ts @@ -5,81 +5,8 @@ * 2.0. */ -import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; - -export const getMockJobSummaryResponse = () => [ - { - id: 'linux_anomalous_network_activity_ecs', - description: - 'SIEM Auditbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)', - groups: ['auditbeat', 'process', 'siem'], - processed_record_count: 141889, - memory_status: 'ok', - jobState: 'opened', - hasDatafeed: true, - datafeedId: 'datafeed-linux_anomalous_network_activity_ecs', - datafeedIndices: ['auditbeat-*'], - datafeedState: 'started', - latestTimestampMs: 1594085401911, - earliestTimestampMs: 1593054845656, - latestResultsTimestampMs: 1594085401911, - isSingleMetricViewerJob: true, - nodeName: 'node', - }, - { - id: 'linux_anomalous_network_port_activity_ecs', - description: - 'SIEM Auditbeat: Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity (beta)', - groups: ['auditbeat', 'process', 'siem'], - processed_record_count: 0, - memory_status: 'ok', - jobState: 'closed', - hasDatafeed: true, - datafeedId: 'datafeed-linux_anomalous_network_port_activity_ecs', - datafeedIndices: ['auditbeat-*'], - datafeedState: 'stopped', - isSingleMetricViewerJob: true, - }, - { - id: 'other_job', - description: 'a job that is custom', - groups: ['auditbeat', 'process', 'security'], - processed_record_count: 0, - memory_status: 'ok', - jobState: 'closed', - hasDatafeed: true, - datafeedId: 'datafeed-other', - datafeedIndices: ['auditbeat-*'], - datafeedState: 'stopped', - isSingleMetricViewerJob: true, - }, - { - id: 'another_job', - description: 'another job that is custom', - groups: ['auditbeat', 'process', 'security'], - processed_record_count: 0, - memory_status: 'ok', - jobState: 'opened', - hasDatafeed: true, - datafeedId: 'datafeed-another', - datafeedIndices: ['auditbeat-*'], - datafeedState: 'started', - isSingleMetricViewerJob: true, - }, - { - id: 'irrelevant_job', - description: 'a non-security job', - groups: ['auditbeat', 'process'], - processed_record_count: 0, - memory_status: 'ok', - jobState: 'opened', - hasDatafeed: true, - datafeedId: 'datafeed-another', - datafeedIndices: ['auditbeat-*'], - datafeedState: 'started', - isSingleMetricViewerJob: true, - }, -]; +import type { SavedObjectsFindResponse } from 'kibana/server'; +import type { RuleSearchResult } from '../../types'; export const getMockListModulesResponse = () => [ { @@ -162,6 +89,80 @@ export const getMockListModulesResponse = () => [ }, ]; +export const getMockMlJobSummaryResponse = () => [ + { + id: 'linux_anomalous_network_activity_ecs', + description: + 'SIEM Auditbeat: Looks for unusual processes using the network which could indicate command-and-control, lateral movement, persistence, or data exfiltration activity (beta)', + groups: ['auditbeat', 'process', 'siem'], + processed_record_count: 141889, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-linux_anomalous_network_activity_ecs', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + latestTimestampMs: 1594085401911, + earliestTimestampMs: 1593054845656, + latestResultsTimestampMs: 1594085401911, + isSingleMetricViewerJob: true, + nodeName: 'node', + }, + { + id: 'linux_anomalous_network_port_activity_ecs', + description: + 'SIEM Auditbeat: Looks for unusual destination port activity that could indicate command-and-control, persistence mechanism, or data exfiltration activity (beta)', + groups: ['auditbeat', 'process', 'siem'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'closed', + hasDatafeed: true, + datafeedId: 'datafeed-linux_anomalous_network_port_activity_ecs', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'stopped', + isSingleMetricViewerJob: true, + }, + { + id: 'other_job', + description: 'a job that is custom', + groups: ['auditbeat', 'process', 'security'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'closed', + hasDatafeed: true, + datafeedId: 'datafeed-other', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'stopped', + isSingleMetricViewerJob: true, + }, + { + id: 'another_job', + description: 'another job that is custom', + groups: ['auditbeat', 'process', 'security'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-another', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + isSingleMetricViewerJob: true, + }, + { + id: 'irrelevant_job', + description: 'a non-security job', + groups: ['auditbeat', 'process'], + processed_record_count: 0, + memory_status: 'ok', + jobState: 'opened', + hasDatafeed: true, + datafeedId: 'datafeed-another', + datafeedIndices: ['auditbeat-*'], + datafeedState: 'started', + isSingleMetricViewerJob: true, + }, +]; + export const getMockMlJobDetailsResponse = () => ({ count: 20, jobs: [ @@ -291,177 +292,100 @@ export const getMockMlDatafeedStatsResponse = () => ({ export const getMockRuleSearchResponse = ( immutableTag: string = '__internal_immutable:true' -): SearchResponse => ({ - took: 2, - timed_out: false, - _shards: { +): SavedObjectsFindResponse => + ({ + page: 1, + per_page: 1_000, total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - hits: { - total: { - value: 1093, - relation: 'eq', - }, - max_score: 0, - hits: [ + saved_objects: [ { - _index: '.kibanaindex', - _id: 'alert:6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', - _score: 0, - _source: { - alert: { - name: 'Azure Diagnostic Settings Deletion', - tags: [ - 'Elastic', - 'Cloud', - 'Azure', - 'Continuous Monitoring', - 'SecOps', - 'Monitoring', - '__internal_rule_id:5370d4cd-2bb3-4d71-abf5-1e1d0ff5a2de', - `${immutableTag}`, + type: 'alert', + id: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', + namespaces: ['default'], + attributes: { + name: 'Azure Diagnostic Settings Deletion', + tags: [ + 'Elastic', + 'Cloud', + 'Azure', + 'Continuous Monitoring', + 'SecOps', + 'Monitoring', + '__internal_rule_id:5370d4cd-2bb3-4d71-abf5-1e1d0ff5a2de', + `${immutableTag}`, + ], + alertTypeId: 'siem.queryRule', + consumer: 'siem', + params: { + author: ['Elastic'], + description: + 'Identifies the deletion of diagnostic settings in Azure, which send platform logs and metrics to different destinations. An adversary may delete diagnostic settings in an attempt to evade defenses.', + ruleId: '5370d4cd-2bb3-4d71-abf5-1e1d0ff5a2de', + index: ['filebeat-*', 'logs-azure*'], + falsePositives: [ + 'Deletion of diagnostic settings may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Diagnostic settings deletion from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule.', ], - alertTypeId: 'siem.signals', - consumer: 'siem', - params: { - author: ['Elastic'], - description: - 'Identifies the deletion of diagnostic settings in Azure, which send platform logs and metrics to different destinations. An adversary may delete diagnostic settings in an attempt to evade defenses.', - ruleId: '5370d4cd-2bb3-4d71-abf5-1e1d0ff5a2de', - index: ['filebeat-*', 'logs-azure*'], - falsePositives: [ - 'Deletion of diagnostic settings may be done by a system or network administrator. Verify whether the username, hostname, and/or resource name should be making changes in your environment. Diagnostic settings deletion from unfamiliar users or hosts should be investigated. If known behavior is causing false positives, it can be exempted from the rule.', - ], - from: 'now-25m', - immutable: true, - query: - 'event.dataset:azure.activitylogs and azure.activitylogs.operation_name:"MICROSOFT.INSIGHTS/DIAGNOSTICSETTINGS/DELETE" and event.outcome:(Success or success)', - language: 'kuery', - license: 'Elastic License v2', - outputIndex: '.siem-signals', - maxSignals: 100, - riskScore: 47, - timestampOverride: 'event.ingested', - to: 'now', - type: 'query', - references: [ - 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings', - ], - note: 'The Azure Filebeat module must be enabled to use this rule.', - version: 4, - exceptionsList: [], - }, - schedule: { - interval: '5m', - }, - enabled: false, - actions: [], - throttle: null, - notifyWhen: 'onActiveAlert', - apiKeyOwner: null, - apiKey: null, - createdBy: 'user', - updatedBy: 'user', - createdAt: '2021-03-23T17:15:59.634Z', - updatedAt: '2021-03-23T17:15:59.634Z', - muteAll: false, - mutedInstanceIds: [], - executionStatus: { - status: 'pending', - lastExecutionDate: '2021-03-23T17:15:59.634Z', - error: null, - }, - meta: { - versionApiKeyLastmodified: '8.0.0', + from: 'now-25m', + immutable: true, + query: + 'event.dataset:azure.activitylogs and azure.activitylogs.operation_name:"MICROSOFT.INSIGHTS/DIAGNOSTICSETTINGS/DELETE" and event.outcome:(Success or success)', + language: 'kuery', + license: 'Elastic License v2', + outputIndex: '.siem-signals', + maxSignals: 100, + riskScore: 47, + timestampOverride: 'event.ingested', + to: 'now', + type: 'query', + references: [ + 'https://docs.microsoft.com/en-us/azure/azure-monitor/platform/diagnostic-settings', + ], + note: 'The Azure Filebeat module must be enabled to use this rule.', + version: 4, + exceptionsList: [], + }, + schedule: { + interval: '5m', + }, + enabled: false, + actions: [], + throttle: null, + notifyWhen: 'onActiveAlert', + apiKeyOwner: null, + apiKey: '', + legacyId: null, + createdBy: 'user', + updatedBy: 'user', + createdAt: '2021-03-23T17:15:59.634Z', + updatedAt: '2021-03-23T17:15:59.634Z', + muteAll: true, + mutedInstanceIds: [], + monitoring: { + execution: { + history: [], + calculated_metrics: { + success_ratio: 1, + p99: 7981, + p50: 1653, + p95: 6523.699999999996, + }, }, }, - type: 'alert', - references: [], - migrationVersion: { - alert: '7.13.0', + meta: { + versionApiKeyLastmodified: '8.2.0', }, - coreMigrationVersion: '8.0.0', - updated_at: '2021-03-23T17:15:59.634Z', + scheduledTaskId: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', }, - }, - ], - }, -}); - -export const getMockRuleAlertsResponse = (docCount: number): SearchResponse => ({ - took: 7, - timed_out: false, - _shards: { - total: 1, - successful: 1, - skipped: 0, - failed: 0, - }, - hits: { - total: { - value: 7322, - relation: 'eq', - }, - max_score: null, - hits: [], - }, - aggregations: { - detectionAlerts: { - doc_count_error_upper_bound: 0, - sum_other_doc_count: 0, - buckets: [ - { - key: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', - doc_count: docCount, - }, - ], - }, - }, -}); - -export const getMockAlertCasesResponse = () => ({ - page: 1, - per_page: 10000, - total: 4, - saved_objects: [ - { - type: 'cases-comments', - id: '3bb5cc10-9249-11eb-85b7-254c8af1a983', - attributes: { - type: 'alert', - alertId: '54802763917f521249c9f68d0d4be0c26cc538404c26dfed1ae7dcfa94ea2226', - index: '.siem-signals-default-000001', - rule: { - id: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', - name: 'Azure Diagnostic Settings Deletion', - }, - created_at: '2021-03-31T17:47:59.449Z', - created_by: { - email: '', - full_name: '', - username: '', + references: [], + migrationVersion: { + alert: '8.0.0', }, - pushed_at: null, - pushed_by: null, - updated_at: null, - updated_by: null, + coreMigrationVersion: '8.2.0', + updated_at: '2021-03-23T17:15:59.634Z', + version: 'Wzk4NTQwLDNd', + score: 0, + sort: ['1644865254209', '19548'], }, - references: [ - { - type: 'cases', - name: 'associated-cases', - id: '3a3a4fa0-9249-11eb-85b7-254c8af1a983', - }, - ], - migrationVersion: {}, - coreMigrationVersion: '8.0.0', - updated_at: '2021-03-31T17:47:59.818Z', - version: 'WzI3MDIyODMsNF0=', - namespaces: ['default'], - score: 0, - }, - ], -}); + ], + // NOTE: We have to cast as "unknown" and then back to "RuleSearchResult" because "RuleSearchResult" isn't an exact type. See notes in the JSDocs fo that type. + } as unknown as SavedObjectsFindResponse); diff --git a/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.ts new file mode 100644 index 0000000000000..2eea42f28d953 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/get_metrics.ts @@ -0,0 +1,100 @@ +/* + * 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 { KibanaRequest, SavedObjectsClientContract, Logger } from 'kibana/server'; +import type { MlDatafeedStats, MlJob, MlPluginSetup } from '../../../../../ml/server'; +import type { MlJobMetric, MlJobUsageMetric } from './types'; + +import { isJobStarted } from '../../../../common/machine_learning/helpers'; +import { isSecurityJob } from '../../../../common/machine_learning/is_security_job'; +import { getInitialMlJobUsage } from './get_initial_usage'; +import { updateMlJobUsage } from './update_usage'; +import { getJobCorrelations } from './transform_utils/get_job_correlations'; + +export interface GetMlJobMetricsOptions { + mlClient: MlPluginSetup | undefined; + savedObjectsClient: SavedObjectsClientContract; + logger: Logger; +} + +export const getMlJobMetrics = async ({ + mlClient, + savedObjectsClient, + logger, +}: GetMlJobMetricsOptions): Promise => { + let jobsUsage = getInitialMlJobUsage(); + + if (mlClient == null) { + logger.debug( + 'Machine learning client is null/undefined, therefore not collecting telemetry from it' + ); + // early return if we don't have ml client + return { + ml_job_usage: getInitialMlJobUsage(), + ml_job_metrics: [], + }; + } + + try { + const fakeRequest = { headers: {} } as KibanaRequest; + + const modules = await mlClient.modulesProvider(fakeRequest, savedObjectsClient).listModules(); + const moduleJobs = modules.flatMap((module) => module.jobs); + const jobs = await mlClient.jobServiceProvider(fakeRequest, savedObjectsClient).jobsSummary(); + + jobsUsage = jobs.filter(isSecurityJob).reduce((usage, job) => { + const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); + const isEnabled = isJobStarted(job.jobState, job.datafeedState); + + return updateMlJobUsage({ isElastic, isEnabled }, usage); + }, getInitialMlJobUsage()); + + const jobsType = 'security'; + const securityJobStats = await mlClient + .anomalyDetectorsProvider(fakeRequest, savedObjectsClient) + .jobStats(jobsType); + + const jobDetails = await mlClient + .anomalyDetectorsProvider(fakeRequest, savedObjectsClient) + .jobs(jobsType); + + const jobDetailsCache = new Map(); + jobDetails.jobs.forEach((detail) => jobDetailsCache.set(detail.job_id, detail)); + + const datafeedStats = await mlClient + .anomalyDetectorsProvider(fakeRequest, savedObjectsClient) + .datafeedStats(); + + const datafeedStatsCache = new Map(); + datafeedStats.datafeeds.forEach((datafeedStat) => + datafeedStatsCache.set(`${datafeedStat.datafeed_id}`, datafeedStat) + ); + + const jobMetrics = securityJobStats.jobs.map((stat) => { + const jobId = stat.job_id; + const jobDetail = jobDetailsCache.get(stat.job_id); + const datafeed = datafeedStatsCache.get(`datafeed-${jobId}`); + return getJobCorrelations({ stat, jobDetail, datafeed }); + }); + + return { + ml_job_usage: jobsUsage, + ml_job_metrics: jobMetrics, + }; + } catch (e) { + // ignore failure, usage will be zeroed. We don't log the message below as currently ML jobs when it does + // not have a "security" job will cause a throw. If this does not normally throw eventually on normal operations + // we should log a debug message like the following below to not unnecessarily worry users as this will not effect them: + // logger.debug( + // `Encountered unexpected condition in telemetry of message: ${e.message}, object: ${e}. Telemetry for "ml_jobs" will be skipped.` + // ); + return { + ml_job_usage: getInitialMlJobUsage(), + ml_job_metrics: [], + }; + } +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/transform_utils/get_job_correlations.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/transform_utils/get_job_correlations.ts new file mode 100644 index 0000000000000..59a23c5dc7bd1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/transform_utils/get_job_correlations.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + MlDatafeedStats, + MlJob, + MlJobStats, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { MlJobMetric } from '../types'; + +export interface GetJobCorrelations { + stat: MlJobStats; + jobDetail: MlJob | undefined; + datafeed: MlDatafeedStats | undefined; +} + +export const getJobCorrelations = ({ + stat, + jobDetail, + datafeed, +}: GetJobCorrelations): MlJobMetric => { + return { + job_id: stat.job_id, + open_time: stat.open_time, + create_time: jobDetail?.create_time, + finished_time: jobDetail?.finished_time, + state: stat.state, + data_counts: { + bucket_count: stat.data_counts.bucket_count, + empty_bucket_count: stat.data_counts.empty_bucket_count, + input_bytes: stat.data_counts.input_bytes, + input_record_count: stat.data_counts.input_record_count, + last_data_time: stat.data_counts.last_data_time, + processed_record_count: stat.data_counts.processed_record_count, + }, + model_size_stats: { + bucket_allocation_failures_count: stat.model_size_stats.bucket_allocation_failures_count, + memory_status: stat.model_size_stats.memory_status, + model_bytes: stat.model_size_stats.model_bytes, + model_bytes_exceeded: stat.model_size_stats.model_bytes_exceeded, + model_bytes_memory_limit: stat.model_size_stats.model_bytes_memory_limit, + peak_model_bytes: stat.model_size_stats.peak_model_bytes, + }, + timing_stats: { + average_bucket_processing_time_ms: stat.timing_stats.average_bucket_processing_time_ms, + bucket_count: stat.timing_stats.bucket_count, + exponential_average_bucket_processing_time_ms: + stat.timing_stats.exponential_average_bucket_processing_time_ms, + exponential_average_bucket_processing_time_per_hour_ms: + stat.timing_stats.exponential_average_bucket_processing_time_per_hour_ms, + maximum_bucket_processing_time_ms: stat.timing_stats.maximum_bucket_processing_time_ms, + minimum_bucket_processing_time_ms: stat.timing_stats.minimum_bucket_processing_time_ms, + total_bucket_processing_time_ms: stat.timing_stats.total_bucket_processing_time_ms, + }, + datafeed: { + datafeed_id: datafeed?.datafeed_id, + state: datafeed?.state, + timing_stats: { + bucket_count: datafeed?.timing_stats.bucket_count, + exponential_average_search_time_per_hour_ms: + datafeed?.timing_stats.exponential_average_search_time_per_hour_ms, + search_count: datafeed?.timing_stats.search_count, + total_search_time_ms: datafeed?.timing_stats.total_search_time_ms, + }, + }, + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/types.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/types.ts new file mode 100644 index 0000000000000..c50fc3166977a --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/types.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + MlDataCounts, + MlDatafeedState, + MlDatafeedStats, + MlDatafeedTimingStats, + MlJob, + MlJobState, + MlJobStats, + MlJobTimingStats, + MlModelSizeStats, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; + +interface FeatureUsage { + enabled: number; + disabled: number; +} + +export interface MlJobUsage { + custom: FeatureUsage; + elastic: FeatureUsage; +} + +export interface MlJobMetric { + job_id: MlJobStats['job_id']; + create_time?: MlJob['create_time']; + finished_time?: MlJob['finished_time']; + open_time?: MlJobStats['open_time']; + state: MlJobState; + data_counts: Partial; + model_size_stats: Partial; + timing_stats: Partial; + datafeed: MlDataFeed; +} + +export interface MlJobUsageMetric { + ml_job_usage: MlJobUsage; + ml_job_metrics: MlJobMetric[]; +} + +export interface DetectionsMetric { + isElastic: boolean; + isEnabled: boolean; +} + +export interface MlDataFeed { + datafeed_id?: MlDatafeedStats['datafeed_id']; + state?: MlDatafeedState; + timing_stats: Partial; +} diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.test.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.test.ts similarity index 70% rename from x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.test.ts rename to x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.test.ts index 3ca0faeca7d36..9d0dc7c02e568 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_ml_helpers.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.test.ts @@ -5,16 +5,17 @@ * 2.0. */ -import { initialMlJobsUsage, updateMlJobsUsage } from './detection_ml_helpers'; +import { getInitialMlJobUsage } from './get_initial_usage'; +import { updateMlJobUsage } from './update_usage'; describe('Security Machine Learning usage metrics', () => { describe('Updates metrics with job information', () => { it('Should update ML total for elastic rules', async () => { - const initialUsage = initialMlJobsUsage; + const initialUsage = getInitialMlJobUsage(); const isElastic = true; const isEnabled = true; - const updatedUsage = updateMlJobsUsage({ isElastic, isEnabled }, initialUsage); + const updatedUsage = updateMlJobUsage({ isElastic, isEnabled }, initialUsage); expect(updatedUsage).toEqual( expect.objectContaining({ @@ -31,11 +32,11 @@ describe('Security Machine Learning usage metrics', () => { }); it('Should update ML total for custom rules', async () => { - const initialUsage = initialMlJobsUsage; + const initialUsage = getInitialMlJobUsage(); const isElastic = false; const isEnabled = true; - const updatedUsage = updateMlJobsUsage({ isElastic, isEnabled }, initialUsage); + const updatedUsage = updateMlJobUsage({ isElastic, isEnabled }, initialUsage); expect(updatedUsage).toEqual( expect.objectContaining({ @@ -52,10 +53,9 @@ describe('Security Machine Learning usage metrics', () => { }); it('Should update ML total for both elastic and custom rules', async () => { - const initialUsage = initialMlJobsUsage; - - let updatedUsage = updateMlJobsUsage({ isElastic: true, isEnabled: true }, initialUsage); - updatedUsage = updateMlJobsUsage({ isElastic: false, isEnabled: true }, updatedUsage); + const initialUsage = getInitialMlJobUsage(); + let updatedUsage = updateMlJobUsage({ isElastic: true, isEnabled: true }, initialUsage); + updatedUsage = updateMlJobUsage({ isElastic: false, isEnabled: true }, updatedUsage); expect(updatedUsage).toEqual( expect.objectContaining({ diff --git a/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.ts new file mode 100644 index 0000000000000..2306bfa051a3b --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/ml_jobs/update_usage.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DetectionsMetric, MlJobUsage } from './types'; + +export const updateMlJobUsage = (jobMetric: DetectionsMetric, usage: MlJobUsage): MlJobUsage => { + const { isEnabled, isElastic } = jobMetric; + if (isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + enabled: usage.elastic.enabled + 1, + }, + }; + } else if (!isEnabled && isElastic) { + return { + ...usage, + elastic: { + ...usage.elastic, + disabled: usage.elastic.disabled + 1, + }, + }; + } else if (isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + enabled: usage.custom.enabled + 1, + }, + }; + } else if (!isEnabled && !isElastic) { + return { + ...usage, + custom: { + ...usage.custom, + disabled: usage.custom.disabled + 1, + }, + }; + } else { + return usage; + } +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/get_initial_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/get_initial_usage.ts new file mode 100644 index 0000000000000..81ea7aec800e3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/get_initial_usage.ts @@ -0,0 +1,84 @@ +/* + * 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 { RulesTypeUsage } from './types'; + +/** + * Default detection rule usage count, split by type + elastic/custom + */ +export const getInitialRulesUsage = (): RulesTypeUsage => ({ + query: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + threshold: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + eql: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + machine_learning: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + threat_match: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + elastic_total: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, + custom_total: { + enabled: 0, + disabled: 0, + alerts: 0, + cases: 0, + legacy_notifications_enabled: 0, + legacy_notifications_disabled: 0, + notifications_enabled: 0, + notifications_disabled: 0, + }, +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.mocks.ts new file mode 100644 index 0000000000000..1801d5bd67782 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.mocks.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import type { SavedObjectsFindResponse } from 'kibana/server'; +import type { AlertAggs } from '../../types'; +import { CommentAttributes, CommentType } from '../../../../../cases/common/api/cases/comment'; + +export const getMockRuleAlertsResponse = (docCount: number): SearchResponse => ({ + took: 7, + timed_out: false, + _shards: { + total: 1, + successful: 1, + skipped: 0, + failed: 0, + }, + hits: { + total: { + value: 7322, + relation: 'eq', + }, + max_score: null, + hits: [], + }, + aggregations: { + buckets: { + after_key: { + detectionAlerts: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', + }, + buckets: [ + { + key: { + detectionAlerts: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', + }, + doc_count: docCount, + }, + ], + }, + }, +}); + +export const getMockAlertCaseCommentsResponse = (): SavedObjectsFindResponse< + Partial, + never +> => ({ + page: 1, + per_page: 10000, + total: 4, + saved_objects: [ + { + type: 'cases-comments', + id: '3bb5cc10-9249-11eb-85b7-254c8af1a983', + attributes: { + type: CommentType.alert, + alertId: '54802763917f521249c9f68d0d4be0c26cc538404c26dfed1ae7dcfa94ea2226', + index: '.siem-signals-default-000001', + rule: { + id: '6eecd8c2-8bfb-11eb-afbe-1b7a66309c6d', + name: 'Azure Diagnostic Settings Deletion', + }, + created_at: '2021-03-31T17:47:59.449Z', + created_by: { + email: '', + full_name: '', + username: '', + }, + pushed_at: null, + pushed_by: null, + updated_at: null, + updated_by: null, + }, + references: [ + { + type: 'cases', + name: 'associated-cases', + id: '3a3a4fa0-9249-11eb-85b7-254c8af1a983', + }, + ], + migrationVersion: {}, + coreMigrationVersion: '8.0.0', + updated_at: '2021-03-31T17:47:59.818Z', + version: 'WzI3MDIyODMsNF0=', + namespaces: ['default'], + score: 0, + }, + ], +}); + +export const getEmptySavedObjectResponse = (): SavedObjectsFindResponse => ({ + page: 1, + per_page: 1_000, + total: 0, + saved_objects: [], +}); diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.ts new file mode 100644 index 0000000000000..b202ea964301c --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/get_metrics.ts @@ -0,0 +1,122 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ElasticsearchClient, SavedObjectsClientContract, Logger } from 'kibana/server'; +import type { RuleAdoption } from './types'; + +import { updateRuleUsage } from './update_usage'; +import { getDetectionRules } from '../../queries/get_detection_rules'; +import { getAlerts } from '../../queries/get_alerts'; +import { MAX_PER_PAGE, MAX_RESULTS_WINDOW } from '../../constants'; +import { getInitialRulesUsage } from './get_initial_usage'; +import { getCaseComments } from '../../queries/get_case_comments'; +import { getRuleIdToCasesMap } from './transform_utils/get_rule_id_to_cases_map'; +import { getAlertIdToCountMap } from './transform_utils/get_alert_id_to_count_map'; +import { getRuleIdToEnabledMap } from './transform_utils/get_rule_id_to_enabled_map'; +import { getRuleObjectCorrelations } from './transform_utils/get_rule_object_correlations'; + +// eslint-disable-next-line no-restricted-imports +import { legacyGetRuleActions } from '../../queries/legacy_get_rule_actions'; + +export interface GetRuleMetricsOptions { + signalsIndex: string; + esClient: ElasticsearchClient; + savedObjectsClient: SavedObjectsClientContract; + logger: Logger; +} + +export const getRuleMetrics = async ({ + signalsIndex, + esClient, + savedObjectsClient, + logger, +}: GetRuleMetricsOptions): Promise => { + try { + // gets rule saved objects + const ruleResults = await getDetectionRules({ + savedObjectsClient, + maxPerPage: MAX_PER_PAGE, + maxSize: MAX_RESULTS_WINDOW, + logger, + }); + + // early return if we don't have any detection rules then there is no need to query anything else + if (ruleResults.length === 0) { + return { + detection_rule_detail: [], + detection_rule_usage: getInitialRulesUsage(), + }; + } + + // gets the alerts data objects + const detectionAlertsRespPromise = getAlerts({ + esClient, + signalsIndex: `${signalsIndex}*`, + maxPerPage: MAX_PER_PAGE, + maxSize: MAX_RESULTS_WINDOW, + logger, + }); + + // gets cases saved objects + const caseCommentsPromise = getCaseComments({ + savedObjectsClient, + maxSize: MAX_PER_PAGE, + maxPerPage: MAX_RESULTS_WINDOW, + logger, + }); + + // gets the legacy rule actions to track legacy notifications. + const legacyRuleActionsPromise = legacyGetRuleActions({ + savedObjectsClient, + maxSize: MAX_PER_PAGE, + maxPerPage: MAX_RESULTS_WINDOW, + logger, + }); + + const [detectionAlertsResp, caseComments, legacyRuleActions] = await Promise.all([ + detectionAlertsRespPromise, + caseCommentsPromise, + legacyRuleActionsPromise, + ]); + + // create in-memory maps for correlation + const legacyNotificationRuleIds = getRuleIdToEnabledMap(legacyRuleActions); + const casesRuleIds = getRuleIdToCasesMap(caseComments); + const alertsCounts = getAlertIdToCountMap(detectionAlertsResp); + + // correlate the rule objects to the results + const rulesCorrelated = getRuleObjectCorrelations({ + ruleResults, + legacyNotificationRuleIds, + casesRuleIds, + alertsCounts, + }); + + // Only bring back rule detail on elastic prepackaged detection rules + const elasticRuleObjects = rulesCorrelated.filter((hit) => hit.elastic_rule === true); + + // calculate the rule usage + const rulesUsage = rulesCorrelated.reduce( + (usage, rule) => updateRuleUsage(rule, usage), + getInitialRulesUsage() + ); + + return { + detection_rule_detail: elasticRuleObjects, + detection_rule_usage: rulesUsage, + }; + } catch (e) { + // ignore failure, usage will be zeroed. We use debug mode to not unnecessarily worry users as this will not effect them. + logger.debug( + `Encountered unexpected condition in telemetry of message: ${e.message}, object: ${e}. Telemetry for "detection rules" being skipped.` + ); + return { + detection_rule_detail: [], + detection_rule_usage: getInitialRulesUsage(), + }; + } +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_alert_id_to_count_map.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_alert_id_to_count_map.ts new file mode 100644 index 0000000000000..ce569564273e4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_alert_id_to_count_map.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { AlertBucket } from '../../../types'; + +export const getAlertIdToCountMap = (alerts: AlertBucket[]): Map => { + const alertsCache = new Map(); + alerts.map((bucket) => alertsCache.set(bucket.key.detectionAlerts, bucket.doc_count)); + return alertsCache; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_cases_map.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_cases_map.ts new file mode 100644 index 0000000000000..d7ce790be0750 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_cases_map.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 { SavedObjectsFindResult } from 'kibana/server'; +import type { CommentAttributes } from '../../../../../../cases/common/api/cases/comment'; + +export const getRuleIdToCasesMap = ( + cases: Array> +): Map => { + return cases.reduce((cache, { attributes: casesObject }) => { + if (casesObject.type === 'alert') { + const ruleId = casesObject.rule.id; + if (ruleId != null) { + const cacheCount = cache.get(ruleId); + if (cacheCount === undefined) { + cache.set(ruleId, 1); + } else { + cache.set(ruleId, cacheCount + 1); + } + } + return cache; + } else { + return cache; + } + }, new Map()); +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_enabled_map.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_enabled_map.ts new file mode 100644 index 0000000000000..b280d3a4ba17d --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_id_to_enabled_map.ts @@ -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 type { SavedObjectsFindResult } from 'kibana/server'; +// eslint-disable-next-line no-restricted-imports +import type { LegacyIRuleActionsAttributesSavedObjectAttributes } from '../../../../lib/detection_engine/rule_actions/legacy_types'; + +export const getRuleIdToEnabledMap = ( + legacyRuleActions: Array< + SavedObjectsFindResult + > +): Map< + string, + { + enabled: boolean; + } +> => { + return legacyRuleActions.reduce((cache, legacyNotificationsObject) => { + const ruleRef = legacyNotificationsObject.references.find( + (reference) => reference.name === 'alert_0' && reference.type === 'alert' + ); + if (ruleRef != null) { + const enabled = legacyNotificationsObject.attributes.ruleThrottle !== 'no_actions'; + cache.set(ruleRef.id, { enabled }); + } + return cache; + }, new Map()); +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_object_correlations.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_object_correlations.ts new file mode 100644 index 0000000000000..0c364efe73bd9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/transform_utils/get_rule_object_correlations.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsFindResult } from 'kibana/server'; +import type { RuleMetric } from '../types'; +import type { RuleSearchResult } from '../../../types'; + +import { isElasticRule } from '../../../queries/utils/is_elastic_rule'; + +export interface RuleObjectCorrelationsOptions { + ruleResults: Array>; + legacyNotificationRuleIds: Map< + string, + { + enabled: boolean; + } + >; + casesRuleIds: Map; + alertsCounts: Map; +} + +export const getRuleObjectCorrelations = ({ + ruleResults, + legacyNotificationRuleIds, + casesRuleIds, + alertsCounts, +}: RuleObjectCorrelationsOptions): RuleMetric[] => { + return ruleResults.map((result) => { + const ruleId = result.id; + const { attributes } = result; + const isElastic = isElasticRule(attributes.tags); + + // Even if the legacy notification is set to "no_actions" we still count the rule as having a legacy notification that is not migrated yet. + const hasLegacyNotification = legacyNotificationRuleIds.get(ruleId) != null; + + // We only count a rule as having a notification and being "enabled" if it is _not_ set to "no_actions"/"muteAll" and it has at least one action within its array. + const hasNotification = + !hasLegacyNotification && + attributes.actions != null && + attributes.actions.length > 0 && + attributes.muteAll !== true; + + return { + rule_name: attributes.name, + rule_id: attributes.params.ruleId, + rule_type: attributes.params.type, + rule_version: attributes.params.version, + enabled: attributes.enabled, + elastic_rule: isElastic, + created_on: attributes.createdAt, + updated_on: attributes.updatedAt, + alert_count_daily: alertsCounts.get(ruleId) || 0, + cases_count_total: casesRuleIds.get(ruleId) || 0, + has_legacy_notification: hasLegacyNotification, + has_notification: hasNotification, + }; + }); +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/types.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/types.ts new file mode 100644 index 0000000000000..54b3e6d6a0084 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/types.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface FeatureTypeUsage { + enabled: number; + disabled: number; + alerts: number; + cases: number; + legacy_notifications_enabled: number; + legacy_notifications_disabled: number; + notifications_enabled: number; + notifications_disabled: number; +} + +export interface RulesTypeUsage { + query: FeatureTypeUsage; + threshold: FeatureTypeUsage; + eql: FeatureTypeUsage; + machine_learning: FeatureTypeUsage; + threat_match: FeatureTypeUsage; + elastic_total: FeatureTypeUsage; + custom_total: FeatureTypeUsage; +} + +export interface RuleAdoption { + detection_rule_detail: RuleMetric[]; + detection_rule_usage: RulesTypeUsage; +} + +export interface RuleMetric { + rule_name: string; + rule_id: string; + rule_type: string; + rule_version: number; + enabled: boolean; + elastic_rule: boolean; + created_on: string; + updated_on: string; + alert_count_daily: number; + cases_count_total: number; + has_legacy_notification: boolean; + has_notification: boolean; +} diff --git a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.test.ts similarity index 92% rename from x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts rename to x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.test.ts index c19e7b18f9e72..d878d0a5145ab 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detection_rule_helpers.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.test.ts @@ -5,8 +5,9 @@ * 2.0. */ -import { initialDetectionRulesUsage, updateDetectionRuleUsage } from './detection_rule_helpers'; -import { DetectionRuleMetric, DetectionRulesTypeUsage } from './types'; +import type { RuleMetric, RulesTypeUsage } from './types'; +import { updateRuleUsage } from './update_usage'; +import { getInitialRulesUsage } from './get_initial_usage'; interface StubRuleOptions { ruleType: string; @@ -26,7 +27,7 @@ const createStubRule = ({ caseCount, hasLegacyNotification, hasNotification, -}: StubRuleOptions): DetectionRuleMetric => ({ +}: StubRuleOptions): RuleMetric => ({ rule_name: 'rule-name', rule_id: 'id-123', rule_type: ruleType, @@ -53,10 +54,10 @@ describe('Detections Usage and Metrics', () => { hasLegacyNotification: false, hasNotification: false, }); - const usage = updateDetectionRuleUsage(stubRule, initialDetectionRulesUsage); + const usage = updateRuleUsage(stubRule, getInitialRulesUsage()); - expect(usage).toEqual({ - ...initialDetectionRulesUsage, + expect(usage).toEqual({ + ...getInitialRulesUsage(), elastic_total: { alerts: 1, cases: 1, @@ -127,14 +128,14 @@ describe('Detections Usage and Metrics', () => { hasNotification: false, }); - let usage = updateDetectionRuleUsage(stubEqlRule, initialDetectionRulesUsage); - usage = updateDetectionRuleUsage(stubQueryRuleOne, usage); - usage = updateDetectionRuleUsage(stubQueryRuleTwo, usage); - usage = updateDetectionRuleUsage(stubMachineLearningOne, usage); - usage = updateDetectionRuleUsage(stubMachineLearningTwo, usage); + let usage = updateRuleUsage(stubEqlRule, getInitialRulesUsage()); + usage = updateRuleUsage(stubQueryRuleOne, usage); + usage = updateRuleUsage(stubQueryRuleTwo, usage); + usage = updateRuleUsage(stubMachineLearningOne, usage); + usage = updateRuleUsage(stubMachineLearningTwo, usage); - expect(usage).toEqual({ - ...initialDetectionRulesUsage, + expect(usage).toEqual({ + ...getInitialRulesUsage(), custom_total: { alerts: 5, cases: 12, @@ -242,8 +243,8 @@ describe('Detections Usage and Metrics', () => { alertCount: 0, caseCount: 0, }); - const usage = updateDetectionRuleUsage(rule1, initialDetectionRulesUsage) as ReturnType< - typeof updateDetectionRuleUsage + const usage = updateRuleUsage(rule1, getInitialRulesUsage()) as ReturnType< + typeof updateRuleUsage > & { [key: string]: unknown }; expect(usage[ruleType]).toEqual( expect.objectContaining({ @@ -264,8 +265,8 @@ describe('Detections Usage and Metrics', () => { alertCount: 0, caseCount: 0, }); - const usageAddedByOne = updateDetectionRuleUsage(rule2, usage) as ReturnType< - typeof updateDetectionRuleUsage + const usageAddedByOne = updateRuleUsage(rule2, usage) as ReturnType< + typeof updateRuleUsage > & { [key: string]: unknown }; expect(usageAddedByOne[ruleType]).toEqual( diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.ts new file mode 100644 index 0000000000000..3aa3c3bbc29b0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/update_usage.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RulesTypeUsage, RuleMetric } from './types'; +import { updateQueryUsage } from './usage_utils/update_query_usage'; +import { updateTotalUsage } from './usage_utils/update_total_usage'; + +export const updateRuleUsage = ( + detectionRuleMetric: RuleMetric, + usage: RulesTypeUsage +): RulesTypeUsage => { + let updatedUsage = usage; + if (detectionRuleMetric.rule_type === 'query') { + updatedUsage = { + ...usage, + query: updateQueryUsage({ + ruleType: detectionRuleMetric.rule_type, + usage, + detectionRuleMetric, + }), + }; + } else if (detectionRuleMetric.rule_type === 'threshold') { + updatedUsage = { + ...usage, + threshold: updateQueryUsage({ + ruleType: detectionRuleMetric.rule_type, + usage, + detectionRuleMetric, + }), + }; + } else if (detectionRuleMetric.rule_type === 'eql') { + updatedUsage = { + ...usage, + eql: updateQueryUsage({ + ruleType: detectionRuleMetric.rule_type, + usage, + detectionRuleMetric, + }), + }; + } else if (detectionRuleMetric.rule_type === 'machine_learning') { + updatedUsage = { + ...usage, + machine_learning: updateQueryUsage({ + ruleType: detectionRuleMetric.rule_type, + usage, + detectionRuleMetric, + }), + }; + } else if (detectionRuleMetric.rule_type === 'threat_match') { + updatedUsage = { + ...usage, + threat_match: updateQueryUsage({ + ruleType: detectionRuleMetric.rule_type, + usage, + detectionRuleMetric, + }), + }; + } + + if (detectionRuleMetric.elastic_rule) { + updatedUsage = { + ...updatedUsage, + elastic_total: updateTotalUsage({ + detectionRuleMetric, + updatedUsage, + totalType: 'elastic_total', + }), + }; + } else { + updatedUsage = { + ...updatedUsage, + custom_total: updateTotalUsage({ + detectionRuleMetric, + updatedUsage, + totalType: 'custom_total', + }), + }; + } + + return updatedUsage; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/get_notifications_enabled_disabled.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/get_notifications_enabled_disabled.ts new file mode 100644 index 0000000000000..aae3f3fe00d0f --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/get_notifications_enabled_disabled.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RuleMetric } from '../types'; + +export const getNotificationsEnabledDisabled = ( + detectionRuleMetric: RuleMetric +): { + legacyNotificationEnabled: boolean; + legacyNotificationDisabled: boolean; + notificationEnabled: boolean; + notificationDisabled: boolean; +} => { + return { + legacyNotificationEnabled: + detectionRuleMetric.has_legacy_notification && detectionRuleMetric.enabled, + legacyNotificationDisabled: + detectionRuleMetric.has_legacy_notification && !detectionRuleMetric.enabled, + notificationEnabled: detectionRuleMetric.has_notification && detectionRuleMetric.enabled, + notificationDisabled: detectionRuleMetric.has_notification && !detectionRuleMetric.enabled, + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_query_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_query_usage.ts new file mode 100644 index 0000000000000..7f40ceec21c8a --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_query_usage.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RulesTypeUsage, RuleMetric, FeatureTypeUsage } from '../types'; +import { getNotificationsEnabledDisabled } from './get_notifications_enabled_disabled'; + +export interface UpdateQueryUsageOptions { + ruleType: keyof RulesTypeUsage; + usage: RulesTypeUsage; + detectionRuleMetric: RuleMetric; +} + +export const updateQueryUsage = ({ + ruleType, + usage, + detectionRuleMetric, +}: UpdateQueryUsageOptions): FeatureTypeUsage => { + const { + legacyNotificationEnabled, + legacyNotificationDisabled, + notificationEnabled, + notificationDisabled, + } = getNotificationsEnabledDisabled(detectionRuleMetric); + return { + enabled: detectionRuleMetric.enabled ? usage[ruleType].enabled + 1 : usage[ruleType].enabled, + disabled: !detectionRuleMetric.enabled + ? usage[ruleType].disabled + 1 + : usage[ruleType].disabled, + alerts: usage[ruleType].alerts + detectionRuleMetric.alert_count_daily, + cases: usage[ruleType].cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? usage[ruleType].legacy_notifications_enabled + 1 + : usage[ruleType].legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? usage[ruleType].legacy_notifications_disabled + 1 + : usage[ruleType].legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? usage[ruleType].notifications_enabled + 1 + : usage[ruleType].notifications_enabled, + notifications_disabled: notificationDisabled + ? usage[ruleType].notifications_disabled + 1 + : usage[ruleType].notifications_disabled, + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_total_usage.ts b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_total_usage.ts new file mode 100644 index 0000000000000..ed0ff37e2a328 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/detections/rules/usage_utils/update_total_usage.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RulesTypeUsage, RuleMetric, FeatureTypeUsage } from '../types'; +import { getNotificationsEnabledDisabled } from './get_notifications_enabled_disabled'; + +export interface UpdateTotalUsageOptions { + detectionRuleMetric: RuleMetric; + updatedUsage: RulesTypeUsage; + totalType: 'custom_total' | 'elastic_total'; +} + +export const updateTotalUsage = ({ + detectionRuleMetric, + updatedUsage, + totalType, +}: UpdateTotalUsageOptions): FeatureTypeUsage => { + const { + legacyNotificationEnabled, + legacyNotificationDisabled, + notificationEnabled, + notificationDisabled, + } = getNotificationsEnabledDisabled(detectionRuleMetric); + + return { + enabled: detectionRuleMetric.enabled + ? updatedUsage[totalType].enabled + 1 + : updatedUsage[totalType].enabled, + disabled: !detectionRuleMetric.enabled + ? updatedUsage[totalType].disabled + 1 + : updatedUsage[totalType].disabled, + alerts: updatedUsage[totalType].alerts + detectionRuleMetric.alert_count_daily, + cases: updatedUsage[totalType].cases + detectionRuleMetric.cases_count_total, + legacy_notifications_enabled: legacyNotificationEnabled + ? updatedUsage[totalType].legacy_notifications_enabled + 1 + : updatedUsage[totalType].legacy_notifications_enabled, + legacy_notifications_disabled: legacyNotificationDisabled + ? updatedUsage[totalType].legacy_notifications_disabled + 1 + : updatedUsage[totalType].legacy_notifications_disabled, + notifications_enabled: notificationEnabled + ? updatedUsage[totalType].notifications_enabled + 1 + : updatedUsage[totalType].notifications_enabled, + notifications_disabled: notificationDisabled + ? updatedUsage[totalType].notifications_disabled + 1 + : updatedUsage[totalType].notifications_disabled, + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/detections/types.ts b/x-pack/plugins/security_solution/server/usage/detections/types.ts index a7eb4c387d4ba..2895e5c6f8b9a 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/types.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/types.ts @@ -5,165 +5,10 @@ * 2.0. */ -interface RuleSearchBody { - query: { - bool: { - filter: { - terms: { [key: string]: string[] }; - }; - }; - }; -} - -export interface RuleSearchParams { - body: RuleSearchBody; - filter_path: string[]; - ignore_unavailable: boolean; - index: string; - size: number; -} - -export interface RuleSearchResult { - alert: { - name: string; - enabled: boolean; - tags: string[]; - createdAt: string; - updatedAt: string; - muteAll: boolean | undefined | null; - params: DetectionRuleParms; - actions: unknown[]; - }; -} - -export interface DetectionsMetric { - isElastic: boolean; - isEnabled: boolean; -} - -interface DetectionRuleParms { - ruleId: string; - version: string; - type: string; -} - -interface FeatureUsage { - enabled: number; - disabled: number; -} - -interface FeatureTypeUsage { - enabled: number; - disabled: number; - alerts: number; - cases: number; - legacy_notifications_enabled: number; - legacy_notifications_disabled: number; - notifications_enabled: number; - notifications_disabled: number; -} -export interface DetectionRulesTypeUsage { - query: FeatureTypeUsage; - threshold: FeatureTypeUsage; - eql: FeatureTypeUsage; - machine_learning: FeatureTypeUsage; - threat_match: FeatureTypeUsage; - elastic_total: FeatureTypeUsage; - custom_total: FeatureTypeUsage; -} - -export interface MlJobsUsage { - custom: FeatureUsage; - elastic: FeatureUsage; -} - -export interface DetectionsUsage { - ml_jobs: MlJobsUsage; -} +import type { MlJobUsageMetric } from './ml_jobs/types'; +import type { RuleAdoption } from './rules/types'; export interface DetectionMetrics { - ml_jobs: MlJobUsage; - detection_rules: DetectionRuleAdoption; -} - -export interface MlJobDataCount { - bucket_count: number; - empty_bucket_count: number; - input_bytes: number; - input_record_count: number; - last_data_time: number; - processed_record_count: number; -} - -export interface MlJobModelSize { - bucket_allocation_failures_count: number; - memory_status: string; - model_bytes: number; - model_bytes_exceeded: number; - model_bytes_memory_limit: number; - peak_model_bytes: number; -} - -export interface MlTimingStats { - bucket_count: number; - exponential_average_bucket_processing_time_ms: number; - exponential_average_bucket_processing_time_per_hour_ms: number; - maximum_bucket_processing_time_ms: number; - minimum_bucket_processing_time_ms: number; - total_bucket_processing_time_ms: number; -} - -export interface MlJobMetric { - job_id: string; - open_time: string; - state: string; - data_counts: MlJobDataCount; - model_size_stats: MlJobModelSize; - timing_stats: MlTimingStats; -} - -export interface DetectionRuleMetric { - rule_name: string; - rule_id: string; - rule_type: string; - rule_version: number; - enabled: boolean; - elastic_rule: boolean; - created_on: string; - updated_on: string; - alert_count_daily: number; - cases_count_total: number; - has_legacy_notification: boolean; - has_notification: boolean; -} - -export interface AlertsAggregationResponse { - hits: { - total: { value: number }; - }; - aggregations: { - [aggName: string]: { - buckets: Array<{ key: string; doc_count: number }>; - }; - }; -} - -export interface CasesSavedObject { - type: string; - alertId: string; - index: string; - rule: { - id: string | null; - name: string | null; - }; -} - -export interface MlJobUsage { - ml_job_usage: MlJobsUsage; - ml_job_metrics: MlJobMetric[]; -} - -export interface DetectionRuleAdoption { - detection_rule_detail: DetectionRuleMetric[]; - detection_rule_usage: DetectionRulesTypeUsage; + ml_jobs: MlJobUsageMetric; + detection_rules: RuleAdoption; } diff --git a/x-pack/plugins/security_solution/server/usage/get_internal_saved_objects_client.ts b/x-pack/plugins/security_solution/server/usage/get_internal_saved_objects_client.ts new file mode 100644 index 0000000000000..aea462ecf1fa6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/get_internal_saved_objects_client.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 type { CoreSetup, SavedObjectsClientContract } from 'kibana/server'; + +import { SAVED_OBJECT_TYPES } from '../../../cases/common/constants'; +// eslint-disable-next-line no-restricted-imports +import { legacyRuleActionsSavedObjectType } from '../lib/detection_engine/rule_actions/legacy_saved_object_mappings'; + +export async function getInternalSavedObjectsClient( + core: CoreSetup +): Promise { + return core.getStartServices().then(async ([coreStart]) => { + // note: we include the "cases" and "alert" hidden types here otherwise we would not be able to query them. If at some point cases and alert is not considered a hidden type this can be removed + return coreStart.savedObjects.createInternalRepository([ + 'alert', + legacyRuleActionsSavedObjectType, + ...SAVED_OBJECT_TYPES, + ]) as unknown as SavedObjectsClientContract; + }); +} diff --git a/x-pack/plugins/security_solution/server/usage/queries/get_alerts.ts b/x-pack/plugins/security_solution/server/usage/queries/get_alerts.ts new file mode 100644 index 0000000000000..792ca28dcfba3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/get_alerts.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 type { OpenPointInTimeResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { + AggregationsCompositeAggregation, + SearchRequest, +} from '@elastic/elasticsearch/lib/api/types'; +import type { ElasticsearchClient, Logger } from 'kibana/server'; +import { ALERT_RULE_UUID } from '@kbn/rule-data-utils'; +import type { AlertBucket, AlertAggs } from '../types'; + +export interface GetAlertsOptions { + esClient: ElasticsearchClient; + signalsIndex: string; + maxSize: number; + maxPerPage: number; + logger: Logger; +} + +export const getAlerts = async ({ + esClient, + signalsIndex, + maxSize, + maxPerPage, + logger, +}: GetAlertsOptions): Promise => { + // default is from looking at Kibana saved objects and online documentation + const keepAlive = '5m'; + + // create and assign an initial point in time + let pitId: OpenPointInTimeResponse['id'] = ( + await esClient.openPointInTime({ + index: signalsIndex, + keep_alive: keepAlive, + }) + ).id; + + let after: AggregationsCompositeAggregation['after']; + let buckets: AlertBucket[] = []; + let fetchMore = true; + while (fetchMore) { + const ruleSearchOptions: SearchRequest = { + aggs: { + buckets: { + composite: { + size: Math.min(maxPerPage, maxSize - buckets.length), + sources: [ + { + detectionAlerts: { + terms: { + field: ALERT_RULE_UUID, + }, + }, + }, + ], + after, + }, + }, + }, + query: { + bool: { + filter: [ + { + range: { + '@timestamp': { + gte: 'now-24h', + lte: 'now', + }, + }, + }, + ], + }, + }, + track_total_hits: false, + sort: [{ _shard_doc: 'desc' }] as unknown as string[], // TODO: Remove this "unknown" once it is typed correctly https://github.com/elastic/elasticsearch-js/issues/1589 + pit: { id: pitId }, + size: 0, + }; + logger.debug( + `Getting alerts with point in time (PIT) query: ${JSON.stringify(ruleSearchOptions)}` + ); + const body = await esClient.search(ruleSearchOptions); + if (body.aggregations?.buckets?.buckets != null) { + buckets = [...buckets, ...body.aggregations.buckets.buckets]; + } + if (body.aggregations?.buckets?.after_key != null) { + after = { + detectionAlerts: body.aggregations.buckets.after_key.detectionAlerts, + }; + } + + fetchMore = + body.aggregations?.buckets?.buckets != null && + body.aggregations?.buckets?.buckets.length !== 0 && + buckets.length < maxSize; + if (body.pit_id != null) { + pitId = body.pit_id; + } + } + try { + await esClient.closePointInTime({ id: pitId }); + } catch (error) { + // Don't fail due to a bad point in time closure. We have seen failures in e2e tests during nominal operations. + logger.warn( + `Error trying to close point in time: "${pitId}", it will expire within "${keepAlive}". Error is: "${error}"` + ); + } + logger.debug(`Returning alerts response of length: "${buckets.length}"`); + return buckets; +}; diff --git a/x-pack/plugins/security_solution/server/usage/queries/get_case_comments.ts b/x-pack/plugins/security_solution/server/usage/queries/get_case_comments.ts new file mode 100644 index 0000000000000..0a6c7f2fc209a --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/get_case_comments.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + SavedObjectsClientContract, + SavedObjectsFindResult, + Logger, + SavedObjectsCreatePointInTimeFinderOptions, +} from 'kibana/server'; + +import { CASE_COMMENT_SAVED_OBJECT } from '../../../../cases/common/constants'; +import type { CommentAttributes } from '../../../../cases/common/api/cases/comment'; + +export interface GetCasesOptions { + savedObjectsClient: SavedObjectsClientContract; + maxSize: number; + maxPerPage: number; + logger: Logger; +} + +export const getCaseComments = async ({ + savedObjectsClient, + maxSize, + maxPerPage, + logger, +}: GetCasesOptions): Promise>> => { + const query: SavedObjectsCreatePointInTimeFinderOptions = { + type: CASE_COMMENT_SAVED_OBJECT, + perPage: maxPerPage, + namespaces: ['*'], + filter: `${CASE_COMMENT_SAVED_OBJECT}.attributes.type: alert`, + }; + logger.debug(`Getting cases with point in time (PIT) query:', ${JSON.stringify(query)}`); + const finder = savedObjectsClient.createPointInTimeFinder(query); + let responses: Array> = []; + for await (const response of finder.find()) { + const extra = responses.length + response.saved_objects.length - maxSize; + if (extra > 0) { + responses = [ + ...responses, + ...response.saved_objects.slice(-response.saved_objects.length, -extra), + ]; + } else { + responses = [...responses, ...response.saved_objects]; + } + } + + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } + + logger.debug(`Returning cases response of length: "${responses.length}"`); + return responses; +}; diff --git a/x-pack/plugins/security_solution/server/usage/queries/get_detection_rules.ts b/x-pack/plugins/security_solution/server/usage/queries/get_detection_rules.ts new file mode 100644 index 0000000000000..62f5691f73d07 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/get_detection_rules.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + Logger, + SavedObjectsCreatePointInTimeFinderOptions, + SavedObjectsClientContract, + SavedObjectsFindResult, +} from 'kibana/server'; +import { + SIGNALS_ID, + EQL_RULE_TYPE_ID, + INDICATOR_RULE_TYPE_ID, + ML_RULE_TYPE_ID, + QUERY_RULE_TYPE_ID, + THRESHOLD_RULE_TYPE_ID, + SAVED_QUERY_RULE_TYPE_ID, +} from '@kbn/securitysolution-rules'; +import type { RuleSearchResult } from '../types'; + +export interface GetDetectionRulesOptions { + maxSize: number; + maxPerPage: number; + logger: Logger; + savedObjectsClient: SavedObjectsClientContract; +} + +export const getDetectionRules = async ({ + maxSize, + maxPerPage, + logger, + savedObjectsClient, +}: GetDetectionRulesOptions): Promise>> => { + const filterAttribute = 'alert.attributes.alertTypeId'; + const filter = [ + `${filterAttribute}: ${SIGNALS_ID}`, + `${filterAttribute}: ${EQL_RULE_TYPE_ID}`, + `${filterAttribute}: ${ML_RULE_TYPE_ID}`, + `${filterAttribute}: ${QUERY_RULE_TYPE_ID}`, + `${filterAttribute}: ${SAVED_QUERY_RULE_TYPE_ID}`, + `${filterAttribute}: ${THRESHOLD_RULE_TYPE_ID}`, + `${filterAttribute}: ${INDICATOR_RULE_TYPE_ID}`, + ].join(' OR '); + + const query: SavedObjectsCreatePointInTimeFinderOptions = { + type: 'alert', + perPage: maxPerPage, + namespaces: ['*'], + filter, + }; + logger.debug( + `Getting detection rules with point in time (PIT) query:', ${JSON.stringify(query)}` + ); + const finder = savedObjectsClient.createPointInTimeFinder(query); + let responses: Array> = []; + for await (const response of finder.find()) { + const extra = responses.length + response.saved_objects.length - maxSize; + if (extra > 0) { + responses = [ + ...responses, + ...response.saved_objects.slice(-response.saved_objects.length, -extra), + ]; + } else { + responses = [...responses, ...response.saved_objects]; + } + } + + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } + + logger.debug(`Returning cases response of length: "${responses.length}"`); + return responses; +}; diff --git a/x-pack/plugins/security_solution/server/usage/queries/legacy_get_rule_actions.ts b/x-pack/plugins/security_solution/server/usage/queries/legacy_get_rule_actions.ts new file mode 100644 index 0000000000000..6d720bef7d822 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/legacy_get_rule_actions.ts @@ -0,0 +1,73 @@ +/* + * 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 { + SavedObjectsClientContract, + SavedObjectsFindResult, + Logger, + SavedObjectsCreatePointInTimeFinderOptions, +} from 'kibana/server'; +// eslint-disable-next-line no-restricted-imports +import type { LegacyIRuleActionsAttributesSavedObjectAttributes } from '../../lib/detection_engine/rule_actions/legacy_types'; + +// eslint-disable-next-line no-restricted-imports +import { legacyRuleActionsSavedObjectType } from '../../lib/detection_engine/rule_actions/legacy_saved_object_mappings'; + +export interface LegacyGetRuleActionsOptions { + savedObjectsClient: SavedObjectsClientContract; + maxSize: number; + maxPerPage: number; + logger: Logger; +} + +/** + * Returns the legacy rule actions + * @deprecated Once we are confident all rules relying on side-car actions SO's have been migrated to SO references we should remove "legacyRuleActions" code including this function + */ +export const legacyGetRuleActions = async ({ + savedObjectsClient, + maxSize, + maxPerPage, + logger, +}: LegacyGetRuleActionsOptions) => { + const query: SavedObjectsCreatePointInTimeFinderOptions = { + type: legacyRuleActionsSavedObjectType, + perPage: maxPerPage, + namespaces: ['*'], + }; + logger.debug( + `Getting legacy rule actions with point in time (PIT) query:', ${JSON.stringify(query)}` + ); + const finder = + savedObjectsClient.createPointInTimeFinder( + query + ); + let responses: Array> = + []; + for await (const response of finder.find()) { + const extra = responses.length + response.saved_objects.length - maxSize; + if (extra > 0) { + responses = [ + ...responses, + ...response.saved_objects.slice(-response.saved_objects.length, -extra), + ]; + } else { + responses = [...responses, ...response.saved_objects]; + } + } + + try { + finder.close(); + } catch (exception) { + // This is just a pre-caution in case the finder does a throw we don't want to blow up + // the response. We have seen this within e2e test containers but nothing happen in normal + // operational conditions which is why this try/catch is here. + } + + logger.debug(`Returning legacy rule actions response of length: "${responses.length}"`); + return responses; +}; diff --git a/x-pack/plugins/security_solution/server/usage/queries/utils/fetch_hits_with_pit.ts b/x-pack/plugins/security_solution/server/usage/queries/utils/fetch_hits_with_pit.ts new file mode 100644 index 0000000000000..34dc545f9b8bf --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/utils/fetch_hits_with_pit.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { + OpenPointInTimeResponse, + SearchHit, + SortResults, +} from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { SearchRequest } from '@elastic/elasticsearch/lib/api/types'; +import type { ElasticsearchClient, Logger } from 'kibana/server'; + +export interface FetchWithPitOptions { + esClient: ElasticsearchClient; + index: string; + maxSize: number; + maxPerPage: number; + searchRequest: SearchRequest; + logger: Logger; +} + +export const fetchHitsWithPit = async ({ + esClient, + index, + searchRequest, + maxSize, + maxPerPage, + logger, +}: FetchWithPitOptions): Promise>> => { + // default is from looking at Kibana saved objects and online documentation + const keepAlive = '5m'; + + // create and assign an initial point in time + let pitId: OpenPointInTimeResponse['id'] = ( + await esClient.openPointInTime({ + index, + keep_alive: '5m', + }) + ).id; + + let searchAfter: SortResults | undefined; + let hits: Array> = []; + let fetchMore = true; + while (fetchMore) { + const ruleSearchOptions: SearchRequest = { + ...searchRequest, + track_total_hits: false, + search_after: searchAfter, + sort: [{ _shard_doc: 'desc' }] as unknown as string[], // TODO: Remove this "unknown" once it is typed correctly https://github.com/elastic/elasticsearch-js/issues/1589 + pit: { id: pitId }, + size: Math.min(maxPerPage, maxSize - hits.length), + }; + logger.debug( + `Getting hits with point in time (PIT) query of: ${JSON.stringify(ruleSearchOptions)}` + ); + const body = await esClient.search(ruleSearchOptions); + hits = [...hits, ...body.hits.hits]; + searchAfter = + body.hits.hits.length !== 0 ? body.hits.hits[body.hits.hits.length - 1].sort : undefined; + + fetchMore = searchAfter != null && body.hits.hits.length > 0 && hits.length < maxSize; + if (body.pit_id != null) { + pitId = body.pit_id; + } + } + try { + await esClient.closePointInTime({ id: pitId }); + } catch (error) { + // Don't fail due to a bad point in time closure. We have seen failures in e2e tests during nominal operations. + logger.warn( + `Error trying to close point in time: "${pitId}", it will expire within "${keepAlive}". Error is: "${error}"` + ); + } + logger.debug(`Returning hits with point in time (PIT) length of: ${hits.length}`); + return hits; +}; diff --git a/x-pack/plugins/security_solution/server/usage/queries/utils/is_elastic_rule.ts b/x-pack/plugins/security_solution/server/usage/queries/utils/is_elastic_rule.ts new file mode 100644 index 0000000000000..f08959702b290 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/queries/utils/is_elastic_rule.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { INTERNAL_IMMUTABLE_KEY } from '../../../../common/constants'; + +export const isElasticRule = (tags: string[] = []) => + tags.includes(`${INTERNAL_IMMUTABLE_KEY}:true`); diff --git a/x-pack/plugins/security_solution/server/usage/types.ts b/x-pack/plugins/security_solution/server/usage/types.ts index 1a3b5d1e2e29f..f591ffd8f422e 100644 --- a/x-pack/plugins/security_solution/server/usage/types.ts +++ b/x-pack/plugins/security_solution/server/usage/types.ts @@ -5,11 +5,49 @@ * 2.0. */ -import { CoreSetup } from 'src/core/server'; -import { SetupPlugins } from '../plugin'; +import type { CoreSetup, Logger } from 'src/core/server'; +import type { SanitizedAlert } from '../../../alerting/common/alert'; +import type { RuleParams } from '../lib/detection_engine/schemas/rule_schemas'; +import type { SetupPlugins } from '../plugin'; export type CollectorDependencies = { - kibanaIndex: string; signalsIndex: string; core: CoreSetup; + logger: Logger; } & Pick; + +export interface AlertBucket { + key: { + detectionAlerts: string; + }; + doc_count: number; +} + +export interface AlertAggs { + buckets?: { + after_key?: { + detectionAlerts: string; + }; + buckets: AlertBucket[]; + }; +} + +/** + * This type is _very_ similar to "RawRule". However, that type is not exposed in a non-restricted-path + * and it does not support generics well. Trying to use "RawRule" directly with TypeScript Omit does not work well. + * If at some point the rules client API supports cross spaces for gathering metrics, then we should remove our use + * of SavedObject types and this type below and instead natively use the rules client. + * + * NOTE: There are additional types not expressed below such as "apiKey" or there could be other slight differences + * but this will the easiest way to keep these in sync and I see other code that is similar to this pattern. + * {@see RawRule} + */ +export type RuleSearchResult = Omit< + SanitizedAlert, + 'createdBy' | 'updatedBy' | 'createdAt' | 'updatedAt' +> & { + createdBy: string | null; + updatedBy: string | null; + createdAt: string; + updatedAt: string; +}; diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/all_types.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/all_types.ts index a8d473597a461..29baea4e4bd90 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/all_types.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/all_types.ts @@ -6,14 +6,14 @@ */ import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex, getStats, } from '../../../../utils'; -import { getInitialDetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/detection_rule_helpers'; +import { getInitialDetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/get_initial_usage'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/detection_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/detection_rules.ts index 8a956d456edec..b93141a1ffe73 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/detection_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/telemetry/usage_collector/detection_rules.ts @@ -6,12 +6,12 @@ */ import expect from '@kbn/expect'; -import { DetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/types'; -import { +import type { DetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/types'; +import type { ThreatMatchCreateSchema, ThresholdCreateSchema, } from '../../../../../../plugins/security_solution/common/detection_engine/schemas/request'; -import { FtrProviderContext } from '../../../../common/ftr_provider_context'; +import type { FtrProviderContext } from '../../../../common/ftr_provider_context'; import { createLegacyRuleAction, createNewAction, @@ -33,7 +33,7 @@ import { waitForSignalsToBePresent, updateRule, } from '../../../../utils'; -import { getInitialDetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/detection_rule_helpers'; +import { getInitialDetectionMetrics } from '../../../../../../plugins/security_solution/server/usage/detections/get_initial_usage'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext) => { From c961b4b1f830169c6f76c9b06d3d4346c9771add Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 15 Feb 2022 12:58:11 -0700 Subject: [PATCH 41/43] [ML] Data Frame Analytics saved search creation functional tests (#125040) * create filter saved search test for each dfa job type * add search query test cases * temp comment of failing with insuff memory test case * remove problem case and update test text * Use downsampled farequote archive * remove unnecessary file Co-authored-by: Robert Oskamp --- .../classification_creation_saved_search.ts | 363 +++++++++++++++++ .../apps/ml/data_frame_analytics/index.ts | 3 + ...outlier_detection_creation_saved_search.ts | 377 ++++++++++++++++++ .../regression_creation_saved_search.ts | 333 ++++++++++++++++ x-pack/test/functional/apps/ml/index.ts | 1 + .../ml/farequote_small/data.json.gz | Bin 0 -> 63274 bytes .../ml/farequote_small/mappings.json | 48 +++ .../functional/services/ml/test_resources.ts | 34 +- .../services/ml/test_resources_data.ts | 5 - 9 files changed, 1147 insertions(+), 17 deletions(-) create mode 100644 x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts create mode 100644 x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts create mode 100644 x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts create mode 100644 x-pack/test/functional/es_archives/ml/farequote_small/data.json.gz create mode 100644 x-pack/test/functional/es_archives/ml/farequote_small/mappings.json diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts new file mode 100644 index 0000000000000..67550ae17a4b0 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts @@ -0,0 +1,363 @@ +/* + * 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 { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + const editedDescription = 'Edited description'; + + describe('classification saved search creation', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote_small'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote_small', '@timestamp'); + await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded('ft_farequote_small'); + await ml.testResources.createSavedSearchFarequoteKueryIfNeeded('ft_farequote_small'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.testResources.deleteSavedSearches(); + await ml.testResources.deleteIndexPatternByTitle('ft_farequote_small'); + }); + + const dateNow = Date.now(); + const testDataList = [ + { + suiteTitle: 'with lucene query', + jobType: 'classification', + jobId: `fq_saved_search_2_${dateNow}`, + jobDescription: 'Classification job based on a saved search with lucene query', + source: 'ft_farequote_lucene', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + dependentVariable: 'airline', + trainingPercent: 20, + modelMemory: '20mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + rocCurveColorState: [ + // tick/grid/axis + { color: '#DDDDDD', percentage: 38 }, + // line + { color: '#98A2B3', percentage: 7 }, + ], + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'classification', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_2_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":320,"test_docs_count":1284,"skipped_docs_count":0}', + description: 'Classification job based on a saved search with lucene query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '8/8' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + { + suiteTitle: 'with kuery query', + jobType: 'classification', + jobId: `fq_saved_search_3_${dateNow}`, + jobDescription: 'Classification job based on a saved search with kuery query', + source: 'ft_farequote_kuery', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + dependentVariable: 'airline', + trainingPercent: 20, + modelMemory: '20mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + rocCurveColorState: [ + // tick/grid/axis + { color: '#DDDDDD', percentage: 38 }, + // line + { color: '#98A2B3', percentage: 7 }, + ], + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'classification', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_3_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":320,"test_docs_count":1283,"skipped_docs_count":0}', + description: 'Classification job based on a saved search with kuery query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '8/8' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + ]; + + for (const testData of testDataList) { + describe(`${testData.suiteTitle}`, function () { + after(async () => { + await ml.api.deleteIndices(testData.destinationIndex); + await ml.testResources.deleteIndexPatternByTitle(testData.destinationIndex); + }); + + it('loads the data frame analytics wizard', async () => { + await ml.testExecution.logTestStep('loads the data frame analytics page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataFrameAnalytics(); + + await ml.testExecution.logTestStep('loads the source selection modal'); + + // Disable anti-aliasing to stabilize canvas image rendering assertions + await ml.commonUI.disableAntiAliasing(); + + await ml.dataFrameAnalytics.startAnalyticsCreation(); + + await ml.testExecution.logTestStep( + 'selects the source data and loads the job wizard page' + ); + await ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + await ml.dataFrameAnalyticsCreation.assertConfigurationStepActive(); + }); + + it('navigates through the wizard and sets all needed fields', async () => { + await ml.testExecution.logTestStep('selects the job type'); + await ml.dataFrameAnalyticsCreation.assertJobTypeSelectExists(); + await ml.dataFrameAnalyticsCreation.selectJobType(testData.jobType); + + await ml.testExecution.logTestStep('displays the runtime mappings editor switch'); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingSwitchExists(); + + await ml.testExecution.logTestStep('enables the runtime mappings editor'); + await ml.dataFrameAnalyticsCreation.toggleRuntimeMappingsEditorSwitch(true); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent(['']); + + await ml.testExecution.logTestStep('sets runtime mappings'); + await ml.dataFrameAnalyticsCreation.setRuntimeMappingsEditorContent( + JSON.stringify(testData.runtimeFields) + ); + await ml.dataFrameAnalyticsCreation.applyRuntimeMappings(); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent( + testData.expected.runtimeFieldsEditorContent + ); + + await ml.testExecution.logTestStep('inputs the dependent variable'); + await ml.dataFrameAnalyticsCreation.assertDependentVariableInputExists(); + await ml.dataFrameAnalyticsCreation.selectDependentVariable(testData.dependentVariable); + + await ml.testExecution.logTestStep('inputs the training percent'); + await ml.dataFrameAnalyticsCreation.assertTrainingPercentInputExists(); + await ml.dataFrameAnalyticsCreation.setTrainingPercent(testData.trainingPercent); + + await ml.testExecution.logTestStep('displays the source data preview'); + await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewExists(); + + await ml.testExecution.logTestStep('displays the include fields selection'); + await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + + await ml.testExecution.logTestStep('continues to the additional options step'); + await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); + + await ml.testExecution.logTestStep('accepts the suggested model memory limit'); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputPopulated(); + + await ml.testExecution.logTestStep('continues to the details step'); + await ml.dataFrameAnalyticsCreation.continueToDetailsStep(); + + await ml.testExecution.logTestStep('inputs the job id'); + await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); + await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); + + await ml.testExecution.logTestStep('inputs the job description'); + await ml.dataFrameAnalyticsCreation.assertJobDescriptionInputExists(); + await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); + + await ml.testExecution.logTestStep( + 'should default the set destination index to job id switch to true' + ); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists(); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true); + + await ml.testExecution.logTestStep('should input the destination index'); + await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false); + await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); + await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); + + await ml.testExecution.logTestStep('continues to the validation step'); + await ml.dataFrameAnalyticsCreation.continueToValidationStep(); + + await ml.testExecution.logTestStep('checks validation callouts exist'); + await ml.dataFrameAnalyticsCreation.assertValidationCalloutsExists(); + // Expect the follow callouts: + // - ✓ Dependent variable + // - ✓ Training percent + // - ✓ Top classes + // - ⚠ Analysis fields + await ml.dataFrameAnalyticsCreation.assertAllValidationCalloutsPresent(4); + + await ml.testExecution.logTestStep('continues to the create step'); + await ml.dataFrameAnalyticsCreation.continueToCreateStep(); + + await ml.testExecution.logTestStep('sets the create data view switch'); + await ml.dataFrameAnalyticsCreation.assertCreateIndexPatternSwitchExists(); + await ml.dataFrameAnalyticsCreation.setCreateIndexPatternSwitchState( + testData.createIndexPattern + ); + }); + + it('runs the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep('creates and starts the analytics job'); + await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); + await ml.dataFrameAnalyticsCreation.assertStartJobCheckboxCheckState(true); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); + + await ml.testExecution.logTestStep('finishes analytics processing'); + await ml.dataFrameAnalytics.waitForAnalyticsCompletion(testData.jobId); + + await ml.testExecution.logTestStep('displays the analytics table'); + await ml.dataFrameAnalyticsCreation.navigateToJobManagementPage(); + await ml.dataFrameAnalytics.assertAnalyticsTableExists(); + + await ml.testExecution.logTestStep('displays the stats bar'); + await ml.dataFrameAnalytics.assertAnalyticsStatsBarExists(); + + await ml.testExecution.logTestStep('displays the created job in the analytics table'); + await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); + await ml.dataFrameAnalyticsTable.filterWithSearchString(testData.jobId, 1); + + await ml.testExecution.logTestStep( + 'displays details for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: testData.jobDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + + await ml.dataFrameAnalyticsTable.assertAnalyticsRowDetails( + testData.jobId, + testData.expected.rowDetails + ); + }); + + it('edits the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep( + 'should open the edit form for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.openEditFlyout(testData.jobId); + + await ml.testExecution.logTestStep('should input the description in the edit form'); + await ml.dataFrameAnalyticsEdit.assertJobDescriptionEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobDescriptionEdit(editedDescription); + + await ml.testExecution.logTestStep( + 'should input the model memory limit in the edit form' + ); + await ml.dataFrameAnalyticsEdit.assertJobMmlEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobMmlEdit('21mb'); + + await ml.testExecution.logTestStep('should submit the edit job form'); + await ml.dataFrameAnalyticsEdit.updateAnalyticsJob(); + + await ml.testExecution.logTestStep( + 'displays details for the edited job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: editedDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + + await ml.testExecution.logTestStep( + 'creates the destination index and writes results to it' + ); + await ml.api.assertIndicesExist(testData.destinationIndex); + await ml.api.assertIndicesNotEmpty(testData.destinationIndex); + + await ml.testExecution.logTestStep('displays the results view for created job'); + await ml.dataFrameAnalyticsTable.openResultsView(testData.jobId); + await ml.dataFrameAnalyticsResults.assertClassificationEvaluatePanelElementsExists(); + await ml.dataFrameAnalyticsResults.assertClassificationTablePanelExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); + await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + + await ml.testExecution.logTestStep('displays the ROC curve chart'); + await ml.commonUI.assertColorsInCanvasElement( + 'mlDFAnalyticsClassificationExplorationRocCurveChart', + testData.expected.rocCurveColorState, + ['#000000'], + undefined, + undefined, + // increased tolerance for ROC curve chart up from 10 to 20 + // since the returned colors vary quite a bit on each run. + 20 + ); + + await ml.commonUI.resetAntiAliasing(); + }); + + it('displays the analytics job in the map view', async () => { + await ml.testExecution.logTestStep('should open the map view for created job'); + await ml.navigation.navigateToDataFrameAnalytics(); + await ml.dataFrameAnalyticsTable.openMapView(testData.jobId); + await ml.dataFrameAnalyticsMap.assertMapElementsExists(); + await ml.dataFrameAnalyticsMap.assertJobMapTitle(testData.jobId); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts index e7b5df70c99a0..908e45daf7105 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts @@ -16,5 +16,8 @@ export default function ({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./classification_creation')); loadTestFile(require.resolve('./cloning')); loadTestFile(require.resolve('./feature_importance')); + loadTestFile(require.resolve('./regression_creation_saved_search')); + loadTestFile(require.resolve('./classification_creation_saved_search')); + loadTestFile(require.resolve('./outlier_detection_creation_saved_search')); }); } diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts new file mode 100644 index 0000000000000..861be18591a11 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts @@ -0,0 +1,377 @@ +/* + * 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 { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + const editedDescription = 'Edited description'; + + describe('outlier detection saved search creation', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote_small'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote_small', '@timestamp'); + await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded('ft_farequote_small'); + await ml.testResources.createSavedSearchFarequoteKueryIfNeeded('ft_farequote_small'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.testResources.deleteSavedSearches(); + await ml.testResources.deleteIndexPatternByTitle('ft_farequote_small'); + }); + + const dateNow = Date.now(); + const testDataList = [ + { + suiteTitle: 'with lucene query', + jobType: 'outlier_detection', + jobId: `fq_saved_search_2_${dateNow}`, + jobDescription: 'Outlier detection job based on a saved search with lucene query', + source: 'ft_farequote_lucene', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + modelMemory: '65mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + histogramCharts: [ + { chartAvailable: true, id: 'uppercase_airline', legend: '5 categories' }, + { chartAvailable: true, id: 'responsetime', legend: '4.91 - 171.08' }, + { chartAvailable: true, id: 'airline', legend: '5 categories' }, + ], + scatterplotMatrixColorsWizard: [ + // markers + { color: '#52B398', percentage: 15 }, + // grey boilerplate + { color: '#6A717D', percentage: 13 }, + ], + scatterplotMatrixColorStatsResults: [ + // red markers + { color: '#D98071', percentage: 1 }, + // tick/grid/axis, grey markers + { color: '#6A717D', percentage: 12 }, + { color: '#D3DAE6', percentage: 8 }, + { color: '#98A1B3', percentage: 12 }, + // anti-aliasing + { color: '#F5F7FA', percentage: 30 }, + ], + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'outlier_detection', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_2_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":1604,"test_docs_count":0,"skipped_docs_count":0}', + description: 'Outlier detection job based on a saved search with lucene query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '4/4' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + { + suiteTitle: 'with kuery query', + jobType: 'outlier_detection', + jobId: `fq_saved_search_3_${dateNow}`, + jobDescription: 'Outlier detection job based on a saved search with kuery query', + source: 'ft_farequote_kuery', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + modelMemory: '65mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + histogramCharts: [ + { chartAvailable: true, id: 'uppercase_airline', legend: '5 categories' }, + { chartAvailable: true, id: 'responsetime', legend: '9.91 - 171.08' }, + { chartAvailable: true, id: 'airline', legend: '5 categories' }, + ], + scatterplotMatrixColorsWizard: [ + // markers + { color: '#52B398', percentage: 15 }, + // grey boilerplate + { color: '#6A717D', percentage: 13 }, + ], + scatterplotMatrixColorStatsResults: [ + // red markers + { color: '#D98071', percentage: 1 }, + // tick/grid/axis, grey markers + { color: '#6A717D', percentage: 12 }, + { color: '#D3DAE6', percentage: 8 }, + { color: '#98A1B3', percentage: 12 }, + // anti-aliasing + { color: '#F5F7FA', percentage: 30 }, + ], + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'outlier_detection', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_3_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":1603,"test_docs_count":0,"skipped_docs_count":0}', + description: 'Outlier detection job based on a saved search with kuery query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '4/4' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + ]; + + for (const testData of testDataList) { + describe(`${testData.suiteTitle}`, function () { + after(async () => { + await ml.api.deleteIndices(testData.destinationIndex); + await ml.testResources.deleteIndexPatternByTitle(testData.destinationIndex); + }); + + it('loads the data frame analytics wizard', async () => { + await ml.testExecution.logTestStep('loads the data frame analytics page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataFrameAnalytics(); + + await ml.testExecution.logTestStep('loads the source selection modal'); + + // Disable anti-aliasing to stabilize canvas image rendering assertions + await ml.commonUI.disableAntiAliasing(); + + await ml.dataFrameAnalytics.startAnalyticsCreation(); + + await ml.testExecution.logTestStep( + 'selects the source data and loads the job wizard page' + ); + await ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + await ml.dataFrameAnalyticsCreation.assertConfigurationStepActive(); + }); + + it('navigates through the wizard and sets all needed fields', async () => { + await ml.testExecution.logTestStep('selects the job type'); + await ml.dataFrameAnalyticsCreation.assertJobTypeSelectExists(); + await ml.dataFrameAnalyticsCreation.selectJobType(testData.jobType); + + await ml.testExecution.logTestStep('displays the runtime mappings editor switch'); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingSwitchExists(); + + await ml.testExecution.logTestStep('enables the runtime mappings editor'); + await ml.dataFrameAnalyticsCreation.toggleRuntimeMappingsEditorSwitch(true); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent(['']); + + await ml.testExecution.logTestStep('sets runtime mappings'); + await ml.dataFrameAnalyticsCreation.setRuntimeMappingsEditorContent( + JSON.stringify(testData.runtimeFields) + ); + await ml.dataFrameAnalyticsCreation.applyRuntimeMappings(); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent( + testData.expected.runtimeFieldsEditorContent + ); + + await ml.testExecution.logTestStep('does not display the dependent variable input'); + await ml.dataFrameAnalyticsCreation.assertDependentVariableInputMissing(); + + await ml.testExecution.logTestStep('does not display the training percent input'); + await ml.dataFrameAnalyticsCreation.assertTrainingPercentInputMissing(); + + await ml.testExecution.logTestStep('displays the source data preview'); + await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewExists(); + + await ml.testExecution.logTestStep('enables the source data preview histogram charts'); + await ml.dataFrameAnalyticsCreation.enableSourceDataPreviewHistogramCharts(true); + + await ml.testExecution.logTestStep('displays the source data preview histogram charts'); + await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewHistogramCharts( + testData.expected.histogramCharts + ); + + await ml.testExecution.logTestStep('displays the include fields selection'); + await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + + await ml.testExecution.logTestStep('continues to the additional options step'); + await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); + + await ml.testExecution.logTestStep('accepts the suggested model memory limit'); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputPopulated(); + + await ml.testExecution.logTestStep('continues to the details step'); + await ml.dataFrameAnalyticsCreation.continueToDetailsStep(); + + await ml.testExecution.logTestStep('inputs the job id'); + await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); + await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); + + await ml.testExecution.logTestStep('inputs the job description'); + await ml.dataFrameAnalyticsCreation.assertJobDescriptionInputExists(); + await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); + + await ml.testExecution.logTestStep( + 'should default the set destination index to job id switch to true' + ); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists(); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true); + + await ml.testExecution.logTestStep('should input the destination index'); + await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false); + await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); + await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); + + await ml.testExecution.logTestStep('continues to the validation step'); + await ml.dataFrameAnalyticsCreation.continueToValidationStep(); + + await ml.testExecution.logTestStep('checks validation callouts exist'); + await ml.dataFrameAnalyticsCreation.assertValidationCalloutsExists(); + await ml.dataFrameAnalyticsCreation.assertAllValidationCalloutsPresent(1); + + await ml.testExecution.logTestStep('continues to the create step'); + await ml.dataFrameAnalyticsCreation.continueToCreateStep(); + + await ml.testExecution.logTestStep('sets the create data view switch'); + await ml.dataFrameAnalyticsCreation.assertCreateIndexPatternSwitchExists(); + await ml.dataFrameAnalyticsCreation.setCreateIndexPatternSwitchState( + testData.createIndexPattern + ); + }); + + it('runs the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep('creates and starts the analytics job'); + await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); + await ml.dataFrameAnalyticsCreation.assertStartJobCheckboxCheckState(true); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); + + await ml.testExecution.logTestStep('finishes analytics processing'); + await ml.dataFrameAnalytics.waitForAnalyticsCompletion(testData.jobId); + + await ml.testExecution.logTestStep('displays the analytics table'); + await ml.dataFrameAnalyticsCreation.navigateToJobManagementPage(); + await ml.dataFrameAnalytics.assertAnalyticsTableExists(); + + await ml.testExecution.logTestStep('displays the stats bar'); + await ml.dataFrameAnalytics.assertAnalyticsStatsBarExists(); + + await ml.testExecution.logTestStep('displays the created job in the analytics table'); + await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); + await ml.dataFrameAnalyticsTable.filterWithSearchString(testData.jobId, 1); + + await ml.testExecution.logTestStep( + 'displays details for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: testData.jobDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + + await ml.dataFrameAnalyticsTable.assertAnalyticsRowDetails( + testData.jobId, + testData.expected.rowDetails + ); + }); + + it('edits the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep( + 'should open the edit form for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.openEditFlyout(testData.jobId); + + await ml.testExecution.logTestStep('should input the description in the edit form'); + await ml.dataFrameAnalyticsEdit.assertJobDescriptionEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobDescriptionEdit(editedDescription); + + await ml.testExecution.logTestStep( + 'should input the model memory limit in the edit form' + ); + await ml.dataFrameAnalyticsEdit.assertJobMmlEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobMmlEdit('21mb'); + + await ml.testExecution.logTestStep('should submit the edit job form'); + await ml.dataFrameAnalyticsEdit.updateAnalyticsJob(); + + await ml.testExecution.logTestStep( + 'displays details for the edited job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: editedDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + + await ml.testExecution.logTestStep( + 'creates the destination index and writes results to it' + ); + await ml.api.assertIndicesExist(testData.destinationIndex); + await ml.api.assertIndicesNotEmpty(testData.destinationIndex); + + await ml.testExecution.logTestStep('displays the results view for created job'); + await ml.dataFrameAnalyticsTable.openResultsView(testData.jobId); + await ml.dataFrameAnalyticsResults.assertOutlierTablePanelExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + await ml.dataFrameAnalyticsResults.assertFeatureInfluenceCellNotEmpty(); + + await ml.commonUI.resetAntiAliasing(); + }); + + it('displays the analytics job in the map view', async () => { + await ml.testExecution.logTestStep('should open the map view for created job'); + await ml.navigation.navigateToDataFrameAnalytics(); + await ml.dataFrameAnalyticsTable.openMapView(testData.jobId); + await ml.dataFrameAnalyticsMap.assertMapElementsExists(); + await ml.dataFrameAnalyticsMap.assertJobMapTitle(testData.jobId); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts new file mode 100644 index 0000000000000..e22c4908486d1 --- /dev/null +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.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 { AnalyticsTableRowDetails } from '../../../services/ml/data_frame_analytics_table'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const ml = getService('ml'); + const editedDescription = 'Edited description'; + + describe('regression saved search creation', function () { + before(async () => { + await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote_small'); + await ml.testResources.createIndexPatternIfNeeded('ft_farequote_small', '@timestamp'); + await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded('ft_farequote_small'); + await ml.testResources.createSavedSearchFarequoteKueryIfNeeded('ft_farequote_small'); + await ml.testResources.setKibanaTimeZoneToUTC(); + + await ml.securityUI.loginAsMlPowerUser(); + }); + + after(async () => { + await ml.api.cleanMlIndices(); + await ml.testResources.deleteSavedSearches(); + await ml.testResources.deleteIndexPatternByTitle('ft_farequote_small'); + }); + + const dateNow = Date.now(); + const testDataList = [ + { + suiteTitle: 'with lucene query', + jobType: 'regression', + jobId: `fq_saved_search_2_${dateNow}`, + jobDescription: 'Regression job based on a saved search with lucene query', + source: 'ft_farequote_lucene', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + dependentVariable: 'responsetime', + trainingPercent: 20, + modelMemory: '20mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'regression', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_2_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":320,"test_docs_count":1284,"skipped_docs_count":0}', + description: 'Regression job based on a saved search with lucene query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '8/8' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + { + suiteTitle: 'with kuery query', + jobType: 'regression', + jobId: `fq_saved_search_3_${dateNow}`, + jobDescription: 'Regression job based on a saved search with kuery query', + source: 'ft_farequote_kuery', + get destinationIndex(): string { + return `user-${this.jobId}`; + }, + runtimeFields: { + uppercase_airline: { + type: 'keyword', + script: 'emit(params._source.airline.toUpperCase())', + }, + }, + dependentVariable: 'responsetime', + trainingPercent: 20, + modelMemory: '20mb', + createIndexPattern: true, + expected: { + source: 'ft_farequote_small', + runtimeFieldsEditorContent: ['{', ' "uppercase_airline": {', ' "type": "keyword",'], + row: { + memoryStatus: 'ok', + type: 'regression', + status: 'stopped', + progress: '100', + }, + rowDetails: { + jobDetails: [ + { + section: 'state', + expectedEntries: { + id: `fq_saved_search_3_${dateNow}`, + state: 'stopped', + data_counts: + '{"training_docs_count":320,"test_docs_count":1283,"skipped_docs_count":0}', + description: 'Regression job based on a saved search with kuery query', + }, + }, + { section: 'progress', expectedEntries: { Phase: '8/8' } }, + ], + } as AnalyticsTableRowDetails, + }, + }, + ]; + + for (const testData of testDataList) { + describe(`${testData.suiteTitle}`, function () { + after(async () => { + await ml.api.deleteIndices(testData.destinationIndex); + await ml.testResources.deleteIndexPatternByTitle(testData.destinationIndex); + }); + + it('loads the data frame analytics wizard', async () => { + await ml.testExecution.logTestStep('loads the data frame analytics page'); + await ml.navigation.navigateToMl(); + await ml.navigation.navigateToDataFrameAnalytics(); + + await ml.testExecution.logTestStep('loads the source selection modal'); + + // Disable anti-aliasing to stabilize canvas image rendering assertions + await ml.commonUI.disableAntiAliasing(); + + await ml.dataFrameAnalytics.startAnalyticsCreation(); + + await ml.testExecution.logTestStep( + 'selects the source data and loads the job wizard page' + ); + await ml.jobSourceSelection.selectSourceForAnalyticsJob(testData.source); + await ml.dataFrameAnalyticsCreation.assertConfigurationStepActive(); + }); + + it('navigates through the wizard and sets all needed fields', async () => { + await ml.testExecution.logTestStep('selects the job type'); + await ml.dataFrameAnalyticsCreation.assertJobTypeSelectExists(); + await ml.dataFrameAnalyticsCreation.selectJobType(testData.jobType); + + await ml.testExecution.logTestStep('displays the runtime mappings editor switch'); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingSwitchExists(); + + await ml.testExecution.logTestStep('enables the runtime mappings editor'); + await ml.dataFrameAnalyticsCreation.toggleRuntimeMappingsEditorSwitch(true); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent(['']); + + await ml.testExecution.logTestStep('sets runtime mappings'); + await ml.dataFrameAnalyticsCreation.setRuntimeMappingsEditorContent( + JSON.stringify(testData.runtimeFields) + ); + await ml.dataFrameAnalyticsCreation.applyRuntimeMappings(); + await ml.dataFrameAnalyticsCreation.assertRuntimeMappingsEditorContent( + testData.expected.runtimeFieldsEditorContent + ); + + await ml.testExecution.logTestStep('inputs the dependent variable'); + await ml.dataFrameAnalyticsCreation.assertDependentVariableInputExists(); + await ml.dataFrameAnalyticsCreation.selectDependentVariable(testData.dependentVariable); + + await ml.testExecution.logTestStep('inputs the training percent'); + await ml.dataFrameAnalyticsCreation.assertTrainingPercentInputExists(); + await ml.dataFrameAnalyticsCreation.setTrainingPercent(testData.trainingPercent); + + await ml.testExecution.logTestStep('displays the source data preview'); + await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewExists(); + + await ml.testExecution.logTestStep('displays the include fields selection'); + await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); + + await ml.testExecution.logTestStep('continues to the additional options step'); + await ml.dataFrameAnalyticsCreation.continueToAdditionalOptionsStep(); + + await ml.testExecution.logTestStep('accepts the suggested model memory limit'); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputExists(); + await ml.dataFrameAnalyticsCreation.assertModelMemoryInputPopulated(); + + await ml.testExecution.logTestStep('continues to the details step'); + await ml.dataFrameAnalyticsCreation.continueToDetailsStep(); + + await ml.testExecution.logTestStep('inputs the job id'); + await ml.dataFrameAnalyticsCreation.assertJobIdInputExists(); + await ml.dataFrameAnalyticsCreation.setJobId(testData.jobId); + + await ml.testExecution.logTestStep('inputs the job description'); + await ml.dataFrameAnalyticsCreation.assertJobDescriptionInputExists(); + await ml.dataFrameAnalyticsCreation.setJobDescription(testData.jobDescription); + + await ml.testExecution.logTestStep( + 'should default the set destination index to job id switch to true' + ); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdSwitchExists(); + await ml.dataFrameAnalyticsCreation.assertDestIndexSameAsIdCheckState(true); + + await ml.testExecution.logTestStep('should input the destination index'); + await ml.dataFrameAnalyticsCreation.setDestIndexSameAsIdCheckState(false); + await ml.dataFrameAnalyticsCreation.assertDestIndexInputExists(); + await ml.dataFrameAnalyticsCreation.setDestIndex(testData.destinationIndex); + + await ml.testExecution.logTestStep('continues to the validation step'); + await ml.dataFrameAnalyticsCreation.continueToValidationStep(); + + await ml.testExecution.logTestStep('checks validation callouts exist'); + await ml.dataFrameAnalyticsCreation.assertValidationCalloutsExists(); + await ml.dataFrameAnalyticsCreation.assertAllValidationCalloutsPresent(3); + + await ml.testExecution.logTestStep('continues to the create step'); + await ml.dataFrameAnalyticsCreation.continueToCreateStep(); + + await ml.testExecution.logTestStep('sets the create data view switch'); + await ml.dataFrameAnalyticsCreation.assertCreateIndexPatternSwitchExists(); + await ml.dataFrameAnalyticsCreation.setCreateIndexPatternSwitchState( + testData.createIndexPattern + ); + }); + + it('runs the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep('creates and starts the analytics job'); + await ml.dataFrameAnalyticsCreation.assertCreateButtonExists(); + await ml.dataFrameAnalyticsCreation.assertStartJobCheckboxCheckState(true); + await ml.dataFrameAnalyticsCreation.createAnalyticsJob(testData.jobId); + + await ml.testExecution.logTestStep('finishes analytics processing'); + await ml.dataFrameAnalytics.waitForAnalyticsCompletion(testData.jobId); + + await ml.testExecution.logTestStep('displays the analytics table'); + await ml.dataFrameAnalyticsCreation.navigateToJobManagementPage(); + await ml.dataFrameAnalytics.assertAnalyticsTableExists(); + + await ml.testExecution.logTestStep('displays the stats bar'); + await ml.dataFrameAnalytics.assertAnalyticsStatsBarExists(); + + await ml.testExecution.logTestStep('displays the created job in the analytics table'); + await ml.dataFrameAnalyticsTable.refreshAnalyticsTable(); + await ml.dataFrameAnalyticsTable.filterWithSearchString(testData.jobId, 1); + + await ml.testExecution.logTestStep( + 'displays details for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: testData.jobDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowDetails( + testData.jobId, + testData.expected.rowDetails + ); + }); + + it('edits the analytics job and displays it correctly in the job list', async () => { + await ml.testExecution.logTestStep( + 'should open the edit form for the created job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.openEditFlyout(testData.jobId); + + await ml.testExecution.logTestStep('should input the description in the edit form'); + await ml.dataFrameAnalyticsEdit.assertJobDescriptionEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobDescriptionEdit(editedDescription); + + await ml.testExecution.logTestStep( + 'should input the model memory limit in the edit form' + ); + await ml.dataFrameAnalyticsEdit.assertJobMmlEditInputExists(); + await ml.dataFrameAnalyticsEdit.setJobMmlEdit('21mb'); + + await ml.testExecution.logTestStep('should submit the edit job form'); + await ml.dataFrameAnalyticsEdit.updateAnalyticsJob(); + + await ml.testExecution.logTestStep( + 'displays details for the edited job in the analytics table' + ); + await ml.dataFrameAnalyticsTable.assertAnalyticsRowFields(testData.jobId, { + id: testData.jobId, + description: editedDescription, + memoryStatus: testData.expected.row.memoryStatus, + sourceIndex: testData.expected.source, + destinationIndex: testData.destinationIndex, + type: testData.expected.row.type, + status: testData.expected.row.status, + progress: testData.expected.row.progress, + }); + + await ml.testExecution.logTestStep( + 'creates the destination index and writes results to it' + ); + await ml.api.assertIndicesExist(testData.destinationIndex); + await ml.api.assertIndicesNotEmpty(testData.destinationIndex); + + await ml.testExecution.logTestStep('displays the results view for created job'); + await ml.dataFrameAnalyticsTable.openResultsView(testData.jobId); + await ml.dataFrameAnalyticsResults.assertRegressionEvaluatePanelElementsExists(); + await ml.dataFrameAnalyticsResults.assertRegressionTablePanelExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableExists(); + await ml.dataFrameAnalyticsResults.assertResultsTableTrainingFiltersExist(); + await ml.dataFrameAnalyticsResults.assertResultsTableNotEmpty(); + + await ml.commonUI.resetAntiAliasing(); + }); + + it('displays the analytics job in the map view', async () => { + await ml.testExecution.logTestStep('should open the map view for created job'); + await ml.navigation.navigateToDataFrameAnalytics(); + await ml.dataFrameAnalyticsTable.openMapView(testData.jobId); + await ml.dataFrameAnalyticsMap.assertMapElementsExists(); + await ml.dataFrameAnalyticsMap.assertJobMapTitle(testData.jobId); + }); + }); + } + }); +} diff --git a/x-pack/test/functional/apps/ml/index.ts b/x-pack/test/functional/apps/ml/index.ts index eeae200f35ba7..c58b20e1c374b 100644 --- a/x-pack/test/functional/apps/ml/index.ts +++ b/x-pack/test/functional/apps/ml/index.ts @@ -25,6 +25,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await ml.securityCommon.cleanMlRoles(); await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote'); + await esArchiver.unload('x-pack/test/functional/es_archives/ml/farequote_small'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/ecommerce'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/categorization_small'); await esArchiver.unload('x-pack/test/functional/es_archives/ml/event_rate_nanos'); diff --git a/x-pack/test/functional/es_archives/ml/farequote_small/data.json.gz b/x-pack/test/functional/es_archives/ml/farequote_small/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..7c82fda817373e596e4e847ebfa642bba10ec5a3 GIT binary patch literal 63274 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

D1(>;(UVd#+hH2St0xi!<>AR;-L>+?EzSb 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�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 BA-w { + async createSavedSearchIfNeeded(savedSearch: any, indexPatternTitle: string): Promise { const title = savedSearch.requestBody.attributes.title; const savedSearchId = await this.getSavedSearchId(title); if (savedSearchId !== undefined) { @@ -195,7 +195,7 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider } else { const body = await this.updateSavedSearchRequestBody( savedSearch.requestBody, - savedSearch.indexPatternTitle + indexPatternTitle ); return await this.createSavedSearch(title, body); } @@ -226,8 +226,8 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider return updatedBody; }, - async createSavedSearchFarequoteFilterIfNeeded() { - await this.createSavedSearchIfNeeded(savedSearches.farequoteFilter); + async createSavedSearchFarequoteFilterIfNeeded(indexPatternTitle: string = 'ft_farequote') { + await this.createSavedSearchIfNeeded(savedSearches.farequoteFilter, indexPatternTitle); }, async createMLTestDashboardIfNeeded() { @@ -249,20 +249,30 @@ export function MachineLearningTestResourcesProvider({ getService }: FtrProvider } }, - async createSavedSearchFarequoteLuceneIfNeeded() { - await this.createSavedSearchIfNeeded(savedSearches.farequoteLucene); + async createSavedSearchFarequoteLuceneIfNeeded(indexPatternTitle: string = 'ft_farequote') { + await this.createSavedSearchIfNeeded(savedSearches.farequoteLucene, indexPatternTitle); }, - async createSavedSearchFarequoteKueryIfNeeded() { - await this.createSavedSearchIfNeeded(savedSearches.farequoteKuery); + async createSavedSearchFarequoteKueryIfNeeded(indexPatternTitle: string = 'ft_farequote') { + await this.createSavedSearchIfNeeded(savedSearches.farequoteKuery, indexPatternTitle); }, - async createSavedSearchFarequoteFilterAndLuceneIfNeeded() { - await this.createSavedSearchIfNeeded(savedSearches.farequoteFilterAndLucene); + async createSavedSearchFarequoteFilterAndLuceneIfNeeded( + indexPatternTitle: string = 'ft_farequote' + ) { + await this.createSavedSearchIfNeeded( + savedSearches.farequoteFilterAndLucene, + indexPatternTitle + ); }, - async createSavedSearchFarequoteFilterAndKueryIfNeeded() { - await this.createSavedSearchIfNeeded(savedSearches.farequoteFilterAndKuery); + async createSavedSearchFarequoteFilterAndKueryIfNeeded( + indexPatternTitle: string = 'ft_farequote' + ) { + await this.createSavedSearchIfNeeded( + savedSearches.farequoteFilterAndKuery, + indexPatternTitle + ); }, async deleteSavedObjectById(id: string, objectType: SavedObjectType, force: boolean = false) { diff --git a/x-pack/test/functional/services/ml/test_resources_data.ts b/x-pack/test/functional/services/ml/test_resources_data.ts index 7502968bd2bb4..aeacc51cecbc9 100644 --- a/x-pack/test/functional/services/ml/test_resources_data.ts +++ b/x-pack/test/functional/services/ml/test_resources_data.ts @@ -7,7 +7,6 @@ export const savedSearches = { farequoteFilter: { - indexPatternTitle: 'ft_farequote', requestBody: { attributes: { title: 'ft_farequote_filter', @@ -66,7 +65,6 @@ export const savedSearches = { }, }, farequoteLucene: { - indexPatternTitle: 'ft_farequote', requestBody: { attributes: { title: 'ft_farequote_lucene', @@ -98,7 +96,6 @@ export const savedSearches = { }, }, farequoteKuery: { - indexPatternTitle: 'ft_farequote', requestBody: { attributes: { title: 'ft_farequote_kuery', @@ -130,7 +127,6 @@ export const savedSearches = { }, }, farequoteFilterAndLucene: { - indexPatternTitle: 'ft_farequote', requestBody: { attributes: { title: 'ft_farequote_filter_and_lucene', @@ -189,7 +185,6 @@ export const savedSearches = { }, }, farequoteFilterAndKuery: { - indexPatternTitle: 'ft_farequote', requestBody: { attributes: { title: 'ft_farequote_filter_and_kuery', From eafa4d998d0a35728d28c197a8a9fd2e4ef60e26 Mon Sep 17 00:00:00 2001 From: Melissa Alvarez Date: Tue, 15 Feb 2022 13:17:12 -0700 Subject: [PATCH 42/43] [ML] Data Frame Analytics audit messages api test (#125325) * wip: add beginning of messages api test * adds dfa audit messages api test * use retry instead of running job --- .../ml_api_service/data_frame_analytics.ts | 3 +- .../apis/ml/data_frame_analytics/get.ts | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts index 8aba633970a78..42a18d80f9581 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/data_frame_analytics.ts @@ -16,6 +16,7 @@ import { } from '../../data_frame_analytics/common'; import { DeepPartial } from '../../../../common/types/common'; import { NewJobCapsResponse } from '../../../../common/types/fields'; +import { JobMessage } from '../../../../common/types/audit_message'; import { DeleteDataFrameAnalyticsWithIndexStatus, AnalyticsMapReturnType, @@ -161,7 +162,7 @@ export const dataFrameAnalytics = { }); }, getAnalyticsAuditMessages(analyticsId: string) { - return http({ + return http({ path: `${basePath()}/data_frame/analytics/${analyticsId}/messages`, method: 'GET', }); diff --git a/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts b/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts index f8c7009e39db6..69f40e46bc7e8 100644 --- a/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts +++ b/x-pack/test/api_integration/apis/ml/data_frame_analytics/get.ts @@ -16,6 +16,7 @@ export default ({ getService }: FtrProviderContext) => { const esArchiver = getService('esArchiver'); const supertest = getService('supertestWithoutAuth'); const ml = getService('ml'); + const retry = getService('retry'); const jobId = `bm_${Date.now()}`; @@ -273,5 +274,38 @@ export default ({ getService }: FtrProviderContext) => { expect(body).to.have.keys('elements', 'details', 'error'); }); }); + + describe('GetDataFrameAnalyticsMessages', () => { + it('should fetch single analytics job messages by id', async () => { + await retry.tryForTime(5000, async () => { + const { body } = await supertest + .get(`/api/ml/data_frame/analytics/${jobId}_1/messages`) + .auth(USER.ML_VIEWER, ml.securityCommon.getPasswordForUser(USER.ML_VIEWER)) + .set(COMMON_REQUEST_HEADERS) + .expect(200); + + expect(body.length).to.eql(1); + expect(body[0].job_id).to.eql(`${jobId}_1`); + expect(body[0]).to.have.keys( + 'job_id', + 'message', + 'level', + 'timestamp', + 'node_name', + 'job_type' + ); + }); + }); + + it('should not allow to retrieve job messages without required permissions', async () => { + const { body } = await supertest + .get(`/api/ml/data_frame/analytics/${jobId}_1/messages`) + .auth(USER.ML_UNAUTHORIZED, ml.securityCommon.getPasswordForUser(USER.ML_UNAUTHORIZED)) + .set(COMMON_REQUEST_HEADERS) + .expect(403); + expect(body.error).to.eql('Forbidden'); + expect(body.message).to.eql('Forbidden'); + }); + }); }); }; From c6b356c437655060925febc5f1ee8ab5857e2161 Mon Sep 17 00:00:00 2001 From: "Joey F. Poon" Date: Tue, 15 Feb 2022 14:48:40 -0600 Subject: [PATCH 43/43] [Security Solution] unskip tests (#125675) --- .../security_solution_endpoint_api_int/apis/metadata.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts index a4c83b649af65..e6fd28d279fe7 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/metadata.ts @@ -38,8 +38,7 @@ export default function ({ getService }: FtrProviderContext) { describe('test metadata apis', () => { describe('list endpoints GET route', () => { - // FLAKY: https://github.com/elastic/kibana/issues/123253 - describe.skip('with .metrics-endpoint.metadata_united_default index', () => { + describe('with .metrics-endpoint.metadata_united_default index', () => { const numberOfHostsInFixture = 2; before(async () => { @@ -65,11 +64,11 @@ export default function ({ getService }: FtrProviderContext) { ]); // wait for latest metadata transform to run - await new Promise((r) => setTimeout(r, 30000)); + await new Promise((r) => setTimeout(r, 60000)); await startTransform(getService, METADATA_UNITED_TRANSFORM); // wait for united metadata transform to run - await new Promise((r) => setTimeout(r, 15000)); + await new Promise((r) => setTimeout(r, 30000)); }); after(async () => {