From b06e6db2f5fda9c75a4473b1efedc553ea5210af Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 8 Oct 2021 15:27:33 -0500 Subject: [PATCH 01/33] [kbn/optimizer] log about high-level optimizer progress (#103354) * [kbn/optimizer] log about high-level optimizer progress * restore logOptimizerProgress helper to fix tests * fix lint error Co-authored-by: spalger Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../kbn-cli-dev-mode/src/optimizer.test.ts | 4 +- packages/kbn-cli-dev-mode/src/optimizer.ts | 9 ++- packages/kbn-optimizer/src/cli.ts | 15 ++++- packages/kbn-optimizer/src/index.ts | 1 + .../src/log_optimizer_progress.ts | 62 +++++++++++++++++++ .../kbn-optimizer/src/log_optimizer_state.ts | 7 +-- 6 files changed, 90 insertions(+), 8 deletions(-) create mode 100644 packages/kbn-optimizer/src/log_optimizer_progress.ts diff --git a/packages/kbn-cli-dev-mode/src/optimizer.test.ts b/packages/kbn-cli-dev-mode/src/optimizer.test.ts index ee8ea5f38ae84..e1763ab4c4756 100644 --- a/packages/kbn-cli-dev-mode/src/optimizer.test.ts +++ b/packages/kbn-cli-dev-mode/src/optimizer.test.ts @@ -18,9 +18,11 @@ import { Optimizer, Options } from './optimizer'; jest.mock('@kbn/optimizer'); const realOptimizer = jest.requireActual('@kbn/optimizer'); -const { runOptimizer, OptimizerConfig, logOptimizerState } = jest.requireMock('@kbn/optimizer'); +const { runOptimizer, OptimizerConfig, logOptimizerState, logOptimizerProgress } = + jest.requireMock('@kbn/optimizer'); logOptimizerState.mockImplementation(realOptimizer.logOptimizerState); +logOptimizerProgress.mockImplementation(realOptimizer.logOptimizerProgress); class MockOptimizerConfig {} diff --git a/packages/kbn-cli-dev-mode/src/optimizer.ts b/packages/kbn-cli-dev-mode/src/optimizer.ts index fab566829f7a6..3f7a6edc22314 100644 --- a/packages/kbn-cli-dev-mode/src/optimizer.ts +++ b/packages/kbn-cli-dev-mode/src/optimizer.ts @@ -18,7 +18,13 @@ import { } from '@kbn/dev-utils'; import * as Rx from 'rxjs'; import { ignoreElements } from 'rxjs/operators'; -import { runOptimizer, OptimizerConfig, logOptimizerState, OptimizerUpdate } from '@kbn/optimizer'; +import { + runOptimizer, + OptimizerConfig, + logOptimizerState, + logOptimizerProgress, + OptimizerUpdate, +} from '@kbn/optimizer'; export interface Options { enabled: boolean; @@ -111,6 +117,7 @@ export class Optimizer { subscriber.add( runOptimizer(config) .pipe( + logOptimizerProgress(log), logOptimizerState(log, config), tap(({ state }) => { this.phase$.next(state.phase); diff --git a/packages/kbn-optimizer/src/cli.ts b/packages/kbn-optimizer/src/cli.ts index d5b9996dfb2cd..7f0c39ccd0e55 100644 --- a/packages/kbn-optimizer/src/cli.ts +++ b/packages/kbn-optimizer/src/cli.ts @@ -13,6 +13,7 @@ import { lastValueFrom } from '@kbn/std'; import { run, createFlagError, Flags } from '@kbn/dev-utils'; import { logOptimizerState } from './log_optimizer_state'; +import { logOptimizerProgress } from './log_optimizer_progress'; import { OptimizerConfig } from './optimizer'; import { runOptimizer } from './run_optimizer'; import { validateLimitsForAllBundles, updateBundleLimits } from './limits'; @@ -97,6 +98,11 @@ export function runKbnOptimizerCli(options: { defaultLimitsPath: string }) { throw createFlagError('expected --report-stats to have no value'); } + const logProgress = flags.progress ?? false; + if (typeof logProgress !== 'boolean') { + throw createFlagError('expected --progress to have no value'); + } + const filter = typeof flags.filter === 'string' ? [flags.filter] : flags.filter; if (!Array.isArray(filter) || !filter.every((f) => typeof f === 'string')) { throw createFlagError('expected --filter to be one or more strings'); @@ -144,7 +150,11 @@ export function runKbnOptimizerCli(options: { defaultLimitsPath: string }) { const update$ = runOptimizer(config); await lastValueFrom( - update$.pipe(logOptimizerState(log, config), reportOptimizerTimings(log, config)) + update$.pipe( + logProgress ? logOptimizerProgress(log) : (x) => x, + logOptimizerState(log, config), + reportOptimizerTimings(log, config) + ) ); if (updateLimits) { @@ -169,6 +179,7 @@ export function runKbnOptimizerCli(options: { defaultLimitsPath: string }) { 'inspect-workers', 'validate-limits', 'update-limits', + 'progress', ], string: ['workers', 'scan-dir', 'filter', 'limits'], default: { @@ -176,12 +187,14 @@ export function runKbnOptimizerCli(options: { defaultLimitsPath: string }) { examples: true, cache: true, 'inspect-workers': true, + progress: true, filter: [], focus: [], }, help: ` --watch run the optimizer in watch mode --workers max number of workers to use + --no-progress disable logging of progress information --oss only build oss plugins --profile profile the webpack builds and write stats.json files to build outputs --no-core disable generating the core bundle diff --git a/packages/kbn-optimizer/src/index.ts b/packages/kbn-optimizer/src/index.ts index a5838a8a0fac8..d5e810d584d29 100644 --- a/packages/kbn-optimizer/src/index.ts +++ b/packages/kbn-optimizer/src/index.ts @@ -9,6 +9,7 @@ export { OptimizerConfig } from './optimizer'; export * from './run_optimizer'; export * from './log_optimizer_state'; +export * from './log_optimizer_progress'; export * from './node'; export * from './limits'; export * from './cli'; diff --git a/packages/kbn-optimizer/src/log_optimizer_progress.ts b/packages/kbn-optimizer/src/log_optimizer_progress.ts new file mode 100644 index 0000000000000..d07c9dc6eff32 --- /dev/null +++ b/packages/kbn-optimizer/src/log_optimizer_progress.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 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 { ToolingLog } from '@kbn/dev-utils'; +import * as Rx from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { OptimizerUpdate } from './run_optimizer'; + +const PROGRESS_REPORT_INTERVAL = 10_000; + +export function logOptimizerProgress( + log: ToolingLog +): Rx.MonoTypeOperatorFunction { + return (update$) => + new Rx.Observable((subscriber) => { + const allBundleIds = new Set(); + const completeBundles = new Set(); + let loggedCompletion = new Set(); + + // catalog bundle ids and which have completed at least once, forward + // updates to next subscriber + subscriber.add( + update$ + .pipe( + tap(({ state }) => { + for (const { bundleId, type } of state.compilerStates) { + allBundleIds.add(bundleId); + if (type !== 'running') { + completeBundles.add(bundleId); + } + } + }), + tap(subscriber) + ) + .subscribe() + ); + + // on interval check to see if at least 3 new bundles have completed at + // least one build and log about our progress if so + subscriber.add( + Rx.interval(PROGRESS_REPORT_INTERVAL).subscribe( + () => { + if (completeBundles.size - loggedCompletion.size < 3) { + return; + } + + log.info( + `[${completeBundles.size}/${allBundleIds.size}] initial bundle builds complete` + ); + loggedCompletion = new Set(completeBundles); + }, + (error) => subscriber.error(error) + ) + ); + }); +} diff --git a/packages/kbn-optimizer/src/log_optimizer_state.ts b/packages/kbn-optimizer/src/log_optimizer_state.ts index 61f6406255a8c..517e3bbfa5133 100644 --- a/packages/kbn-optimizer/src/log_optimizer_state.ts +++ b/packages/kbn-optimizer/src/log_optimizer_state.ts @@ -82,14 +82,11 @@ export function logOptimizerState(log: ToolingLog, config: OptimizerConfig) { continue; } + bundleStates.set(id, type); + if (type === 'running') { bundlesThatWereBuilt.add(id); } - - bundleStates.set(id, type); - log.debug( - `[${id}] state = "${type}"${type !== 'running' ? ` after ${state.durSec} sec` : ''}` - ); } if (state.phase === 'running' || state.phase === 'initializing') { From 032473ba29849f40e459a3f5c7410fb12457ff3d Mon Sep 17 00:00:00 2001 From: Spencer Date: Fri, 8 Oct 2021 17:52:44 -0500 Subject: [PATCH 02/33] [ci-stats-reporter] ensure HTTP adapter is used (#114367) * [ci-stats-reporter] ensure HTTP adapter is used * update kbn/pm dist Co-authored-by: spalger --- .../src/ci_stats_reporter/ci_stats_reporter.ts | 3 +++ packages/kbn-pm/dist/index.js | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) 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 45d31c1eefad9..fe48ce99e6857 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 @@ -13,6 +13,8 @@ import Path from 'path'; import crypto from 'crypto'; import execa from 'execa'; import Axios from 'axios'; +// @ts-expect-error not "public", but necessary to prevent Jest shimming from breaking things +import httpAdapter from 'axios/lib/adapters/http'; import { ToolingLog } from '../tooling_log'; import { parseConfig, Config } from './ci_stats_config'; @@ -225,6 +227,7 @@ export class CiStatsReporter { baseURL: BASE_URL, headers, data: body, + adapter: httpAdapter, }); return true; diff --git a/packages/kbn-pm/dist/index.js b/packages/kbn-pm/dist/index.js index cab1f6d916f02..f395636379141 100644 --- a/packages/kbn-pm/dist/index.js +++ b/packages/kbn-pm/dist/index.js @@ -8965,6 +8965,8 @@ var _execa = _interopRequireDefault(__webpack_require__(134)); var _axios = _interopRequireDefault(__webpack_require__(177)); +var _http = _interopRequireDefault(__webpack_require__(199)); + var _ci_stats_config = __webpack_require__(218); /* @@ -8974,6 +8976,7 @@ var _ci_stats_config = __webpack_require__(218); * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ +// @ts-expect-error not "public", but necessary to prevent Jest shimming from breaking things const BASE_URL = 'https://ci-stats.kibana.dev'; class CiStatsReporter { @@ -9173,7 +9176,8 @@ class CiStatsReporter { url: path, baseURL: BASE_URL, headers, - data: body + data: body, + adapter: _http.default }); return true; } catch (error) { From cbc4f5235c0a38e2f076ce97e7af2564036f9a5b Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Sat, 9 Oct 2021 02:48:23 -0400 Subject: [PATCH 03/33] [Fleet] Add installed integration callouts (#113893) Co-authored-by: Clint Andrew Hall Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...-plugin-core-public.doclinksstart.links.md | 1 + .../public/doc_links/doc_links_service.ts | 2 + src/core/public/public.api.md | 1 + .../epm/components/package_list_grid.tsx | 3 ++ .../sections/epm/screens/home/index.tsx | 40 ++++++++++++++++++- .../fleet/storybook/context/doc_links.ts | 21 ++++++++++ .../plugins/fleet/storybook/context/index.tsx | 4 +- .../plugins/fleet/storybook/context/stubs.tsx | 2 - 8 files changed, 70 insertions(+), 4 deletions(-) create mode 100644 x-pack/plugins/fleet/storybook/context/doc_links.ts diff --git a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md index fc7d2e976b578..c8ccdfeedb83f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md +++ b/docs/development/core/public/kibana-plugin-core-public.doclinksstart.links.md @@ -235,6 +235,7 @@ readonly links: { datastreamsNamingScheme: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; + learnMoreBlog: string; }>; readonly ecs: { readonly guide: string; diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 6c34693b6052d..01108298adc99 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -476,6 +476,7 @@ export class DocLinksService { datastreamsNamingScheme: `${FLEET_DOCS}data-streams.html#data-streams-naming-scheme`, upgradeElasticAgent: `${FLEET_DOCS}upgrade-elastic-agent.html`, upgradeElasticAgent712lower: `${FLEET_DOCS}upgrade-elastic-agent.html#upgrade-7.12-lower`, + learnMoreBlog: `${ELASTIC_WEBSITE_URL}blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic`, }, ecs: { guide: `${ELASTIC_WEBSITE_URL}guide/en/ecs/current/index.html`, @@ -730,6 +731,7 @@ export interface DocLinksStart { datastreamsNamingScheme: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; + learnMoreBlog: string; }>; readonly ecs: { readonly guide: string; diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 7170c43f36e7c..45b7e3bdc02b5 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -704,6 +704,7 @@ export interface DocLinksStart { datastreamsNamingScheme: string; upgradeElasticAgent: string; upgradeElasticAgent712lower: string; + learnMoreBlog: string; }>; readonly ecs: { readonly guide: string; diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx index ac83cc83b6849..109f7500f160b 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/package_list_grid.tsx @@ -38,6 +38,7 @@ export interface ListProps { setSelectedCategory: (category: string) => void; onSearchChange: (search: string) => void; showMissingIntegrationMessage?: boolean; + callout?: JSX.Element | null; } export function PackageListGrid({ @@ -49,6 +50,7 @@ export function PackageListGrid({ onSearchChange, setSelectedCategory, showMissingIntegrationMessage = false, + callout, }: ListProps) { const [searchTerm, setSearchTerm] = useState(initialSearch || ''); const localSearchRef = useLocalSearch(list); @@ -105,6 +107,7 @@ export function PackageListGrid({ }} onChange={onQueryChange} /> + {callout ? callout : null} {gridContent} {showMissingIntegrationMessage && ( diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx index 62225d14d3857..06cf85699bf67 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/screens/home/index.tsx @@ -5,10 +5,13 @@ * 2.0. */ -import React, { memo, useMemo } from 'react'; +import React, { memo, useMemo, Fragment } from 'react'; import { Switch, Route, useLocation, useHistory, useParams } from 'react-router-dom'; import semverLt from 'semver/functions/lt'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiCallOut, EuiLink, EuiSpacer } from '@elastic/eui'; import { installationStatuses } from '../../../../../../../common/constants'; import type { DynamicPage, DynamicPagePathValues, StaticPage } from '../../../../constants'; @@ -24,6 +27,7 @@ import { useGetAppendCustomIntegrations, useGetReplacementCustomIntegrations, useLink, + useStartServices, } from '../../../../hooks'; import { doesPackageHaveIntegrations } from '../../../../services'; import { DefaultLayout } from '../../../../layouts'; @@ -143,6 +147,7 @@ const InstalledPackages: React.FC = memo(() => { experimental: true, }); const { getHref, getAbsolutePath } = useLink(); + const { docLinks } = useStartServices(); const { selectedCategory, searchParam } = getParams( useParams(), @@ -225,6 +230,38 @@ const InstalledPackages: React.FC = memo(() => { return mapToCard(getAbsolutePath, getHref, item); }); + const link = ( + + {i18n.translate('xpack.fleet.epmList.availableCalloutBlogText', { + defaultMessage: 'announcement blog post', + })} + + ); + const calloutMessage = ( + + ); + + const callout = + selectedCategory === 'updates_available' ? null : ( + + + +

{calloutMessage}

+
+
+ ); + return ( { initialSearch={searchParam} title={title} list={cards} + callout={callout} /> ); }); diff --git a/x-pack/plugins/fleet/storybook/context/doc_links.ts b/x-pack/plugins/fleet/storybook/context/doc_links.ts new file mode 100644 index 0000000000000..56287dd9116a9 --- /dev/null +++ b/x-pack/plugins/fleet/storybook/context/doc_links.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { DocLinksStart } from 'kibana/public'; + +export const getDocLinks = () => { + const docLinks: DocLinksStart = { + links: { + fleet: { + learnMoreBlog: + 'https://www.elastic.co/blog/elastic-agent-and-fleet-make-it-easier-to-integrate-your-systems-with-elastic', + }, + }, + } as unknown as DocLinksStart; + + return docLinks; +}; diff --git a/x-pack/plugins/fleet/storybook/context/index.tsx b/x-pack/plugins/fleet/storybook/context/index.tsx index e5a360c28385b..6d563346e917d 100644 --- a/x-pack/plugins/fleet/storybook/context/index.tsx +++ b/x-pack/plugins/fleet/storybook/context/index.tsx @@ -27,6 +27,7 @@ import { getHttp } from './http'; import { getUiSettings } from './ui_settings'; import { getNotifications } from './notifications'; import { stubbedStartServices } from './stubs'; +import { getDocLinks } from './doc_links'; // TODO: clintandrewhall - this is not ideal, or complete. The root context of Fleet applications // requires full start contracts of its dependencies. As a result, we have to mock all of those contracts @@ -42,8 +43,10 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({ const history = new ScopedHistory(browserHistory, basepath); const startServices: FleetStartServices = { + ...stubbedStartServices, application: getApplication(), chrome: getChrome(), + docLinks: getDocLinks(), http: getHttp(), notifications: getNotifications(), uiSettings: getUiSettings(), @@ -58,7 +61,6 @@ export const StorybookContext: React.FC<{ storyContext?: StoryContext }> = ({ customIntegrations: { ContextProvider: getStorybookContextProvider(), }, - ...stubbedStartServices, }; setHttpClient(startServices.http); diff --git a/x-pack/plugins/fleet/storybook/context/stubs.tsx b/x-pack/plugins/fleet/storybook/context/stubs.tsx index 54ae18b083a2f..a7db4bd8f68cd 100644 --- a/x-pack/plugins/fleet/storybook/context/stubs.tsx +++ b/x-pack/plugins/fleet/storybook/context/stubs.tsx @@ -9,7 +9,6 @@ import type { FleetStartServices } from '../../public/plugin'; type Stubs = | 'storage' - | 'docLinks' | 'data' | 'deprecations' | 'fatalErrors' @@ -22,7 +21,6 @@ type StubbedStartServices = Pick; export const stubbedStartServices: StubbedStartServices = { storage: {} as FleetStartServices['storage'], - docLinks: {} as FleetStartServices['docLinks'], data: {} as FleetStartServices['data'], deprecations: {} as FleetStartServices['deprecations'], fatalErrors: {} as FleetStartServices['fatalErrors'], From 961fe752c5a25c75eb4d02d3e8019b55a5661af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Sat, 9 Oct 2021 14:53:55 +0100 Subject: [PATCH 04/33] [Watcher] Use fixed_interval instead of interval (#113527) --- .../forms/hook_form_lib/hooks/use_field.ts | 1 - .../helpers/app_context.mock.tsx | 4 +- .../client_integration/helpers/index.ts | 2 +- .../helpers/setup_environment.ts | 1 + .../helpers/watch_create_threshold.helpers.ts | 2 +- .../helpers/watch_list.helpers.ts | 16 +- .../helpers/watch_status.helpers.ts | 5 +- .../watch_create_json.test.ts | 22 ++- .../watch_create_threshold.test.tsx | 148 ++++++++++++------ .../client_integration/watch_edit.test.ts | 22 +-- .../client_integration/watch_status.test.ts | 16 +- x-pack/plugins/watcher/public/plugin.ts | 6 +- .../get_interval_type.test.ts | 36 +++++ .../get_interval_type/get_interval_type.ts | 21 +++ .../watch/lib/get_interval_type/index.ts | 8 + .../threshold_watch/build_visualize_query.js | 36 +++-- .../watch/threshold_watch/threshold_watch.js | 4 +- x-pack/plugins/watcher/server/plugin.ts | 9 +- .../api/watch/register_visualize_route.ts | 3 +- x-pack/plugins/watcher/server/types.ts | 2 + 20 files changed, 232 insertions(+), 132 deletions(-) create mode 100644 x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/get_interval_type.test.ts create mode 100644 x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/get_interval_type.ts create mode 100644 x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/index.ts diff --git a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts index dedc390c47719..c01295f6ee42c 100644 --- a/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts +++ b/src/plugins/es_ui_shared/static/forms/hook_form_lib/hooks/use_field.ts @@ -515,7 +515,6 @@ export const useField = ( if (resetValue) { hasBeenReset.current = true; const newValue = deserializeValue(updatedDefaultValue ?? defaultValue); - // updateStateIfMounted('value', newValue); setValue(newValue); return newValue; } diff --git a/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx b/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx index 01c7155832745..8176d3fcbbca2 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx +++ b/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { of } from 'rxjs'; import { ComponentType } from 'enzyme'; import { LocationDescriptorObject } from 'history'; + import { docLinksServiceMock, uiSettingsServiceMock, @@ -17,6 +18,7 @@ import { scopedHistoryMock, } from '../../../../../../src/core/public/mocks'; import { AppContextProvider } from '../../../public/application/app_context'; +import { AppDeps } from '../../../public/application/app'; import { LicenseStatus } from '../../../common/types/license_status'; class MockTimeBuckets { @@ -35,7 +37,7 @@ history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}${location.search ? '?' + location.search : ''}`; }); -export const mockContextValue = { +export const mockContextValue: AppDeps = { licenseStatus$: of({ valid: true }), docLinks: docLinksServiceMock.createStartContract(), setBreadcrumbs: jest.fn(), diff --git a/x-pack/plugins/watcher/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/watcher/__jest__/client_integration/helpers/index.ts index 961e2a458dc0c..09a841ff147a4 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/helpers/index.ts @@ -11,7 +11,7 @@ import { setup as watchCreateJsonSetup } from './watch_create_json.helpers'; import { setup as watchCreateThresholdSetup } from './watch_create_threshold.helpers'; import { setup as watchEditSetup } from './watch_edit.helpers'; -export { nextTick, getRandomString, findTestSubject, TestBed } from '@kbn/test/jest'; +export { getRandomString, findTestSubject, TestBed } from '@kbn/test/jest'; export { wrapBodyResponse, unwrapBodyResponse } from './body_response'; export { setupEnvironment } from './setup_environment'; diff --git a/x-pack/plugins/watcher/__jest__/client_integration/helpers/setup_environment.ts b/x-pack/plugins/watcher/__jest__/client_integration/helpers/setup_environment.ts index 05b325ee946bd..5ba0387d21ba7 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/helpers/setup_environment.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/helpers/setup_environment.ts @@ -7,6 +7,7 @@ import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; + import { init as initHttpRequests } from './http_requests'; import { setHttpClient, setSavedObjectsClient } from '../../../public/application/lib/api'; diff --git a/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts b/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts index c70684b80a6d5..caddf1df93d40 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_create_threshold.helpers.ts @@ -93,7 +93,7 @@ export type TestSubjects = | 'toEmailAddressInput' | 'triggerIntervalSizeInput' | 'watchActionAccordion' - | 'watchActionAccordion.mockComboBox' + | 'watchActionAccordion.toEmailAddressInput' | 'watchActionsPanel' | 'watchThresholdButton' | 'watchThresholdInput' diff --git a/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_list.helpers.ts b/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_list.helpers.ts index ad171f9e40cad..c0643e70dded9 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_list.helpers.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_list.helpers.ts @@ -7,7 +7,7 @@ import { act } from 'react-dom/test-utils'; -import { registerTestBed, findTestSubject, TestBed, TestBedConfig, nextTick } from '@kbn/test/jest'; +import { registerTestBed, findTestSubject, TestBed, TestBedConfig } from '@kbn/test/jest'; import { WatchList } from '../../../public/application/sections/watch_list/components/watch_list'; import { ROUTES, REFRESH_INTERVALS } from '../../../common/constants'; import { withAppContext } from './app_context.mock'; @@ -24,7 +24,6 @@ const initTestBed = registerTestBed(withAppContext(WatchList), testBedConfig); export interface WatchListTestBed extends TestBed { actions: { selectWatchAt: (index: number) => void; - clickWatchAt: (index: number) => void; clickWatchActionAt: (index: number, action: 'delete' | 'edit') => void; searchWatches: (term: string) => void; advanceTimeToTableRefresh: () => Promise; @@ -45,18 +44,6 @@ export const setup = async (): Promise => { checkBox.simulate('change', { target: { checked: true } }); }; - const clickWatchAt = async (index: number) => { - const { rows } = testBed.table.getMetaData('watchesTable'); - const watchesLink = findTestSubject(rows[index].reactWrapper, 'watchesLink'); - - await act(async () => { - const { href } = watchesLink.props(); - testBed.router.navigateTo(href!); - await nextTick(); - testBed.component.update(); - }); - }; - const clickWatchActionAt = async (index: number, action: 'delete' | 'edit') => { const { component, table } = testBed; const { rows } = table.getMetaData('watchesTable'); @@ -95,7 +82,6 @@ export const setup = async (): Promise => { ...testBed, actions: { selectWatchAt, - clickWatchAt, clickWatchActionAt, searchWatches, advanceTimeToTableRefresh, diff --git a/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_status.helpers.ts b/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_status.helpers.ts index a1c7e8b404997..02b6908fc1d4c 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_status.helpers.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/helpers/watch_status.helpers.ts @@ -7,7 +7,7 @@ import { act } from 'react-dom/test-utils'; -import { registerTestBed, findTestSubject, TestBed, TestBedConfig, delay } from '@kbn/test/jest'; +import { registerTestBed, findTestSubject, TestBed, TestBedConfig } from '@kbn/test/jest'; import { WatchStatus } from '../../../public/application/sections/watch_status/components/watch_status'; import { ROUTES } from '../../../common/constants'; import { WATCH_ID } from './jest_constants'; @@ -89,9 +89,8 @@ export const setup = async (): Promise => { await act(async () => { button.simulate('click'); - await delay(100); - component.update(); }); + component.update(); }; return { diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_create_json.test.ts b/x-pack/plugins/watcher/__jest__/client_integration/watch_create_json.test.ts index 4a632d9752cac..f9ea51a80ae76 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_create_json.test.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_create_json.test.ts @@ -9,7 +9,7 @@ import { act } from 'react-dom/test-utils'; import { getExecuteDetails } from '../../__fixtures__'; import { defaultWatch } from '../../public/application/models/watch'; -import { setupEnvironment, pageHelpers, nextTick, wrapBodyResponse } from './helpers'; +import { setupEnvironment, pageHelpers, wrapBodyResponse } from './helpers'; import { WatchCreateJsonTestBed } from './helpers/watch_create_json.helpers'; import { WATCH } from './helpers/jest_constants'; @@ -19,19 +19,19 @@ describe(' create route', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); let testBed: WatchCreateJsonTestBed; + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); server.restore(); }); describe('on component mount', () => { beforeEach(async () => { testBed = await setup(); - - await act(async () => { - const { component } = testBed; - await nextTick(); - component.update(); - }); + testBed.component.update(); }); test('should set the correct page title', () => { @@ -92,7 +92,6 @@ describe(' create route', () => { await act(async () => { actions.clickSubmitButton(); - await nextTick(); }); const latestRequest = server.requests[server.requests.length - 1]; @@ -141,9 +140,8 @@ describe(' create route', () => { await act(async () => { actions.clickSubmitButton(); - await nextTick(); - component.update(); }); + component.update(); expect(exists('sectionError')).toBe(true); expect(find('sectionError').text()).toContain(error.message); @@ -169,7 +167,6 @@ describe(' create route', () => { await act(async () => { actions.clickSimulateButton(); - await nextTick(); }); const latestRequest = server.requests[server.requests.length - 1]; @@ -230,9 +227,8 @@ describe(' create route', () => { await act(async () => { actions.clickSimulateButton(); - await nextTick(); - component.update(); }); + component.update(); const latestRequest = server.requests[server.requests.length - 1]; diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx b/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx index 77e65dfd91c75..481f59093d7dc 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_create_threshold.test.tsx @@ -9,15 +9,10 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import axios from 'axios'; + import { getExecuteDetails } from '../../__fixtures__'; import { WATCH_TYPES } from '../../common/constants'; -import { - setupEnvironment, - pageHelpers, - nextTick, - wrapBodyResponse, - unwrapBodyResponse, -} from './helpers'; +import { setupEnvironment, pageHelpers, wrapBodyResponse, unwrapBodyResponse } from './helpers'; import { WatchCreateThresholdTestBed } from './helpers/watch_create_threshold.helpers'; const WATCH_NAME = 'my_test_watch'; @@ -76,7 +71,9 @@ jest.mock('@elastic/eui', () => { // which does not produce a valid component wrapper EuiComboBox: (props: any) => ( { props.onChange([syntheticEvent['0']]); }} @@ -91,7 +88,12 @@ describe(' create route', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); let testBed: WatchCreateThresholdTestBed; + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); server.restore(); }); @@ -99,7 +101,6 @@ describe(' create route', () => { beforeEach(async () => { testBed = await setup(); const { component } = testBed; - await nextTick(); component.update(); }); @@ -159,46 +160,60 @@ describe(' create route', () => { test('it should enable the Create button and render additional content with valid fields', async () => { const { form, find, component, exists } = testBed; - form.setInputValue('nameInput', 'my_test_watch'); - find('mockComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); // Using mocked EuiComboBox - form.setInputValue('watchTimeFieldSelect', '@timestamp'); + expect(find('saveWatchButton').props().disabled).toBe(true); await act(async () => { - await nextTick(); - component.update(); + form.setInputValue('nameInput', 'my_test_watch'); + find('indicesComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); // Using mocked EuiComboBox + form.setInputValue('watchTimeFieldSelect', '@timestamp'); }); + component.update(); - expect(find('saveWatchButton').props().disabled).toEqual(false); - + expect(find('saveWatchButton').props().disabled).toBe(false); expect(find('watchConditionTitle').text()).toBe('Match the following condition'); expect(exists('watchVisualizationChart')).toBe(true); expect(exists('watchActionsPanel')).toBe(true); }); - // Looks like there is an issue with using 'mockComboBox'. - describe.skip('watch conditions', () => { - beforeEach(() => { - const { form, find } = testBed; + describe('watch conditions', () => { + beforeEach(async () => { + const { form, find, component } = testBed; // Name, index and time fields are required before the watch condition expression renders - form.setInputValue('nameInput', 'my_test_watch'); - act(() => { - find('mockComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); // Using mocked EuiComboBox + await act(async () => { + form.setInputValue('nameInput', 'my_test_watch'); + find('indicesComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); // Using mocked EuiComboBox + form.setInputValue('watchTimeFieldSelect', '@timestamp'); }); - form.setInputValue('watchTimeFieldSelect', '@timestamp'); + component.update(); }); - test('should require a threshold value', () => { - const { form, find } = testBed; + test('should require a threshold value', async () => { + const { form, find, component } = testBed; + // Display the threshold pannel act(() => { find('watchThresholdButton').simulate('click'); + }); + component.update(); + + await act(async () => { // Provide invalid value form.setInputValue('watchThresholdInput', ''); + }); + + // We need to wait for the debounced validation to be triggered and update the DOM + jest.advanceTimersByTime(500); + component.update(); + + expect(form.getErrorsMessages()).toContain('A value is required.'); + + await act(async () => { // Provide valid value form.setInputValue('watchThresholdInput', '0'); }); - expect(form.getErrorsMessages()).toContain('A value is required.'); + component.update(); + // No need to wait as the validation errors are cleared whenever the field changes expect(form.getErrorsMessages().length).toEqual(0); }); }); @@ -209,14 +224,12 @@ describe(' create route', () => { const { form, find, component } = testBed; // Set up valid fields needed for actions component to render - form.setInputValue('nameInput', WATCH_NAME); - find('mockComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); // Using mocked EuiComboBox - form.setInputValue('watchTimeFieldSelect', WATCH_TIME_FIELD); - await act(async () => { - await nextTick(); - component.update(); + form.setInputValue('nameInput', WATCH_NAME); + find('indicesComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); + form.setInputValue('watchTimeFieldSelect', WATCH_TIME_FIELD); }); + component.update(); }); test('should simulate a logging action', async () => { @@ -240,7 +253,6 @@ describe(' create route', () => { await act(async () => { actions.clickSimulateButton(); - await nextTick(); }); // Verify request @@ -303,7 +315,6 @@ describe(' create route', () => { await act(async () => { actions.clickSimulateButton(); - await nextTick(); }); // Verify request @@ -366,7 +377,6 @@ describe(' create route', () => { await act(async () => { actions.clickSimulateButton(); - await nextTick(); }); // Verify request @@ -431,15 +441,14 @@ describe(' create route', () => { expect(exists('watchActionAccordion')).toBe(true); // Provide valid fields and verify - find('watchActionAccordion.mockComboBox').simulate('change', [ + find('watchActionAccordion.toEmailAddressInput').simulate('change', [ { label: EMAIL_RECIPIENT, value: EMAIL_RECIPIENT }, - ]); // Using mocked EuiComboBox + ]); form.setInputValue('emailSubjectInput', EMAIL_SUBJECT); form.setInputValue('emailBodyInput', EMAIL_BODY); await act(async () => { actions.clickSimulateButton(); - await nextTick(); }); // Verify request @@ -532,7 +541,6 @@ describe(' create route', () => { await act(async () => { actions.clickSimulateButton(); - await nextTick(); }); // Verify request @@ -621,7 +629,6 @@ describe(' create route', () => { await act(async () => { actions.clickSimulateButton(); - await nextTick(); }); // Verify request @@ -702,7 +709,6 @@ describe(' create route', () => { await act(async () => { actions.clickSimulateButton(); - await nextTick(); }); // Verify request @@ -753,20 +759,66 @@ describe(' create route', () => { }); }); + describe('watch visualize data payload', () => { + test('should send the correct payload', async () => { + const { form, find, component } = testBed; + + // Set up required fields + await act(async () => { + form.setInputValue('nameInput', WATCH_NAME); + find('indicesComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); + form.setInputValue('watchTimeFieldSelect', WATCH_TIME_FIELD); + }); + component.update(); + + const latestReqToGetVisualizeData = server.requests.find( + (req) => req.method === 'POST' && req.url === '/api/watcher/watch/visualize' + ); + if (!latestReqToGetVisualizeData) { + throw new Error(`No request found to fetch visualize data.`); + } + + const requestBody = unwrapBodyResponse(latestReqToGetVisualizeData.requestBody); + + expect(requestBody.watch).toEqual({ + id: requestBody.watch.id, // id is dynamic + name: 'my_test_watch', + type: 'threshold', + isNew: true, + isActive: true, + actions: [], + index: ['index1'], + timeField: '@timestamp', + triggerIntervalSize: 1, + triggerIntervalUnit: 'm', + aggType: 'count', + termSize: 5, + termOrder: 'desc', + thresholdComparator: '>', + timeWindowSize: 5, + timeWindowUnit: 'm', + hasTermsAgg: false, + threshold: 1000, + }); + + expect(requestBody.options.interval).toBeDefined(); + }); + }); + describe('form payload', () => { test('should send the correct payload', async () => { const { form, find, component, actions } = testBed; // Set up required fields - form.setInputValue('nameInput', WATCH_NAME); - find('mockComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); // Using mocked EuiComboBox - form.setInputValue('watchTimeFieldSelect', WATCH_TIME_FIELD); + await act(async () => { + form.setInputValue('nameInput', WATCH_NAME); + find('indicesComboBox').simulate('change', [{ label: 'index1', value: 'index1' }]); + form.setInputValue('watchTimeFieldSelect', WATCH_TIME_FIELD); + }); + component.update(); await act(async () => { - await nextTick(); - component.update(); actions.clickSubmitButton(); - await nextTick(); }); // Verify request diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts b/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts index e8782edc829a4..1188cc8469a58 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_edit.test.ts @@ -12,7 +12,7 @@ import { getRandomString } from '@kbn/test/jest'; import { getWatch } from '../../__fixtures__'; import { defaultWatch } from '../../public/application/models/watch'; -import { setupEnvironment, pageHelpers, nextTick, wrapBodyResponse } from './helpers'; +import { setupEnvironment, pageHelpers, wrapBodyResponse } from './helpers'; import { WatchEditTestBed } from './helpers/watch_edit.helpers'; import { WATCH } from './helpers/jest_constants'; @@ -41,7 +41,12 @@ describe('', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); let testBed: WatchEditTestBed; + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); server.restore(); }); @@ -50,11 +55,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadWatchResponse(WATCH); testBed = await setup(); - - await act(async () => { - await nextTick(); - testBed.component.update(); - }); + testBed.component.update(); }); describe('on component mount', () => { @@ -87,7 +88,6 @@ describe('', () => { await act(async () => { actions.clickSubmitButton(); - await nextTick(); }); const latestRequest = server.requests[server.requests.length - 1]; @@ -141,12 +141,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadWatchResponse({ watch }); testBed = await setup(); - - await act(async () => { - const { component } = testBed; - await nextTick(); - component.update(); - }); + testBed.component.update(); }); describe('on component mount', () => { @@ -172,7 +167,6 @@ describe('', () => { await act(async () => { actions.clickSubmitButton(); - await nextTick(); }); const latestRequest = server.requests[server.requests.length - 1]; diff --git a/x-pack/plugins/watcher/__jest__/client_integration/watch_status.test.ts b/x-pack/plugins/watcher/__jest__/client_integration/watch_status.test.ts index c19ec62b94477..1b1b813617da6 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/watch_status.test.ts +++ b/x-pack/plugins/watcher/__jest__/client_integration/watch_status.test.ts @@ -9,7 +9,7 @@ import { act } from 'react-dom/test-utils'; import moment from 'moment'; import { getWatchHistory } from '../../__fixtures__'; import { ROUTES, WATCH_STATES, ACTION_STATES } from '../../common/constants'; -import { setupEnvironment, pageHelpers, nextTick } from './helpers'; +import { setupEnvironment, pageHelpers } from './helpers'; import { WatchStatusTestBed } from './helpers/watch_status.helpers'; import { WATCH } from './helpers/jest_constants'; @@ -43,7 +43,12 @@ describe('', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); let testBed: WatchStatusTestBed; + beforeAll(() => { + jest.useFakeTimers(); + }); + afterAll(() => { + jest.useRealTimers(); server.restore(); }); @@ -53,11 +58,7 @@ describe('', () => { httpRequestsMockHelpers.setLoadWatchHistoryResponse(watchHistoryItems); testBed = await setup(); - - await act(async () => { - await nextTick(); - testBed.component.update(); - }); + testBed.component.update(); }); test('should set the correct page title', () => { @@ -175,9 +176,8 @@ describe('', () => { await act(async () => { confirmButton!.click(); - await nextTick(); - component.update(); }); + component.update(); const latestRequest = server.requests[server.requests.length - 1]; diff --git a/x-pack/plugins/watcher/public/plugin.ts b/x-pack/plugins/watcher/public/plugin.ts index 6c6d6f1169658..093f34e70400f 100644 --- a/x-pack/plugins/watcher/public/plugin.ts +++ b/x-pack/plugins/watcher/public/plugin.ts @@ -8,13 +8,11 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, Plugin, CoreStart, Capabilities } from 'kibana/public'; import { first, map, skip } from 'rxjs/operators'; - import { Subject, combineLatest } from 'rxjs'; -import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; - -import { LicenseStatus } from '../common/types/license_status'; +import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { ILicense } from '../../licensing/public'; +import { LicenseStatus } from '../common/types/license_status'; import { PLUGIN } from '../common/constants'; import { Dependencies } from './types'; diff --git a/x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/get_interval_type.test.ts b/x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/get_interval_type.test.ts new file mode 100644 index 0000000000000..a90876d1baf2e --- /dev/null +++ b/x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/get_interval_type.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { getIntervalType } from './get_interval_type'; + +describe('get interval type', () => { + test('should detect fixed intervals', () => { + ['1ms', '1s', '1m', '1h', '1d', '21s', '7d'].forEach((interval) => { + const intervalDetected = getIntervalType(interval); + try { + expect(intervalDetected).toBe('fixed_interval'); + } catch (e) { + throw new Error( + `Expected [${interval}] to be a fixed interval but got [${intervalDetected}]` + ); + } + }); + }); + + test('should detect calendar intervals', () => { + ['1w', '1M', '1q', '1y'].forEach((interval) => { + const intervalDetected = getIntervalType(interval); + try { + expect(intervalDetected).toBe('calendar_interval'); + } catch (e) { + throw new Error( + `Expected [${interval}] to be a calendar interval but got [${intervalDetected}]` + ); + } + }); + }); +}); diff --git a/x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/get_interval_type.ts b/x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/get_interval_type.ts new file mode 100644 index 0000000000000..5e23523a133c4 --- /dev/null +++ b/x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/get_interval_type.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +/** + * Since 8.x we use the "fixed_interval" or "calendar_interval" parameter instead + * of the less precise "interval". This helper parse the interval and return its type. + * @param interval Interval value (e.g. "1d", "1w"...) + */ +export const getIntervalType = (interval: string): 'fixed_interval' | 'calendar_interval' => { + // We will consider all interval as fixed except if they are + // weekly (w), monthly (M), quarterly (q) or yearly (y) + const intervalMetric = interval.charAt(interval.length - 1); + if (['w', 'M', 'q', 'y'].includes(intervalMetric)) { + return 'calendar_interval'; + } + return 'fixed_interval'; +}; diff --git a/x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/index.ts b/x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/index.ts new file mode 100644 index 0000000000000..0bb505e4ea722 --- /dev/null +++ b/x-pack/plugins/watcher/server/models/watch/lib/get_interval_type/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { getIntervalType } from './get_interval_type'; diff --git a/x-pack/plugins/watcher/server/models/watch/threshold_watch/build_visualize_query.js b/x-pack/plugins/watcher/server/models/watch/threshold_watch/build_visualize_query.js index 10ba68c3193a0..60b2dd5a546be 100644 --- a/x-pack/plugins/watcher/server/models/watch/threshold_watch/build_visualize_query.js +++ b/x-pack/plugins/watcher/server/models/watch/threshold_watch/build_visualize_query.js @@ -6,8 +6,10 @@ */ import { cloneDeep } from 'lodash'; + import { buildInput } from '../../../../common/lib/serialization'; import { AGG_TYPES } from '../../../../common/constants'; +import { getIntervalType } from '../lib/get_interval_type'; /* input.search.request.body.query.bool.filter.range @@ -22,17 +24,6 @@ function buildRange({ rangeFrom, rangeTo, timeField }) { }; } -function buildDateAgg({ field, interval, timeZone }) { - return { - date_histogram: { - field, - interval, - time_zone: timeZone, - min_doc_count: 1, - }, - }; -} - function buildAggsCount(body, dateAgg) { return { dateAgg, @@ -93,7 +84,7 @@ function buildAggs(body, { aggType, termField }, dateAgg) { } } -export function buildVisualizeQuery(watch, visualizeOptions) { +export function buildVisualizeQuery(watch, visualizeOptions, kibanaVersion) { const { index, timeWindowSize, @@ -117,11 +108,22 @@ export function buildVisualizeQuery(watch, visualizeOptions) { termOrder, }); const body = watchInput.search.request.body; - const dateAgg = buildDateAgg({ - field: watch.timeField, - interval: visualizeOptions.interval, - timeZone: visualizeOptions.timezone, - }); + const dateAgg = { + date_histogram: { + field: watch.timeField, + time_zone: visualizeOptions.timezone, + min_doc_count: 1, + }, + }; + + if (kibanaVersion.major < 8) { + // In 7.x we use the deprecated "interval" in date_histogram agg + dateAgg.date_histogram.interval = visualizeOptions.interval; + } else { + // From 8.x we use the more precise "fixed_interval" or "calendar_interval" + const intervalType = getIntervalType(visualizeOptions.interval); + dateAgg.date_histogram[intervalType] = visualizeOptions.interval; + } // override the query range body.query.bool.filter.range = buildRange({ diff --git a/x-pack/plugins/watcher/server/models/watch/threshold_watch/threshold_watch.js b/x-pack/plugins/watcher/server/models/watch/threshold_watch/threshold_watch.js index 5cc8a5535c8c3..a20b83e83e3b7 100644 --- a/x-pack/plugins/watcher/server/models/watch/threshold_watch/threshold_watch.js +++ b/x-pack/plugins/watcher/server/models/watch/threshold_watch/threshold_watch.js @@ -48,8 +48,8 @@ export class ThresholdWatch extends BaseWatch { return serializeThresholdWatch(this); } - getVisualizeQuery(visualizeOptions) { - return buildVisualizeQuery(this, visualizeOptions); + getVisualizeQuery(visualizeOptions, kibanaVersion) { + return buildVisualizeQuery(this, visualizeOptions, kibanaVersion); } formatVisualizeData(results) { diff --git a/x-pack/plugins/watcher/server/plugin.ts b/x-pack/plugins/watcher/server/plugin.ts index aea8368c7bbed..52d77520183ab 100644 --- a/x-pack/plugins/watcher/server/plugin.ts +++ b/x-pack/plugins/watcher/server/plugin.ts @@ -6,7 +6,7 @@ */ import { i18n } from '@kbn/i18n'; - +import { SemVer } from 'semver'; import { CoreStart, CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; import { PLUGIN, INDEX_NAMES } from '../common/constants'; @@ -27,17 +27,19 @@ export class WatcherServerPlugin implements Plugin { private readonly license: License; private readonly logger: Logger; - constructor(ctx: PluginInitializerContext) { + constructor(private ctx: PluginInitializerContext) { this.logger = ctx.logger.get(); this.license = new License(); } - setup({ http, getStartServices }: CoreSetup, { licensing, features }: SetupDependencies) { + setup({ http }: CoreSetup, { features }: SetupDependencies) { this.license.setup({ pluginName: PLUGIN.getI18nName(i18n), logger: this.logger, }); + const kibanaVersion = new SemVer(this.ctx.env.packageInfo.version); + const router = http.createRouter(); const routeDependencies: RouteDependencies = { router, @@ -45,6 +47,7 @@ export class WatcherServerPlugin implements Plugin { lib: { handleEsError, }, + kibanaVersion, }; features.registerElasticsearchFeature({ diff --git a/x-pack/plugins/watcher/server/routes/api/watch/register_visualize_route.ts b/x-pack/plugins/watcher/server/routes/api/watch/register_visualize_route.ts index 61836d0ebae47..60442bf43bd68 100644 --- a/x-pack/plugins/watcher/server/routes/api/watch/register_visualize_route.ts +++ b/x-pack/plugins/watcher/server/routes/api/watch/register_visualize_route.ts @@ -37,6 +37,7 @@ export function registerVisualizeRoute({ router, license, lib: { handleEsError }, + kibanaVersion, }: RouteDependencies) { router.post( { @@ -48,7 +49,7 @@ export function registerVisualizeRoute({ license.guardApiRoute(async (ctx, request, response) => { const watch = Watch.fromDownstreamJson(request.body.watch); const options = VisualizeOptions.fromDownstreamJson(request.body.options); - const body = watch.getVisualizeQuery(options); + const body = watch.getVisualizeQuery(options, kibanaVersion); try { const hits = await fetchVisualizeData(ctx.core.elasticsearch.client, watch.index, body); diff --git a/x-pack/plugins/watcher/server/types.ts b/x-pack/plugins/watcher/server/types.ts index c9d43528d9ffa..87cd5e40c2792 100644 --- a/x-pack/plugins/watcher/server/types.ts +++ b/x-pack/plugins/watcher/server/types.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { SemVer } from 'semver'; import type { IRouter } from 'src/core/server'; import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server'; @@ -33,4 +34,5 @@ export interface RouteDependencies { lib: { handleEsError: typeof handleEsError; }; + kibanaVersion: SemVer; } From 52858582527a7821294af4a7fb630ed0224c7290 Mon Sep 17 00:00:00 2001 From: Joe Portner <5295965+jportner@users.noreply.github.com> Date: Sun, 10 Oct 2021 09:34:17 -0400 Subject: [PATCH 05/33] Update sharing saved objects dev docs (#114395) --- ...jects-faq-multiple-deep-link-objects-1.png | Bin 0 -> 33939 bytes ...jects-faq-multiple-deep-link-objects-2.png | Bin 0 -> 61047 bytes .../images/sharing-saved-objects-step-3.png | Bin 127926 -> 126244 bytes .../advanced/sharing-saved-objects.asciidoc | 15 +++++++++------ 4 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 docs/developer/advanced/images/sharing-saved-objects-faq-multiple-deep-link-objects-1.png create mode 100644 docs/developer/advanced/images/sharing-saved-objects-faq-multiple-deep-link-objects-2.png diff --git a/docs/developer/advanced/images/sharing-saved-objects-faq-multiple-deep-link-objects-1.png b/docs/developer/advanced/images/sharing-saved-objects-faq-multiple-deep-link-objects-1.png new file mode 100644 index 0000000000000000000000000000000000000000..d0fd93574d2fafc34add8aea0d188b5a583e1af8 GIT binary patch literal 33939 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 z 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 QBQltjn7n9>utC880sIs3NdN!< literal 0 HcmV?d00001 diff --git a/docs/developer/advanced/images/sharing-saved-objects-faq-multiple-deep-link-objects-2.png b/docs/developer/advanced/images/sharing-saved-objects-faq-multiple-deep-link-objects-2.png new file mode 100644 index 0000000000000000000000000000000000000000..c959042ab8d53233e504c92580ccb5a9776be21a GIT binary patch literal 61047 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 literal 0 HcmV?d00001 diff --git a/docs/developer/advanced/images/sharing-saved-objects-step-3.png b/docs/developer/advanced/images/sharing-saved-objects-step-3.png index 92dd7ebfef88e03356c137611ba07a114ff1b594..482b5f4e93a4ab9e238926540469c59deca334a5 100644 GIT binary patch delta 43300 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
{visInstance && appState && currentAppState && ( @@ -74,6 +127,7 @@ export const VisualizeEditorCommon = ({ )} {visInstance?.vis?.type?.stage === 'experimental' && } {visInstance?.vis?.type?.getInfoMessage?.(visInstance.vis)} + {getLegacyUrlConflictCallout()} {visInstance && (

diff --git a/src/plugins/visualize/public/application/components/visualize_listing.tsx b/src/plugins/visualize/public/application/components/visualize_listing.tsx index 07c3d18b54b0d..7319a9b5e52f8 100644 --- a/src/plugins/visualize/public/application/components/visualize_listing.tsx +++ b/src/plugins/visualize/public/application/components/visualize_listing.tsx @@ -31,7 +31,6 @@ export const VisualizeListing = () => { chrome, dashboard, history, - savedVisualizations, toastNotifications, visualizations, stateTransferService, @@ -113,16 +112,16 @@ export const VisualizeListing = () => { } const isLabsEnabled = uiSettings.get(VISUALIZE_ENABLE_LABS_SETTING); - return savedVisualizations - .findListItems(searchTerm, { size: listingLimit, references }) - .then(({ total, hits }: { total: number; hits: object[] }) => ({ + return visualizations + .findListItems(searchTerm, listingLimit, references) + .then(({ total, hits }: { total: number; hits: Array> }) => ({ total, hits: hits.filter( (result: any) => isLabsEnabled || result.type?.stage !== 'experimental' ), })); }, - [listingLimit, savedVisualizations, uiSettings, savedObjectsTagging] + [listingLimit, uiSettings, savedObjectsTagging, visualizations] ); const deleteItems = useCallback( diff --git a/src/plugins/visualize/public/application/types.ts b/src/plugins/visualize/public/application/types.ts index 7e9f69163f5a6..4debd9a4a7b7d 100644 --- a/src/plugins/visualize/public/application/types.ts +++ b/src/plugins/visualize/public/application/types.ts @@ -42,6 +42,7 @@ import type { SavedObjectsStart, SavedObject } from 'src/plugins/saved_objects/p import type { EmbeddableStart, EmbeddableStateTransfer } from 'src/plugins/embeddable/public'; import type { UrlForwardingStart } from 'src/plugins/url_forwarding/public'; import type { PresentationUtilPluginStart } from 'src/plugins/presentation_util/public'; +import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/public'; import type { DashboardStart } from '../../../dashboard/public'; import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; import type { UsageCollectionStart } from '../../../usage_collection/public'; @@ -94,7 +95,6 @@ export interface VisualizeServices extends CoreStart { dashboardCapabilities: Record>; visualizations: VisualizationsStart; savedObjectsPublic: SavedObjectsStart; - savedVisualizations: VisualizationsStart['savedVisualizationsLoader']; setActiveUrl: (newUrl: string) => void; createVisEmbeddableFromObject: VisualizationsStart['__LEGACY']['createVisEmbeddableFromObject']; restorePreviousUrl: () => void; @@ -105,6 +105,7 @@ export interface VisualizeServices extends CoreStart { presentationUtil: PresentationUtilPluginStart; usageCollection?: UsageCollectionStart; getKibanaVersion: () => string; + spaces?: SpacesPluginStart; } export interface SavedVisInstance { diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index 0dc37ca00a6aa..9d1c93f25645c 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -14,7 +14,11 @@ import { parse } from 'query-string'; import { Capabilities } from 'src/core/public'; import { TopNavMenuData } from 'src/plugins/navigation/public'; -import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput } from '../../../../visualizations/public'; +import { + VISUALIZE_EMBEDDABLE_TYPE, + VisualizeInput, + getFullPath, +} from '../../../../visualizations/public'; import { showSaveModal, SavedObjectSaveModalOrigin, @@ -87,6 +91,7 @@ export const getTopNavConfig = ( data, application, chrome, + overlays, history, share, setActiveUrl, @@ -99,6 +104,8 @@ export const getTopNavConfig = ( presentationUtil, usageCollection, getKibanaVersion, + savedObjects, + visualizations, }: VisualizeServices ) => { const { vis, embeddableHandler } = visInstance; @@ -117,8 +124,10 @@ export const getTopNavConfig = ( /** * Called when the user clicks "Save" button. */ - async function doSave(saveOptions: SavedObjectSaveOpts & { dashboardId?: string }) { - const newlyCreated = !Boolean(savedVis.id) || savedVis.copyOnSave; + async function doSave( + saveOptions: SavedObjectSaveOpts & { dashboardId?: string; copyOnSave?: boolean } + ) { + const newlyCreated = !Boolean(savedVis.id) || saveOptions.copyOnSave; // vis.title was not bound and it's needed to reflect title into visState stateContainer.transitions.setVis({ title: savedVis.title, @@ -129,7 +138,7 @@ export const getTopNavConfig = ( setHasUnsavedChanges(false); try { - const id = await savedVis.save(saveOptions); + const id = await visualizations.saveVisualization(savedVis, saveOptions); if (id) { toastNotifications.addSuccess({ @@ -142,6 +151,8 @@ export const getTopNavConfig = ( 'data-test-subj': 'saveVisualizationSuccess', }); + chrome.recentlyAccessed.add(getFullPath(id), savedVis.title, String(id)); + if ((originatingApp && saveOptions.returnToOrigin) || saveOptions.dashboardId) { if (!embeddableId) { const appPath = `${VisualizeConstants.EDIT_PATH}/${encodeURIComponent(id)}`; @@ -164,7 +175,7 @@ export const getTopNavConfig = ( state: { type: VISUALIZE_EMBEDDABLE_TYPE, input: { savedObjectId: id }, - embeddableId: savedVis.copyOnSave ? undefined : embeddableId, + embeddableId: saveOptions.copyOnSave ? undefined : embeddableId, searchSessionId: data.search.session.getSessionId(), }, path, @@ -392,11 +403,10 @@ export const getTopNavConfig = ( const currentTitle = savedVis.title; savedVis.title = newTitle; embeddableHandler.updateInput({ title: newTitle }); - savedVis.copyOnSave = newCopyOnSave; savedVis.description = newDescription; - if (savedObjectsTagging && savedObjectsTagging.ui.hasTagDecoration(savedVis)) { - savedVis.setTags(selectedTags); + if (savedObjectsTagging) { + savedVis.tags = selectedTags; } const saveOptions = { @@ -405,6 +415,7 @@ export const getTopNavConfig = ( onTitleDuplicate, returnToOrigin, dashboardId: !!dashboardId ? dashboardId : undefined, + copyOnSave: newCopyOnSave, }; // If we're adding to a dashboard and not saving to library, @@ -457,9 +468,7 @@ export const getTopNavConfig = ( let tagOptions: React.ReactNode | undefined; if (savedObjectsTagging) { - if (savedVis && savedObjectsTagging.ui.hasTagDecoration(savedVis)) { - selectedTags = savedVis.getTags(); - } + selectedTags = savedVis.tags || []; tagOptions = ( ({ })), })); +let savedVisMock: VisSavedObject; + describe('getVisualizationInstance', () => { const serializedVisMock = { type: 'area', }; - let savedVisMock: VisSavedObject; let visMock: Vis; let mockServices: jest.Mocked; let subj: BehaviorSubject; @@ -47,13 +48,16 @@ describe('getVisualizationInstance', () => { data: {}, } as Vis; savedVisMock = {} as VisSavedObject; + // @ts-expect-error mockServices.data.search.showError.mockImplementation(() => {}); // @ts-expect-error - mockServices.savedVisualizations.get.mockImplementation(() => savedVisMock); - // @ts-expect-error mockServices.visualizations.convertToSerializedVis.mockImplementation(() => serializedVisMock); // @ts-expect-error + mockServices.visualizations.getSavedVisualization.mockImplementation( + (opts: unknown) => savedVisMock + ); + // @ts-expect-error mockServices.visualizations.createVis.mockImplementation(() => visMock); // @ts-expect-error mockServices.createVisEmbeddableFromObject.mockImplementation(() => ({ @@ -71,7 +75,9 @@ describe('getVisualizationInstance', () => { opts ); - expect(mockServices.savedVisualizations.get).toHaveBeenCalledWith(opts); + expect((mockServices.visualizations.getSavedVisualization as jest.Mock).mock.calls[0][0]).toBe( + opts + ); expect(savedVisMock.searchSourceFields).toEqual({ index: opts.indexPattern, }); @@ -98,7 +104,9 @@ describe('getVisualizationInstance', () => { visMock.type.setup = jest.fn(() => newVisObj); const { vis } = await getVisualizationInstance(mockServices, 'saved_vis_id'); - expect(mockServices.savedVisualizations.get).toHaveBeenCalledWith('saved_vis_id'); + expect((mockServices.visualizations.getSavedVisualization as jest.Mock).mock.calls[0][0]).toBe( + 'saved_vis_id' + ); expect(savedVisMock.searchSourceFields).toBeUndefined(); expect(visMock.type.setup).toHaveBeenCalledWith(visMock); expect(vis).toBe(newVisObj); @@ -128,7 +136,6 @@ describe('getVisualizationInstanceInput', () => { const serializedVisMock = { type: 'pie', }; - let savedVisMock: VisSavedObject; let visMock: Vis; let mockServices: jest.Mocked; let subj: BehaviorSubject; @@ -142,10 +149,12 @@ describe('getVisualizationInstanceInput', () => { } as Vis; savedVisMock = {} as VisSavedObject; // @ts-expect-error - mockServices.savedVisualizations.get.mockImplementation(() => savedVisMock); - // @ts-expect-error mockServices.visualizations.createVis.mockImplementation(() => visMock); // @ts-expect-error + mockServices.visualizations.getSavedVisualization.mockImplementation( + (opts: unknown) => savedVisMock + ); + // @ts-expect-error mockServices.createVisEmbeddableFromObject.mockImplementation(() => ({ getOutput$: jest.fn(() => subj.asObservable()), })); @@ -183,7 +192,7 @@ describe('getVisualizationInstanceInput', () => { const { savedVis, savedSearch, vis, embeddableHandler } = await getVisualizationInstanceFromInput(mockServices, input); - expect(mockServices.savedVisualizations.get).toHaveBeenCalled(); + expect(mockServices.visualizations.getSavedVisualization).toHaveBeenCalled(); expect(mockServices.visualizations.createVis).toHaveBeenCalledWith( serializedVisMock.type, input.savedVis diff --git a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts index 88797ce264e25..faf25ff28cec0 100644 --- a/src/plugins/visualize/public/application/utils/get_visualization_instance.ts +++ b/src/plugins/visualize/public/application/utils/get_visualization_instance.ts @@ -66,14 +66,15 @@ export const getVisualizationInstanceFromInput = async ( visualizeServices: VisualizeServices, input: VisualizeInput ) => { - const { visualizations, savedVisualizations } = visualizeServices; + const { visualizations } = visualizeServices; const visState = input.savedVis as SerializedVis; /** * A saved vis is needed even in by value mode to support 'save to library' which converts the 'by value' * state of the visualization, into a new saved object. */ - const savedVis: VisSavedObject = await savedVisualizations.get(); + const savedVis: VisSavedObject = await visualizations.getSavedVisualization(); + if (visState.uiState && Object.keys(visState.uiState).length !== 0) { savedVis.uiStateJSON = JSON.stringify(visState.uiState); } @@ -107,8 +108,8 @@ export const getVisualizationInstance = async ( */ opts?: Record | string ) => { - const { visualizations, savedVisualizations } = visualizeServices; - const savedVis: VisSavedObject = await savedVisualizations.get(opts); + const { visualizations } = visualizeServices; + const savedVis: VisSavedObject = await visualizations.getSavedVisualization(opts); if (typeof opts !== 'string') { savedVis.searchSourceFields = { index: opts?.indexPattern } as SearchSourceFields; diff --git a/src/plugins/visualize/public/application/utils/mocks.ts b/src/plugins/visualize/public/application/utils/mocks.ts index a7029071851ca..f26c81ed99a89 100644 --- a/src/plugins/visualize/public/application/utils/mocks.ts +++ b/src/plugins/visualize/public/application/utils/mocks.ts @@ -26,7 +26,6 @@ export const createVisualizeServicesMock = () => { location: { pathname: '' }, }, visualizations, - savedVisualizations: visualizations.savedVisualizationsLoader, createVisEmbeddableFromObject: visualizations.__LEGACY.createVisEmbeddableFromObject, } as unknown as jest.Mocked; }; diff --git a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts index b142f3fcd4061..f81744326365a 100644 --- a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts +++ b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.test.ts @@ -22,7 +22,6 @@ import { createEmbeddableStateTransferMock } from '../../../../../embeddable/pub const mockDefaultEditorControllerDestroy = jest.fn(); const mockEmbeddableHandlerDestroy = jest.fn(); const mockEmbeddableHandlerRender = jest.fn(); -const mockSavedVisDestroy = jest.fn(); const savedVisId = '9ca7aa90-b892-11e8-a6d9-e546fe2bba5f'; const mockSavedVisInstance = { embeddableHandler: { @@ -32,7 +31,6 @@ const mockSavedVisInstance = { savedVis: { id: savedVisId, title: 'Test Vis', - destroy: mockSavedVisDestroy, }, vis: { type: {}, @@ -103,7 +101,6 @@ describe('useSavedVisInstance', () => { mockDefaultEditorControllerDestroy.mockClear(); mockEmbeddableHandlerDestroy.mockClear(); mockEmbeddableHandlerRender.mockClear(); - mockSavedVisDestroy.mockClear(); toastNotifications.addWarning.mockClear(); mockGetVisualizationInstance.mockClear(); }); @@ -153,7 +150,6 @@ describe('useSavedVisInstance', () => { expect(mockDefaultEditorControllerDestroy.mock.calls.length).toBe(1); expect(mockEmbeddableHandlerDestroy).not.toHaveBeenCalled(); - expect(mockSavedVisDestroy.mock.calls.length).toBe(1); }); }); @@ -236,7 +232,6 @@ describe('useSavedVisInstance', () => { unmount(); expect(mockDefaultEditorControllerDestroy).not.toHaveBeenCalled(); expect(mockEmbeddableHandlerDestroy.mock.calls.length).toBe(1); - expect(mockSavedVisDestroy.mock.calls.length).toBe(1); }); }); }); diff --git a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts index 965951bfbd88d..b5919ec074966 100644 --- a/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts +++ b/src/plugins/visualize/public/application/utils/use/use_saved_vis_instance.ts @@ -176,9 +176,6 @@ export const useSavedVisInstance = ( } else if (state.savedVisInstance?.embeddableHandler) { state.savedVisInstance.embeddableHandler.destroy(); } - if (state.savedVisInstance?.savedVis) { - state.savedVisInstance.savedVis.destroy(); - } }; }, [state]); diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index b128c09209743..c9df6a6ec57d8 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -28,6 +28,7 @@ import { createKbnUrlStateStorage, withNotifyOnErrors, } from '../../kibana_utils/public'; +import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; import { VisualizeConstants } from './application/visualize_constants'; import { DataPublicPluginStart, DataPublicPluginSetup, esFilters } from '../../data/public'; @@ -61,6 +62,7 @@ export interface VisualizePluginStartDependencies { savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; presentationUtil: PresentationUtilPluginStart; usageCollection?: UsageCollectionStart; + spaces: SpacesPluginStart; } export interface VisualizePluginSetupDependencies { @@ -192,7 +194,6 @@ export class VisualizePlugin data: pluginsStart.data, localStorage: new Storage(localStorage), navigation: pluginsStart.navigation, - savedVisualizations: pluginsStart.visualizations.savedVisualizationsLoader, share: pluginsStart.share, toastNotifications: coreStart.notifications.toasts, visualizeCapabilities: coreStart.application.capabilities.visualize, @@ -212,6 +213,7 @@ export class VisualizePlugin presentationUtil: pluginsStart.presentationUtil, usageCollection: pluginsStart.usageCollection, getKibanaVersion: () => this.initializerContext.env.packageInfo.version, + spaces: pluginsStart.spaces, }; params.element.classList.add('visAppWrapper'); diff --git a/src/plugins/visualize/tsconfig.json b/src/plugins/visualize/tsconfig.json index 3f1f7487085bf..9c1e3fd72ff8b 100644 --- a/src/plugins/visualize/tsconfig.json +++ b/src/plugins/visualize/tsconfig.json @@ -24,6 +24,7 @@ { "path": "../kibana_react/tsconfig.json" }, { "path": "../home/tsconfig.json" }, { "path": "../presentation_util/tsconfig.json" }, - { "path": "../discover/tsconfig.json" } + { "path": "../discover/tsconfig.json" }, + { "path": "../../../x-pack/plugins/spaces/tsconfig.json" }, ] } From 930fe96260d08830d64cd6e7f52b553341ceaa48 Mon Sep 17 00:00:00 2001 From: juliaElastic <90178898+juliaElastic@users.noreply.github.com> Date: Mon, 11 Oct 2021 11:00:53 +0200 Subject: [PATCH 12/33] [Fleet] added support for installing tag saved objects (#114110) * added tag saved objects to assets * fixed review comments * added translation to constants * added missing icon type Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../package_to_package_policy.test.ts | 1 + .../plugins/fleet/common/types/models/epm.ts | 2 + .../components/assets_facet_group.stories.tsx | 1 + .../integrations/sections/epm/constants.tsx | 65 ++++++++++++++----- .../services/epm/kibana/assets/install.ts | 2 + ...kage_policies_to_agent_permissions.test.ts | 3 + .../context/fixtures/integration.nginx.ts | 1 + .../context/fixtures/integration.okta.ts | 1 + .../apis/epm/install_remove_assets.ts | 11 ++++ .../apis/epm/update_assets.ts | 5 ++ .../kibana/dashboard/sample_dashboard.json | 3 +- .../0.1.0/kibana/tag/sample_tag.json | 17 +++++ .../kibana/dashboard/sample_dashboard.json | 3 +- .../0.2.0/kibana/tag/sample_tag.json | 17 +++++ .../error_handling/0.2.0/manifest.yml | 1 + 15 files changed, 116 insertions(+), 17 deletions(-) create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json create mode 100644 x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json diff --git a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts index 275cf237a9621..e554eb925c38a 100644 --- a/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts +++ b/x-pack/plugins/fleet/common/services/package_to_package_policy.test.ts @@ -33,6 +33,7 @@ describe('Fleet - packageToPackagePolicy', () => { lens: [], ml_module: [], security_rule: [], + tag: [], }, elasticsearch: { ingest_pipeline: [], diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 1325d74f82b68..a487fd0a37e70 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -69,6 +69,7 @@ export enum KibanaAssetType { lens = 'lens', securityRule = 'security_rule', mlModule = 'ml_module', + tag = 'tag', } /* @@ -83,6 +84,7 @@ export enum KibanaSavedObjectType { lens = 'lens', mlModule = 'ml-module', securityRule = 'security-rule', + tag = 'tag', } export enum ElasticsearchAssetType { diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx index d98f2b2408d56..a7fa069e77a69 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/components/assets_facet_group.stories.tsx @@ -36,6 +36,7 @@ export const AssetsFacetGroup = ({ width }: Args) => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], diff --git a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx index 8e900e625215f..25604bb6b984d 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/sections/epm/constants.tsx @@ -6,6 +6,7 @@ */ import type { IconType } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import type { ServiceName } from '../../types'; import { ElasticsearchAssetType, KibanaAssetType } from '../../types'; @@ -22,21 +23,54 @@ export const DisplayedAssets: ServiceNameToAssetTypes = { export type DisplayedAssetType = ElasticsearchAssetType | KibanaAssetType | 'view'; export const AssetTitleMap: Record = { - dashboard: 'Dashboards', - ilm_policy: 'ILM policies', - ingest_pipeline: 'Ingest pipelines', - transform: 'Transforms', - index_pattern: 'Index patterns', - index_template: 'Index templates', - component_template: 'Component templates', - search: 'Saved searches', - visualization: 'Visualizations', - map: 'Maps', - data_stream_ilm_policy: 'Data stream ILM policies', - lens: 'Lens', - security_rule: 'Security rules', - ml_module: 'ML modules', - view: 'Views', + dashboard: i18n.translate('xpack.fleet.epm.assetTitles.dashboards', { + defaultMessage: 'Dashboards', + }), + ilm_policy: i18n.translate('xpack.fleet.epm.assetTitles.ilmPolicies', { + defaultMessage: 'ILM policies', + }), + ingest_pipeline: i18n.translate('xpack.fleet.epm.assetTitles.ingestPipelines', { + defaultMessage: 'Ingest pipelines', + }), + transform: i18n.translate('xpack.fleet.epm.assetTitles.transforms', { + defaultMessage: 'Transforms', + }), + index_pattern: i18n.translate('xpack.fleet.epm.assetTitles.indexPatterns', { + defaultMessage: 'Index patterns', + }), + index_template: i18n.translate('xpack.fleet.epm.assetTitles.indexTemplates', { + defaultMessage: 'Index templates', + }), + component_template: i18n.translate('xpack.fleet.epm.assetTitles.componentTemplates', { + defaultMessage: 'Component templates', + }), + search: i18n.translate('xpack.fleet.epm.assetTitles.savedSearches', { + defaultMessage: 'Saved searches', + }), + visualization: i18n.translate('xpack.fleet.epm.assetTitles.visualizations', { + defaultMessage: 'Visualizations', + }), + map: i18n.translate('xpack.fleet.epm.assetTitles.maps', { + defaultMessage: 'Maps', + }), + data_stream_ilm_policy: i18n.translate('xpack.fleet.epm.assetTitles.dataStreamILM', { + defaultMessage: 'Data stream ILM policies', + }), + lens: i18n.translate('xpack.fleet.epm.assetTitles.lens', { + defaultMessage: 'Lens', + }), + security_rule: i18n.translate('xpack.fleet.epm.assetTitles.securityRules', { + defaultMessage: 'Security rules', + }), + ml_module: i18n.translate('xpack.fleet.epm.assetTitles.mlModules', { + defaultMessage: 'ML modules', + }), + view: i18n.translate('xpack.fleet.epm.assetTitles.views', { + defaultMessage: 'Views', + }), + tag: i18n.translate('xpack.fleet.epm.assetTitles.tag', { + defaultMessage: 'Tag', + }), }; export const ServiceTitleMap: Record = { @@ -53,6 +87,7 @@ export const AssetIcons: Record = { lens: 'lensApp', security_rule: 'securityApp', ml_module: 'mlApp', + tag: 'tagApp', }; export const ServiceIcons: Record = { diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index 0f2d7b6679bf9..50c0239cd8c56 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -39,6 +39,7 @@ const KibanaSavedObjectTypeMapping: Record { diff --git a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts index 9f8ac01afe6c9..845e4f1d2670e 100644 --- a/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts +++ b/x-pack/plugins/fleet/server/services/package_policies_to_agent_permissions.test.ts @@ -97,6 +97,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], @@ -207,6 +208,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], @@ -323,6 +325,7 @@ describe('storedPackagePoliciesToAgentPermissions()', () => { lens: [], security_rule: [], ml_module: [], + tag: [], }, elasticsearch: { component_template: [], diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts b/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts index 50262b73a6a41..e0179897a59c7 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/integration.nginx.ts @@ -252,6 +252,7 @@ export const response: GetInfoResponse['response'] = { lens: [], map: [], security_rule: [], + tag: [], }, elasticsearch: { ingest_pipeline: [ diff --git a/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts b/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts index efef00579f4bd..387161171485b 100644 --- a/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts +++ b/x-pack/plugins/fleet/storybook/context/fixtures/integration.okta.ts @@ -105,6 +105,7 @@ export const response: GetInfoResponse['response'] = { lens: [], ml_module: [], security_rule: [], + tag: [], }, elasticsearch: { ingest_pipeline: [ diff --git a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts index e57899531e939..79f3d52821f75 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/install_remove_assets.ts @@ -414,6 +414,7 @@ const expectAssetsInstalled = ({ id: 'sample_dashboard', }); expect(resDashboard.id).equal('sample_dashboard'); + expect(resDashboard.references.map((ref: any) => ref.id).includes('sample_tag')).equal(true); const resDashboard2 = await kibanaServer.savedObjects.get({ type: 'dashboard', id: 'sample_dashboard2', @@ -444,6 +445,11 @@ const expectAssetsInstalled = ({ id: 'sample_security_rule', }); expect(resSecurityRule.id).equal('sample_security_rule'); + const resTag = await kibanaServer.savedObjects.get({ + type: 'tag', + id: 'sample_tag', + }); + expect(resTag.id).equal('sample_tag'); const resIndexPattern = await kibanaServer.savedObjects.get({ type: 'index-pattern', id: 'test-*', @@ -521,6 +527,10 @@ const expectAssetsInstalled = ({ id: 'sample_security_rule', type: 'security-rule', }, + { + id: 'sample_tag', + type: 'tag', + }, { id: 'sample_visualization', type: 'visualization', @@ -607,6 +617,7 @@ const expectAssetsInstalled = ({ { id: '4c758d70-ecf1-56b3-b704-6d8374841b34', type: 'epm-packages-assets' }, { id: 'e786cbd9-0f3b-5a0b-82a6-db25145ebf58', type: 'epm-packages-assets' }, { id: 'd8b175c3-0d42-5ec7-90c1-d1e4b307a4c2', type: 'epm-packages-assets' }, + { id: 'b265a5e0-c00b-5eda-ac44-2ddbd36d9ad0', type: 'epm-packages-assets' }, { id: '53c94591-aa33-591d-8200-cd524c2a0561', type: 'epm-packages-assets' }, { id: 'b658d2d4-752e-54b8-afc2-4c76155c1466', type: 'epm-packages-assets' }, ], diff --git a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts index f46dcdb761e6d..5282312164148 100644 --- a/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts +++ b/x-pack/test/fleet_api_integration/apis/epm/update_assets.ts @@ -339,6 +339,10 @@ export default function (providerContext: FtrProviderContext) { id: 'sample_ml_module', type: 'ml-module', }, + { + id: 'sample_tag', + type: 'tag', + }, ], installed_es: [ { @@ -418,6 +422,7 @@ export default function (providerContext: FtrProviderContext) { { id: '4281a436-45a8-54ab-9724-fda6849f789d', type: 'epm-packages-assets' }, { id: '2e56f08b-1d06-55ed-abee-4708e1ccf0aa', type: 'epm-packages-assets' }, { id: '4035007b-9c33-5227-9803-2de8a17523b5', type: 'epm-packages-assets' }, + { id: 'e6ae7d31-6920-5408-9219-91ef1662044b', type: 'epm-packages-assets' }, { id: 'c7bf1a39-e057-58a0-afde-fb4b48751d8c', type: 'epm-packages-assets' }, { id: '8c665f28-a439-5f43-b5fd-8fda7b576735', type: 'epm-packages-assets' }, ], diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json index 7f416c26cc9aa..c75dd7673dc38 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/dashboard/sample_dashboard.json @@ -15,7 +15,8 @@ { "id": "sample_visualization", "name": "panel_0", "type": "visualization" }, { "id": "sample_search", "name": "panel_1", "type": "search" }, { "id": "sample_search", "name": "panel_2", "type": "search" }, - { "id": "sample_visualization", "name": "panel_3", "type": "visualization" } + { "id": "sample_visualization", "name": "panel_3", "type": "visualization" }, + { "id": "sample_tag", "type": "tag", "name": "tag-ref-sample_tag" } ], "id": "sample_dashboard", "type": "dashboard" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json new file mode 100644 index 0000000000000..c6494d42679b9 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.1.0/kibana/tag/sample_tag.json @@ -0,0 +1,17 @@ +{ + "id": "sample_tag", + "type": "tag", + "namespaces": [ + "default" + ], + "attributes": { + "name": "my tag", + "description": "", + "color": "#a80853" + }, + "references": [], + "migrationVersion": { + "tag": "8.0.0" + }, + "coreMigrationVersion": "8.0.0" +} \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json index 4513c07f27786..1215a934c6368 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/dashboard/sample_dashboard.json @@ -15,7 +15,8 @@ { "id": "sample_visualization", "name": "panel_0", "type": "visualization" }, { "id": "sample_search2", "name": "panel_1", "type": "search" }, { "id": "sample_search2", "name": "panel_2", "type": "search" }, - { "id": "sample_visualization", "name": "panel_3", "type": "visualization" } + { "id": "sample_visualization", "name": "panel_3", "type": "visualization" }, + { "id": "sample_tag", "type": "tag", "name": "tag-ref-sample_tag" } ], "id": "sample_dashboard", "type": "dashboard" diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json new file mode 100644 index 0000000000000..c6494d42679b9 --- /dev/null +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/all_assets/0.2.0/kibana/tag/sample_tag.json @@ -0,0 +1,17 @@ +{ + "id": "sample_tag", + "type": "tag", + "namespaces": [ + "default" + ], + "attributes": { + "name": "my tag", + "description": "", + "color": "#a80853" + }, + "references": [], + "migrationVersion": { + "tag": "8.0.0" + }, + "coreMigrationVersion": "8.0.0" +} \ No newline at end of file diff --git a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml index c92f0ab5ae7f3..c473ce29b87d5 100644 --- a/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml +++ b/x-pack/test/fleet_api_integration/apis/fixtures/test_packages/error_handling/0.2.0/manifest.yml @@ -17,3 +17,4 @@ requirement: icons: - src: '/img/logo_overrides_64_color.svg' size: '16x16' + type: 'image/svg+xml' From 25fef38f123dc6c7b5e6c94f7268d42a248b5db7 Mon Sep 17 00:00:00 2001 From: Clint Andrew Hall Date: Mon, 11 Oct 2021 04:19:00 -0500 Subject: [PATCH 13/33] [fleet][ui] Fix offset image; scrollbar flashing; missing assets in Stories (#114406) --- packages/kbn-storybook/src/lib/run_storybook_cli.ts | 6 +++++- .../plugins/fleet/public/applications/integrations/app.tsx | 6 ++---- .../public/applications/integrations/layouts/default.tsx | 2 ++ 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/kbn-storybook/src/lib/run_storybook_cli.ts b/packages/kbn-storybook/src/lib/run_storybook_cli.ts index 24a3e4511f7be..93197a1f2b318 100644 --- a/packages/kbn-storybook/src/lib/run_storybook_cli.ts +++ b/packages/kbn-storybook/src/lib/run_storybook_cli.ts @@ -36,7 +36,11 @@ export function runStorybookCli({ configDir, name }: { configDir: string; name: async ({ flags, log }) => { log.debug('Global config:\n', constants); - const staticDir = [UiSharedDepsNpm.distDir, UiSharedDepsSrc.distDir]; + const staticDir = [ + UiSharedDepsNpm.distDir, + UiSharedDepsSrc.distDir, + 'src/plugins/kibana_react/public/assets:plugins/kibanaReact/assets', + ]; const config: Record = { configDir, mode: flags.site ? 'static' : 'dev', diff --git a/x-pack/plugins/fleet/public/applications/integrations/app.tsx b/x-pack/plugins/fleet/public/applications/integrations/app.tsx index c5cc1e1892eda..b10cef9d3ffe4 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/app.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/app.tsx @@ -39,15 +39,13 @@ import { Error, Loading, SettingFlyout } from './components'; import type { UIExtensionsStorage } from './types'; import { EPMApp } from './sections/epm'; -import { DefaultLayout, WithoutHeaderLayout } from './layouts'; +import { DefaultLayout } from './layouts'; import { PackageInstallProvider } from './hooks'; import { useBreadcrumbs, UIExtensionsContext } from './hooks'; const ErrorLayout = ({ children }: { children: JSX.Element }) => ( - - {children} - + {children} ); diff --git a/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx b/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx index e4de48a85c35a..70e55c9bd56b0 100644 --- a/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx +++ b/x-pack/plugins/fleet/public/applications/integrations/layouts/default.tsx @@ -28,6 +28,8 @@ interface Props { const Illustration = styled(EuiImage)` margin-bottom: -68px; + position: relative; + top: -20px; width: 80%; `; From cba91fdaab4153c4a7257c506f187c30755b3031 Mon Sep 17 00:00:00 2001 From: Pierre Gayvallet Date: Mon, 11 Oct 2021 11:48:10 +0200 Subject: [PATCH 14/33] Status service: improve overall status summary (#114228) * improve getSummaryStatus * fix unit tests --- .../server/status/get_summary_status.test.ts | 153 +++++++++--------- src/core/server/status/get_summary_status.ts | 40 +++-- src/core/server/status/plugins_status.test.ts | 32 ++-- src/core/server/status/status_service.test.ts | 26 +-- 4 files changed, 127 insertions(+), 124 deletions(-) diff --git a/src/core/server/status/get_summary_status.test.ts b/src/core/server/status/get_summary_status.test.ts index 33b2e6f7913a1..2c91aa8c7b16a 100644 --- a/src/core/server/status/get_summary_status.test.ts +++ b/src/core/server/status/get_summary_status.test.ts @@ -81,93 +81,86 @@ describe('getSummaryStatus', () => { }); describe('summary', () => { - describe('when a single service is at highest level', () => { - it('returns all information about that single service', () => { - expect( - getSummaryStatus( - Object.entries({ - s1: degraded, - s2: { - level: ServiceStatusLevels.unavailable, - summary: 'Lorem ipsum', - meta: { - custom: { data: 'here' }, - }, + it('returns correct summary when a single service is affected', () => { + expect( + getSummaryStatus( + Object.entries({ + s1: degraded, + s2: { + level: ServiceStatusLevels.unavailable, + summary: 'Lorem ipsum', + meta: { + custom: { data: 'here' }, }, - }) - ) - ).toEqual({ - level: ServiceStatusLevels.unavailable, - summary: '[s2]: Lorem ipsum', - detail: 'See the status page for more information', - meta: { - affectedServices: ['s2'], - }, - }); + }, + }) + ) + ).toEqual({ + level: ServiceStatusLevels.unavailable, + summary: '1 service is unavailable: s2', + detail: 'See the status page for more information', + meta: { + affectedServices: ['s2'], + }, }); + }); - it('allows the single service to override the detail and documentationUrl fields', () => { - expect( - getSummaryStatus( - Object.entries({ - s1: degraded, - s2: { - level: ServiceStatusLevels.unavailable, - summary: 'Lorem ipsum', - detail: 'Vivamus pulvinar sem ac luctus ultrices.', - documentationUrl: 'http://helpmenow.com/problem1', - meta: { - custom: { data: 'here' }, - }, + it('returns correct summary when multiple services are affected', () => { + expect( + getSummaryStatus( + Object.entries({ + s1: degraded, + s2: { + level: ServiceStatusLevels.unavailable, + summary: 'Lorem ipsum', + detail: 'Vivamus pulvinar sem ac luctus ultrices.', + documentationUrl: 'http://helpmenow.com/problem1', + meta: { + custom: { data: 'here' }, + }, + }, + s3: { + level: ServiceStatusLevels.unavailable, + summary: 'Proin mattis', + detail: 'Nunc quis nulla at mi lobortis pretium.', + documentationUrl: 'http://helpmenow.com/problem2', + meta: { + other: { data: 'over there' }, }, - }) - ) - ).toEqual({ - level: ServiceStatusLevels.unavailable, - summary: '[s2]: Lorem ipsum', - detail: 'Vivamus pulvinar sem ac luctus ultrices.', - documentationUrl: 'http://helpmenow.com/problem1', - meta: { - affectedServices: ['s2'], - }, - }); + }, + }) + ) + ).toEqual({ + level: ServiceStatusLevels.unavailable, + summary: '2 services are unavailable: s2, s3', + detail: 'See the status page for more information', + meta: { + affectedServices: ['s2', 's3'], + }, }); }); - describe('when multiple services is at highest level', () => { - it('returns aggregated information about the affected services', () => { - expect( - getSummaryStatus( - Object.entries({ - s1: degraded, - s2: { - level: ServiceStatusLevels.unavailable, - summary: 'Lorem ipsum', - detail: 'Vivamus pulvinar sem ac luctus ultrices.', - documentationUrl: 'http://helpmenow.com/problem1', - meta: { - custom: { data: 'here' }, - }, - }, - s3: { - level: ServiceStatusLevels.unavailable, - summary: 'Proin mattis', - detail: 'Nunc quis nulla at mi lobortis pretium.', - documentationUrl: 'http://helpmenow.com/problem2', - meta: { - other: { data: 'over there' }, - }, - }, - }) - ) - ).toEqual({ - level: ServiceStatusLevels.unavailable, - summary: '[2] services are unavailable', - detail: 'See the status page for more information', - meta: { - affectedServices: ['s2', 's3'], - }, - }); + it('returns correct summary more than `maxServices` services are affected', () => { + expect( + getSummaryStatus( + Object.entries({ + s1: degraded, + s2: available, + s3: degraded, + s4: degraded, + s5: degraded, + s6: available, + s7: degraded, + }), + { maxServices: 3 } + ) + ).toEqual({ + level: ServiceStatusLevels.degraded, + summary: '5 services are degraded: s1, s3, s4 and 2 other(s)', + detail: 'See the status page for more information', + meta: { + affectedServices: ['s1', 's3', 's4', 's5', 's7'], + }, }); }); }); diff --git a/src/core/server/status/get_summary_status.ts b/src/core/server/status/get_summary_status.ts index 9124023148dd1..1dc939ce3f80c 100644 --- a/src/core/server/status/get_summary_status.ts +++ b/src/core/server/status/get_summary_status.ts @@ -10,11 +10,13 @@ import { ServiceStatus, ServiceStatusLevels, ServiceStatusLevel } from './types' /** * Returns a single {@link ServiceStatus} that summarizes the most severe status level from a group of statuses. - * @param statuses */ export const getSummaryStatus = ( statuses: Array<[string, ServiceStatus]>, - { allAvailableSummary = `All services are available` }: { allAvailableSummary?: string } = {} + { + allAvailableSummary = `All services are available`, + maxServices = 3, + }: { allAvailableSummary?: string; maxServices?: number } = {} ): ServiceStatus => { const { highestLevel, highestStatuses } = highestLevelSummary(statuses); @@ -23,30 +25,38 @@ export const getSummaryStatus = ( level: ServiceStatusLevels.available, summary: allAvailableSummary, }; - } else if (highestStatuses.length === 1) { - const [serviceName, status] = highestStatuses[0]! as [string, ServiceStatus]; - return { - ...status, - summary: `[${serviceName}]: ${status.summary!}`, - // TODO: include URL to status page - detail: status.detail ?? `See the status page for more information`, - meta: { - affectedServices: [serviceName], - }, - }; } else { + const affectedServices = highestStatuses.map(([serviceName]) => serviceName); return { level: highestLevel, - summary: `[${highestStatuses.length}] services are ${highestLevel.toString()}`, + summary: getSummaryContent(affectedServices, highestLevel, maxServices), // TODO: include URL to status page detail: `See the status page for more information`, meta: { - affectedServices: highestStatuses.map(([serviceName]) => serviceName), + affectedServices, }, }; } }; +const getSummaryContent = ( + affectedServices: string[], + statusLevel: ServiceStatusLevel, + maxServices: number +): string => { + const serviceCount = affectedServices.length; + if (serviceCount === 1) { + return `1 service is ${statusLevel.toString()}: ${affectedServices[0]}`; + } else if (serviceCount > maxServices) { + const exceedingCount = serviceCount - maxServices; + return `${serviceCount} services are ${statusLevel.toString()}: ${affectedServices + .slice(0, maxServices) + .join(', ')} and ${exceedingCount} other(s)`; + } else { + return `${serviceCount} services are ${statusLevel.toString()}: ${affectedServices.join(', ')}`; + } +}; + type StatusPair = [string, ServiceStatus]; const highestLevelSummary = ( diff --git a/src/core/server/status/plugins_status.test.ts b/src/core/server/status/plugins_status.test.ts index b7c0733de728e..0befbf63bd186 100644 --- a/src/core/server/status/plugins_status.test.ts +++ b/src/core/server/status/plugins_status.test.ts @@ -73,7 +73,7 @@ describe('PluginStatusService', () => { }); expect(await serviceDegraded.getDerivedStatus$('a').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -84,7 +84,7 @@ describe('PluginStatusService', () => { }); expect(await serviceCritical.getDerivedStatus$('a').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.critical, - summary: '[elasticsearch]: elasticsearch critical', + summary: '1 service is critical: elasticsearch', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -95,7 +95,7 @@ describe('PluginStatusService', () => { service.set('a', of({ level: ServiceStatusLevels.degraded, summary: 'a is degraded' })); expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: savedObjects, a', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -106,7 +106,7 @@ describe('PluginStatusService', () => { service.set('a', of({ level: ServiceStatusLevels.unavailable, summary: 'a is not working' })); expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.unavailable, - summary: '[a]: a is not working', + summary: '1 service is unavailable: a', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -120,7 +120,7 @@ describe('PluginStatusService', () => { service.set('a', of({ level: ServiceStatusLevels.unavailable, summary: 'a is not working' })); expect(await service.getDerivedStatus$('b').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.critical, - summary: '[elasticsearch]: elasticsearch critical', + summary: '1 service is critical: elasticsearch', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -132,7 +132,7 @@ describe('PluginStatusService', () => { service.set('b', of({ level: ServiceStatusLevels.unavailable, summary: 'b is not working' })); expect(await service.getDerivedStatus$('c').pipe(first()).toPromise()).toEqual({ level: ServiceStatusLevels.unavailable, - summary: '[b]: b is not working', + summary: '1 service is unavailable: b', detail: 'See the status page for more information', meta: expect.any(Object), }); @@ -166,19 +166,19 @@ describe('PluginStatusService', () => { expect(await serviceDegraded.getAll$().pipe(first()).toPromise()).toEqual({ a: { level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }, b: { level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }, c: { level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }, @@ -191,19 +191,19 @@ describe('PluginStatusService', () => { expect(await serviceCritical.getAll$().pipe(first()).toPromise()).toEqual({ a: { level: ServiceStatusLevels.critical, - summary: '[elasticsearch]: elasticsearch critical', + summary: '1 service is critical: elasticsearch', detail: 'See the status page for more information', meta: expect.any(Object), }, b: { level: ServiceStatusLevels.critical, - summary: '[elasticsearch]: elasticsearch critical', + summary: '1 service is critical: elasticsearch', detail: 'See the status page for more information', meta: expect.any(Object), }, c: { level: ServiceStatusLevels.critical, - summary: '[elasticsearch]: elasticsearch critical', + summary: '1 service is critical: elasticsearch', detail: 'See the status page for more information', meta: expect.any(Object), }, @@ -218,13 +218,13 @@ describe('PluginStatusService', () => { a: { level: ServiceStatusLevels.available, summary: 'a status' }, // a is available depsite savedObjects being degraded b: { level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }, c: { level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: savedObjects, b', detail: 'See the status page for more information', meta: expect.any(Object), }, @@ -298,7 +298,7 @@ describe('PluginStatusService', () => { a: { level: ServiceStatusLevels.unavailable, summary: 'Status check timed out after 30s' }, b: { level: ServiceStatusLevels.unavailable, - summary: '[a]: Status check timed out after 30s', + summary: '1 service is unavailable: a', detail: 'See the status page for more information', meta: { affectedServices: ['a'], @@ -341,7 +341,7 @@ describe('PluginStatusService', () => { a: { level: ServiceStatusLevels.available, summary: 'a status' }, // a is available depsite savedObjects being degraded b: { level: ServiceStatusLevels.degraded, - summary: '[savedObjects]: savedObjects degraded', + summary: '1 service is degraded: savedObjects', detail: 'See the status page for more information', meta: expect.any(Object), }, diff --git a/src/core/server/status/status_service.test.ts b/src/core/server/status/status_service.test.ts index 255ed821bc2fe..dfd0ff9a7e103 100644 --- a/src/core/server/status/status_service.test.ts +++ b/src/core/server/status/status_service.test.ts @@ -188,7 +188,7 @@ describe('StatusService', () => { ); expect(await setup.overall$.pipe(first()).toPromise()).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); }); @@ -208,15 +208,15 @@ describe('StatusService', () => { const subResult3 = await setup.overall$.pipe(first()).toPromise(); expect(subResult1).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); expect(subResult2).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); expect(subResult3).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); }); @@ -265,7 +265,7 @@ describe('StatusService', () => { "savedObjects", ], }, - "summary": "[savedObjects]: This is degraded!", + "summary": "1 service is degraded: savedObjects", }, Object { "level": available, @@ -315,7 +315,7 @@ describe('StatusService', () => { "savedObjects", ], }, - "summary": "[savedObjects]: This is degraded!", + "summary": "1 service is degraded: savedObjects", }, Object { "level": available, @@ -340,7 +340,7 @@ describe('StatusService', () => { ); expect(await setup.coreOverall$.pipe(first()).toPromise()).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); }); @@ -357,7 +357,7 @@ describe('StatusService', () => { ); expect(await setup.coreOverall$.pipe(first()).toPromise()).toMatchObject({ level: ServiceStatusLevels.critical, - summary: '[savedObjects]: This is critical!', + summary: '1 service is critical: savedObjects', }); }); @@ -379,15 +379,15 @@ describe('StatusService', () => { expect(subResult1).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); expect(subResult2).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); expect(subResult3).toMatchObject({ level: ServiceStatusLevels.degraded, - summary: '[2] services are degraded', + summary: '2 services are degraded: elasticsearch, savedObjects', }); }); @@ -436,7 +436,7 @@ describe('StatusService', () => { "savedObjects", ], }, - "summary": "[savedObjects]: This is degraded!", + "summary": "1 service is degraded: savedObjects", }, Object { "level": available, @@ -486,7 +486,7 @@ describe('StatusService', () => { "savedObjects", ], }, - "summary": "[savedObjects]: This is degraded!", + "summary": "1 service is degraded: savedObjects", }, Object { "level": available, From 9d2c536ccb717bc439c957f6cea1b7c16f217529 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Mon, 11 Oct 2021 11:49:20 +0200 Subject: [PATCH 15/33] [Discover] Unskip Painless date functional test (#114224) * [Discover] Unskip functional test * Remove comment Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- test/functional/apps/management/_scripted_fields.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 2e965c275d6dd..72f45e1fedb4d 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -367,8 +367,7 @@ export default function ({ getService, getPageObjects }) { }); }); - // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/113745 - describe.skip('creating and using Painless date scripted fields', function describeIndexTests() { + describe('creating and using Painless date scripted fields', function describeIndexTests() { const scriptedPainlessFieldName2 = 'painDate'; it('should create scripted field', async function () { @@ -384,7 +383,7 @@ export default function ({ getService, getPageObjects }) { 'date', { format: 'date', datePattern: 'YYYY-MM-DD HH:00' }, '1', - "doc['utc_time'].value.getMillis() + (1000) * 60 * 60" + "doc['utc_time'].value.toEpochMilli() + (1000) * 60 * 60" ); await retry.try(async function () { expect(parseInt(await PageObjects.settings.getScriptedFieldsTabCount())).to.be( From 1bf09e6930daa027b54f33a1477caa4d3af8310f Mon Sep 17 00:00:00 2001 From: Marco Liberati Date: Mon, 11 Oct 2021 12:32:19 +0200 Subject: [PATCH 16/33] [Lens] Thresholds: Add text to markers body (#113629) * :bug: Add padding to the tick label to fit threshold markers * :bug: Better icon detection * :bug: Fix edge cases with no title or labels * :camera_flash: Update snapshots * :sparkles: Add icon placement flag * :sparkles: Sync padding computation with marker positioning * :ok_hand: Make disabled when no icon is selected * :sparkles: First text on marker implementation * :bug: Fix some edge cases with auto positioning * Update x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx Co-authored-by: Michael Marcialis * :bug: Fix minor details * :lipstick: Small tweak * :sparkles: Reduce the padding if no icon is shown on the axis * :white_check_mark: Fix broken unit tests * :lipstick: Fix vertical text centering * :rotating_light: Fix linting issue * :bug: Fix issue * :lipstick: Reorder panel inputs * :lipstick: Move styling to sass * :ok_hand: Address feedback Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Michael Marcialis --- .../expressions/xy_chart/axis_config.ts | 5 + .../dimension_panel/dimension_editor.tsx | 32 ++-- .../dimension_panel/dimension_panel.test.tsx | 20 ++- .../public/xy_visualization/expression.tsx | 1 + .../expression_thresholds.scss | 18 +++ .../expression_thresholds.tsx | 134 +++++++++++++---- .../public/xy_visualization/to_expression.ts | 10 +- .../xy_config_panel/threshold_panel.tsx | 137 +++++++++++------- 8 files changed, 248 insertions(+), 109 deletions(-) create mode 100644 x-pack/plugins/lens/public/xy_visualization/expression_thresholds.scss diff --git a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts index 47bb1f91b4ab2..9ff1b5a4dc3f7 100644 --- a/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts +++ b/x-pack/plugins/lens/common/expressions/xy_chart/axis_config.ts @@ -41,6 +41,7 @@ export interface YConfig { lineStyle?: LineStyle; fill?: FillStyle; iconPosition?: IconPosition; + textVisibility?: boolean; } export type AxisTitlesVisibilityConfigResult = AxesSettingsConfig & { @@ -187,6 +188,10 @@ export const yAxisConfig: ExpressionFunctionDefinition< options: ['auto', 'above', 'below', 'left', 'right'], help: 'The placement of the icon for the threshold line', }, + textVisibility: { + types: ['boolean'], + help: 'Visibility of the label on the threshold line', + }, fill: { types: ['string'], options: ['none', 'above', 'below'], diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx index 29bbe6a96b9e1..2f1c00bc5cca0 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_editor.tsx @@ -107,16 +107,19 @@ export function DimensionEditor(props: DimensionEditorProps) { ); const setStateWrapper = ( - setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer) + setter: IndexPatternLayer | ((prevLayer: IndexPatternLayer) => IndexPatternLayer), + options: { forceRender?: boolean } = {} ) => { const hypotheticalLayer = typeof setter === 'function' ? setter(state.layers[layerId]) : setter; + const isDimensionComplete = Boolean(hypotheticalLayer.columns[columnId]); setState( (prevState) => { const layer = typeof setter === 'function' ? setter(prevState.layers[layerId]) : setter; return mergeLayer({ state: prevState, layerId, newLayer: layer }); }, { - isDimensionComplete: Boolean(hypotheticalLayer.columns[columnId]), + isDimensionComplete, + ...options, } ); }; @@ -169,20 +172,8 @@ export function DimensionEditor(props: DimensionEditorProps) { ) => { if (temporaryStaticValue) { setTemporaryState('none'); - if (typeof setter === 'function') { - return setState( - (prevState) => { - const layer = setter(addStaticValueColumn(prevState.layers[layerId])); - return mergeLayer({ state: prevState, layerId, newLayer: layer }); - }, - { - isDimensionComplete: true, - forceRender: true, - } - ); - } } - return setStateWrapper(setter); + return setStateWrapper(setter, { forceRender: true }); }; const ParamEditor = getParamEditor( @@ -314,7 +305,7 @@ export function DimensionEditor(props: DimensionEditorProps) { temporaryQuickFunction && isQuickFunction(newLayer.columns[columnId].operationType) ) { - // Only switch the tab once the formula is fully removed + // Only switch the tab once the "non quick function" is fully removed setTemporaryState('none'); } setStateWrapper(newLayer); @@ -344,13 +335,12 @@ export function DimensionEditor(props: DimensionEditorProps) { visualizationGroups: dimensionGroups, targetGroup: props.groupId, }); - // ); } if ( temporaryQuickFunction && isQuickFunction(newLayer.columns[columnId].operationType) ) { - // Only switch the tab once the formula is fully removed + // Only switch the tab once the "non quick function" is fully removed setTemporaryState('none'); } setStateWrapper(newLayer); @@ -508,6 +498,9 @@ export function DimensionEditor(props: DimensionEditorProps) { } incompleteOperation={incompleteOperation} onChoose={(choice) => { + if (temporaryQuickFunction) { + setTemporaryState('none'); + } setStateWrapper( insertOrReplaceColumn({ layer: state.layers[layerId], @@ -518,7 +511,8 @@ export function DimensionEditor(props: DimensionEditorProps) { visualizationGroups: dimensionGroups, targetGroup: props.groupId, incompleteParams, - }) + }), + { forceRender: temporaryQuickFunction } ); }} /> diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 6df4360aeac4c..6d9e1ae3fe81b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -513,7 +513,10 @@ describe('IndexPatternDimensionEditorPanel', () => { comboBox.prop('onChange')!([option]); }); - expect(setState.mock.calls[0]).toEqual([expect.any(Function), { isDimensionComplete: true }]); + expect(setState.mock.calls[0]).toEqual([ + expect.any(Function), + { isDimensionComplete: true, forceRender: false }, + ]); expect(setState.mock.calls[0][0](defaultProps.state)).toEqual({ ...initialState, layers: { @@ -545,7 +548,10 @@ describe('IndexPatternDimensionEditorPanel', () => { comboBox.prop('onChange')!([option]); }); - expect(setState.mock.calls[0]).toEqual([expect.any(Function), { isDimensionComplete: true }]); + expect(setState.mock.calls[0]).toEqual([ + expect.any(Function), + { isDimensionComplete: true, forceRender: false }, + ]); expect(setState.mock.calls[0][0](defaultProps.state)).toEqual({ ...state, layers: { @@ -1037,7 +1043,10 @@ describe('IndexPatternDimensionEditorPanel', () => { }); expect(setState.mock.calls.length).toEqual(2); - expect(setState.mock.calls[1]).toEqual([expect.any(Function), { isDimensionComplete: true }]); + expect(setState.mock.calls[1]).toEqual([ + expect.any(Function), + { isDimensionComplete: true, forceRender: false }, + ]); expect(setState.mock.calls[1][0](state)).toEqual({ ...state, layers: { @@ -1921,7 +1930,10 @@ describe('IndexPatternDimensionEditorPanel', () => { comboBox.prop('onChange')!([option]); }); - expect(setState.mock.calls[0]).toEqual([expect.any(Function), { isDimensionComplete: true }]); + expect(setState.mock.calls[0]).toEqual([ + expect.any(Function), + { isDimensionComplete: true, forceRender: false }, + ]); expect(setState.mock.calls[0][0](defaultProps.state)).toEqual({ ...state, layers: { diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.tsx b/x-pack/plugins/lens/public/xy_visualization/expression.tsx index 484032e5ffbd9..5dfad58f50018 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression.tsx @@ -924,6 +924,7 @@ export function XYChart({ right: Boolean(yAxesMap.right), }} isHorizontal={shouldRotate} + thresholdPaddingMap={thresholdPaddings} /> ) : null} diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.scss b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.scss new file mode 100644 index 0000000000000..41b30ce40676b --- /dev/null +++ b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.scss @@ -0,0 +1,18 @@ +.lnsXyDecorationRotatedWrapper { + display: inline-block; + overflow: hidden; + line-height: $euiLineHeight; + + .lnsXyDecorationRotatedWrapper__label { + display: inline-block; + white-space: nowrap; + transform: translate(0, 100%) rotate(-90deg); + transform-origin: 0 0; + + &::after { + content: ''; + float: left; + margin-top: 100%; + } + } +} \ No newline at end of file diff --git a/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx index 7532d41f091d1..67e994b734b84 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/expression_thresholds.tsx @@ -5,6 +5,7 @@ * 2.0. */ +import './expression_thresholds.scss'; import React from 'react'; import { groupBy } from 'lodash'; import { EuiIcon } from '@elastic/eui'; @@ -14,8 +15,9 @@ import type { FieldFormat } from 'src/plugins/field_formats/common'; import { euiLightVars } from '@kbn/ui-shared-deps-src/theme'; import type { LayerArgs, YConfig } from '../../common/expressions'; import type { LensMultiTable } from '../../common/types'; +import { hasIcon } from './xy_config_panel/threshold_panel'; -const THRESHOLD_ICON_SIZE = 20; +const THRESHOLD_MARKER_SIZE = 20; export const computeChartMargins = ( thresholdPaddings: Partial>, @@ -51,27 +53,35 @@ export const computeChartMargins = ( return result; }; -function hasIcon(icon: string | undefined): icon is string { - return icon != null && icon !== 'none'; -} - // Note: it does not take into consideration whether the threshold is in view or not export const getThresholdRequiredPaddings = ( thresholdLayers: LayerArgs[], axesMap: Record<'left' | 'right', unknown> ) => { - const positions = Object.keys(Position); - return thresholdLayers.reduce((memo, layer) => { - if (positions.some((pos) => !(pos in memo))) { - layer.yConfig?.forEach(({ axisMode, icon, iconPosition }) => { - if (axisMode && hasIcon(icon)) { - const placement = getBaseIconPlacement(iconPosition, axisMode, axesMap); - memo[placement] = THRESHOLD_ICON_SIZE; - } - }); + // collect all paddings for the 4 axis: if any text is detected double it. + const paddings: Partial> = {}; + const icons: Partial> = {}; + thresholdLayers.forEach((layer) => { + layer.yConfig?.forEach(({ axisMode, icon, iconPosition, textVisibility }) => { + if (axisMode && (hasIcon(icon) || textVisibility)) { + const placement = getBaseIconPlacement(iconPosition, axisMode, axesMap); + paddings[placement] = Math.max( + paddings[placement] || 0, + THRESHOLD_MARKER_SIZE * (textVisibility ? 2 : 1) // double the padding size if there's text + ); + icons[placement] = (icons[placement] || 0) + (hasIcon(icon) ? 1 : 0); + } + }); + }); + // post-process the padding based on the icon presence: + // if no icon is present for the placement, just reduce the padding + (Object.keys(paddings) as Position[]).forEach((placement) => { + if (!icons[placement]) { + paddings[placement] = THRESHOLD_MARKER_SIZE; } - return memo; - }, {} as Partial>); + }); + + return paddings; }; function mapVerticalToHorizontalPlacement(placement: Position) { @@ -117,17 +127,57 @@ function getBaseIconPlacement( return Position.Top; } -function getIconPlacement( - iconPosition: YConfig['iconPosition'], - axisMode: YConfig['axisMode'], - axesMap: Record, - isHorizontal: boolean -) { - const vPosition = getBaseIconPlacement(iconPosition, axisMode, axesMap); +function getMarkerBody(label: string | undefined, isHorizontal: boolean) { + if (!label) { + return; + } if (isHorizontal) { - return mapVerticalToHorizontalPlacement(vPosition); + return ( +
+ {label} +
+ ); + } + return ( +
+
+ {label} +
+
+ ); +} + +function getMarkerToShow( + yConfig: YConfig, + label: string | undefined, + isHorizontal: boolean, + hasReducedPadding: boolean +) { + // show an icon if present + if (hasIcon(yConfig.icon)) { + return ; + } + // if there's some text, check whether to show it as marker, or just show some padding for the icon + if (yConfig.textVisibility) { + if (hasReducedPadding) { + return getMarkerBody( + label, + (!isHorizontal && yConfig.axisMode === 'bottom') || + (isHorizontal && yConfig.axisMode !== 'bottom') + ); + } + return ; } - return vPosition; } export const ThresholdAnnotations = ({ @@ -138,6 +188,7 @@ export const ThresholdAnnotations = ({ syncColors, axesMap, isHorizontal, + thresholdPaddingMap, }: { thresholdLayers: LayerArgs[]; data: LensMultiTable; @@ -146,6 +197,7 @@ export const ThresholdAnnotations = ({ syncColors: boolean; axesMap: Record<'left' | 'right', boolean>; isHorizontal: boolean; + thresholdPaddingMap: Partial>; }) => { return ( <> @@ -180,15 +232,35 @@ export const ThresholdAnnotations = ({ const defaultColor = euiLightVars.euiColorDarkShade; + // get the position for vertical chart + const markerPositionVertical = getBaseIconPlacement( + yConfig.iconPosition, + yConfig.axisMode, + axesMap + ); + // the padding map is built for vertical chart + const hasReducedPadding = + thresholdPaddingMap[markerPositionVertical] === THRESHOLD_MARKER_SIZE; + const props = { groupId, - marker: hasIcon(yConfig.icon) ? : undefined, - markerPosition: getIconPlacement( - yConfig.iconPosition, - yConfig.axisMode, - axesMap, - isHorizontal + marker: getMarkerToShow( + yConfig, + columnToLabelMap[yConfig.forAccessor], + isHorizontal, + hasReducedPadding + ), + markerBody: getMarkerBody( + yConfig.textVisibility && !hasReducedPadding + ? columnToLabelMap[yConfig.forAccessor] + : undefined, + (!isHorizontal && yConfig.axisMode === 'bottom') || + (isHorizontal && yConfig.axisMode !== 'bottom') ), + // rotate the position if required + markerPosition: isHorizontal + ? mapVerticalToHorizontalPlacement(markerPositionVertical) + : markerPositionVertical, }; const annotations = []; diff --git a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts index bb65b69a8d121..96ea9b84dd983 100644 --- a/x-pack/plugins/lens/public/xy_visualization/to_expression.ts +++ b/x-pack/plugins/lens/public/xy_visualization/to_expression.ts @@ -13,6 +13,7 @@ import { OperationMetadata, DatasourcePublicAPI } from '../types'; import { getColumnToLabelMap } from './state_helpers'; import type { ValidLayer, XYLayerConfig } from '../../common/expressions'; import { layerTypes } from '../../common'; +import { hasIcon } from './xy_config_panel/threshold_panel'; import { defaultThresholdColor } from './color_assignment'; export const getSortedAccessors = (datasource: DatasourcePublicAPI, layer: XYLayerConfig) => { @@ -66,6 +67,7 @@ export function toPreviewExpression( ...config, lineWidth: 1, icon: undefined, + textVisibility: false, })), } ), @@ -344,8 +346,12 @@ export const buildExpression = ( lineStyle: [yConfig.lineStyle || 'solid'], lineWidth: [yConfig.lineWidth || 1], fill: [yConfig.fill || 'none'], - icon: yConfig.icon ? [yConfig.icon] : [], - iconPosition: [yConfig.iconPosition || 'auto'], + icon: hasIcon(yConfig.icon) ? [yConfig.icon] : [], + iconPosition: + hasIcon(yConfig.icon) || yConfig.textVisibility + ? [yConfig.iconPosition || 'auto'] + : ['auto'], + textVisibility: [yConfig.textVisibility || false], }, }, ], diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx index cdf5bb2cc2ef1..7c31d72e6cbde 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel/threshold_panel.tsx @@ -8,7 +8,14 @@ import './xy_config_panel.scss'; import React, { useCallback, useState } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonGroup, EuiComboBox, EuiFormRow, EuiIcon, EuiRange } from '@elastic/eui'; +import { + EuiButtonGroup, + EuiComboBox, + EuiFormRow, + EuiIcon, + EuiRange, + EuiSwitch, +} from '@elastic/eui'; import type { PaletteRegistry } from 'src/plugins/charts/public'; import type { VisualizationDimensionEditorProps } from '../../types'; import { State, XYState } from '../types'; @@ -177,6 +184,10 @@ function getIconPositionOptions({ return [...options, ...yOptions]; } +export function hasIcon(icon: string | undefined): icon is string { + return icon != null && icon !== 'none'; +} + export const ThresholdPanel = ( props: VisualizationDimensionEditorProps & { formatFactory: FormatFactory; @@ -220,6 +231,78 @@ export const ThresholdPanel = ( return ( <> + + { + setYConfig({ forAccessor: accessor, textVisibility: !currentYConfig?.textVisibility }); + }} + /> + + + { + setYConfig({ forAccessor: accessor, icon: newIcon }); + }} + /> + + + + { + const newMode = id.replace(idPrefix, '') as IconPosition; + setYConfig({ forAccessor: accessor, iconPosition: newMode }); + }} + /> + + - - { - setYConfig({ forAccessor: accessor, icon: newIcon }); - }} - /> - - - - { - const newMode = id.replace(idPrefix, '') as IconPosition; - setYConfig({ forAccessor: accessor, iconPosition: newMode }); - }} - /> - - ); }; From 9a1779d364044dff27672c110f6aa344e61fce15 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Mon, 11 Oct 2021 13:37:01 +0300 Subject: [PATCH 17/33] [Visualize] unskip the reporting funtional test (#114094) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- x-pack/test/functional/apps/visualize/reporting.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/x-pack/test/functional/apps/visualize/reporting.ts b/x-pack/test/functional/apps/visualize/reporting.ts index efffa0b6a692b..07ce3d9b23128 100644 --- a/x-pack/test/functional/apps/visualize/reporting.ts +++ b/x-pack/test/functional/apps/visualize/reporting.ts @@ -25,13 +25,16 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'visEditor', ]); - // Failing: See https://github.com/elastic/kibana/issues/113496 - describe.skip('Visualize Reporting Screenshots', () => { + describe('Visualize Reporting Screenshots', () => { before('initialize tests', async () => { log.debug('ReportingPage:initTests'); await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/reporting/ecommerce'); await kibanaServer.importExport.load(ecommerceSOPath); await browser.setWindowSize(1600, 850); + await kibanaServer.uiSettings.replace({ + 'timepicker:timeDefaults': + '{ "from": "2019-04-27T23:56:51.374Z", "to": "2019-08-23T16:18:51.821Z"}', + }); }); after('clean up archives', async () => { await esArchiver.unload('x-pack/test/functional/es_archives/reporting/ecommerce'); @@ -41,6 +44,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { refresh: true, body: { query: { match_all: {} } }, }); + await kibanaServer.uiSettings.unset('timepicker:timeDefaults'); }); describe('Print PDF button', () => { @@ -54,11 +58,6 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('becomes available when saved', async () => { - await PageObjects.timePicker.timePickerExists(); - const fromTime = 'Apr 27, 2019 @ 23:56:51.374'; - const toTime = 'Aug 23, 2019 @ 16:18:51.821'; - await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); - await PageObjects.visEditor.clickBucket('X-axis'); await PageObjects.visEditor.selectAggregation('Date Histogram'); await PageObjects.visEditor.clickGo(); From b2108f4c2c939f7f9ea8f0cf272c6f856d260b0e Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Mon, 11 Oct 2021 11:53:49 +0100 Subject: [PATCH 18/33] Add all APM configuration settings to the documentation (#114139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add APM configuration settings to the documentation * Rename the deprecated apm_oss.* configurations to xpack.apm.* * Remove new lines * Add ess icon to config settings * Add link to the APM configuration settings docs Co-authored-by: Søren Louv-Jansen Co-authored-by: Søren Louv-Jansen Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- docs/settings/apm-settings.asciidoc | 48 +++++++++++++++++++---------- x-pack/plugins/apm/server/index.ts | 2 ++ 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/docs/settings/apm-settings.asciidoc b/docs/settings/apm-settings.asciidoc index e565bda0dff47..fb96f68355330 100644 --- a/docs/settings/apm-settings.asciidoc +++ b/docs/settings/apm-settings.asciidoc @@ -40,57 +40,71 @@ Changing these settings may disable features of the APM App. [cols="2*<"] |=== -| `xpack.apm.enabled` +| `xpack.apm.enabled` {ess-icon} | deprecated:[7.16.0,"In 8.0 and later, this setting will no longer be supported."] Set to `false` to disable the APM app. Defaults to `true`. -| `xpack.apm.maxServiceEnvironments` +| `xpack.apm.maxServiceEnvironments` {ess-icon} | Maximum number of unique service environments recognized by the UI. Defaults to `100`. -| `xpack.apm.serviceMapFingerprintBucketSize` +| `xpack.apm.serviceMapFingerprintBucketSize` {ess-icon} | Maximum number of unique transaction combinations sampled for generating service map focused on a specific service. Defaults to `100`. -| `xpack.apm.serviceMapFingerprintGlobalBucketSize` +| `xpack.apm.serviceMapFingerprintGlobalBucketSize` {ess-icon} | Maximum number of unique transaction combinations sampled for generating the global service map. Defaults to `100`. +| `xpack.apm.serviceMapEnabled` {ess-icon} + | Set to `false` to disable service maps. Defaults to `true`. + +| `xpack.apm.serviceMapTraceIdBucketSize` {ess-icon} + | Maximum number of trace IDs sampled for generating service map focused on a specific service. Defaults to `65`. + +| `xpack.apm.serviceMapTraceIdGlobalBucketSize` {ess-icon} + | Maximum number of trace IDs sampled for generating the global service map. Defaults to `6`. + +| `xpack.apm.serviceMapMaxTracesPerRequest` {ess-icon} + | Maximum number of traces per request for generating the global service map. Defaults to `50`. + | `xpack.apm.ui.enabled` {ess-icon} | Set to `false` to hide the APM app from the main menu. Defaults to `true`. -| `xpack.apm.ui.transactionGroupBucketSize` +| `xpack.apm.ui.transactionGroupBucketSize` {ess-icon} | Number of top transaction groups displayed in the APM app. Defaults to `1000`. | `xpack.apm.ui.maxTraceItems` {ess-icon} | Maximum number of child items displayed when viewing trace details. Defaults to `1000`. -| `xpack.observability.annotations.index` +| `xpack.observability.annotations.index` {ess-icon} | Index name where Observability annotations are stored. Defaults to `observability-annotations`. -| `xpack.apm.searchAggregatedTransactions` +| `xpack.apm.searchAggregatedTransactions` {ess-icon} | experimental[] Enables Transaction histogram metrics. Defaults to `never` and aggregated transactions are not used. When set to `auto`, the UI will use metric indices over transaction indices for transactions if aggregated transactions are found. When set to `always`, additional configuration in APM Server is required. See {apm-server-ref-v}/transaction-metrics.html[Configure transaction metrics] for more information. -| `apm_oss.indexPattern` {ess-icon} - | The index pattern used for integrations with Machine Learning and Query Bar. - It must match all apm indices. Defaults to `apm-*`. +| `xpack.apm.metricsInterval` {ess-icon} + | Sets a `fixed_interval` for date histograms in metrics aggregations. Defaults to `30`. + +| `xpack.apm.agent.migrations.enabled` {ess-icon} + | Set to `false` to disable cloud APM migrations. Defaults to `true`. -| `apm_oss.errorIndices` {ess-icon} +| `xpack.apm.errorIndices` {ess-icon} | Matcher for all {apm-server-ref}/error-indices.html[error indices]. Defaults to `apm-*`. -| `apm_oss.onboardingIndices` +| `xpack.apm.onboardingIndices` {ess-icon} | Matcher for all onboarding indices. Defaults to `apm-*`. -| `apm_oss.spanIndices` {ess-icon} +| `xpack.apm.spanIndices` {ess-icon} | Matcher for all {apm-server-ref}/span-indices.html[span indices]. Defaults to `apm-*`. -| `apm_oss.transactionIndices` {ess-icon} +| `xpack.apm.transactionIndices` {ess-icon} | Matcher for all {apm-server-ref}/transaction-indices.html[transaction indices]. Defaults to `apm-*`. -| `apm_oss.metricsIndices` +| `xpack.apm.metricsIndices` {ess-icon} | Matcher for all {apm-server-ref}/metricset-indices.html[metrics indices]. Defaults to `apm-*`. -| `apm_oss.sourcemapIndices` +| `xpack.apm.sourcemapIndices` {ess-icon} | Matcher for all {apm-server-ref}/sourcemap-indices.html[source map indices]. Defaults to `apm-*`. |=== -// end::general-apm-settings[] +// end::general-apm-settings[] \ No newline at end of file diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index b7002ff7cbe79..22787b0301ce0 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -74,6 +74,8 @@ export type APMXPackConfig = TypeOf; export type APMConfig = ReturnType; // plugin config and ui indices settings +// All options should be documented in the APM configuration settings: https://github.com/elastic/kibana/blob/master/docs/settings/apm-settings.asciidoc +// and be included on cloud allow list unless there are specific reasons not to export function mergeConfigs( apmOssConfig: APMOSSConfig, apmConfig: APMXPackConfig From 75983cf45065fc498feb150f4be8e95b6b85886f Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Mon, 11 Oct 2021 12:58:59 +0200 Subject: [PATCH 19/33] [Reporting] Update chromium exit behaviour (#113544) * move uncaught exception out of exit$ * reintroduce original error, but as a log instead * change log level: error -> warning. also update copy to make it explicit that the error will be ignored Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../browsers/chromium/driver_factory/index.ts | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts index 2170b50f195b4..a0487421a9a0d 100644 --- a/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts +++ b/x-pack/plugins/reporting/server/browsers/chromium/driver_factory/index.ts @@ -220,6 +220,17 @@ export class HeadlessChromiumDriverFactory { }) ); + const uncaughtExceptionPageError$ = Rx.fromEvent(page, 'pageerror').pipe( + map((err) => { + logger.warning( + i18n.translate('xpack.reporting.browsers.chromium.pageErrorDetected', { + defaultMessage: `Reporting encountered an uncaught error on the page that will be ignored: {err}`, + values: { err: err.toString() }, + }) + ); + }) + ); + const pageRequestFailed$ = Rx.fromEvent(page, 'requestfailed').pipe( map((req) => { const failure = req.failure && req.failure(); @@ -231,7 +242,7 @@ export class HeadlessChromiumDriverFactory { }) ); - return Rx.merge(consoleMessages$, pageRequestFailed$); + return Rx.merge(consoleMessages$, uncaughtExceptionPageError$, pageRequestFailed$); } getProcessLogger(browser: puppeteer.Browser, logger: LevelLogger): Rx.Observable { @@ -266,21 +277,10 @@ export class HeadlessChromiumDriverFactory { }) ); - const uncaughtExceptionPageError$ = Rx.fromEvent(page, 'pageerror').pipe( - mergeMap((err) => { - return Rx.throwError( - i18n.translate('xpack.reporting.browsers.chromium.pageErrorDetected', { - defaultMessage: `Reporting encountered an error on the page: {err}`, - values: { err: err.toString() }, - }) - ); - }) - ); - const browserDisconnect$ = Rx.fromEvent(browser, 'disconnected').pipe( mergeMap(() => Rx.throwError(getChromiumDisconnectedError())) ); - return Rx.merge(pageError$, uncaughtExceptionPageError$, browserDisconnect$); + return Rx.merge(pageError$, browserDisconnect$); } } From ce7b1ea6530653ddb910ca57af00d3503f8e3362 Mon Sep 17 00:00:00 2001 From: Dmitry Shevchenko Date: Mon, 11 Oct 2021 13:05:52 +0200 Subject: [PATCH 20/33] Implement writing rule execution events to event_log (#112286) --- .../plugins/event_log/generated/mappings.json | 43 +++ x-pack/plugins/event_log/generated/schemas.ts | 23 ++ x-pack/plugins/event_log/scripts/mappings.js | 48 +++- x-pack/plugins/security_solution/kibana.json | 5 +- .../security_solution/server/config.ts | 14 + .../routes/__mocks__/index.ts | 4 + .../__mocks__/rule_execution_log_client.ts | 2 +- .../event_log_adapter/constants.ts | 15 + .../event_log_adapter/event_log_adapter.ts | 87 ++++++ .../event_log_adapter/event_log_client.ts | 157 ++++++++++ .../register_event_log_provider.ts | 16 ++ .../rule_execution_log_client.ts | 34 ++- .../rule_registry_adapter.ts | 106 ------- .../rule_registry_log_client/constants.ts | 41 --- .../parse_rule_execution_log.ts | 40 --- .../rule_execution_field_map.ts | 32 --- .../rule_registry_log_client.ts | 270 ------------------ .../rule_registry_log_client/utils.ts | 76 ----- .../saved_objects_adapter.ts | 35 ++- .../rule_execution_log/types.ts | 55 ++-- .../rule_types/__mocks__/rule_type.ts | 4 +- .../create_security_rule_type_factory.ts | 49 ++-- .../eql/create_eql_alert_type.test.ts | 6 +- .../rule_types/eql/create_eql_alert_type.ts | 17 +- .../build_alert_group_from_sequence.test.ts | 1 + .../create_indicator_match_alert_type.test.ts | 16 +- .../create_indicator_match_alert_type.ts | 17 +- .../ml/create_ml_alert_type.test.ts | 6 +- .../rule_types/ml/create_ml_alert_type.ts | 8 +- .../query/create_query_alert_type.test.ts | 11 +- .../query/create_query_alert_type.ts | 17 +- .../create_threshold_alert_type.test.ts | 6 +- .../threshold/create_threshold_alert_type.ts | 17 +- .../lib/detection_engine/rule_types/types.ts | 12 +- .../lib/detection_engine/rules/enable_rule.ts | 2 + .../signals/__mocks__/es_results.ts | 1 + .../signals/executors/eql.test.ts | 1 + .../signals/executors/threshold.test.ts | 1 + .../signals/signal_rule_alert_type.test.ts | 23 +- .../signals/signal_rule_alert_type.ts | 63 ++-- .../lib/detection_engine/signals/types.ts | 1 + .../detection_engine/signals/utils.test.ts | 4 + .../lib/detection_engine/signals/utils.ts | 23 +- .../security_solution/server/plugin.ts | 26 +- .../event_log/service_api_integration.ts | 15 + 45 files changed, 653 insertions(+), 797 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_adapter.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_adapter.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/constants.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/parse_rule_execution_log.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_execution_field_map.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts delete mode 100644 x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/utils.ts diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index cbb59cc3204c0..aba23eef79e3f 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -267,6 +267,42 @@ } } }, + "alert": { + "properties": { + "rule": { + "properties": { + "execution": { + "properties": { + "uuid": { + "type": "keyword", + "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 + }, + "status_order": { + "type": "long" + }, + "metrics": { + "properties": { + "total_indexing_duration_ms": { + "type": "long" + }, + "total_search_duration_ms": { + "type": "long" + }, + "execution_gap_duration_s": { + "type": "long" + } + } + } + } + } + } + } + } + }, "saved_objects": { "type": "nested", "properties": { @@ -292,6 +328,13 @@ } } }, + "space_ids": { + "type": "keyword", + "ignore_above": 1024, + "meta": { + "isArray": "true" + } + }, "version": { "type": "version" } diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index 15dc182dbe653..e73bafd9cb81e 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -116,6 +116,28 @@ export const EventSchema = schema.maybe( status: ecsString(), }) ), + alert: schema.maybe( + schema.object({ + rule: schema.maybe( + schema.object({ + execution: schema.maybe( + schema.object({ + uuid: ecsString(), + status: ecsString(), + status_order: ecsNumber(), + metrics: schema.maybe( + schema.object({ + total_indexing_duration_ms: ecsNumber(), + total_search_duration_ms: ecsNumber(), + execution_gap_duration_s: ecsNumber(), + }) + ), + }) + ), + }) + ), + }) + ), saved_objects: schema.maybe( schema.arrayOf( schema.object({ @@ -127,6 +149,7 @@ export const EventSchema = schema.maybe( }) ) ), + space_ids: ecsStringMulti(), version: ecsVersion(), }) ), diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index d114603052491..231cc225f7c47 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -49,6 +49,42 @@ exports.EcsCustomPropertyMappings = { }, }, }, + alert: { + properties: { + rule: { + properties: { + execution: { + properties: { + uuid: { + type: 'keyword', + ignore_above: 1024, + }, + status: { + type: 'keyword', + ignore_above: 1024, + }, + status_order: { + type: 'long', + }, + metrics: { + properties: { + total_indexing_duration_ms: { + type: 'long', + }, + total_search_duration_ms: { + type: 'long', + }, + execution_gap_duration_s: { + type: 'long', + }, + }, + }, + }, + }, + }, + }, + }, + }, // array of saved object references, for "linking" via search saved_objects: { type: 'nested', @@ -77,6 +113,10 @@ exports.EcsCustomPropertyMappings = { }, }, }, + space_ids: { + type: 'keyword', + ignore_above: 1024, + }, version: { type: 'version', }, @@ -105,4 +145,10 @@ exports.EcsPropertiesToGenerate = [ /** * These properties can have multiple values (are arrays in the generated event schema). */ -exports.EcsEventLogMultiValuedProperties = ['tags', 'event.category', 'event.type', 'rule.author']; +exports.EcsEventLogMultiValuedProperties = [ + 'tags', + 'event.category', + 'event.type', + 'rule.author', + 'kibana.space_ids', +]; diff --git a/x-pack/plugins/security_solution/kibana.json b/x-pack/plugins/security_solution/kibana.json index 8bb1f4d75e6bc..a76b942e555bc 100644 --- a/x-pack/plugins/security_solution/kibana.json +++ b/x-pack/plugins/security_solution/kibana.json @@ -12,15 +12,16 @@ "actions", "alerting", "cases", - "ruleRegistry", "data", "dataEnhanced", "embeddable", + "eventLog", "features", - "taskManager", "inspector", "licensing", "maps", + "ruleRegistry", + "taskManager", "timelines", "triggersActionsUi", "uiActions" diff --git a/x-pack/plugins/security_solution/server/config.ts b/x-pack/plugins/security_solution/server/config.ts index 0850e43b21eda..bc5b43c6d25fd 100644 --- a/x-pack/plugins/security_solution/server/config.ts +++ b/x-pack/plugins/security_solution/server/config.ts @@ -12,6 +12,7 @@ import { getExperimentalAllowedValues, isValidExperimentalValue, } from '../common/experimental_features'; +import { UnderlyingLogClient } from './lib/detection_engine/rule_execution_log/types'; const allowedExperimentalValues = getExperimentalAllowedValues(); @@ -103,6 +104,19 @@ export const configSchema = schema.object({ }, }), + /** + * Rule Execution Log Configuration + */ + ruleExecutionLog: schema.object({ + underlyingClient: schema.oneOf( + [ + schema.literal(UnderlyingLogClient.eventLog), + schema.literal(UnderlyingLogClient.savedObjects), + ], + { defaultValue: UnderlyingLogClient.savedObjects } + ), + }), + /** * Host Endpoint Configuration */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts index 1ac85f9a27969..2f401d27813ac 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts @@ -11,6 +11,7 @@ import { serverMock } from './server'; import { requestMock } from './request'; import { responseMock } from './response_factory'; import { ConfigType } from '../../../../config'; +import { UnderlyingLogClient } from '../../rule_execution_log/types'; export { requestMock, requestContextMock, responseMock, serverMock }; @@ -29,6 +30,9 @@ export const createMockConfig = (): ConfigType => ({ alertIgnoreFields: [], prebuiltRulesFromFileSystem: true, prebuiltRulesFromSavedObjects: false, + ruleExecutionLog: { + underlyingClient: UnderlyingLogClient.savedObjects, + }, }); export const mockGetCurrentUser = { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/__mocks__/rule_execution_log_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/__mocks__/rule_execution_log_client.ts index bc9723e47a9d0..910e1ecaa508f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/__mocks__/rule_execution_log_client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/__mocks__/rule_execution_log_client.ts @@ -14,7 +14,7 @@ export const ruleExecutionLogClientMock = { update: jest.fn(), delete: jest.fn(), logStatusChange: jest.fn(), - logExecutionMetric: jest.fn(), + logExecutionMetrics: jest.fn(), }), }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts new file mode 100644 index 0000000000000..f09eb43bf15f1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/constants.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const RULE_EXECUTION_LOG_PROVIDER = 'rule-execution.security'; + +export const ALERT_SAVED_OBJECT_TYPE = 'alert'; + +export enum RuleExecutionLogAction { + 'status-change' = 'status-change', + 'execution-metrics' = 'execution-metrics', +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_adapter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_adapter.ts new file mode 100644 index 0000000000000..6b1a0cd5b18d0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_adapter.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEventLogService } from '../../../../../../event_log/server'; +import { + FindBulkExecutionLogArgs, + FindExecutionLogArgs, + IRuleExecutionLogClient, + LogExecutionMetricsArgs, + LogStatusChangeArgs, + UpdateExecutionLogArgs, +} from '../types'; +import { EventLogClient } from './event_log_client'; + +export class EventLogAdapter implements IRuleExecutionLogClient { + private eventLogClient: EventLogClient; + + constructor(eventLogService: IEventLogService) { + this.eventLogClient = new EventLogClient(eventLogService); + } + + public async find({ ruleId, logsCount = 1, spaceId }: FindExecutionLogArgs) { + return []; // TODO Implement + } + + public async findBulk({ ruleIds, logsCount = 1, spaceId }: FindBulkExecutionLogArgs) { + return {}; // TODO Implement + } + + public async update({ attributes, spaceId, ruleName, ruleType }: UpdateExecutionLogArgs) { + // execution events are immutable, so we just log a status change istead of updating previous + if (attributes.status) { + this.eventLogClient.logStatusChange({ + ruleName, + ruleType, + ruleId: attributes.alertId, + newStatus: attributes.status, + spaceId, + }); + } + } + + public async delete(id: string) { + // execution events are immutable, nothing to do here + } + + public async logExecutionMetrics({ + ruleId, + spaceId, + ruleType, + ruleName, + metrics, + }: LogExecutionMetricsArgs) { + this.eventLogClient.logExecutionMetrics({ + ruleId, + ruleName, + ruleType, + spaceId, + metrics: { + executionGapDuration: metrics.executionGap?.asSeconds(), + totalIndexingDuration: metrics.indexingDurations?.reduce( + (acc, cur) => acc + Number(cur), + 0 + ), + totalSearchDuration: metrics.searchDurations?.reduce((acc, cur) => acc + Number(cur), 0), + }, + }); + } + + public async logStatusChange(args: LogStatusChangeArgs) { + if (args.metrics) { + this.logExecutionMetrics({ + ruleId: args.ruleId, + ruleName: args.ruleName, + ruleType: args.ruleType, + spaceId: args.spaceId, + metrics: args.metrics, + }); + } + + this.eventLogClient.logStatusChange(args); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_client.ts new file mode 100644 index 0000000000000..d85c67e422035 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/event_log_client.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObjectsUtils } from '../../../../../../../../src/core/server'; +import { + IEventLogger, + IEventLogService, + SAVED_OBJECT_REL_PRIMARY, +} from '../../../../../../event_log/server'; +import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { LogStatusChangeArgs } from '../types'; +import { + RuleExecutionLogAction, + RULE_EXECUTION_LOG_PROVIDER, + ALERT_SAVED_OBJECT_TYPE, +} from './constants'; + +const spaceIdToNamespace = SavedObjectsUtils.namespaceStringToId; + +const statusSeverityDict: Record = { + [RuleExecutionStatus.succeeded]: 0, + [RuleExecutionStatus['going to run']]: 10, + [RuleExecutionStatus.warning]: 20, + [RuleExecutionStatus['partial failure']]: 20, + [RuleExecutionStatus.failed]: 30, +}; + +interface FindExecutionLogArgs { + ruleIds: string[]; + spaceId: string; + logsCount?: number; + statuses?: RuleExecutionStatus[]; +} + +interface LogExecutionMetricsArgs { + ruleId: string; + ruleName: string; + ruleType: string; + spaceId: string; + metrics: EventLogExecutionMetrics; +} + +interface EventLogExecutionMetrics { + totalSearchDuration?: number; + totalIndexingDuration?: number; + executionGapDuration?: number; +} + +interface IExecLogEventLogClient { + find: (args: FindExecutionLogArgs) => Promise<{}>; + logStatusChange: (args: LogStatusChangeArgs) => void; + logExecutionMetrics: (args: LogExecutionMetricsArgs) => void; +} + +export class EventLogClient implements IExecLogEventLogClient { + private sequence = 0; + private eventLogger: IEventLogger; + + constructor(eventLogService: IEventLogService) { + this.eventLogger = eventLogService.getLogger({ + event: { provider: RULE_EXECUTION_LOG_PROVIDER }, + }); + } + + public async find({ ruleIds, spaceId, statuses, logsCount = 1 }: FindExecutionLogArgs) { + return {}; // TODO implement + } + + public logExecutionMetrics({ + ruleId, + ruleName, + ruleType, + metrics, + spaceId, + }: LogExecutionMetricsArgs) { + this.eventLogger.logEvent({ + rule: { + id: ruleId, + name: ruleName, + category: ruleType, + }, + event: { + kind: 'metric', + action: RuleExecutionLogAction['execution-metrics'], + sequence: this.sequence++, + }, + kibana: { + alert: { + rule: { + execution: { + metrics: { + execution_gap_duration_s: metrics.executionGapDuration, + total_search_duration_ms: metrics.totalSearchDuration, + total_indexing_duration_ms: metrics.totalIndexingDuration, + }, + }, + }, + }, + space_ids: [spaceId], + saved_objects: [ + { + rel: SAVED_OBJECT_REL_PRIMARY, + type: ALERT_SAVED_OBJECT_TYPE, + id: ruleId, + namespace: spaceIdToNamespace(spaceId), + }, + ], + }, + }); + } + + public logStatusChange({ + ruleId, + ruleName, + ruleType, + newStatus, + message, + spaceId, + }: LogStatusChangeArgs) { + this.eventLogger.logEvent({ + rule: { + id: ruleId, + name: ruleName, + category: ruleType, + }, + event: { + kind: 'event', + action: RuleExecutionLogAction['status-change'], + sequence: this.sequence++, + }, + message, + kibana: { + alert: { + rule: { + execution: { + status: newStatus, + status_order: statusSeverityDict[newStatus], + }, + }, + }, + space_ids: [spaceId], + saved_objects: [ + { + rel: SAVED_OBJECT_REL_PRIMARY, + type: ALERT_SAVED_OBJECT_TYPE, + id: ruleId, + namespace: spaceIdToNamespace(spaceId), + }, + ], + }, + }); + } +} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider.ts new file mode 100644 index 0000000000000..7f28715198da6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { IEventLogService } from '../../../../../../event_log/server'; +import { RuleExecutionLogAction, RULE_EXECUTION_LOG_PROVIDER } from './constants'; + +export const registerEventLogProvider = (eventLogService: IEventLogService) => { + eventLogService.registerProviderActions( + RULE_EXECUTION_LOG_PROVIDER, + Object.keys(RuleExecutionLogAction) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_execution_log_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_execution_log_client.ts index 135cefe2243b2..87a3b00cf4ed3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_execution_log_client.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_execution_log_client.ts @@ -6,34 +6,40 @@ */ import { SavedObjectsClientContract } from '../../../../../../../src/core/server'; -import { RuleRegistryAdapter } from './rule_registry_adapter/rule_registry_adapter'; +import { IEventLogService } from '../../../../../event_log/server'; +import { EventLogAdapter } from './event_log_adapter/event_log_adapter'; import { SavedObjectsAdapter } from './saved_objects_adapter/saved_objects_adapter'; import { - ExecutionMetric, - ExecutionMetricArgs, + LogExecutionMetricsArgs, FindBulkExecutionLogArgs, FindExecutionLogArgs, - IRuleDataPluginService, IRuleExecutionLogClient, LogStatusChangeArgs, UpdateExecutionLogArgs, + UnderlyingLogClient, } from './types'; export interface RuleExecutionLogClientArgs { - ruleDataService: IRuleDataPluginService; savedObjectsClient: SavedObjectsClientContract; + eventLogService: IEventLogService; + underlyingClient: UnderlyingLogClient; } -const RULE_REGISTRY_LOG_ENABLED = false; - export class RuleExecutionLogClient implements IRuleExecutionLogClient { private client: IRuleExecutionLogClient; - constructor({ ruleDataService, savedObjectsClient }: RuleExecutionLogClientArgs) { - if (RULE_REGISTRY_LOG_ENABLED) { - this.client = new RuleRegistryAdapter(ruleDataService); - } else { - this.client = new SavedObjectsAdapter(savedObjectsClient); + constructor({ + savedObjectsClient, + eventLogService, + underlyingClient, + }: RuleExecutionLogClientArgs) { + switch (underlyingClient) { + case UnderlyingLogClient.savedObjects: + this.client = new SavedObjectsAdapter(savedObjectsClient); + break; + case UnderlyingLogClient.eventLog: + this.client = new EventLogAdapter(eventLogService); + break; } } @@ -53,8 +59,8 @@ export class RuleExecutionLogClient implements IRuleExecutionLogClient { return this.client.delete(id); } - public async logExecutionMetric(args: ExecutionMetricArgs) { - return this.client.logExecutionMetric(args); + public async logExecutionMetrics(args: LogExecutionMetricsArgs) { + return this.client.logExecutionMetrics(args); } public async logStatusChange(args: LogStatusChangeArgs) { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_adapter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_adapter.ts deleted file mode 100644 index ab8664ae995bf..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_adapter.ts +++ /dev/null @@ -1,106 +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 { merge } from 'lodash'; -import { RuleExecutionStatus } from '../../../../../common/detection_engine/schemas/common/schemas'; -import { RuleRegistryLogClient } from './rule_registry_log_client/rule_registry_log_client'; -import { - CreateExecutionLogArgs, - ExecutionMetric, - ExecutionMetricArgs, - FindBulkExecutionLogArgs, - FindExecutionLogArgs, - IRuleDataPluginService, - IRuleExecutionLogClient, - LogStatusChangeArgs, - UpdateExecutionLogArgs, -} from '../types'; - -/** - * @deprecated RuleRegistryAdapter is kept here only as a reference. It will be superseded with EventLog implementation - */ -export class RuleRegistryAdapter implements IRuleExecutionLogClient { - private ruleRegistryClient: RuleRegistryLogClient; - - constructor(ruleDataService: IRuleDataPluginService) { - this.ruleRegistryClient = new RuleRegistryLogClient(ruleDataService); - } - - public async find({ ruleId, logsCount = 1, spaceId }: FindExecutionLogArgs) { - const logs = await this.ruleRegistryClient.find({ - ruleIds: [ruleId], - logsCount, - spaceId, - }); - - return logs[ruleId].map((log) => ({ - id: '', - type: '', - score: 0, - attributes: log, - references: [], - })); - } - - public async findBulk({ ruleIds, logsCount = 1, spaceId }: FindBulkExecutionLogArgs) { - const [statusesById, lastErrorsById] = await Promise.all([ - this.ruleRegistryClient.find({ ruleIds, spaceId }), - this.ruleRegistryClient.find({ - ruleIds, - statuses: [RuleExecutionStatus.failed], - logsCount, - spaceId, - }), - ]); - return merge(statusesById, lastErrorsById); - } - - private async create({ attributes, spaceId }: CreateExecutionLogArgs) { - if (attributes.status) { - await this.ruleRegistryClient.logStatusChange({ - ruleId: attributes.alertId, - newStatus: attributes.status, - spaceId, - }); - } - - if (attributes.bulkCreateTimeDurations) { - await this.ruleRegistryClient.logExecutionMetric({ - ruleId: attributes.alertId, - metric: ExecutionMetric.indexingDurationMax, - value: Math.max(...attributes.bulkCreateTimeDurations.map(Number)), - spaceId, - }); - } - - if (attributes.gap) { - await this.ruleRegistryClient.logExecutionMetric({ - ruleId: attributes.alertId, - metric: ExecutionMetric.executionGap, - value: Number(attributes.gap), - spaceId, - }); - } - } - - public async update({ attributes, spaceId }: UpdateExecutionLogArgs) { - // execution events are immutable, so we just use 'create' here instead of 'update' - await this.create({ attributes, spaceId }); - } - - public async delete(id: string) { - // execution events are immutable, nothing to do here - } - - public async logExecutionMetric(args: ExecutionMetricArgs) { - return this.ruleRegistryClient.logExecutionMetric(args); - } - - public async logStatusChange(args: LogStatusChangeArgs) { - return this.ruleRegistryClient.logStatusChange(args); - } -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/constants.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/constants.ts deleted file mode 100644 index 8d74c71bf447d..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/constants.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. - */ - -/** - * @deprecated EVENTS_INDEX_PREFIX is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const EVENTS_INDEX_PREFIX = '.kibana_alerts-security.events'; - -/** - * @deprecated MESSAGE is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const MESSAGE = 'message' as const; - -/** - * @deprecated EVENT_SEQUENCE is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const EVENT_SEQUENCE = 'event.sequence' as const; - -/** - * @deprecated EVENT_DURATION is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const EVENT_DURATION = 'event.duration' as const; - -/** - * @deprecated EVENT_END is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const EVENT_END = 'event.end' as const; - -/** - * @deprecated RULE_STATUS is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const RULE_STATUS = 'kibana.rac.detection_engine.rule_status' as const; - -/** - * @deprecated RULE_STATUS_SEVERITY is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const RULE_STATUS_SEVERITY = 'kibana.rac.detection_engine.rule_status_severity' as const; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/parse_rule_execution_log.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/parse_rule_execution_log.ts deleted file mode 100644 index cbc6e570e936f..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/parse_rule_execution_log.ts +++ /dev/null @@ -1,40 +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 { isLeft } from 'fp-ts/lib/Either'; -import { PathReporter } from 'io-ts/lib/PathReporter'; -import { technicalRuleFieldMap } from '../../../../../../../rule_registry/common/assets/field_maps/technical_rule_field_map'; -import { - mergeFieldMaps, - runtimeTypeFromFieldMap, -} from '../../../../../../../rule_registry/common/field_map'; -import { ruleExecutionFieldMap } from './rule_execution_field_map'; - -const ruleExecutionLogRuntimeType = runtimeTypeFromFieldMap( - mergeFieldMaps(technicalRuleFieldMap, ruleExecutionFieldMap) -); - -/** - * @deprecated parseRuleExecutionLog is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const parseRuleExecutionLog = (input: unknown) => { - const validate = ruleExecutionLogRuntimeType.decode(input); - - if (isLeft(validate)) { - throw new Error(PathReporter.report(validate).join('\n')); - } - - return ruleExecutionLogRuntimeType.encode(validate.right); -}; - -/** - * @deprecated RuleExecutionEvent is kept here only as a reference. It will be superseded with EventLog implementation - * - * It's marked as `Partial` because the field map is not yet appropriate for - * execution log events. - */ -export type RuleExecutionEvent = Partial>; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_execution_field_map.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_execution_field_map.ts deleted file mode 100644 index b3c70cd56d9e6..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_execution_field_map.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { - EVENT_DURATION, - EVENT_END, - EVENT_SEQUENCE, - MESSAGE, - RULE_STATUS, - RULE_STATUS_SEVERITY, -} from './constants'; - -/** - * @deprecated ruleExecutionFieldMap is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const ruleExecutionFieldMap = { - [MESSAGE]: { type: 'keyword' }, - [EVENT_SEQUENCE]: { type: 'long' }, - [EVENT_END]: { type: 'date' }, - [EVENT_DURATION]: { type: 'long' }, - [RULE_STATUS]: { type: 'keyword' }, - [RULE_STATUS_SEVERITY]: { type: 'integer' }, -} as const; - -/** - * @deprecated RuleExecutionFieldMap is kept here only as a reference. It will be superseded with EventLog implementation - */ -export type RuleExecutionFieldMap = typeof ruleExecutionFieldMap; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts deleted file mode 100644 index 3cd6171b5bbeb..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/rule_registry_log_client.ts +++ /dev/null @@ -1,270 +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 { estypes } from '@elastic/elasticsearch'; -import { - ALERT_RULE_CONSUMER, - ALERT_RULE_TYPE_ID, - EVENT_ACTION, - EVENT_KIND, - SPACE_IDS, - TIMESTAMP, - ALERT_RULE_UUID, -} from '@kbn/rule-data-utils'; -import moment from 'moment'; - -import { mappingFromFieldMap } from '../../../../../../../rule_registry/common/mapping_from_field_map'; -import { Dataset, IRuleDataClient } from '../../../../../../../rule_registry/server'; -import { SERVER_APP_ID } from '../../../../../../common/constants'; -import { RuleExecutionStatus } from '../../../../../../common/detection_engine/schemas/common/schemas'; -import { invariant } from '../../../../../../common/utils/invariant'; -import { IRuleStatusSOAttributes } from '../../../rules/types'; -import { makeFloatString } from '../../../signals/utils'; -import { - ExecutionMetric, - ExecutionMetricArgs, - IRuleDataPluginService, - LogStatusChangeArgs, -} from '../../types'; -import { EVENT_SEQUENCE, MESSAGE, RULE_STATUS, RULE_STATUS_SEVERITY } from './constants'; -import { parseRuleExecutionLog, RuleExecutionEvent } from './parse_rule_execution_log'; -import { ruleExecutionFieldMap } from './rule_execution_field_map'; -import { - getLastEntryAggregation, - getMetricAggregation, - getMetricField, - sortByTimeDesc, -} from './utils'; - -const statusSeverityDict: Record = { - [RuleExecutionStatus.succeeded]: 0, - [RuleExecutionStatus['going to run']]: 10, - [RuleExecutionStatus.warning]: 20, - [RuleExecutionStatus['partial failure']]: 20, - [RuleExecutionStatus.failed]: 30, -}; - -interface FindExecutionLogArgs { - ruleIds: string[]; - spaceId: string; - logsCount?: number; - statuses?: RuleExecutionStatus[]; -} - -interface IRuleRegistryLogClient { - find: (args: FindExecutionLogArgs) => Promise<{ - [ruleId: string]: IRuleStatusSOAttributes[] | undefined; - }>; - create: (event: RuleExecutionEvent) => Promise; - logStatusChange: (args: LogStatusChangeArgs) => Promise; - logExecutionMetric: (args: ExecutionMetricArgs) => Promise; -} - -/** - * @deprecated RuleRegistryLogClient is kept here only as a reference. It will be superseded with EventLog implementation - */ -export class RuleRegistryLogClient implements IRuleRegistryLogClient { - private sequence = 0; - private ruleDataClient: IRuleDataClient; - - constructor(ruleDataService: IRuleDataPluginService) { - this.ruleDataClient = ruleDataService.initializeIndex({ - feature: SERVER_APP_ID, - registrationContext: 'security', - dataset: Dataset.events, - componentTemplateRefs: [], - componentTemplates: [ - { - name: 'mappings', - mappings: mappingFromFieldMap(ruleExecutionFieldMap, 'strict'), - }, - ], - }); - } - - public async find({ ruleIds, spaceId, statuses, logsCount = 1 }: FindExecutionLogArgs) { - if (ruleIds.length === 0) { - return {}; - } - - const filter: estypes.QueryDslQueryContainer[] = [ - { terms: { [ALERT_RULE_UUID]: ruleIds } }, - { terms: { [SPACE_IDS]: [spaceId] } }, - ]; - - if (statuses) { - filter.push({ terms: { [RULE_STATUS]: statuses } }); - } - - const result = await this.ruleDataClient.getReader().search({ - size: 0, - body: { - query: { - bool: { - filter, - }, - }, - aggs: { - rules: { - terms: { - field: ALERT_RULE_UUID, - size: ruleIds.length, - }, - aggs: { - most_recent_logs: { - top_hits: { - sort: sortByTimeDesc, - size: logsCount, - }, - }, - last_failure: getLastEntryAggregation(RuleExecutionStatus.failed), - last_success: getLastEntryAggregation(RuleExecutionStatus.succeeded), - execution_gap: getMetricAggregation(ExecutionMetric.executionGap), - search_duration_max: getMetricAggregation(ExecutionMetric.searchDurationMax), - indexing_duration_max: getMetricAggregation(ExecutionMetric.indexingDurationMax), - indexing_lookback: getMetricAggregation(ExecutionMetric.indexingLookback), - }, - }, - }, - }, - }); - - if (result.hits.total.value === 0) { - return {}; - } - - invariant(result.aggregations, 'Search response should contain aggregations'); - - return Object.fromEntries( - result.aggregations.rules.buckets.map<[ruleId: string, logs: IRuleStatusSOAttributes[]]>( - (bucket) => [ - bucket.key as string, - bucket.most_recent_logs.hits.hits.map((event) => { - const logEntry = parseRuleExecutionLog(event._source); - invariant( - logEntry[ALERT_RULE_UUID] ?? '', - 'Malformed execution log entry: rule.id field not found' - ); - - const lastFailure = bucket.last_failure.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.last_failure.event.hits.hits[0]._source) - : undefined; - - const lastSuccess = bucket.last_success.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.last_success.event.hits.hits[0]._source) - : undefined; - - const lookBack = bucket.indexing_lookback.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.indexing_lookback.event.hits.hits[0]._source) - : undefined; - - const executionGap = bucket.execution_gap.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.execution_gap.event.hits.hits[0]._source)[ - getMetricField(ExecutionMetric.executionGap) - ] - : undefined; - - const searchDuration = bucket.search_duration_max.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.search_duration_max.event.hits.hits[0]._source)[ - getMetricField(ExecutionMetric.searchDurationMax) - ] - : undefined; - - const indexingDuration = bucket.indexing_duration_max.event.hits.hits[0] - ? parseRuleExecutionLog(bucket.indexing_duration_max.event.hits.hits[0]._source)[ - getMetricField(ExecutionMetric.indexingDurationMax) - ] - : undefined; - - const alertId = logEntry[ALERT_RULE_UUID] ?? ''; - const statusDate = logEntry[TIMESTAMP]; - const lastFailureAt = lastFailure?.[TIMESTAMP]; - const lastFailureMessage = lastFailure?.[MESSAGE]; - const lastSuccessAt = lastSuccess?.[TIMESTAMP]; - const lastSuccessMessage = lastSuccess?.[MESSAGE]; - const status = (logEntry[RULE_STATUS] as RuleExecutionStatus) || null; - const lastLookBackDate = lookBack?.[getMetricField(ExecutionMetric.indexingLookback)]; - const gap = executionGap ? moment.duration(executionGap).humanize() : null; - const bulkCreateTimeDurations = indexingDuration - ? [makeFloatString(indexingDuration)] - : null; - const searchAfterTimeDurations = searchDuration - ? [makeFloatString(searchDuration)] - : null; - - return { - alertId, - statusDate, - lastFailureAt, - lastFailureMessage, - lastSuccessAt, - lastSuccessMessage, - status, - lastLookBackDate, - gap, - bulkCreateTimeDurations, - searchAfterTimeDurations, - }; - }), - ] - ) - ); - } - - public async logExecutionMetric({ - ruleId, - namespace, - metric, - value, - spaceId, - }: ExecutionMetricArgs) { - await this.create( - { - [SPACE_IDS]: [spaceId], - [EVENT_ACTION]: metric, - [EVENT_KIND]: 'metric', - [getMetricField(metric)]: value, - [ALERT_RULE_UUID]: ruleId ?? '', - [TIMESTAMP]: new Date().toISOString(), - [ALERT_RULE_CONSUMER]: SERVER_APP_ID, - [ALERT_RULE_TYPE_ID]: SERVER_APP_ID, - }, - namespace - ); - } - - public async logStatusChange({ - ruleId, - newStatus, - namespace, - message, - spaceId, - }: LogStatusChangeArgs) { - await this.create( - { - [SPACE_IDS]: [spaceId], - [EVENT_ACTION]: 'status-change', - [EVENT_KIND]: 'event', - [EVENT_SEQUENCE]: this.sequence++, - [MESSAGE]: message, - [ALERT_RULE_UUID]: ruleId ?? '', - [RULE_STATUS_SEVERITY]: statusSeverityDict[newStatus], - [RULE_STATUS]: newStatus, - [TIMESTAMP]: new Date().toISOString(), - [ALERT_RULE_CONSUMER]: SERVER_APP_ID, - [ALERT_RULE_TYPE_ID]: SERVER_APP_ID, - }, - namespace - ); - } - - public async create(event: RuleExecutionEvent, namespace?: string) { - await this.ruleDataClient.getWriter({ namespace }).bulk({ - body: [{ index: {} }, event], - }); - } -} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/utils.ts deleted file mode 100644 index 713cf73890e7f..0000000000000 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/rule_registry_adapter/rule_registry_log_client/utils.ts +++ /dev/null @@ -1,76 +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 { SearchSort } from '@elastic/elasticsearch/api/types'; -import { EVENT_ACTION, TIMESTAMP } from '@kbn/rule-data-utils'; -import { RuleExecutionStatus } from '../../../../../../common/detection_engine/schemas/common/schemas'; -import { ExecutionMetric } from '../../types'; -import { RULE_STATUS, EVENT_SEQUENCE, EVENT_DURATION, EVENT_END } from './constants'; - -const METRIC_FIELDS = { - [ExecutionMetric.executionGap]: EVENT_DURATION, - [ExecutionMetric.searchDurationMax]: EVENT_DURATION, - [ExecutionMetric.indexingDurationMax]: EVENT_DURATION, - [ExecutionMetric.indexingLookback]: EVENT_END, -}; - -/** - * Returns ECS field in which metric value is stored - * @deprecated getMetricField is kept here only as a reference. It will be superseded with EventLog implementation - * - * @param metric - execution metric - * @returns ECS field - */ -export const getMetricField = (metric: T) => METRIC_FIELDS[metric]; - -/** - * @deprecated sortByTimeDesc is kept here only as a reference. It will be superseded with EventLog implementation - */ -export const sortByTimeDesc: SearchSort = [{ [TIMESTAMP]: 'desc' }, { [EVENT_SEQUENCE]: 'desc' }]; - -/** - * Builds aggregation to retrieve the most recent metric value - * @deprecated getMetricAggregation is kept here only as a reference. It will be superseded with EventLog implementation - * - * @param metric - execution metric - * @returns aggregation - */ -export const getMetricAggregation = (metric: ExecutionMetric) => ({ - filter: { - term: { [EVENT_ACTION]: metric }, - }, - aggs: { - event: { - top_hits: { - size: 1, - sort: sortByTimeDesc, - _source: [TIMESTAMP, getMetricField(metric)], - }, - }, - }, -}); - -/** - * Builds aggregation to retrieve the most recent log entry with the given status - * @deprecated getLastEntryAggregation is kept here only as a reference. It will be superseded with EventLog implementation - * - * @param status - rule execution status - * @returns aggregation - */ -export const getLastEntryAggregation = (status: RuleExecutionStatus) => ({ - filter: { - term: { [RULE_STATUS]: status }, - }, - aggs: { - event: { - top_hits: { - sort: sortByTimeDesc, - size: 1, - }, - }, - }, -}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts index 27329ebf8f90c..ca806bd58e369 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/saved_objects_adapter/saved_objects_adapter.ts @@ -14,12 +14,11 @@ import { ruleStatusSavedObjectsClientFactory, } from './rule_status_saved_objects_client'; import { - ExecutionMetric, - ExecutionMetricArgs, + LogExecutionMetricsArgs, FindBulkExecutionLogArgs, FindExecutionLogArgs, IRuleExecutionLogClient, - LegacyMetrics, + ExecutionMetrics, LogStatusChangeArgs, UpdateExecutionLogArgs, } from '../types'; @@ -28,14 +27,16 @@ import { assertUnreachable } from '../../../../../common'; // 1st is mutable status, followed by 5 most recent failures export const MAX_RULE_STATUSES = 6; -const METRIC_FIELDS = { - [ExecutionMetric.executionGap]: 'gap', - [ExecutionMetric.searchDurationMax]: 'searchAfterTimeDurations', - [ExecutionMetric.indexingDurationMax]: 'bulkCreateTimeDurations', - [ExecutionMetric.indexingLookback]: 'lastLookBackDate', -} as const; - -const getMetricField = (metric: T) => METRIC_FIELDS[metric]; +const convertMetricFields = ( + metrics: ExecutionMetrics +): Pick< + IRuleStatusSOAttributes, + 'gap' | 'searchAfterTimeDurations' | 'bulkCreateTimeDurations' +> => ({ + gap: metrics.executionGap?.humanize(), + searchAfterTimeDurations: metrics.searchDurations, + bulkCreateTimeDurations: metrics.indexingDurations, +}); export class SavedObjectsAdapter implements IRuleExecutionLogClient { private ruleStatusClient: RuleStatusSavedObjectsClient; @@ -66,16 +67,12 @@ export class SavedObjectsAdapter implements IRuleExecutionLogClient { await this.ruleStatusClient.delete(id); } - public async logExecutionMetric({ - ruleId, - metric, - value, - }: ExecutionMetricArgs) { + public async logExecutionMetrics({ ruleId, metrics }: LogExecutionMetricsArgs) { const [currentStatus] = await this.getOrCreateRuleStatuses(ruleId); await this.ruleStatusClient.update(currentStatus.id, { ...currentStatus.attributes, - [getMetricField(metric)]: value, + ...convertMetricFields(metrics), }); } @@ -158,11 +155,11 @@ export class SavedObjectsAdapter implements IRuleExecutionLogClient { const buildRuleStatusAttributes: ( status: RuleExecutionStatus, message?: string, - metrics?: LegacyMetrics + metrics?: ExecutionMetrics ) => Partial = (status, message, metrics = {}) => { const now = new Date().toISOString(); const baseAttributes: Partial = { - ...metrics, + ...convertMetricFields(metrics), status: status === RuleExecutionStatus.warning ? RuleExecutionStatus['partial failure'] : status, statusDate: now, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/types.ts index 9c66032f681de..e38f974ddee2e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_execution_log/types.ts @@ -5,28 +5,16 @@ * 2.0. */ -import { PublicMethodsOf } from '@kbn/utility-types'; +import { Duration } from 'moment'; import { SavedObjectsFindResult } from '../../../../../../../src/core/server'; -import { RuleDataPluginService } from '../../../../../rule_registry/server'; import { RuleExecutionStatus } from '../../../../common/detection_engine/schemas/common/schemas'; import { IRuleStatusSOAttributes } from '../rules/types'; -export enum ExecutionMetric { - 'executionGap' = 'executionGap', - 'searchDurationMax' = 'searchDurationMax', - 'indexingDurationMax' = 'indexingDurationMax', - 'indexingLookback' = 'indexingLookback', +export enum UnderlyingLogClient { + 'savedObjects' = 'savedObjects', + 'eventLog' = 'eventLog', } -export type IRuleDataPluginService = PublicMethodsOf; - -export type ExecutionMetricValue = { - [ExecutionMetric.executionGap]: number; - [ExecutionMetric.searchDurationMax]: number; - [ExecutionMetric.indexingDurationMax]: number; - [ExecutionMetric.indexingLookback]: Date; -}[T]; - export interface FindExecutionLogArgs { ruleId: string; spaceId: string; @@ -39,29 +27,34 @@ export interface FindBulkExecutionLogArgs { logsCount?: number; } -/** - * @deprecated LegacyMetrics are only kept here for backward compatibility - * and should be replaced by ExecutionMetric in the future - */ -export interface LegacyMetrics { - searchAfterTimeDurations?: string[]; - bulkCreateTimeDurations?: string[]; +export interface ExecutionMetrics { + searchDurations?: string[]; + indexingDurations?: string[]; + /** + * @deprecated lastLookBackDate is logged only by SavedObjectsAdapter and should be removed in the future + */ lastLookBackDate?: string; - gap?: string; + executionGap?: Duration; } export interface LogStatusChangeArgs { ruleId: string; + ruleName: string; + ruleType: string; spaceId: string; newStatus: RuleExecutionStatus; - namespace?: string; message?: string; - metrics?: LegacyMetrics; + /** + * @deprecated Use RuleExecutionLogClient.logExecutionMetrics to write metrics instead + */ + metrics?: ExecutionMetrics; } export interface UpdateExecutionLogArgs { id: string; attributes: IRuleStatusSOAttributes; + ruleName: string; + ruleType: string; spaceId: string; } @@ -70,12 +63,12 @@ export interface CreateExecutionLogArgs { spaceId: string; } -export interface ExecutionMetricArgs { +export interface LogExecutionMetricsArgs { ruleId: string; + ruleName: string; + ruleType: string; spaceId: string; - namespace?: string; - metric: T; - value: ExecutionMetricValue; + metrics: ExecutionMetrics; } export interface FindBulkExecutionLogResponse { @@ -90,5 +83,5 @@ export interface IRuleExecutionLogClient { update: (args: UpdateExecutionLogArgs) => Promise; delete: (id: string) => Promise; logStatusChange: (args: LogStatusChangeArgs) => Promise; - logExecutionMetric: (args: ExecutionMetricArgs) => Promise; + logExecutionMetrics: (args: LogExecutionMetricsArgs) => Promise; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts index bcab8a0af5ffb..c6f818f04fc5d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/__mocks__/rule_type.ts @@ -14,6 +14,7 @@ import { mlPluginServerMock } from '../../../../../../ml/server/mocks'; import type { IRuleDataClient } from '../../../../../../rule_registry/server'; import { ruleRegistryMocks } from '../../../../../../rule_registry/server/mocks'; +import { eventLogServiceMock } from '../../../../../../event_log/server/mocks'; import { PluginSetupContract as AlertingPluginSetupContract } from '../../../../../../alerting/server'; import { ConfigType } from '../../../../config'; import { AlertAttributes } from '../../signals/types'; @@ -55,6 +56,7 @@ export const createRuleTypeMocks = ( references: [], attributes: { actions: [], + alertTypeId: 'siem.signals', enabled: true, name: 'mock rule', tags: [], @@ -89,7 +91,7 @@ export const createRuleTypeMocks = ( ruleDataClient: ruleRegistryMocks.createRuleDataClient( '.alerts-security.alerts' ) as IRuleDataClient, - ruleDataService: ruleRegistryMocks.createRuleDataPluginService(), + eventLogService: eventLogServiceMock.create(), }, services, scheduleActions, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts index df15d4b2c0112..9ea36abe997c3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/create_security_rule_type_factory.ts @@ -40,8 +40,9 @@ import { scheduleThrottledNotificationActions } from '../notifications/schedule_ /* eslint-disable complexity */ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = - ({ lists, logger, mergeStrategy, ignoreFields, ruleDataClient, ruleDataService }) => + ({ lists, logger, config, ruleDataClient, eventLogService }) => (type) => { + const { alertIgnoreFields: ignoreFields, alertMergeStrategy: mergeStrategy } = config; const persistenceRuleType = createPersistenceRuleTypeFactory({ ruleDataClient, logger }); return persistenceRuleType({ ...type, @@ -65,13 +66,15 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = const ruleStatusClient = new RuleExecutionLogClient({ savedObjectsClient, - ruleDataService, + eventLogService, + underlyingClient: config.ruleExecutionLog.underlyingClient, }); const ruleSO = await savedObjectsClient.get('alert', alertId); const { actions, name, + alertTypeId, schedule: { interval }, } = ruleSO.attributes; const refresh = actions.length ? 'wait_for' : false; @@ -87,9 +90,14 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = logger.debug(buildRuleMessage(`interval: ${interval}`)); let wroteWarningStatus = false; - await ruleStatusClient.logStatusChange({ + const basicLogArguments = { spaceId, ruleId: alertId, + ruleName: name, + ruleType: alertTypeId, + }; + await ruleStatusClient.logStatusChange({ + ...basicLogArguments, newStatus: RuleExecutionStatus['going to run'], }); @@ -125,8 +133,7 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = tryCatch( () => hasReadIndexPrivileges({ - spaceId, - ruleId: alertId, + ...basicLogArguments, privileges, logger, buildRuleMessage, @@ -138,8 +145,7 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = tryCatch( () => hasTimestampFields({ - spaceId, - ruleId: alertId, + ...basicLogArguments, wroteStatus: wroteStatus as boolean, timestampField: hasTimestampOverride ? (timestampOverride as string) @@ -179,11 +185,10 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = logger.warn(gapMessage); hasError = true; await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message: gapMessage, - metrics: { gap: gapString }, + metrics: { executionGap: remainingGap }, }); } @@ -262,8 +267,7 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = if (result.warningMessages.length) { const warningMessage = buildRuleMessage(result.warningMessages.join()); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus['partial failure'], message: warningMessage, }); @@ -327,13 +331,12 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = if (!hasError && !wroteWarningStatus && !result.warning) { await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.succeeded, message: 'succeeded', metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookbackDate?.toISOString(), }, }); @@ -356,13 +359,12 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = ); logger.error(errorMessage); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message: errorMessage, metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookbackDate?.toISOString(), }, }); @@ -376,13 +378,12 @@ export const createSecurityRuleTypeFactory: CreateSecurityRuleTypeFactory = logger.error(message); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message, metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookbackDate?.toISOString(), }, }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts index 868419179c76b..43860d396ac5d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.test.ts @@ -12,6 +12,7 @@ import { allowedExperimentalValues } from '../../../../../common/experimental_fe import { createEqlAlertType } from './create_eql_alert_type'; import { createRuleTypeMocks } from '../__mocks__/rule_type'; import { getEqlRuleParams } from '../../schemas/rule_schemas.mock'; +import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../../rule_execution_log/rule_execution_log_client'); @@ -26,10 +27,9 @@ describe('Event correlation alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - ignoreFields: [], - mergeStrategy: 'allFields', + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); dependencies.alerting.registerType(eqlAlertType); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts index 5893c6fdc86c2..9324b469bf644 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/eql/create_eql_alert_type.ts @@ -14,23 +14,14 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact import { CreateRuleOptions } from '../types'; export const createEqlAlertType = (createOptions: CreateRuleOptions) => { - const { - experimentalFeatures, - lists, - logger, - ignoreFields, - mergeStrategy, - ruleDataClient, - version, - ruleDataService, - } = createOptions; + const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = + createOptions; const createSecurityRuleType = createSecurityRuleTypeFactory({ lists, logger, - ignoreFields, - mergeStrategy, + config, ruleDataClient, - ruleDataService, + eventLogService, }); return createSecurityRuleType({ id: EQL_RULE_TYPE_ID, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert_group_from_sequence.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert_group_from_sequence.test.ts index 6daafbfae40f2..a7accc4ae8a0f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert_group_from_sequence.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/factories/utils/build_alert_group_from_sequence.test.ts @@ -44,6 +44,7 @@ describe('buildAlert', () => { const ruleSO = { attributes: { actions: [], + alertTypeId: 'siem.signals', createdAt: new Date().toISOString(), createdBy: 'gandalf', params: getQueryRuleParams(), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts index fe836c872dcad..3db4f5686abdc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.test.ts @@ -16,6 +16,7 @@ import { createIndicatorMatchAlertType } from './create_indicator_match_alert_ty import { sampleDocNoSortId } from '../../signals/__mocks__/es_results'; import { CountResponse } from 'kibana/server'; import { RuleParams } from '../../schemas/rule_schemas'; +import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../utils/get_list_client', () => ({ getListClient: jest.fn().mockReturnValue({ @@ -56,10 +57,9 @@ describe('Indicator Match Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - ignoreFields: [], - mergeStrategy: 'allFields', + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); @@ -97,10 +97,9 @@ describe('Indicator Match Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); @@ -136,10 +135,9 @@ describe('Indicator Match Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts index e2d5da1def707..c30fdd7d99c2a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/indicator_match/create_indicator_match_alert_type.ts @@ -14,23 +14,14 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact import { CreateRuleOptions } from '../types'; export const createIndicatorMatchAlertType = (createOptions: CreateRuleOptions) => { - const { - experimentalFeatures, - lists, - logger, - mergeStrategy, - ignoreFields, - ruleDataClient, - version, - ruleDataService, - } = createOptions; + const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = + createOptions; const createSecurityRuleType = createSecurityRuleTypeFactory({ lists, logger, - mergeStrategy, - ignoreFields, + config, ruleDataClient, - ruleDataService, + eventLogService, }); return createSecurityRuleType({ id: INDICATOR_RULE_TYPE_ID, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts index 23cd2e94aedf8..bffc20c3df1e3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.test.ts @@ -14,6 +14,7 @@ import { createRuleTypeMocks } from '../__mocks__/rule_type'; import { createMlAlertType } from './create_ml_alert_type'; import { RuleParams } from '../../schemas/rule_schemas'; +import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../../signals/bulk_create_ml_signals'); @@ -97,11 +98,10 @@ describe('Machine Learning Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ml: mlMock, ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts index ec2f5dd104646..ac2d3f14831a4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/ml/create_ml_alert_type.ts @@ -14,15 +14,13 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact import { CreateRuleOptions } from '../types'; export const createMlAlertType = (createOptions: CreateRuleOptions) => { - const { lists, logger, mergeStrategy, ignoreFields, ml, ruleDataClient, ruleDataService } = - createOptions; + const { lists, logger, config, ml, ruleDataClient, eventLogService } = createOptions; const createSecurityRuleType = createSecurityRuleTypeFactory({ lists, logger, - mergeStrategy, - ignoreFields, + config, ruleDataClient, - ruleDataService, + eventLogService, }); return createSecurityRuleType({ id: ML_RULE_TYPE_ID, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts index e45d8440386fe..4fdeac8047b1d 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.test.ts @@ -14,6 +14,7 @@ import { allowedExperimentalValues } from '../../../../../common/experimental_fe import { sampleDocNoSortId } from '../../signals/__mocks__/es_results'; import { createQueryAlertType } from './create_query_alert_type'; import { createRuleTypeMocks } from '../__mocks__/rule_type'; +import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../utils/get_list_client', () => ({ getListClient: jest.fn().mockReturnValue({ @@ -31,10 +32,9 @@ describe('Custom Query Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); @@ -79,10 +79,9 @@ describe('Custom Query Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts index d5af7a4c8b5a4..469c237112dcb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/query/create_query_alert_type.ts @@ -14,23 +14,14 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact import { CreateRuleOptions } from '../types'; export const createQueryAlertType = (createOptions: CreateRuleOptions) => { - const { - experimentalFeatures, - lists, - logger, - mergeStrategy, - ignoreFields, - ruleDataClient, - version, - ruleDataService, - } = createOptions; + const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = + createOptions; const createSecurityRuleType = createSecurityRuleTypeFactory({ lists, logger, - mergeStrategy, - ignoreFields, + config, ruleDataClient, - ruleDataService, + eventLogService, }); return createSecurityRuleType({ id: QUERY_RULE_TYPE_ID, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts index 74435cb300472..aff57dbdf3cd4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.test.ts @@ -9,6 +9,7 @@ import { allowedExperimentalValues } from '../../../../../common/experimental_fe import { createThresholdAlertType } from './create_threshold_alert_type'; import { createRuleTypeMocks } from '../__mocks__/rule_type'; import { getThresholdRuleParams } from '../../schemas/rule_schemas.mock'; +import { createMockConfig } from '../../routes/__mocks__'; jest.mock('../../rule_execution_log/rule_execution_log_client'); @@ -20,10 +21,9 @@ describe('Threshold Alerts', () => { experimentalFeatures: allowedExperimentalValues, lists: dependencies.lists, logger: dependencies.logger, - mergeStrategy: 'allFields', - ignoreFields: [], + config: createMockConfig(), ruleDataClient: dependencies.ruleDataClient, - ruleDataService: dependencies.ruleDataService, + eventLogService: dependencies.eventLogService, version: '1.0.0', }); dependencies.alerting.registerType(thresholdAlertTpe); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts index a503cf5aedbea..789e4525c58ab 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/threshold/create_threshold_alert_type.ts @@ -16,23 +16,14 @@ import { createSecurityRuleTypeFactory } from '../create_security_rule_type_fact import { CreateRuleOptions } from '../types'; export const createThresholdAlertType = (createOptions: CreateRuleOptions) => { - const { - experimentalFeatures, - lists, - logger, - mergeStrategy, - ignoreFields, - ruleDataClient, - version, - ruleDataService, - } = createOptions; + const { experimentalFeatures, lists, logger, config, ruleDataClient, version, eventLogService } = + createOptions; const createSecurityRuleType = createSecurityRuleTypeFactory({ lists, logger, - mergeStrategy, - ignoreFields, + config, ruleDataClient, - ruleDataService, + eventLogService, }); return createSecurityRuleType({ id: THRESHOLD_RULE_TYPE_ID, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts index 6280a50d4981c..c94339da03b93 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rule_types/types.ts @@ -30,12 +30,12 @@ import { import { BaseHit } from '../../../../common/detection_engine/types'; import { ConfigType } from '../../../config'; import { SetupPlugins } from '../../../plugin'; -import { IRuleDataPluginService } from '../rule_execution_log/types'; import { RuleParams } from '../schemas/rule_schemas'; import { BuildRuleMessage } from '../signals/rule_messages'; import { AlertAttributes, BulkCreate, WrapHits, WrapSequences } from '../signals/types'; import { AlertsFieldMap, RulesFieldMap } from './field_maps'; import { ExperimentalFeatures } from '../../../../common/experimental_features'; +import { IEventLogService } from '../../../../../event_log/server'; export interface SecurityAlertTypeReturnValue { bulkCreateTimes: string[]; @@ -98,10 +98,9 @@ type SecurityAlertTypeWithExecutor< export type CreateSecurityRuleTypeFactory = (options: { lists: SetupPlugins['lists']; logger: Logger; - mergeStrategy: ConfigType['alertMergeStrategy']; - ignoreFields: ConfigType['alertIgnoreFields']; + config: ConfigType; ruleDataClient: IRuleDataClient; - ruleDataService: IRuleDataPluginService; + eventLogService: IEventLogService; }) => < TParams extends RuleParams & { index?: string[] | undefined }, TAlertInstanceContext extends AlertInstanceContext, @@ -127,10 +126,9 @@ export interface CreateRuleOptions { experimentalFeatures: ExperimentalFeatures; lists: SetupPlugins['lists']; logger: Logger; - mergeStrategy: ConfigType['alertMergeStrategy']; - ignoreFields: ConfigType['alertIgnoreFields']; + config: ConfigType; ml?: SetupPlugins['ml']; ruleDataClient: IRuleDataClient; version: string; - ruleDataService: IRuleDataPluginService; + eventLogService: IEventLogService; } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts index c6a5c00380242..2f3d05e0c9586 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/enable_rule.ts @@ -44,6 +44,8 @@ export const enableRule = async ({ const currentStatusToDisable = ruleCurrentStatus[0]; await ruleStatusClient.update({ id: currentStatusToDisable.id, + ruleName: rule.name, + ruleType: rule.alertTypeId, attributes: { ...currentStatusToDisable.attributes, status: RuleExecutionStatus['going to run'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 850eee3993b60..207ea497c7e8e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -33,6 +33,7 @@ export const sampleRuleSO = (params: T): SavedObject { updated_at: '2020-03-27T22:55:59.577Z', attributes: { actions: [], + alertTypeId: 'siem.signals', enabled: true, name: 'rule-name', tags: ['some fake tag 1', 'some fake tag 2'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts index 5766390099e29..11145405dcc99 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/executors/threshold.test.ts @@ -30,6 +30,7 @@ describe('threshold_executor', () => { updated_at: '2020-03-27T22:55:59.577Z', attributes: { actions: [], + alertTypeId: 'siem.signals', enabled: true, name: 'rule-name', tags: ['some fake tag 1', 'some fake tag 2'], diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts index 2696d6981083e..c2923b566175e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.test.ts @@ -32,10 +32,11 @@ import { mlExecutor } from './executors/ml'; import { getMlRuleParams, getQueryRuleParams } from '../schemas/rule_schemas.mock'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { allowedExperimentalValues } from '../../../../common/experimental_features'; -import { ruleRegistryMocks } from '../../../../../rule_registry/server/mocks'; import { scheduleNotificationActions } from '../notifications/schedule_notification_actions'; import { ruleExecutionLogClientMock } from '../rule_execution_log/__mocks__/rule_execution_log_client'; import { RuleExecutionStatus } from '../../../../common/detection_engine/schemas/common/schemas'; +import { eventLogServiceMock } from '../../../../../event_log/server/mocks'; +import { createMockConfig } from '../routes/__mocks__'; jest.mock('./utils', () => { const original = jest.requireActual('./utils'); @@ -124,12 +125,12 @@ describe('signal_rule_alert_type', () => { let alert: ReturnType; let logger: ReturnType; let alertServices: AlertServicesMock; - let ruleDataService: ReturnType; + let eventLogService: ReturnType; beforeEach(() => { alertServices = alertsMock.createAlertServices(); logger = loggingSystemMock.createLogger(); - ruleDataService = ruleRegistryMocks.createRuleDataPluginService(); + eventLogService = eventLogServiceMock.create(); (getListsClient as jest.Mock).mockReturnValue({ listClient: getListClientMock(), exceptionsClient: getExceptionListClientMock(), @@ -194,9 +195,8 @@ describe('signal_rule_alert_type', () => { version, ml: mlMock, lists: listMock.createSetup(), - mergeStrategy: 'missingFields', - ignoreFields: [], - ruleDataService, + config: createMockConfig(), + eventLogService, }); mockRuleExecutionLogClient.logStatusChange.mockClear(); @@ -217,11 +217,18 @@ describe('signal_rule_alert_type', () => { payload.previousStartedAt = moment().subtract(100, 'm').toDate(); await alert.executor(payload); expect(logger.warn).toHaveBeenCalled(); - expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenLastCalledWith( + expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + newStatus: RuleExecutionStatus['going to run'], + }) + ); + expect(mockRuleExecutionLogClient.logStatusChange).toHaveBeenNthCalledWith( + 2, expect.objectContaining({ newStatus: RuleExecutionStatus.failed, metrics: { - gap: 'an hour', + executionGap: expect.any(Object), }, }) ); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 9a6c099ed1760..1e3a8a513c4a1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -70,9 +70,9 @@ import { ConfigType } from '../../../config'; import { ExperimentalFeatures } from '../../../../common/experimental_features'; import { injectReferences, extractReferences } from './saved_object_references'; import { RuleExecutionLogClient } from '../rule_execution_log/rule_execution_log_client'; -import { IRuleDataPluginService } from '../rule_execution_log/types'; import { RuleExecutionStatus } from '../../../../common/detection_engine/schemas/common/schemas'; import { scheduleThrottledNotificationActions } from '../notifications/schedule_throttle_notification_actions'; +import { IEventLogService } from '../../../../../event_log/server'; export const signalRulesAlertType = ({ logger, @@ -81,9 +81,8 @@ export const signalRulesAlertType = ({ version, ml, lists, - mergeStrategy, - ignoreFields, - ruleDataService, + config, + eventLogService, }: { logger: Logger; eventsTelemetry: TelemetryEventsSender | undefined; @@ -91,10 +90,10 @@ export const signalRulesAlertType = ({ version: string; ml: SetupPlugins['ml']; lists: SetupPlugins['lists'] | undefined; - mergeStrategy: ConfigType['alertMergeStrategy']; - ignoreFields: ConfigType['alertIgnoreFields']; - ruleDataService: IRuleDataPluginService; + config: ConfigType; + eventLogService: IEventLogService; }): SignalRuleAlertTypeDefinition => { + const { alertMergeStrategy: mergeStrategy, alertIgnoreFields: ignoreFields } = config; return { id: SIGNALS_ID, name: 'SIEM signal', @@ -138,14 +137,16 @@ export const signalRulesAlertType = ({ let hasError: boolean = false; let result = createSearchAfterReturnType(); const ruleStatusClient = new RuleExecutionLogClient({ - ruleDataService, + eventLogService, savedObjectsClient: services.savedObjectsClient, + underlyingClient: config.ruleExecutionLog.underlyingClient, }); const savedObject = await services.savedObjectsClient.get('alert', alertId); const { actions, name, + alertTypeId, schedule: { interval }, } = savedObject.attributes; const refresh = actions.length ? 'wait_for' : false; @@ -159,10 +160,16 @@ export const signalRulesAlertType = ({ logger.debug(buildRuleMessage('[+] Starting Signal Rule execution')); logger.debug(buildRuleMessage(`interval: ${interval}`)); let wroteWarningStatus = false; - await ruleStatusClient.logStatusChange({ + const basicLogArguments = { + spaceId, ruleId: alertId, + ruleName: name, + ruleType: alertTypeId, + }; + + await ruleStatusClient.logStatusChange({ + ...basicLogArguments, newStatus: RuleExecutionStatus['going to run'], - spaceId, }); // check if rule has permissions to access given index pattern @@ -194,8 +201,7 @@ export const signalRulesAlertType = ({ tryCatch( () => hasReadIndexPrivileges({ - spaceId, - ruleId: alertId, + ...basicLogArguments, privileges, logger, buildRuleMessage, @@ -207,13 +213,11 @@ export const signalRulesAlertType = ({ tryCatch( () => hasTimestampFields({ - spaceId, - ruleId: alertId, + ...basicLogArguments, wroteStatus: wroteStatus as boolean, timestampField: hasTimestampOverride ? (timestampOverride as string) : '@timestamp', - ruleName: name, timestampFieldCapsResponse: timestampFieldCaps, inputIndices, ruleStatusClient, @@ -247,11 +251,10 @@ export const signalRulesAlertType = ({ logger.warn(gapMessage); hasError = true; await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message: gapMessage, - metrics: { gap: gapString }, + metrics: { executionGap: remainingGap }, }); } try { @@ -383,8 +386,7 @@ export const signalRulesAlertType = ({ if (result.warningMessages.length) { const warningMessage = buildRuleMessage(result.warningMessages.join()); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus['partial failure'], message: warningMessage, }); @@ -445,13 +447,12 @@ export const signalRulesAlertType = ({ ); if (!hasError && !wroteWarningStatus && !result.warning) { await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.succeeded, message: 'succeeded', metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookBackDate?.toISOString(), }, }); @@ -474,13 +475,12 @@ export const signalRulesAlertType = ({ ); logger.error(errorMessage); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message: errorMessage, metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookBackDate?.toISOString(), }, }); @@ -494,13 +494,12 @@ export const signalRulesAlertType = ({ logger.error(message); await ruleStatusClient.logStatusChange({ - spaceId, - ruleId: alertId, + ...basicLogArguments, newStatus: RuleExecutionStatus.failed, message, metrics: { - bulkCreateTimeDurations: result.bulkCreateTimes, - searchAfterTimeDurations: result.searchAfterTimes, + indexingDurations: result.bulkCreateTimes, + searchDurations: result.searchAfterTimes, lastLookBackDate: result.lastLookBackDate?.toISOString(), }, }); 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 c1e7e23c3b161..82b4a46f482b6 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 @@ -255,6 +255,7 @@ export interface SignalHit { export interface AlertAttributes { actions: RuleAlertAction[]; + alertTypeId: string; enabled: boolean; name: string; tags: string[]; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts index c3e95d6d196ca..7d2eafa46d382 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.test.ts @@ -789,6 +789,7 @@ describe('utils', () => { inputIndices: ['myfa*'], ruleStatusClient, ruleId: 'ruleId', + ruleType: 'ruleType', spaceId: 'default', logger: mockLogger, buildRuleMessage, @@ -832,6 +833,7 @@ describe('utils', () => { inputIndices: ['myfa*'], ruleStatusClient, ruleId: 'ruleId', + ruleType: 'ruleType', spaceId: 'default', logger: mockLogger, buildRuleMessage, @@ -861,6 +863,7 @@ describe('utils', () => { inputIndices: ['logs-endpoint.alerts-*'], ruleStatusClient, ruleId: 'ruleId', + ruleType: 'ruleType', spaceId: 'default', logger: mockLogger, buildRuleMessage, @@ -890,6 +893,7 @@ describe('utils', () => { inputIndices: ['logs-endpoint.alerts-*'], ruleStatusClient, ruleId: 'ruleId', + ruleType: 'ruleType', spaceId: 'default', logger: mockLogger, buildRuleMessage, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts index 5993dd626729f..2aefc7ea0bd64 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/utils.ts @@ -99,9 +99,20 @@ export const hasReadIndexPrivileges = async (args: { buildRuleMessage: BuildRuleMessage; ruleStatusClient: IRuleExecutionLogClient; ruleId: string; + ruleName: string; + ruleType: string; spaceId: string; }): Promise => { - const { privileges, logger, buildRuleMessage, ruleStatusClient, ruleId, spaceId } = args; + const { + privileges, + logger, + buildRuleMessage, + ruleStatusClient, + ruleId, + ruleName, + ruleType, + spaceId, + } = args; const indexNames = Object.keys(privileges.index); const [indexesWithReadPrivileges, indexesWithNoReadPrivileges] = partition( @@ -119,6 +130,8 @@ export const hasReadIndexPrivileges = async (args: { await ruleStatusClient.logStatusChange({ message: errorString, ruleId, + ruleName, + ruleType, spaceId, newStatus: RuleExecutionStatus['partial failure'], }); @@ -136,6 +149,8 @@ export const hasReadIndexPrivileges = async (args: { await ruleStatusClient.logStatusChange({ message: errorString, ruleId, + ruleName, + ruleType, spaceId, newStatus: RuleExecutionStatus['partial failure'], }); @@ -156,6 +171,7 @@ export const hasTimestampFields = async (args: { ruleStatusClient: IRuleExecutionLogClient; ruleId: string; spaceId: string; + ruleType: string; logger: Logger; buildRuleMessage: BuildRuleMessage; }): Promise => { @@ -167,6 +183,7 @@ export const hasTimestampFields = async (args: { inputIndices, ruleStatusClient, ruleId, + ruleType, spaceId, logger, buildRuleMessage, @@ -184,6 +201,8 @@ export const hasTimestampFields = async (args: { await ruleStatusClient.logStatusChange({ message: errorString.trimEnd(), ruleId, + ruleName, + ruleType, spaceId, newStatus: RuleExecutionStatus['partial failure'], }); @@ -210,6 +229,8 @@ export const hasTimestampFields = async (args: { await ruleStatusClient.logStatusChange({ message: errorString, ruleId, + ruleName, + ruleType, spaceId, newStatus: RuleExecutionStatus['partial failure'], }); diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index 9c4d739e0f434..bffcc823d047e 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -109,11 +109,14 @@ import { ctiFieldMap } from './lib/detection_engine/rule_types/field_maps/cti'; import { legacyRulesNotificationAlertType } from './lib/detection_engine/notifications/legacy_rules_notification_alert_type'; // eslint-disable-next-line no-restricted-imports import { legacyIsNotificationAlertExecutor } from './lib/detection_engine/notifications/legacy_types'; +import { IEventLogClientService, IEventLogService } from '../../event_log/server'; +import { registerEventLogProvider } from './lib/detection_engine/rule_execution_log/event_log_adapter/register_event_log_provider'; export interface SetupPlugins { alerting: AlertingSetup; data: DataPluginSetup; encryptedSavedObjects?: EncryptedSavedObjectsSetup; + eventLog: IEventLogService; features: FeaturesSetup; lists?: ListPluginSetup; ml?: MlSetup; @@ -121,20 +124,21 @@ export interface SetupPlugins { security?: SecuritySetup; spaces?: SpacesSetup; taskManager?: TaskManagerSetupContract; - usageCollection?: UsageCollectionSetup; telemetry?: TelemetryPluginSetup; + usageCollection?: UsageCollectionSetup; } export interface StartPlugins { alerting: AlertPluginStartContract; + cases?: CasesPluginStartContract; data: DataPluginStart; + eventLog: IEventLogClientService; fleet?: FleetStartContract; licensing: LicensingPluginStart; ruleRegistry: RuleRegistryPluginStartContract; + security: SecurityPluginStart; taskManager?: TaskManagerStartContract; telemetry?: TelemetryPluginStart; - security: SecurityPluginStart; - cases?: CasesPluginStartContract; } // eslint-disable-next-line @typescript-eslint/no-empty-interface @@ -202,6 +206,9 @@ export class Plugin implements IPlugin(); core.http.registerRouteHandlerContext( APP_ID, @@ -210,8 +217,9 @@ export class Plugin implements IPlugin plugins.spaces?.spacesService?.getSpaceId(request) || DEFAULT_SPACE_ID, getExecutionLogClient: () => new RuleExecutionLogClient({ - ruleDataService: plugins.ruleRegistry.ruleDataService, savedObjectsClient: context.core.savedObjects.client, + eventLogService, + underlyingClient: config.ruleExecutionLog.underlyingClient, }), }) ); @@ -262,11 +270,10 @@ export class Plugin implements IPlugin Date: Mon, 11 Oct 2021 13:49:54 +0200 Subject: [PATCH 21/33] [bfetch] Fix memory leak (#113756) --- .../create_streaming_batched_function.test.ts | 50 ++++++++--------- .../create_streaming_batched_function.ts | 7 +-- src/plugins/bfetch/public/plugin.ts | 29 ++++------ .../public/streaming/fetch_streaming.test.ts | 29 +++++----- .../public/streaming/fetch_streaming.ts | 55 ++++++++----------- 5 files changed, 76 insertions(+), 94 deletions(-) diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts index 0b6dbe49d0e81..32adc0d7df0cf 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.test.ts @@ -9,7 +9,7 @@ import { createStreamingBatchedFunction } from './create_streaming_batched_function'; import { fetchStreaming as fetchStreamingReal } from '../streaming/fetch_streaming'; import { AbortError, defer, of } from '../../../kibana_utils/public'; -import { Subject, of as rxof } from 'rxjs'; +import { Subject } from 'rxjs'; const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); @@ -61,7 +61,7 @@ describe('createStreamingBatchedFunction()', () => { const fn = createStreamingBatchedFunction({ url: '/test', fetchStreaming, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); expect(typeof fn).toBe('function'); }); @@ -71,7 +71,7 @@ describe('createStreamingBatchedFunction()', () => { const fn = createStreamingBatchedFunction({ url: '/test', fetchStreaming, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const res = fn({}); expect(typeof res.then).toBe('function'); @@ -85,7 +85,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); expect(fetchStreaming).toHaveBeenCalledTimes(0); @@ -105,7 +105,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); expect(fetchStreaming).toHaveBeenCalledTimes(0); @@ -120,7 +120,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); fn({ foo: 'bar' }); @@ -139,7 +139,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); fn({ foo: 'bar' }); @@ -161,7 +161,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); expect(fetchStreaming).toHaveBeenCalledTimes(0); @@ -180,7 +180,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const abortController = new AbortController(); @@ -203,7 +203,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); fn({ a: '1' }); @@ -227,7 +227,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); fn({ a: '1' }); @@ -248,7 +248,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = fn({ a: '1' }); @@ -266,7 +266,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); await flushPromises(); @@ -310,7 +310,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = fn({ a: '1' }); @@ -348,7 +348,7 @@ describe('createStreamingBatchedFunction()', () => { fn({ a: '1' }); - const dontCompress = await fetchStreaming.mock.calls[0][0].compressionDisabled$.toPromise(); + const dontCompress = await fetchStreaming.mock.calls[0][0].getIsCompressionDisabled(); expect(dontCompress).toBe(false); }); @@ -359,7 +359,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = fn({ a: '1' }); @@ -401,7 +401,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise = fn({ a: '1' }); @@ -430,7 +430,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = of(fn({ a: '1' })); @@ -483,7 +483,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const abortController = new AbortController(); @@ -514,7 +514,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const abortController = new AbortController(); @@ -554,7 +554,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = of(fn({ a: '1' })); @@ -585,7 +585,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = of(fn({ a: '1' })); @@ -623,7 +623,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = of(fn({ a: '1' })); @@ -656,7 +656,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); const promise1 = of(fn({ a: '1' })); @@ -693,7 +693,7 @@ describe('createStreamingBatchedFunction()', () => { fetchStreaming, maxItemAge: 5, flushOnMaxItems: 3, - compressionDisabled$: rxof(true), + getIsCompressionDisabled: () => true, }); await flushPromises(); diff --git a/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts b/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts index d5f955f517d13..3ff8da08cfce7 100644 --- a/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts +++ b/src/plugins/bfetch/public/batching/create_streaming_batched_function.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { Observable, of } from 'rxjs'; import { AbortError, abortSignalToPromise, defer } from '../../../kibana_utils/public'; import { ItemBufferParams, @@ -51,7 +50,7 @@ export interface StreamingBatchedFunctionParams { /** * Disabled zlib compression of response chunks. */ - compressionDisabled$?: Observable; + getIsCompressionDisabled?: () => boolean; } /** @@ -69,7 +68,7 @@ export const createStreamingBatchedFunction = ( fetchStreaming: fetchStreamingInjected = fetchStreaming, flushOnMaxItems = 25, maxItemAge = 10, - compressionDisabled$ = of(false), + getIsCompressionDisabled = () => false, } = params; const [fn] = createBatchedFunction({ onCall: (payload: Payload, signal?: AbortSignal) => { @@ -125,7 +124,7 @@ export const createStreamingBatchedFunction = ( body: JSON.stringify({ batch }), method: 'POST', signal: abortController.signal, - compressionDisabled$, + getIsCompressionDisabled, }); const handleStreamError = (error: any) => { diff --git a/src/plugins/bfetch/public/plugin.ts b/src/plugins/bfetch/public/plugin.ts index 3ad451c7713ea..54bcb305d8675 100644 --- a/src/plugins/bfetch/public/plugin.ts +++ b/src/plugins/bfetch/public/plugin.ts @@ -6,13 +6,12 @@ * Side Public License, v 1. */ -import { CoreStart, PluginInitializerContext, CoreSetup, Plugin } from 'src/core/public'; -import { from, Observable, of } from 'rxjs'; -import { switchMap } from 'rxjs/operators'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; import { fetchStreaming as fetchStreamingStatic, FetchStreamingParams } from './streaming'; import { DISABLE_BFETCH_COMPRESSION, removeLeadingSlash } from '../common'; import { createStreamingBatchedFunction, StreamingBatchedFunctionParams } from './batching'; import { BatchedFunc } from './batching/types'; +import { createStartServicesGetter } from '../../kibana_utils/public'; // eslint-disable-next-line export interface BfetchPublicSetupDependencies {} @@ -50,16 +49,12 @@ export class BfetchPublicPlugin const { version } = this.initializerContext.env.packageInfo; const basePath = core.http.basePath.get(); - const compressionDisabled$ = from(core.getStartServices()).pipe( - switchMap((deps) => { - return of(deps[0]); - }), - switchMap((coreStart) => { - return coreStart.uiSettings.get$(DISABLE_BFETCH_COMPRESSION); - }) - ); - const fetchStreaming = this.fetchStreaming(version, basePath, compressionDisabled$); - const batchedFunction = this.batchedFunction(fetchStreaming, compressionDisabled$); + const startServices = createStartServicesGetter(core.getStartServices); + const getIsCompressionDisabled = () => + startServices().core.uiSettings.get(DISABLE_BFETCH_COMPRESSION); + + const fetchStreaming = this.fetchStreaming(version, basePath, getIsCompressionDisabled); + const batchedFunction = this.batchedFunction(fetchStreaming, getIsCompressionDisabled); this.contract = { fetchStreaming, @@ -79,7 +74,7 @@ export class BfetchPublicPlugin ( version: string, basePath: string, - compressionDisabled$: Observable + getIsCompressionDisabled: () => boolean ): BfetchPublicSetup['fetchStreaming'] => (params) => fetchStreamingStatic({ @@ -90,18 +85,18 @@ export class BfetchPublicPlugin 'kbn-version': version, ...(params.headers || {}), }, - compressionDisabled$, + getIsCompressionDisabled, }); private batchedFunction = ( fetchStreaming: BfetchPublicContract['fetchStreaming'], - compressionDisabled$: Observable + getIsCompressionDisabled: () => boolean ): BfetchPublicContract['batchedFunction'] => (params) => createStreamingBatchedFunction({ ...params, - compressionDisabled$, + getIsCompressionDisabled, fetchStreaming: params.fetchStreaming || fetchStreaming, }); } diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts index a5d066f6d9a24..67ebf8d5a1c23 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.test.ts @@ -8,7 +8,6 @@ import { fetchStreaming } from './fetch_streaming'; import { mockXMLHttpRequest } from '../test_helpers/xhr'; -import { of } from 'rxjs'; import { promisify } from 'util'; import { deflate } from 'zlib'; const pDeflate = promisify(deflate); @@ -30,7 +29,7 @@ test('returns XHR request', () => { setup(); const { xhr } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(typeof xhr.readyState).toBe('number'); }); @@ -39,7 +38,7 @@ test('returns stream', () => { setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(typeof stream.subscribe).toBe('function'); }); @@ -48,7 +47,7 @@ test('promise resolves when request completes', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); let resolved = false; @@ -81,7 +80,7 @@ test('promise resolves when compressed request completes', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(false), + getIsCompressionDisabled: () => false, }); let resolved = false; @@ -116,7 +115,7 @@ test('promise resolves when compressed chunked request completes', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(false), + getIsCompressionDisabled: () => false, }); let resolved = false; @@ -160,7 +159,7 @@ test('streams incoming text as it comes through, according to separators', async const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); const spy = jest.fn(); @@ -201,7 +200,7 @@ test('completes stream observable when request finishes', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); const spy = jest.fn(); @@ -226,7 +225,7 @@ test('completes stream observable when aborted', async () => { const { stream } = fetchStreaming({ url: 'http://example.com', signal: abort.signal, - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); const spy = jest.fn(); @@ -252,7 +251,7 @@ test('promise throws when request errors', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); const spy = jest.fn(); @@ -279,7 +278,7 @@ test('stream observable errors when request errors', async () => { const env = setup(); const { stream } = fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); const spy = jest.fn(); @@ -312,7 +311,7 @@ test('sets custom headers', async () => { 'Content-Type': 'text/plain', Authorization: 'Bearer 123', }, - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(env.xhr.setRequestHeader).toHaveBeenCalledWith('Content-Type', 'text/plain'); @@ -326,7 +325,7 @@ test('uses credentials', async () => { fetchStreaming({ url: 'http://example.com', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(env.xhr.withCredentials).toBe(true); @@ -342,7 +341,7 @@ test('opens XHR request and sends specified body', async () => { url: 'http://elastic.co', method: 'GET', body: 'foobar', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(env.xhr.open).toHaveBeenCalledTimes(1); @@ -355,7 +354,7 @@ test('uses POST request method by default', async () => { const env = setup(); fetchStreaming({ url: 'http://elastic.co', - compressionDisabled$: of(true), + getIsCompressionDisabled: () => true, }); expect(env.xhr.open).toHaveBeenCalledWith('POST', 'http://elastic.co'); }); diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.ts index 1af35ef68fb85..a94c8d3980cba 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.ts @@ -6,8 +6,7 @@ * Side Public License, v 1. */ -import { Observable, of } from 'rxjs'; -import { map, share, switchMap } from 'rxjs/operators'; +import { map, share } from 'rxjs/operators'; import { inflateResponse } from '.'; import { fromStreamingXhr } from './from_streaming_xhr'; import { split } from './split'; @@ -18,7 +17,7 @@ export interface FetchStreamingParams { method?: 'GET' | 'POST'; body?: string; signal?: AbortSignal; - compressionDisabled$?: Observable; + getIsCompressionDisabled?: () => boolean; } /** @@ -31,49 +30,39 @@ export function fetchStreaming({ method = 'POST', body = '', signal, - compressionDisabled$ = of(false), + getIsCompressionDisabled = () => false, }: FetchStreamingParams) { const xhr = new window.XMLHttpRequest(); - const msgStream = compressionDisabled$.pipe( - switchMap((compressionDisabled) => { - // Begin the request - xhr.open(method, url); - xhr.withCredentials = true; + // Begin the request + xhr.open(method, url); + xhr.withCredentials = true; - if (!compressionDisabled) { - headers['X-Chunk-Encoding'] = 'deflate'; - } + const isCompressionDisabled = getIsCompressionDisabled(); - // Set the HTTP headers - Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); + if (!isCompressionDisabled) { + headers['X-Chunk-Encoding'] = 'deflate'; + } - const stream = fromStreamingXhr(xhr, signal); + // Set the HTTP headers + Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); - // Send the payload to the server - xhr.send(body); + const stream = fromStreamingXhr(xhr, signal); - // Return a stream of chunked decompressed messages - return stream.pipe( - split('\n'), - map((msg) => { - return compressionDisabled ? msg : inflateResponse(msg); - }) - ); + // Send the payload to the server + xhr.send(body); + + // Return a stream of chunked decompressed messages + const stream$ = stream.pipe( + split('\n'), + map((msg) => { + return isCompressionDisabled ? msg : inflateResponse(msg); }), share() ); - // start execution - const msgStreamSub = msgStream.subscribe({ - error: (e) => {}, - complete: () => { - msgStreamSub.unsubscribe(); - }, - }); - return { xhr, - stream: msgStream, + stream: stream$, }; } From 5ff38a122b7fb4b0c77f9b3bbc43c6878ebb060c Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 11 Oct 2021 13:10:52 +0100 Subject: [PATCH 22/33] skip failing es promotion suites (#114471) --- x-pack/test/api_integration/apis/maps/get_tile.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/api_integration/apis/maps/get_tile.js b/x-pack/test/api_integration/apis/maps/get_tile.js index 03a16175931a5..b153cc1ff030c 100644 --- a/x-pack/test/api_integration/apis/maps/get_tile.js +++ b/x-pack/test/api_integration/apis/maps/get_tile.js @@ -13,7 +13,8 @@ import { MVT_SOURCE_LAYER_NAME } from '../../../../plugins/maps/common/constants export default function ({ getService }) { const supertest = getService('supertest'); - describe('getTile', () => { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/114471 + describe.skip('getTile', () => { it('should return vector tile containing document', async () => { const resp = await supertest .get( From 76546920fc3daab9fb3ddb891e1973775f4c61aa Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 11 Oct 2021 13:17:33 +0100 Subject: [PATCH 23/33] skip failing es promotion suites (#114473, #114474) --- .../functional/apps/index_lifecycle_management/home_page.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts b/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts index c51e2968baee0..e2540d80280c2 100644 --- a/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts +++ b/x-pack/test/functional/apps/index_lifecycle_management/home_page.ts @@ -16,7 +16,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const retry = getService('retry'); const esClient = getService('es'); - describe('Home page', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/114473 and https://github.com/elastic/kibana/issues/114474 + describe.skip('Home page', function () { before(async () => { await pageObjects.common.navigateToApp('indexLifecycleManagement'); }); From c34e99ee73ede89c3425dfdd13ad05747637c4b7 Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 11 Oct 2021 13:37:04 +0100 Subject: [PATCH 24/33] skip flaky suites (#100951) --- .../__jest__/client_integration/follower_indices_list.test.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js index b9e47b029e302..bcd4aaf82eeb3 100644 --- a/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js +++ b/x-pack/plugins/cross_cluster_replication/public/__jest__/client_integration/follower_indices_list.test.js @@ -314,7 +314,8 @@ describe('', () => { }); }); - describe('detail panel', () => { + // FLAKY: https://github.com/elastic/kibana/issues/100951 + describe.skip('detail panel', () => { test('should open a detail panel when clicking on a follower index', async () => { expect(exists('followerIndexDetail')).toBe(false); From f2bfa595ee4cbc4cdea33947f8c539c40ffbf1ed Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Mon, 11 Oct 2021 13:50:27 +0100 Subject: [PATCH 25/33] skip flaky suite (#106053) --- .../test/functional/apps/ml/anomaly_detection/custom_urls.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts b/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts index 0dcb767309608..7d4df75ccdcf7 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/custom_urls.ts @@ -81,7 +81,8 @@ export default function ({ getService }: FtrProviderContext) { const ml = getService('ml'); const browser = getService('browser'); - describe('custom urls', function () { + // FLAKY: https://github.com/elastic/kibana/issues/106053 + describe.skip('custom urls', function () { this.tags(['mlqa']); before(async () => { await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote'); From 2ffbf6e58eadade0bb9b5386de7d3406d3cc5ae8 Mon Sep 17 00:00:00 2001 From: Esteban Beltran Date: Mon, 11 Oct 2021 15:20:27 +0200 Subject: [PATCH 26/33] [Security Solution] Add host isolation exception IPs UI (#113762) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../host_isolation_exceptions/service.ts | 14 ++ .../host_isolation_exceptions/store/action.ts | 17 +- .../store/builders.ts | 4 + .../store/middleware.test.ts | 80 ++++++- .../store/middleware.ts | 46 +++- .../store/reducer.test.ts | 10 + .../store/reducer.ts | 18 ++ .../pages/host_isolation_exceptions/types.ts | 5 + .../pages/host_isolation_exceptions/utils.ts | 41 ++++ .../view/components/empty.tsx | 12 +- .../view/components/form.test.tsx | 75 +++++++ .../view/components/form.tsx | 206 ++++++++++++++++++ .../view/components/form_flyout.test.tsx | 114 ++++++++++ .../view/components/form_flyout.tsx | 180 +++++++++++++++ .../view/components/translations.ts | 64 ++++++ .../host_isolation_exceptions_list.test.tsx | 22 +- .../view/host_isolation_exceptions_list.tsx | 33 ++- 17 files changed, 925 insertions(+), 16 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts index 79ca595fbb61b..8af353a3c9531 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/service.ts @@ -6,6 +6,7 @@ */ import { + CreateExceptionListItemSchema, ExceptionListItemSchema, FoundExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; @@ -65,6 +66,19 @@ export async function getHostIsolationExceptionItems({ return entries; } +export async function createHostIsolationExceptionItem({ + http, + exception, +}: { + http: HttpStart; + exception: CreateExceptionListItemSchema; +}): Promise { + await ensureHostIsolationExceptionsListExists(http); + return http.post(EXCEPTION_LIST_ITEM_URL, { + body: JSON.stringify(exception), + }); +} + export async function deleteHostIsolationExceptionItems(http: HttpStart, id: string) { await ensureHostIsolationExceptionsListExists(http); return http.delete(EXCEPTION_LIST_ITEM_URL, { diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts index 0a9f776655371..a5fae36486f98 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/action.ts @@ -14,6 +14,20 @@ export type HostIsolationExceptionsPageDataChanged = payload: HostIsolationExceptionsPageState['entries']; }; +export type HostIsolationExceptionsFormStateChanged = + Action<'hostIsolationExceptionsFormStateChanged'> & { + payload: HostIsolationExceptionsPageState['form']['status']; + }; + +export type HostIsolationExceptionsFormEntryChanged = + Action<'hostIsolationExceptionsFormEntryChanged'> & { + payload: HostIsolationExceptionsPageState['form']['entry']; + }; + +export type HostIsolationExceptionsCreateEntry = Action<'hostIsolationExceptionsCreateEntry'> & { + payload: HostIsolationExceptionsPageState['form']['entry']; +}; + export type HostIsolationExceptionsDeleteItem = Action<'hostIsolationExceptionsMarkToDelete'> & { payload?: ExceptionListItemSchema; }; @@ -24,9 +38,10 @@ export type HostIsolationExceptionsDeleteStatusChanged = Action<'hostIsolationExceptionsDeleteStatusChanged'> & { payload: HostIsolationExceptionsPageState['deletion']['status']; }; - export type HostIsolationExceptionsPageAction = | HostIsolationExceptionsPageDataChanged + | HostIsolationExceptionsCreateEntry + | HostIsolationExceptionsFormStateChanged | HostIsolationExceptionsDeleteItem | HostIsolationExceptionsSubmitDelete | HostIsolationExceptionsDeleteStatusChanged; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts index 68a50f9c813f4..8f32d9cf8d456 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/builders.ts @@ -16,6 +16,10 @@ export const initialHostIsolationExceptionsPageState = (): HostIsolationExceptio page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, filter: '', }, + form: { + entry: undefined, + status: createUninitialisedResourceState(), + }, deletion: { item: undefined, status: createUninitialisedResourceState(), diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts index 984794e074ebb..266853fdab5e2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.test.ts @@ -5,10 +5,11 @@ * 2.0. */ +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { applyMiddleware, createStore, Store } from 'redux'; -import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; +import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; import { AppAction } from '../../../../common/store/actions'; import { createSpyMiddleware, @@ -19,8 +20,13 @@ import { isLoadedResourceState, isLoadingResourceState, } from '../../../state'; -import { getHostIsolationExceptionItems, deleteHostIsolationExceptionItems } from '../service'; +import { + createHostIsolationExceptionItem, + deleteHostIsolationExceptionItems, + getHostIsolationExceptionItems, +} from '../service'; import { HostIsolationExceptionsPageState } from '../types'; +import { createEmptyHostIsolationException } from '../utils'; import { initialHostIsolationExceptionsPageState } from './builders'; import { createHostIsolationExceptionsPageMiddleware } from './middleware'; import { hostIsolationExceptionsPageReducer } from './reducer'; @@ -29,6 +35,7 @@ import { getListFetchError } from './selector'; jest.mock('../service'); const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; const deleteHostIsolationExceptionItemsMock = deleteHostIsolationExceptionItems as jest.Mock; +const createHostIsolationExceptionItemMock = createHostIsolationExceptionItem as jest.Mock; const fakeCoreStart = coreMock.createStart({ basePath: '/mock' }); @@ -81,7 +88,7 @@ describe('Host isolation exceptions middleware', () => { }; beforeEach(() => { - getHostIsolationExceptionItemsMock.mockClear(); + getHostIsolationExceptionItemsMock.mockReset(); getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock); }); @@ -145,11 +152,74 @@ describe('Host isolation exceptions middleware', () => { }); }); + describe('When adding an item to host isolation exceptions', () => { + let entry: CreateExceptionListItemSchema; + beforeEach(() => { + createHostIsolationExceptionItemMock.mockReset(); + entry = { + ...createEmptyHostIsolationException(), + name: 'test name', + description: 'description', + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'match', + value: '10.0.0.1', + }, + ], + }; + }); + it('should dispatch a form loading state when an entry is submited', async () => { + const waiter = spyMiddleware.waitForAction('hostIsolationExceptionsFormStateChanged', { + validate({ payload }) { + return isLoadingResourceState(payload); + }, + }); + store.dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + await waiter; + }); + it('should dispatch a form success state when an entry is confirmed by the API', async () => { + const waiter = spyMiddleware.waitForAction('hostIsolationExceptionsFormStateChanged', { + validate({ payload }) { + return isLoadedResourceState(payload); + }, + }); + store.dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + await waiter; + expect(createHostIsolationExceptionItemMock).toHaveBeenCalledWith({ + http: fakeCoreStart.http, + exception: entry, + }); + }); + it('should dispatch a form failure state when an entry is rejected by the API', async () => { + createHostIsolationExceptionItemMock.mockRejectedValue({ + body: { message: 'error message', statusCode: 500, error: 'Not today' }, + }); + const waiter = spyMiddleware.waitForAction('hostIsolationExceptionsFormStateChanged', { + validate({ payload }) { + return isFailedResourceState(payload); + }, + }); + store.dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + await waiter; + }); + }); + describe('When deleting an item from host isolation exceptions', () => { beforeEach(() => { - deleteHostIsolationExceptionItemsMock.mockClear(); + deleteHostIsolationExceptionItemsMock.mockReset(); deleteHostIsolationExceptionItemsMock.mockReturnValue(undefined); - getHostIsolationExceptionItemsMock.mockClear(); + getHostIsolationExceptionItemsMock.mockReset(); getHostIsolationExceptionItemsMock.mockImplementation(getFoundExceptionListItemSchemaMock); store.dispatch({ type: 'hostIsolationExceptionsMarkToDelete', diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts index 4946cac488700..bbc754e8155b0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/middleware.ts @@ -6,11 +6,13 @@ */ import { + CreateExceptionListItemSchema, ExceptionListItemSchema, FoundExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; import { CoreStart, HttpSetup, HttpStart } from 'kibana/public'; import { matchPath } from 'react-router-dom'; +import { transformNewItemOutput } from '@kbn/securitysolution-list-hooks'; import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; import { ImmutableMiddleware, ImmutableMiddlewareAPI } from '../../../../common/store'; import { AppAction } from '../../../../common/store/actions'; @@ -20,7 +22,11 @@ import { createFailedResourceState, createLoadedResourceState, } from '../../../state/async_resource_builders'; -import { deleteHostIsolationExceptionItems, getHostIsolationExceptionItems } from '../service'; +import { + deleteHostIsolationExceptionItems, + getHostIsolationExceptionItems, + createHostIsolationExceptionItem, +} from '../service'; import { HostIsolationExceptionsPageState } from '../types'; import { getCurrentListPageDataState, getCurrentLocation, getItemToDelete } from './selector'; @@ -39,12 +45,50 @@ export const createHostIsolationExceptionsPageMiddleware = ( if (action.type === 'userChangedUrl' && isHostIsolationExceptionsPage(action.payload)) { loadHostIsolationExceptionsList(store, coreStart.http); } + + if (action.type === 'hostIsolationExceptionsCreateEntry') { + createHostIsolationException(store, coreStart.http); + } + if (action.type === 'hostIsolationExceptionsSubmitDelete') { deleteHostIsolationExceptionsItem(store, coreStart.http); } }; }; +async function createHostIsolationException( + store: ImmutableMiddlewareAPI, + http: HttpStart +) { + const { dispatch } = store; + const entry = transformNewItemOutput( + store.getState().form.entry as CreateExceptionListItemSchema + ); + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'LoadingResourceState', + // @ts-expect-error-next-line will be fixed with when AsyncResourceState is refactored (#830) + previousState: entry, + }, + }); + try { + const response = await createHostIsolationExceptionItem({ + http, + exception: entry, + }); + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: createLoadedResourceState(response), + }); + } catch (error) { + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: createFailedResourceState(error.body ?? error), + }); + } +} + async function loadHostIsolationExceptionsList( store: ImmutableMiddlewareAPI, http: HttpStart diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts index 211b03f36d965..98b459fac41d3 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.test.ts @@ -11,6 +11,7 @@ import { initialHostIsolationExceptionsPageState } from './builders'; import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; import { hostIsolationExceptionsPageReducer } from './reducer'; import { getCurrentLocation } from './selector'; +import { createEmptyHostIsolationException } from '../utils'; describe('Host Isolation Exceptions Reducer', () => { let initialState: HostIsolationExceptionsPageState; @@ -41,4 +42,13 @@ describe('Host Isolation Exceptions Reducer', () => { }); }); }); + it('should set an initial loading state when creating new entries', () => { + const entry = createEmptyHostIsolationException(); + const result = hostIsolationExceptionsPageReducer(initialState, { + type: 'hostIsolationExceptionsCreateEntry', + payload: entry, + }); + expect(result.form.status).toEqual({ type: 'UninitialisedResourceState' }); + expect(result.form.entry).toBe(entry); + }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts index 09182661a80b3..d97295598f445 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/store/reducer.ts @@ -38,6 +38,24 @@ export const hostIsolationExceptionsPageReducer: StateReducer = ( action ) => { switch (action.type) { + case 'hostIsolationExceptionsCreateEntry': { + return { + ...state, + form: { + entry: action.payload, + status: createUninitialisedResourceState(), + }, + }; + } + case 'hostIsolationExceptionsFormStateChanged': { + return { + ...state, + form: { + ...state.form, + status: action.payload, + }, + }; + } case 'hostIsolationExceptionsPageDataChanged': { return { ...state, diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts index 443a86fefab83..1a74042fb652e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/types.ts @@ -6,6 +6,7 @@ */ import type { + CreateExceptionListItemSchema, ExceptionListItemSchema, FoundExceptionListItemSchema, } from '@kbn/securitysolution-io-ts-list-types'; @@ -27,4 +28,8 @@ export interface HostIsolationExceptionsPageState { item?: ExceptionListItemSchema; status: AsyncResourceState; }; + form: { + entry?: CreateExceptionListItemSchema; + status: AsyncResourceState; + }; } diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.ts new file mode 100644 index 0000000000000..bfb1ac048e286 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/utils.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import { ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID } from '@kbn/securitysolution-list-constants'; +import ipaddr from 'ipaddr.js'; + +export function createEmptyHostIsolationException(): CreateExceptionListItemSchema { + return { + comments: [], + description: '', + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'match', + value: '', + }, + ], + item_id: undefined, + list_id: ENDPOINT_HOST_ISOLATION_EXCEPTIONS_LIST_ID, + name: '', + namespace_type: 'agnostic', + os_types: ['windows', 'linux', 'macos'], + tags: ['policy:all'], + type: 'simple', + }; +} + +export function isValidIPv4OrCIDR(maybeIp: string): boolean { + try { + ipaddr.IPv4.parseCIDR(maybeIp); + return true; + } catch (e) { + return ipaddr.IPv4.isValid(maybeIp); + } +} diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx index d7c512794173c..eb53268a9fbd8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/empty.tsx @@ -7,7 +7,7 @@ import React, { memo } from 'react'; import styled, { css } from 'styled-components'; -import { EuiEmptyPrompt } from '@elastic/eui'; +import { EuiButton, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; const EmptyPrompt = styled(EuiEmptyPrompt)` @@ -16,7 +16,7 @@ const EmptyPrompt = styled(EuiEmptyPrompt)` `} `; -export const HostIsolationExceptionsEmptyState = memo<{}>(() => { +export const HostIsolationExceptionsEmptyState = memo<{ onAdd: () => void }>(({ onAdd }) => { return ( (() => { defaultMessage="There are currently no host isolation exceptions" /> } + actions={ + + + + } /> ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx new file mode 100644 index 0000000000000..b06449de69d8c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.test.tsx @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { createEmptyHostIsolationException } from '../../utils'; +import { HostIsolationExceptionsForm } from './form'; +import React from 'react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import userEvent from '@testing-library/user-event'; + +describe('When on the host isolation exceptions add entry form', () => { + let render: ( + exception: CreateExceptionListItemSchema + ) => ReturnType; + let renderResult: ReturnType; + const onChange = jest.fn(); + const onError = jest.fn(); + + beforeEach(() => { + onChange.mockReset(); + onError.mockReset(); + const mockedContext = createAppRootMockRenderer(); + render = (exception: CreateExceptionListItemSchema) => { + return mockedContext.render( + + ); + }; + }); + + describe('When creating a new exception', () => { + let newException: CreateExceptionListItemSchema; + beforeEach(() => { + newException = createEmptyHostIsolationException(); + renderResult = render(newException); + }); + it('should render the form with empty inputs', () => { + expect(renderResult.getByTestId('hostIsolationExceptions-form-name-input')).toHaveValue(''); + expect(renderResult.getByTestId('hostIsolationExceptions-form-ip-input')).toHaveValue(''); + expect( + renderResult.getByTestId('hostIsolationExceptions-form-description-input') + ).toHaveValue(''); + }); + it('should call onError with true when a wrong ip value is introduced', () => { + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + userEvent.type(ipInput, 'not an ip'); + expect(onError).toHaveBeenCalledWith(true); + }); + it('should call onError with false when a correct values are introduced', () => { + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + const nameInput = renderResult.getByTestId('hostIsolationExceptions-form-name-input'); + + userEvent.type(nameInput, 'test name'); + userEvent.type(ipInput, '10.0.0.1'); + + expect(onError).toHaveBeenLastCalledWith(false); + }); + it('should call onChange when a value is introduced in a field', () => { + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + userEvent.type(ipInput, '10.0.0.1'); + expect(onChange).toHaveBeenLastCalledWith({ + ...newException, + entries: [ + { field: 'destination.ip', operator: 'included', type: 'match', value: '10.0.0.1' }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx new file mode 100644 index 0000000000000..84263f9d07c81 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form.tsx @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiFieldText, + EuiForm, + EuiFormRow, + EuiHorizontalRule, + EuiSpacer, + EuiText, + EuiTextArea, + EuiTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { isValidIPv4OrCIDR } from '../../utils'; +import { + DESCRIPTION_LABEL, + DESCRIPTION_PLACEHOLDER, + IP_ERROR, + IP_LABEL, + IP_PLACEHOLDER, + NAME_ERROR, + NAME_LABEL, + NAME_PLACEHOLDER, +} from './translations'; + +interface ExceptionIpEntry { + field: 'destination.ip'; + operator: 'included'; + type: 'match'; + value: ''; +} + +export const HostIsolationExceptionsForm: React.FC<{ + exception: CreateExceptionListItemSchema; + onError: (error: boolean) => void; + onChange: (exception: CreateExceptionListItemSchema) => void; +}> = memo(({ exception, onError, onChange }) => { + const [hasBeenInputNameVisited, setHasBeenInputNameVisited] = useState(false); + const [hasBeenInputIpVisited, setHasBeenInputIpVisited] = useState(false); + const [hasNameError, setHasNameError] = useState(true); + const [hasIpError, setHasIpError] = useState(true); + + useEffect(() => { + onError(hasNameError || hasIpError); + }, [hasNameError, hasIpError, onError]); + + const handleOnChangeName = useCallback( + (event: React.ChangeEvent) => { + const name = event.target.value; + if (!name.trim()) { + setHasNameError(true); + return; + } + setHasNameError(false); + onChange({ ...exception, name }); + }, + [exception, onChange] + ); + + const handleOnIpChange = useCallback( + (event: React.ChangeEvent) => { + const ip = event.target.value; + if (!isValidIPv4OrCIDR(ip)) { + setHasIpError(true); + return; + } + setHasIpError(false); + onChange({ + ...exception, + entries: [ + { + field: 'destination.ip', + operator: 'included', + type: 'match', + value: ip, + }, + ], + }); + }, + [exception, onChange] + ); + + const handleOnDescriptionChange = useCallback( + (event: React.ChangeEvent) => { + onChange({ ...exception, description: event.target.value }); + }, + [exception, onChange] + ); + + const nameInput = useMemo( + () => ( + + !hasBeenInputNameVisited && setHasBeenInputNameVisited(true)} + /> + + ), + [hasNameError, hasBeenInputNameVisited, exception.name, handleOnChangeName] + ); + + const ipInput = useMemo( + () => ( + + !hasBeenInputIpVisited && setHasBeenInputIpVisited(true)} + /> + + ), + [hasIpError, hasBeenInputIpVisited, exception.entries, handleOnIpChange] + ); + + const descriptionInput = useMemo( + () => ( + + + + ), + [exception.description, handleOnDescriptionChange] + ); + + return ( + + +

+ +

+
+ + + + + {nameInput} + {descriptionInput} + + + +

+ +

+
+ + + + + {ipInput} +
+ ); +}); + +HostIsolationExceptionsForm.displayName = 'HostIsolationExceptionsForm'; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx new file mode 100644 index 0000000000000..6cfc9f56beadf --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.test.tsx @@ -0,0 +1,114 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { + AppContextTestRender, + createAppRootMockRenderer, +} from '../../../../../common/mock/endpoint'; +import userEvent from '@testing-library/user-event'; +import { HostIsolationExceptionsFormFlyout } from './form_flyout'; +import { act } from 'react-dom/test-utils'; +import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../../common/constants'; + +jest.mock('../../service.ts'); + +describe('When on the host isolation exceptions flyout form', () => { + let mockedContext: AppContextTestRender; + let render: () => ReturnType; + let renderResult: ReturnType; + let waitForAction: AppContextTestRender['middlewareSpy']['waitForAction']; + + // const createHostIsolationExceptionItemMock = createHostIsolationExceptionItem as jest.mock; + + beforeEach(() => { + mockedContext = createAppRootMockRenderer(); + render = () => { + return mockedContext.render(); + }; + waitForAction = mockedContext.middlewareSpy.waitForAction; + }); + + describe('When creating a new exception', () => { + describe('with invalid data', () => { + it('should show disabled buttons when the form first load', () => { + renderResult = render(); + expect(renderResult.getByTestId('add-exception-cancel-button')).not.toHaveAttribute( + 'disabled' + ); + expect(renderResult.getByTestId('add-exception-confirm-button')).toHaveAttribute( + 'disabled' + ); + }); + }); + describe('with valid data', () => { + beforeEach(() => { + renderResult = render(); + const ipInput = renderResult.getByTestId('hostIsolationExceptions-form-ip-input'); + const nameInput = renderResult.getByTestId('hostIsolationExceptions-form-name-input'); + userEvent.type(nameInput, 'test name'); + userEvent.type(ipInput, '10.0.0.1'); + }); + it('should show enable buttons when the form is valid', () => { + expect(renderResult.getByTestId('add-exception-cancel-button')).not.toHaveAttribute( + 'disabled' + ); + expect(renderResult.getByTestId('add-exception-confirm-button')).not.toHaveAttribute( + 'disabled' + ); + }); + it('should submit the entry data when submit is pressed with valid data', async () => { + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton).not.toHaveAttribute('disabled'); + const waiter = waitForAction('hostIsolationExceptionsCreateEntry'); + userEvent.click(confirmButton); + await waiter; + }); + it('should disable the submit button when an operation is in progress', () => { + act(() => { + mockedContext.store.dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'LoadingResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }); + }); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton).toHaveAttribute('disabled'); + }); + it('should show a toast and close the flyout when the operation is finished', () => { + mockedContext.history.push(`${HOST_ISOLATION_EXCEPTIONS_PATH}?show=create`); + act(() => { + mockedContext.store.dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'LoadedResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }); + }); + expect(mockedContext.coreStart.notifications.toasts.addSuccess).toHaveBeenCalled(); + expect(mockedContext.history.location.search).toBe(''); + }); + it('should show an error toast operation fails and enable the submit button', () => { + act(() => { + mockedContext.store.dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'FailedResourceState', + previousState: { type: 'UninitialisedResourceState' }, + }, + }); + }); + expect(mockedContext.coreStart.notifications.toasts.addDanger).toHaveBeenCalled(); + const confirmButton = renderResult.getByTestId('add-exception-confirm-button'); + expect(confirmButton).not.toHaveAttribute('disabled'); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx new file mode 100644 index 0000000000000..5502a1b8ea2b1 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/form_flyout.tsx @@ -0,0 +1,180 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { CreateExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; +import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { Dispatch } from 'redux'; +import { Loader } from '../../../../../common/components/loader'; +import { useToasts } from '../../../../../common/lib/kibana'; +import { + isFailedResourceState, + isLoadedResourceState, + isLoadingResourceState, +} from '../../../../state/async_resource_state'; +import { HostIsolationExceptionsPageAction } from '../../store/action'; +import { createEmptyHostIsolationException } from '../../utils'; +import { + useHostIsolationExceptionsNavigateCallback, + useHostIsolationExceptionsSelector, +} from '../hooks'; +import { HostIsolationExceptionsForm } from './form'; + +export const HostIsolationExceptionsFormFlyout: React.FC<{}> = memo(() => { + const dispatch = useDispatch>(); + const toasts = useToasts(); + + const creationInProgress = useHostIsolationExceptionsSelector((state) => + isLoadingResourceState(state.form.status) + ); + const creationSuccessful = useHostIsolationExceptionsSelector((state) => + isLoadedResourceState(state.form.status) + ); + const creationFailure = useHostIsolationExceptionsSelector((state) => + isFailedResourceState(state.form.status) + ); + + const navigateCallback = useHostIsolationExceptionsNavigateCallback(); + + const [formHasError, setFormHasError] = useState(true); + const [exception, setException] = useState(undefined); + + const onCancel = useCallback( + () => + navigateCallback({ + show: undefined, + id: undefined, + }), + [navigateCallback] + ); + + useEffect(() => { + setException(createEmptyHostIsolationException()); + }, []); + + useEffect(() => { + if (creationSuccessful) { + onCancel(); + dispatch({ + type: 'hostIsolationExceptionsFormStateChanged', + payload: { + type: 'UninitialisedResourceState', + }, + }); + toasts.addSuccess( + i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.creationSuccessToastTitle', + { + defaultMessage: '"{name}" has been added to the host isolation exceptions list.', + values: { name: exception?.name }, + } + ) + ); + } + }, [creationSuccessful, onCancel, dispatch, toasts, exception?.name]); + + useEffect(() => { + if (creationFailure) { + toasts.addDanger( + i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.creationFailureToastTitle', + { + defaultMessage: 'There was an error creating the exception', + } + ) + ); + } + }, [dispatch, toasts, creationFailure]); + + const handleOnCancel = useCallback(() => { + if (creationInProgress) return; + onCancel(); + }, [creationInProgress, onCancel]); + + const handleOnSubmit = useCallback(() => { + dispatch({ + type: 'hostIsolationExceptionsCreateEntry', + payload: exception, + }); + }, [dispatch, exception]); + + const confirmButtonMemo = useMemo( + () => ( + + + + ), + [formHasError, creationInProgress, handleOnSubmit] + ); + + return exception ? ( + + + +

+ +

+
+
+ + + + + + + + + + + + + {confirmButtonMemo} + + +
+ ) : ( + + ); +}); + +HostIsolationExceptionsFormFlyout.displayName = 'HostIsolationExceptionsFormFlyout'; diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts new file mode 100644 index 0000000000000..df179c7a2221c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/components/translations.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { i18n } from '@kbn/i18n'; + +export const NAME_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.name.placeholder', + { + defaultMessage: 'New IP', + } +); + +export const NAME_LABEL = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.name.label', + { + defaultMessage: 'Name your host isolation exceptions', + } +); + +export const NAME_ERROR = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.name.error', + { + defaultMessage: "The name can't be empty", + } +); + +export const DESCRIPTION_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.description.placeholder', + { + defaultMessage: 'Describe your Host Isolation Exception', + } +); + +export const DESCRIPTION_LABEL = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.description.label', + { + defaultMessage: 'Description', + } +); + +export const IP_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.ip.placeholder', + { + defaultMessage: 'Ex 0.0.0.0/24', + } +); + +export const IP_LABEL = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.ip.label', + { + defaultMessage: 'Enter IP Address', + } +); + +export const IP_ERROR = i18n.translate( + 'xpack.securitySolution.hostIsolationExceptions.form.ip.error', + { + defaultMessage: 'The ip is invalid. Only IPv4 with optional CIDR is supported', + } +); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx index 53b8bc33c252f..ac472fdae4d7b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.test.tsx @@ -5,16 +5,18 @@ * 2.0. */ -import React from 'react'; import { act } from '@testing-library/react'; -import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; +import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; import { HOST_ISOLATION_EXCEPTIONS_PATH } from '../../../../../common/constants'; -import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; +import { AppContextTestRender, createAppRootMockRenderer } from '../../../../common/mock/endpoint'; import { isFailedResourceState, isLoadedResourceState } from '../../../state'; import { getHostIsolationExceptionItems } from '../service'; -import { getFoundExceptionListItemSchemaMock } from '../../../../../../lists/common/schemas/response/found_exception_list_item_schema.mock'; +import { HostIsolationExceptionsList } from './host_isolation_exceptions_list'; jest.mock('../service'); + const getHostIsolationExceptionItemsMock = getHostIsolationExceptionItems as jest.Mock; describe('When on the host isolation exceptions page', () => { @@ -103,5 +105,17 @@ describe('When on the host isolation exceptions page', () => { ).toEqual(' Server is too far away'); }); }); + it('should show the create flyout when the add button is pressed', () => { + render(); + act(() => { + userEvent.click(renderResult.getByTestId('hostIsolationExceptionsListAddButton')); + }); + expect(renderResult.getByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeTruthy(); + }); + it('should show the create flyout when the show location is create', () => { + history.push(`${HOST_ISOLATION_EXCEPTIONS_PATH}?show=create`); + render(); + expect(renderResult.getByTestId('hostIsolationExceptionsCreateEditFlyout')).toBeTruthy(); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx index 53fb74d5bd8f7..cfb0121396e24 100644 --- a/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/host_isolation_exceptions/view/host_isolation_exceptions_list.tsx @@ -8,7 +8,7 @@ import { ExceptionListItemSchema } from '@kbn/securitysolution-io-ts-list-types'; import { i18n } from '@kbn/i18n'; import React, { Dispatch, useCallback } from 'react'; -import { EuiSpacer } from '@elastic/eui'; +import { EuiButton, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { useDispatch } from 'react-redux'; import { ExceptionItem } from '../../../../common/components/exceptions/viewer/exception_item'; @@ -32,6 +32,7 @@ import { ArtifactEntryCard, ArtifactEntryCardProps } from '../../../components/a import { HostIsolationExceptionsEmptyState } from './components/empty'; import { HostIsolationExceptionsPageAction } from '../store/action'; import { HostIsolationExceptionDeleteModal } from './components/delete_modal'; +import { HostIsolationExceptionsFormFlyout } from './components/form_flyout'; type HostIsolationExceptionPaginatedContent = PaginatedContentProps< Immutable, @@ -54,6 +55,8 @@ export const HostIsolationExceptionsList = () => { const dispatch = useDispatch>(); const itemToDelete = useHostIsolationExceptionsSelector(getItemToDelete); + const showFlyout = !!location.show; + const navigateCallback = useHostIsolationExceptionsNavigateCallback(); const handleOnSearch = useCallback( @@ -92,6 +95,15 @@ export const HostIsolationExceptionsList = () => { [navigateCallback] ); + const handleAddButtonClick = useCallback( + () => + navigateCallback({ + show: 'create', + id: undefined, + }), + [navigateCallback] + ); + return ( { defaultMessage="Host Isolation Exceptions" /> } - actions={[]} + actions={ + + + + } > + {showFlyout && } + { pagination={pagination} contentClassName="host-isolation-exceptions-container" data-test-subj="hostIsolationExceptionsContent" - noItemsMessage={} + noItemsMessage={} /> ); From 7c5c566696fa3837b48d6147ae61dd97526f1a1d Mon Sep 17 00:00:00 2001 From: Orhan Toy Date: Mon, 11 Oct 2021 15:52:15 +0200 Subject: [PATCH 27/33] [Enterprise Search] Fix typo (#114462) Fixing a small typo (`recieve` -> `receive`) I noticed in comments. --- .../crawler_status_indicator/crawler_status_indicator.test.tsx | 2 +- .../applications/shared/flash_messages/handle_api_errors.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx index 9d585789d8e50..c46c360934d0b 100644 --- a/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/app_search/components/crawler/components/crawler_status_indicator/crawler_status_indicator.test.tsx @@ -37,7 +37,7 @@ describe('CrawlerStatusIndicator', () => { describe('when status is not a valid status', () => { it('is disabled', () => { // this tests a codepath that should be impossible to reach, status should always be a CrawlerStatus - // but we use a switch statement and need to test the default case for this to recieve 100% coverage + // but we use a switch statement and need to test the default case for this to receive 100% coverage setMockValues({ ...MOCK_VALUES, mostRecentCrawlRequestStatus: null, diff --git a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts index 4fe8ad1cb851d..abaa67e06f606 100644 --- a/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts +++ b/x-pack/plugins/enterprise_search/public/applications/shared/flash_messages/handle_api_errors.ts @@ -14,7 +14,7 @@ import { IFlashMessage } from './types'; /** * The API errors we are handling can come from one of two ways: - * - When our http calls recieve a response containing an error code, such as a 404 or 500 + * - When our http calls receive a response containing an error code, such as a 404 or 500 * - Our own JS while handling a successful response * * In the first case, if it is a purposeful error (like a 404) we will receive an From 53109bdcd5fb00c8e4287facd03ed923362f8540 Mon Sep 17 00:00:00 2001 From: Pete Hampton Date: Mon, 11 Oct 2021 15:20:39 +0100 Subject: [PATCH 28/33] Detection Rule Exception List telemetry (#113239) * Add telemetry for detection rule exception lists to improve UX. * Add length for debugging. * Fix type. * Clean up exception list telemetry document. * Dynamically set kibana index (just in case). * Update task title. * Rename version to rule_version. Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../server/lib/telemetry/constants.ts | 6 +- .../server/lib/telemetry/filters.ts | 5 +- .../server/lib/telemetry/helpers.test.ts | 80 +++++++--- .../server/lib/telemetry/helpers.ts | 48 ++++-- .../server/lib/telemetry/mocks.ts | 11 +- .../server/lib/telemetry/receiver.ts | 68 +++++++- .../server/lib/telemetry/sender.ts | 10 +- .../telemetry/tasks/detection_rule.test.ts | 127 +++++++++++++++ .../lib/telemetry/tasks/detection_rule.ts | 149 ++++++++++++++++++ .../server/lib/telemetry/tasks/endpoint.ts | 4 +- .../server/lib/telemetry/tasks/index.ts | 1 + .../server/lib/telemetry/types.ts | 39 ++++- .../security_solution/server/plugin.ts | 8 +- 13 files changed, 499 insertions(+), 57 deletions(-) create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts b/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts index 771e3e059c336..91f83d5e7cb37 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/constants.ts @@ -7,12 +7,14 @@ export const TELEMETRY_MAX_BUFFER_SIZE = 100; -export const TELEMETRY_CHANNEL_LISTS = 'security-lists'; +export const TELEMETRY_CHANNEL_LISTS = 'security-lists-v2'; export const TELEMETRY_CHANNEL_ENDPOINT_META = 'endpoint-metadata'; -export const LIST_TRUSTED_APPLICATION = 'trusted_application'; +export const LIST_DETECTION_RULE_EXCEPTION = 'detection_rule_exception'; export const LIST_ENDPOINT_EXCEPTION = 'endpoint_exception'; export const LIST_ENDPOINT_EVENT_FILTER = 'endpoint_event_filter'; + +export const LIST_TRUSTED_APPLICATION = 'trusted_application'; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts index 61172fac511f7..a29f195ed5ecc 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/filters.ts @@ -129,13 +129,12 @@ export const allowlistEventFields: AllowlistFields = { export const exceptionListEventFields: AllowlistFields = { created_at: true, - description: true, effectScope: true, entries: true, id: true, name: true, - os: true, os_types: true, + rule_version: true, }; /** @@ -143,7 +142,7 @@ export const exceptionListEventFields: AllowlistFields = { * * @param allowlist * @param event - * @returns + * @returns TelemetryEvent with explicitly required fields */ export function copyAllowlistedFields( allowlist: AllowlistFields, diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts index 647219e8c5585..528082d8cb5b1 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.test.ts @@ -8,13 +8,14 @@ import moment from 'moment'; import { createMockPackagePolicy } from './mocks'; import { + LIST_DETECTION_RULE_EXCEPTION, LIST_ENDPOINT_EXCEPTION, LIST_ENDPOINT_EVENT_FILTER, LIST_TRUSTED_APPLICATION, } from './constants'; import { getPreviousDiagTaskTimestamp, - getPreviousEpMetaTaskTimestamp, + getPreviousDailyTaskTimestamp, batchTelemetryRecords, isPackagePolicyList, templateExceptionList, @@ -53,7 +54,7 @@ describe('test endpoint meta telemetry scheduled task timing helper', () => { test('test -24 hours is returned when there is no previous task run', async () => { const executeTo = moment().utc().toISOString(); const executeFrom = undefined; - const newExecuteFrom = getPreviousEpMetaTaskTimestamp(executeTo, executeFrom); + const newExecuteFrom = getPreviousDailyTaskTimestamp(executeTo, executeFrom); expect(newExecuteFrom).toEqual(moment(executeTo).subtract(24, 'hours').toISOString()); }); @@ -61,7 +62,7 @@ describe('test endpoint meta telemetry scheduled task timing helper', () => { test('test -24 hours is returned when there was a previous task run', async () => { const executeTo = moment().utc().toISOString(); const executeFrom = moment(executeTo).subtract(24, 'hours').toISOString(); - const newExecuteFrom = getPreviousEpMetaTaskTimestamp(executeTo, executeFrom); + const newExecuteFrom = getPreviousDailyTaskTimestamp(executeTo, executeFrom); expect(newExecuteFrom).toEqual(executeFrom); }); @@ -71,7 +72,7 @@ describe('test endpoint meta telemetry scheduled task timing helper', () => { test('test 24 hours is returned when previous task run took longer than 24 hours', async () => { const executeTo = moment().utc().toISOString(); const executeFrom = moment(executeTo).subtract(72, 'hours').toISOString(); // down 3 days - const newExecuteFrom = getPreviousEpMetaTaskTimestamp(executeTo, executeFrom); + const newExecuteFrom = getPreviousDailyTaskTimestamp(executeTo, executeFrom); expect(newExecuteFrom).toEqual(moment(executeTo).subtract(24, 'hours').toISOString()); }); @@ -134,61 +135,88 @@ describe('test package policy type guard', () => { }); describe('list telemetry schema', () => { + test('detection rules document is correctly formed', () => { + const data = [{ id: 'test_1' }] as ExceptionListItem[]; + const templatedItems = templateExceptionList(data, LIST_DETECTION_RULE_EXCEPTION); + + expect(templatedItems[0]?.detection_rule).not.toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); + }); + + test('detection rules document is correctly formed with multiple entries', () => { + const data = [{ id: 'test_2' }, { id: 'test_2' }] as ExceptionListItem[]; + const templatedItems = templateExceptionList(data, LIST_DETECTION_RULE_EXCEPTION); + + expect(templatedItems[0]?.detection_rule).not.toBeUndefined(); + expect(templatedItems[1]?.detection_rule).not.toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); + }); + test('trusted apps document is correctly formed', () => { const data = [{ id: 'test_1' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_TRUSTED_APPLICATION); - expect(templatedItems[0]?.trusted_application.length).toEqual(1); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(0); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).not.toBeUndefined(); }); test('trusted apps document is correctly formed with multiple entries', () => { const data = [{ id: 'test_2' }, { id: 'test_2' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_TRUSTED_APPLICATION); - expect(templatedItems[0]?.trusted_application.length).toEqual(1); - expect(templatedItems[1]?.trusted_application.length).toEqual(1); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(0); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).not.toBeUndefined(); + expect(templatedItems[1]?.trusted_application).not.toBeUndefined(); }); test('endpoint exception document is correctly formed', () => { const data = [{ id: 'test_3' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EXCEPTION); - expect(templatedItems[0]?.trusted_application.length).toEqual(0); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(1); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(0); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).not.toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); }); test('endpoint exception document is correctly formed with multiple entries', () => { const data = [{ id: 'test_4' }, { id: 'test_4' }, { id: 'test_4' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EXCEPTION); - expect(templatedItems[0]?.trusted_application.length).toEqual(0); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(1); - expect(templatedItems[1]?.endpoint_exception.length).toEqual(1); - expect(templatedItems[2]?.endpoint_exception.length).toEqual(1); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(0); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).not.toBeUndefined(); + expect(templatedItems[1]?.endpoint_exception).not.toBeUndefined(); + expect(templatedItems[2]?.endpoint_exception).not.toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); }); test('endpoint event filters document is correctly formed', () => { const data = [{ id: 'test_5' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EVENT_FILTER); - expect(templatedItems[0]?.trusted_application.length).toEqual(0); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(1); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).not.toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); }); test('endpoint event filters document is correctly formed with multiple entries', () => { const data = [{ id: 'test_6' }, { id: 'test_6' }] as ExceptionListItem[]; const templatedItems = templateExceptionList(data, LIST_ENDPOINT_EVENT_FILTER); - expect(templatedItems[0]?.trusted_application.length).toEqual(0); - expect(templatedItems[0]?.endpoint_exception.length).toEqual(0); - expect(templatedItems[0]?.endpoint_event_filter.length).toEqual(1); - expect(templatedItems[1]?.endpoint_event_filter.length).toEqual(1); + expect(templatedItems[0]?.detection_rule).toBeUndefined(); + expect(templatedItems[0]?.endpoint_event_filter).not.toBeUndefined(); + expect(templatedItems[1]?.endpoint_event_filter).not.toBeUndefined(); + expect(templatedItems[0]?.endpoint_exception).toBeUndefined(); + expect(templatedItems[0]?.trusted_application).toBeUndefined(); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts index a9eaef3ce6edc..e72b0ba7d16fe 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/helpers.ts @@ -11,6 +11,7 @@ import { PackagePolicy } from '../../../../fleet/common/types/models/package_pol import { copyAllowlistedFields, exceptionListEventFields } from './filters'; import { ExceptionListItem, ListTemplate, TelemetryEvent } from './types'; import { + LIST_DETECTION_RULE_EXCEPTION, LIST_ENDPOINT_EXCEPTION, LIST_ENDPOINT_EVENT_FILTER, LIST_TRUSTED_APPLICATION, @@ -46,7 +47,7 @@ export const getPreviousDiagTaskTimestamp = ( * @param lastExecutionTimestamp * @returns the timestamp to search from */ -export const getPreviousEpMetaTaskTimestamp = ( +export const getPreviousDailyTaskTimestamp = ( executeTo: string, lastExecutionTimestamp?: string ) => { @@ -97,18 +98,16 @@ export function isPackagePolicyList( * Maps trusted application to shared telemetry object * * @param exceptionListItem - * @returns collection of endpoint exceptions + * @returns collection of trusted applications */ export const trustedApplicationToTelemetryEntry = (trustedApplication: TrustedApp) => { return { id: trustedApplication.id, - version: trustedApplication.version || '', name: trustedApplication.name, - description: trustedApplication.description, created_at: trustedApplication.created_at, updated_at: trustedApplication.updated_at, entries: trustedApplication.entries, - os: trustedApplication.os, + os_types: [trustedApplication.os], } as ExceptionListItem; }; @@ -121,9 +120,29 @@ export const trustedApplicationToTelemetryEntry = (trustedApplication: TrustedAp export const exceptionListItemToTelemetryEntry = (exceptionListItem: ExceptionListItemSchema) => { return { id: exceptionListItem.id, - version: exceptionListItem._version || '', name: exceptionListItem.name, - description: exceptionListItem.description, + created_at: exceptionListItem.created_at, + updated_at: exceptionListItem.updated_at, + entries: exceptionListItem.entries, + os_types: exceptionListItem.os_types, + } as ExceptionListItem; +}; + +/** + * Maps detection rule exception list items to shared telemetry object + * + * @param exceptionListItem + * @param ruleVersion + * @returns collection of detection rule exceptions + */ +export const ruleExceptionListItemToTelemetryEvent = ( + exceptionListItem: ExceptionListItemSchema, + ruleVersion: number +) => { + return { + id: exceptionListItem.item_id, + name: exceptionListItem.description, + rule_version: ruleVersion, created_at: exceptionListItem.created_at, updated_at: exceptionListItem.updated_at, entries: exceptionListItem.entries, @@ -141,9 +160,7 @@ export const exceptionListItemToTelemetryEntry = (exceptionListItem: ExceptionLi export const templateExceptionList = (listData: ExceptionListItem[], listType: string) => { return listData.map((item) => { const template: ListTemplate = { - trusted_application: [], - endpoint_exception: [], - endpoint_event_filter: [], + '@timestamp': new Date().getTime(), }; // cast exception list type to a TelemetryEvent for allowlist filtering @@ -152,18 +169,23 @@ export const templateExceptionList = (listData: ExceptionListItem[], listType: s item as unknown as TelemetryEvent ); + if (listType === LIST_DETECTION_RULE_EXCEPTION) { + template.detection_rule = filteredListItem; + return template; + } + if (listType === LIST_TRUSTED_APPLICATION) { - template.trusted_application.push(filteredListItem); + template.trusted_application = filteredListItem; return template; } if (listType === LIST_ENDPOINT_EXCEPTION) { - template.endpoint_exception.push(filteredListItem); + template.endpoint_exception = filteredListItem; return template; } if (listType === LIST_ENDPOINT_EVENT_FILTER) { - template.endpoint_event_filter.push(filteredListItem); + template.endpoint_event_filter = filteredListItem; return template; } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts b/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts index 20a71657b2ffe..9168683141e48 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/mocks.ts @@ -8,7 +8,7 @@ // eslint-disable-next-line max-classes-per-file import { TelemetryEventsSender } from './sender'; import { TelemetryReceiver } from './receiver'; -import { DiagnosticTask, EndpointTask, ExceptionListsTask } from './tasks'; +import { DiagnosticTask, EndpointTask, ExceptionListsTask, DetectionRulesTask } from './tasks'; import { PackagePolicy } from '../../../../fleet/common/types/models/package_policy'; /** @@ -40,6 +40,8 @@ export const createMockTelemetryReceiver = (): jest.Mocked => fetchEndpointMetrics: jest.fn(), fetchEndpointPolicyResponses: jest.fn(), fetchTrustedApplications: jest.fn(), + fetchDetectionRules: jest.fn(), + fetchDetectionExceptionList: jest.fn(), } as unknown as jest.Mocked; }; @@ -79,3 +81,10 @@ export class MockTelemetryEndpointTask extends EndpointTask { export class MockExceptionListsTask extends ExceptionListsTask { public runTask = jest.fn(); } + +/** + * Creates a mocked Telemetry detection rules lists Task + */ +export class MockDetectionRuleListsTask extends DetectionRulesTask { + public runTask = jest.fn(); +} diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts index 038b7687784f4..94aa6c867304f 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/receiver.ts @@ -17,8 +17,18 @@ import { AgentService, AgentPolicyServiceInterface } from '../../../../fleet/ser import { ExceptionListClient } from '../../../../lists/server'; import { EndpointAppContextService } from '../../endpoint/endpoint_app_context_services'; import { TELEMETRY_MAX_BUFFER_SIZE } from './constants'; -import { exceptionListItemToTelemetryEntry, trustedApplicationToTelemetryEntry } from './helpers'; -import { TelemetryEvent, ESLicense, ESClusterInfo, GetEndpointListResponse } from './types'; +import { + exceptionListItemToTelemetryEntry, + trustedApplicationToTelemetryEntry, + ruleExceptionListItemToTelemetryEvent, +} from './helpers'; +import { + TelemetryEvent, + ESLicense, + ESClusterInfo, + GetEndpointListResponse, + RuleSearchResult, +} from './types'; export class TelemetryReceiver { private readonly logger: Logger; @@ -27,6 +37,7 @@ export class TelemetryReceiver { private esClient?: ElasticsearchClient; private exceptionListClient?: ExceptionListClient; private soClient?: SavedObjectsClientContract; + private kibanaIndex?: string; private readonly max_records = 10_000; constructor(logger: Logger) { @@ -35,9 +46,11 @@ export class TelemetryReceiver { public async start( core?: CoreStart, + kibanaIndex?: string, endpointContextService?: EndpointAppContextService, exceptionListClient?: ExceptionListClient ) { + this.kibanaIndex = kibanaIndex; this.agentService = endpointContextService?.getAgentService(); this.agentPolicyService = endpointContextService?.getAgentPolicyService(); this.esClient = core?.elasticsearch.client.asInternalUser; @@ -240,6 +253,57 @@ export class TelemetryReceiver { }; } + public async fetchDetectionRules() { + if (this.esClient === undefined || this.esClient === null) { + throw Error('elasticsearch client is unavailable: cannot retrieve diagnostic alerts'); + } + + const query: SearchRequest = { + expand_wildcards: 'open,hidden', + index: `${this.kibanaIndex}*`, + ignore_unavailable: true, + size: this.max_records, + body: { + query: { + bool: { + filter: [ + { term: { 'alert.alertTypeId': 'siem.signals' } }, + { term: { 'alert.params.immutable': true } }, + ], + }, + }, + }, + }; + + return this.esClient.search(query); + } + + public async fetchDetectionExceptionList(listId: string, ruleVersion: number) { + if (this?.exceptionListClient === undefined || this?.exceptionListClient === null) { + throw Error('exception list client is unavailable: could not retrieve trusted applications'); + } + + // Ensure list is created if it does not exist + await this.exceptionListClient.createTrustedAppsList(); + + const results = await this.exceptionListClient?.findExceptionListsItem({ + listId: [listId], + filter: [], + perPage: this.max_records, + page: 1, + sortField: 'exception-list.created_at', + sortOrder: 'desc', + namespaceType: ['single'], + }); + + return { + data: results?.data.map((r) => ruleExceptionListItemToTelemetryEvent(r, ruleVersion)) ?? [], + total: results?.total ?? 0, + page: results?.page ?? 1, + per_page: results?.per_page ?? this.max_records, + }; + } + public async fetchClusterInfo(): Promise { if (this.esClient === undefined || this.esClient === null) { throw Error('elasticsearch client is unavailable: cannot retrieve cluster infomation'); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts index 0037aaa28fee3..b0792ed7b4610 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/sender.ts @@ -18,7 +18,7 @@ import { } from '../../../../task_manager/server'; import { TelemetryReceiver } from './receiver'; import { allowlistEventFields, copyAllowlistedFields } from './filters'; -import { DiagnosticTask, EndpointTask, ExceptionListsTask } from './tasks'; +import { DiagnosticTask, EndpointTask, ExceptionListsTask, DetectionRulesTask } from './tasks'; import { createUsageCounterLabel } from './helpers'; import { TelemetryEvent } from './types'; import { TELEMETRY_MAX_BUFFER_SIZE } from './constants'; @@ -42,6 +42,7 @@ export class TelemetryEventsSender { private diagnosticTask?: DiagnosticTask; private endpointTask?: EndpointTask; private exceptionListsTask?: ExceptionListsTask; + private detectionRulesTask?: DetectionRulesTask; constructor(logger: Logger) { this.logger = logger.get('telemetry_events'); @@ -59,6 +60,12 @@ export class TelemetryEventsSender { if (taskManager) { this.diagnosticTask = new DiagnosticTask(this.logger, taskManager, this, telemetryReceiver); this.endpointTask = new EndpointTask(this.logger, taskManager, this, telemetryReceiver); + this.detectionRulesTask = new DetectionRulesTask( + this.logger, + taskManager, + this, + telemetryReceiver + ); this.exceptionListsTask = new ExceptionListsTask( this.logger, taskManager, @@ -80,6 +87,7 @@ export class TelemetryEventsSender { this.logger.debug(`starting security telemetry tasks`); this.diagnosticTask.start(taskManager); this.endpointTask.start(taskManager); + this.detectionRulesTask?.start(taskManager); this.exceptionListsTask?.start(taskManager); } diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.ts new file mode 100644 index 0000000000000..0a05afb8a6535 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.test.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 { loggingSystemMock } from 'src/core/server/mocks'; +import { taskManagerMock } from '../../../../../task_manager/server/mocks'; +import { TaskStatus } from '../../../../../task_manager/server'; +import { + TelemetryDetectionRulesTask, + TelemetryDetectionRuleListsTaskConstants, +} from './detection_rule'; +import { + createMockTelemetryEventsSender, + MockDetectionRuleListsTask, + createMockTelemetryReceiver, +} from '../mocks'; + +describe('test detection rule exception lists telemetry', () => { + let logger: ReturnType; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + }); + + describe('basic telemetry sanity checks', () => { + test('detection rule lists task can register', () => { + const telemetryDiagTask = new TelemetryDetectionRulesTask( + logger, + taskManagerMock.createSetup(), + createMockTelemetryEventsSender(true), + createMockTelemetryReceiver() + ); + + expect(telemetryDiagTask).toBeInstanceOf(TelemetryDetectionRulesTask); + }); + }); + + test('detection rule task should be registered', () => { + const mockTaskManager = taskManagerMock.createSetup(); + new TelemetryDetectionRulesTask( + logger, + mockTaskManager, + createMockTelemetryEventsSender(true), + createMockTelemetryReceiver() + ); + + expect(mockTaskManager.registerTaskDefinitions).toHaveBeenCalled(); + }); + + test('detection rule task should be scheduled', async () => { + const mockTaskManagerSetup = taskManagerMock.createSetup(); + const telemetryDiagTask = new TelemetryDetectionRulesTask( + logger, + mockTaskManagerSetup, + createMockTelemetryEventsSender(true), + createMockTelemetryReceiver() + ); + + const mockTaskManagerStart = taskManagerMock.createStart(); + await telemetryDiagTask.start(mockTaskManagerStart); + expect(mockTaskManagerStart.ensureScheduled).toHaveBeenCalled(); + }); + + test('detection rule task should run', async () => { + const mockContext = createMockTelemetryEventsSender(true); + const mockTaskManager = taskManagerMock.createSetup(); + const mockReceiver = createMockTelemetryReceiver(); + const telemetryDiagTask = new MockDetectionRuleListsTask( + logger, + mockTaskManager, + mockContext, + mockReceiver + ); + + const mockTaskInstance = { + id: TelemetryDetectionRuleListsTaskConstants.TYPE, + runAt: new Date(), + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: new Date(), + scheduledAt: new Date(), + retryAt: new Date(), + params: {}, + state: {}, + taskType: TelemetryDetectionRuleListsTaskConstants.TYPE, + }; + const createTaskRunner = + mockTaskManager.registerTaskDefinitions.mock.calls[0][0][ + TelemetryDetectionRuleListsTaskConstants.TYPE + ].createTaskRunner; + const taskRunner = createTaskRunner({ taskInstance: mockTaskInstance }); + await taskRunner.run(); + expect(telemetryDiagTask.runTask).toHaveBeenCalled(); + }); + + test('detection rule task should not query elastic if telemetry is not opted in', async () => { + const mockSender = createMockTelemetryEventsSender(false); + const mockTaskManager = taskManagerMock.createSetup(); + const mockReceiver = createMockTelemetryReceiver(); + new MockDetectionRuleListsTask(logger, mockTaskManager, mockSender, mockReceiver); + + const mockTaskInstance = { + id: TelemetryDetectionRuleListsTaskConstants.TYPE, + runAt: new Date(), + attempts: 0, + ownerId: '', + status: TaskStatus.Running, + startedAt: new Date(), + scheduledAt: new Date(), + retryAt: new Date(), + params: {}, + state: {}, + taskType: TelemetryDetectionRuleListsTaskConstants.TYPE, + }; + const createTaskRunner = + mockTaskManager.registerTaskDefinitions.mock.calls[0][0][ + TelemetryDetectionRuleListsTaskConstants.TYPE + ].createTaskRunner; + const taskRunner = createTaskRunner({ taskInstance: mockTaskInstance }); + await taskRunner.run(); + expect(mockReceiver.fetchDiagnosticAlerts).not.toHaveBeenCalled(); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.ts new file mode 100644 index 0000000000000..a362be187921d --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/detection_rule.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 moment from 'moment'; +import { Logger } from 'src/core/server'; +import { + ConcreteTaskInstance, + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../../task_manager/server'; +import { LIST_DETECTION_RULE_EXCEPTION, TELEMETRY_CHANNEL_LISTS } from '../constants'; +import { batchTelemetryRecords, templateExceptionList } from '../helpers'; +import { TelemetryEventsSender } from '../sender'; +import { TelemetryReceiver } from '../receiver'; +import { ExceptionListItem, RuleSearchResult } from '../types'; + +export const TelemetryDetectionRuleListsTaskConstants = { + TIMEOUT: '10m', + TYPE: 'security:telemetry-detection-rules', + INTERVAL: '24h', + VERSION: '1.0.0', +}; + +const MAX_TELEMETRY_BATCH = 1_000; + +export class TelemetryDetectionRulesTask { + private readonly logger: Logger; + private readonly sender: TelemetryEventsSender; + private readonly receiver: TelemetryReceiver; + + constructor( + logger: Logger, + taskManager: TaskManagerSetupContract, + sender: TelemetryEventsSender, + receiver: TelemetryReceiver + ) { + this.logger = logger; + this.sender = sender; + this.receiver = receiver; + + taskManager.registerTaskDefinitions({ + [TelemetryDetectionRuleListsTaskConstants.TYPE]: { + title: 'Security Solution Detection Rule Lists Telemetry', + timeout: TelemetryDetectionRuleListsTaskConstants.TIMEOUT, + createTaskRunner: ({ taskInstance }: { taskInstance: ConcreteTaskInstance }) => { + const { state } = taskInstance; + + return { + run: async () => { + const taskExecutionTime = moment().utc().toISOString(); + const hits = await this.runTask(taskInstance.id); + + return { + state: { + lastExecutionTimestamp: taskExecutionTime, + runs: (state.runs || 0) + 1, + hits, + }, + }; + }, + cancel: async () => {}, + }; + }, + }, + }); + } + + public start = async (taskManager: TaskManagerStartContract) => { + try { + await taskManager.ensureScheduled({ + id: this.getTaskId(), + taskType: TelemetryDetectionRuleListsTaskConstants.TYPE, + scope: ['securitySolution'], + schedule: { + interval: TelemetryDetectionRuleListsTaskConstants.INTERVAL, + }, + state: { runs: 0 }, + params: { version: TelemetryDetectionRuleListsTaskConstants.VERSION }, + }); + } catch (e) { + this.logger.error(`Error scheduling task, received ${e.message}`); + } + }; + + private getTaskId = (): string => { + return `${TelemetryDetectionRuleListsTaskConstants.TYPE}:${TelemetryDetectionRuleListsTaskConstants.VERSION}`; + }; + + public runTask = async (taskId: string) => { + if (taskId !== this.getTaskId()) { + return 0; + } + + const isOptedIn = await this.sender.isTelemetryOptedIn(); + if (!isOptedIn) { + return 0; + } + + // Lists Telemetry: Detection Rules + + const { body: prebuiltRules } = await this.receiver.fetchDetectionRules(); + + const cacheArray = prebuiltRules.hits.hits.reduce((cache, searchHit) => { + const rule = searchHit._source as RuleSearchResult; + const ruleId = rule.alert.params.ruleId; + + const shouldNotProcess = + rule === null || + rule === undefined || + ruleId === null || + ruleId === undefined || + searchHit._source?.alert.params.exceptionsList.length === 0; + + if (shouldNotProcess) { + return cache; + } + + cache.push(rule); + return cache; + }, [] as RuleSearchResult[]); + + const detectionRuleExceptions = [] as ExceptionListItem[]; + for (const item of cacheArray) { + const ruleVersion = item.alert.params.version; + + for (const ex of item.alert.params.exceptionsList) { + const listItem = await this.receiver.fetchDetectionExceptionList(ex.list_id, ruleVersion); + for (const exceptionItem of listItem.data) { + detectionRuleExceptions.push(exceptionItem); + } + } + } + + const detectionRuleExceptionsJson = templateExceptionList( + detectionRuleExceptions, + LIST_DETECTION_RULE_EXCEPTION + ); + + batchTelemetryRecords(detectionRuleExceptionsJson, MAX_TELEMETRY_BATCH).forEach((batch) => { + this.sender.sendOnDemand(TELEMETRY_CHANNEL_LISTS, batch); + }); + + return detectionRuleExceptions.length; + }; +} diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts index 0c066deea17d9..c6bf4b06e70f0 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/endpoint.ts @@ -14,7 +14,7 @@ import { } from '../../../../../task_manager/server'; import { batchTelemetryRecords, - getPreviousEpMetaTaskTimestamp, + getPreviousDailyTaskTimestamp, isPackagePolicyList, } from '../helpers'; import { TelemetryEventsSender } from '../sender'; @@ -76,7 +76,7 @@ export class TelemetryEndpointTask { return { run: async () => { const taskExecutionTime = moment().utc().toISOString(); - const lastExecutionTimestamp = getPreviousEpMetaTaskTimestamp( + const lastExecutionTimestamp = getPreviousDailyTaskTimestamp( taskExecutionTime, taskInstance.state?.lastExecutionTimestamp ); diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts index e090252b88d8f..a850f848567cb 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/tasks/index.ts @@ -8,3 +8,4 @@ export { TelemetryDiagTask as DiagnosticTask } from './diagnostic'; export { TelemetryEndpointTask as EndpointTask } from './endpoint'; export { TelemetryExceptionListsTask as ExceptionListsTask } from './security_lists'; +export { TelemetryDetectionRulesTask as DetectionRulesTask } from './detection_rule'; diff --git a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts index abcad26ed000c..6aaf6f4371475 100644 --- a/x-pack/plugins/security_solution/server/lib/telemetry/types.ts +++ b/x-pack/plugins/security_solution/server/lib/telemetry/types.ts @@ -217,18 +217,45 @@ export interface GetEndpointListResponse { export interface ExceptionListItem { id: string; - version: string; + rule_version?: number; name: string; - description: string; created_at: string; updated_at: string; entries: object; - os: string; os_types: object; } export interface ListTemplate { - trusted_application: TelemetryEvent[]; - endpoint_exception: TelemetryEvent[]; - endpoint_event_filter: TelemetryEvent[]; + '@timestamp': number; + detection_rule?: TelemetryEvent; + endpoint_exception?: TelemetryEvent; + endpoint_event_filter?: TelemetryEvent; + trusted_application?: TelemetryEvent; +} + +// Detection Rule types + +interface ExceptionListEntry { + id: string; + list_id: string; + type: string; + namespace_type: string; +} + +interface DetectionRuleParms { + ruleId: string; + version: number; + type: string; + exceptionsList: ExceptionListEntry[]; +} + +export interface RuleSearchResult { + alert: { + name: string; + enabled: boolean; + tags: string[]; + createdAt: string; + updatedAt: string; + params: DetectionRuleParms; + }; } diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index bffcc823d047e..f0a91f8b06c00 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -459,7 +459,13 @@ export class Plugin implements IPlugin Date: Mon, 11 Oct 2021 17:30:18 +0200 Subject: [PATCH 29/33] [bfetch] Pass `compress` flag in query instead of headers (#113929) --- src/plugins/bfetch/common/util/index.ts | 1 + .../bfetch/common/util/query_params.ts | 12 +++ .../public/streaming/fetch_streaming.ts | 12 +-- src/plugins/bfetch/server/index.ts | 1 - src/plugins/bfetch/server/mocks.ts | 1 - src/plugins/bfetch/server/plugin.ts | 78 +------------------ .../bfetch/server/streaming/create_stream.ts | 8 +- src/plugins/bfetch/server/types.ts | 27 ------- test/api_integration/apis/search/bsearch.ts | 66 +++++++--------- 9 files changed, 56 insertions(+), 150 deletions(-) create mode 100644 src/plugins/bfetch/common/util/query_params.ts delete mode 100644 src/plugins/bfetch/server/types.ts diff --git a/src/plugins/bfetch/common/util/index.ts b/src/plugins/bfetch/common/util/index.ts index 1651d24d96b14..f20d30eb3cdf0 100644 --- a/src/plugins/bfetch/common/util/index.ts +++ b/src/plugins/bfetch/common/util/index.ts @@ -8,3 +8,4 @@ export * from './normalize_error'; export * from './remove_leading_slash'; +export * from './query_params'; diff --git a/src/plugins/bfetch/common/util/query_params.ts b/src/plugins/bfetch/common/util/query_params.ts new file mode 100644 index 0000000000000..ed65699fbcafc --- /dev/null +++ b/src/plugins/bfetch/common/util/query_params.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 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 const appendQueryParam = (url: string, key: string, value: string): string => { + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}${key}=${value}`; +}; diff --git a/src/plugins/bfetch/public/streaming/fetch_streaming.ts b/src/plugins/bfetch/public/streaming/fetch_streaming.ts index a94c8d3980cba..77e5acffc1af3 100644 --- a/src/plugins/bfetch/public/streaming/fetch_streaming.ts +++ b/src/plugins/bfetch/public/streaming/fetch_streaming.ts @@ -10,6 +10,7 @@ import { map, share } from 'rxjs/operators'; import { inflateResponse } from '.'; import { fromStreamingXhr } from './from_streaming_xhr'; import { split } from './split'; +import { appendQueryParam } from '../../common'; export interface FetchStreamingParams { url: string; @@ -34,16 +35,15 @@ export function fetchStreaming({ }: FetchStreamingParams) { const xhr = new window.XMLHttpRequest(); - // Begin the request - xhr.open(method, url); - xhr.withCredentials = true; - const isCompressionDisabled = getIsCompressionDisabled(); - if (!isCompressionDisabled) { - headers['X-Chunk-Encoding'] = 'deflate'; + url = appendQueryParam(url, 'compress', 'true'); } + // Begin the request + xhr.open(method, url); + xhr.withCredentials = true; + // Set the HTTP headers Object.entries(headers).forEach(([k, v]) => xhr.setRequestHeader(k, v)); diff --git a/src/plugins/bfetch/server/index.ts b/src/plugins/bfetch/server/index.ts index c533b2ad7a3df..f4c41d10e42cb 100644 --- a/src/plugins/bfetch/server/index.ts +++ b/src/plugins/bfetch/server/index.ts @@ -10,7 +10,6 @@ import { PluginInitializerContext } from '../../../core/server'; import { BfetchServerPlugin } from './plugin'; export { BfetchServerSetup, BfetchServerStart, BatchProcessingRouteParams } from './plugin'; -export { StreamingRequestHandler } from './types'; export function plugin(initializerContext: PluginInitializerContext) { return new BfetchServerPlugin(initializerContext); diff --git a/src/plugins/bfetch/server/mocks.ts b/src/plugins/bfetch/server/mocks.ts index bbb596bf8d5ff..dfa365d9e70b2 100644 --- a/src/plugins/bfetch/server/mocks.ts +++ b/src/plugins/bfetch/server/mocks.ts @@ -17,7 +17,6 @@ const createSetupContract = (): Setup => { const setupContract: Setup = { addBatchProcessingRoute: jest.fn(), addStreamingResponseRoute: jest.fn(), - createStreamingRequestHandler: jest.fn(), }; return setupContract; }; diff --git a/src/plugins/bfetch/server/plugin.ts b/src/plugins/bfetch/server/plugin.ts index 7b60be9a8fc75..f7127445f96c5 100644 --- a/src/plugins/bfetch/server/plugin.ts +++ b/src/plugins/bfetch/server/plugin.ts @@ -13,9 +13,6 @@ import type { Plugin, Logger, KibanaRequest, - RouteMethod, - RequestHandler, - RequestHandlerContext, StartServicesAccessor, } from 'src/core/server'; import { schema } from '@kbn/config-schema'; @@ -28,7 +25,6 @@ import { removeLeadingSlash, normalizeError, } from '../common'; -import { StreamingRequestHandler } from './types'; import { createStream } from './streaming'; import { getUiSettings } from './ui_settings'; @@ -52,44 +48,6 @@ export interface BfetchServerSetup { path: string, params: (request: KibanaRequest) => StreamingResponseHandler ) => void; - /** - * Create a streaming request handler to be able to use an Observable to return chunked content to the client. - * This is meant to be used with the `fetchStreaming` API of the `bfetch` client-side plugin. - * - * @example - * ```ts - * setup({ http }: CoreStart, { bfetch }: SetupDeps) { - * const router = http.createRouter(); - * router.post( - * { - * path: '/api/my-plugin/stream-endpoint, - * validate: { - * body: schema.object({ - * term: schema.string(), - * }), - * } - * }, - * bfetch.createStreamingResponseHandler(async (ctx, req) => { - * const { term } = req.body; - * const results$ = await myApi.getResults$(term); - * return results$; - * }) - * )} - * - * ``` - * - * @param streamHandler - */ - createStreamingRequestHandler: < - Response, - P, - Q, - B, - Context extends RequestHandlerContext = RequestHandlerContext, - Method extends RouteMethod = any - >( - streamHandler: StreamingRequestHandler - ) => RequestHandler; } // eslint-disable-next-line @@ -124,15 +82,10 @@ export class BfetchServerPlugin logger, }); const addBatchProcessingRoute = this.addBatchProcessingRoute(addStreamingResponseRoute); - const createStreamingRequestHandler = this.createStreamingRequestHandler({ - getStartServices: core.getStartServices, - logger, - }); return { addBatchProcessingRoute, addStreamingResponseRoute, - createStreamingRequestHandler, }; } @@ -142,10 +95,6 @@ export class BfetchServerPlugin public stop() {} - private getCompressionDisabled(request: KibanaRequest) { - return request.headers['x-chunk-encoding'] !== 'deflate'; - } - private addStreamingResponseRoute = ({ getStartServices, @@ -162,42 +111,21 @@ export class BfetchServerPlugin path: `/${removeLeadingSlash(path)}`, validate: { body: schema.any(), + query: schema.object({ compress: schema.boolean({ defaultValue: false }) }), }, }, async (context, request, response) => { const handlerInstance = handler(request); const data = request.body; - const compressionDisabled = this.getCompressionDisabled(request); + const compress = request.query.compress; return response.ok({ headers: streamingHeaders, - body: createStream( - handlerInstance.getResponseStream(data), - logger, - compressionDisabled - ), + body: createStream(handlerInstance.getResponseStream(data), logger, compress), }); } ); }; - private createStreamingRequestHandler = - ({ - logger, - getStartServices, - }: { - logger: Logger; - getStartServices: StartServicesAccessor; - }): BfetchServerSetup['createStreamingRequestHandler'] => - (streamHandler) => - async (context, request, response) => { - const response$ = await streamHandler(context, request); - const compressionDisabled = this.getCompressionDisabled(request); - return response.ok({ - headers: streamingHeaders, - body: createStream(response$, logger, compressionDisabled), - }); - }; - private addBatchProcessingRoute = ( addStreamingResponseRoute: BfetchServerSetup['addStreamingResponseRoute'] diff --git a/src/plugins/bfetch/server/streaming/create_stream.ts b/src/plugins/bfetch/server/streaming/create_stream.ts index 7d6981294341b..756a806a60229 100644 --- a/src/plugins/bfetch/server/streaming/create_stream.ts +++ b/src/plugins/bfetch/server/streaming/create_stream.ts @@ -15,9 +15,9 @@ import { createNDJSONStream } from './create_ndjson_stream'; export function createStream( response$: Observable, logger: Logger, - compressionDisabled: boolean + compress: boolean ): Stream { - return compressionDisabled - ? createNDJSONStream(response$, logger) - : createCompressedStream(response$, logger); + return compress + ? createCompressedStream(response$, logger) + : createNDJSONStream(response$, logger); } diff --git a/src/plugins/bfetch/server/types.ts b/src/plugins/bfetch/server/types.ts deleted file mode 100644 index 4e54744f4c374..0000000000000 --- a/src/plugins/bfetch/server/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 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 { Observable } from 'rxjs'; -import { KibanaRequest, RequestHandlerContext, RouteMethod } from 'kibana/server'; - -/** - * Request handler modified to allow to return an observable. - * - * See {@link BfetchServerSetup.createStreamingRequestHandler} for usage example. - * @public - */ -export type StreamingRequestHandler< - Response = unknown, - P = unknown, - Q = unknown, - B = unknown, - Method extends RouteMethod = any -> = ( - context: RequestHandlerContext, - request: KibanaRequest -) => Observable | Promise>; diff --git a/test/api_integration/apis/search/bsearch.ts b/test/api_integration/apis/search/bsearch.ts index f80bc1d0d9dfa..6aee2b542da0f 100644 --- a/test/api_integration/apis/search/bsearch.ts +++ b/test/api_integration/apis/search/bsearch.ts @@ -29,28 +29,25 @@ export default function ({ getService }: FtrProviderContext) { describe('bsearch', () => { describe('post', () => { it('should return 200 a single response', async () => { - const resp = await supertest - .post(`/internal/bsearch`) - .set({ 'X-Chunk-Encoding': '' }) - .send({ - batch: [ - { - request: { - params: { - index: '.kibana', - body: { - query: { - match_all: {}, - }, + const resp = await supertest.post(`/internal/bsearch`).send({ + batch: [ + { + request: { + params: { + index: '.kibana', + body: { + query: { + match_all: {}, }, }, }, - options: { - strategy: 'es', - }, }, - ], - }); + options: { + strategy: 'es', + }, + }, + ], + }); const jsonBody = parseBfetchResponse(resp); @@ -62,28 +59,25 @@ export default function ({ getService }: FtrProviderContext) { }); it('should return 200 a single response from compressed', async () => { - const resp = await supertest - .post(`/internal/bsearch`) - .set({ 'X-Chunk-Encoding': 'deflate' }) - .send({ - batch: [ - { - request: { - params: { - index: '.kibana', - body: { - query: { - match_all: {}, - }, + const resp = await supertest.post(`/internal/bsearch?compress=true`).send({ + batch: [ + { + request: { + params: { + index: '.kibana', + body: { + query: { + match_all: {}, }, }, }, - options: { - strategy: 'es', - }, }, - ], - }); + options: { + strategy: 'es', + }, + }, + ], + }); const jsonBody = parseBfetchResponse(resp, true); From 32e00f1b0cd9b87a492b2bafa3f8fa2ed46731db Mon Sep 17 00:00:00 2001 From: Kyle Pollich Date: Mon, 11 Oct 2021 11:41:01 -0400 Subject: [PATCH 30/33] [Fleet] Fix previous configuration modal title (#114475) * Fix previous configuration modal title * Revert translation --- .../sections/agent_policy/edit_package_policy_page/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 7a2f46247d14a..4d940534c4a7b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -687,7 +687,7 @@ const UpgradeStatusCallout: React.FunctionComponent<{

From b03237a72d6675bd6deb976a73c2dddcdcb6b445 Mon Sep 17 00:00:00 2001 From: Josh Dover <1813008+joshdover@users.noreply.github.com> Date: Mon, 11 Oct 2021 18:16:26 +0200 Subject: [PATCH 31/33] Enable `bearer` scheme by default to support service token authorization (#112654) Co-authored-by: Aleh Zasypkin --- docs/settings/security-settings.asciidoc | 2 +- .../security/authentication/index.asciidoc | 6 +- .../authentication/authenticator.test.ts | 6 +- x-pack/plugins/security/server/config.test.ts | 9 ++ x-pack/plugins/security/server/config.ts | 2 +- .../security_usage_collector.test.ts | 2 +- x-pack/scripts/functional_tests.js | 1 + .../http_bearer.config.ts | 36 ++++++ .../tests/http_bearer/header.ts | 103 ++++++++++++++++++ .../tests/http_bearer/index.ts | 15 +++ 10 files changed, 174 insertions(+), 8 deletions(-) create mode 100644 x-pack/test/security_api_integration/http_bearer.config.ts create mode 100644 x-pack/test/security_api_integration/tests/http_bearer/header.ts create mode 100644 x-pack/test/security_api_integration/tests/http_bearer/index.ts diff --git a/docs/settings/security-settings.asciidoc b/docs/settings/security-settings.asciidoc index 906af1dfbb28e..11072509da1fc 100644 --- a/docs/settings/security-settings.asciidoc +++ b/docs/settings/security-settings.asciidoc @@ -218,7 +218,7 @@ There is a very limited set of cases when you'd want to change these settings. F | Determines if HTTP authentication schemes used by the enabled authentication providers should be automatically supported during HTTP authentication. By default, this setting is set to `true`. | `xpack.security.authc.http.schemes[]` -| List of HTTP authentication schemes that {kib} HTTP authentication should support. By default, this setting is set to `['apikey']` to support HTTP authentication with <> scheme. +| List of HTTP authentication schemes that {kib} HTTP authentication should support. By default, this setting is set to `['apikey', 'bearer']` to support HTTP authentication with the <> and <> schemes. |=== diff --git a/docs/user/security/authentication/index.asciidoc b/docs/user/security/authentication/index.asciidoc index bc564308c057e..2f2b279389799 100644 --- a/docs/user/security/authentication/index.asciidoc +++ b/docs/user/security/authentication/index.asciidoc @@ -437,14 +437,14 @@ This type of authentication is usually useful for machine-to-machine interaction By default {kib} supports <> authentication scheme _and_ any scheme supported by the currently enabled authentication provider. For example, `Basic` authentication scheme is automatically supported when basic authentication provider is enabled, or `Bearer` scheme when any of the token based authentication providers is enabled (Token, SAML, OpenID Connect, PKI or Kerberos). But it's also possible to add support for any other authentication scheme in the `kibana.yml` configuration file, as follows: -NOTE: Don't forget to explicitly specify default `apikey` scheme when you just want to add a new one to the list. +NOTE: Don't forget to explicitly specify the default `apikey` and `bearer` schemes when you just want to add a new one to the list. [source,yaml] -------------------------------------------------------------------------------- -xpack.security.authc.http.schemes: [apikey, basic, something-custom] +xpack.security.authc.http.schemes: [apikey, bearer, basic, something-custom] -------------------------------------------------------------------------------- -With this configuration, you can send requests to {kib} with the `Authorization` header using `ApiKey`, `Basic` or `Something-Custom` HTTP schemes (case insensitive). Under the hood, {kib} relays this header to {es}, then {es} authenticates the request using the credentials in the header. +With this configuration, you can send requests to {kib} with the `Authorization` header using `ApiKey`, `Bearer`, `Basic` or `Something-Custom` HTTP schemes (case insensitive). Under the hood, {kib} relays this header to {es}, then {es} authenticates the request using the credentials in the header. [float] [[embedded-content-authentication]] diff --git a/x-pack/plugins/security/server/authentication/authenticator.test.ts b/x-pack/plugins/security/server/authentication/authenticator.test.ts index ce97c142f5584..4e35b84a93119 100644 --- a/x-pack/plugins/security/server/authentication/authenticator.test.ts +++ b/x-pack/plugins/security/server/authentication/authenticator.test.ts @@ -210,7 +210,7 @@ describe('Authenticator', () => { expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider ).toHaveBeenCalledWith(expect.anything(), { - supportedSchemes: new Set(['apikey', 'basic']), + supportedSchemes: new Set(['apikey', 'bearer', 'basic']), }); }); @@ -238,7 +238,9 @@ describe('Authenticator', () => { expect( jest.requireMock('./providers/http').HTTPAuthenticationProvider - ).toHaveBeenCalledWith(expect.anything(), { supportedSchemes: new Set(['apikey']) }); + ).toHaveBeenCalledWith(expect.anything(), { + supportedSchemes: new Set(['apikey', 'bearer']), + }); }); it('disabled if explicitly disabled', () => { diff --git a/x-pack/plugins/security/server/config.test.ts b/x-pack/plugins/security/server/config.test.ts index 4a7d8c7961cf5..1baf3fd4aac50 100644 --- a/x-pack/plugins/security/server/config.test.ts +++ b/x-pack/plugins/security/server/config.test.ts @@ -27,6 +27,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Object { @@ -80,6 +81,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Object { @@ -133,6 +135,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Object { @@ -311,6 +314,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "oidc": Object { @@ -342,6 +346,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "oidc": Object { @@ -373,6 +378,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Array [ @@ -391,6 +397,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Array [ @@ -412,6 +419,7 @@ describe('config schema', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Array [ @@ -1485,6 +1493,7 @@ describe('createConfig()', () => { "enabled": true, "schemes": Array [ "apikey", + "bearer", ], }, "providers": Object { diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 07ff81e092f5f..89918e73369d3 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -269,7 +269,7 @@ export const ConfigSchema = schema.object({ http: schema.object({ enabled: schema.boolean({ defaultValue: true }), autoSchemesEnabled: schema.boolean({ defaultValue: true }), - schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey'] }), + schemes: schema.arrayOf(schema.string(), { defaultValue: ['apikey', 'bearer'] }), }), }), audit: schema.object( diff --git a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts index 0515a1e1969bf..83f09ef017b01 100644 --- a/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts +++ b/x-pack/plugins/security/server/usage_collector/security_usage_collector.test.ts @@ -46,7 +46,7 @@ describe('Security UsageCollector', () => { authProviderCount: 1, enabledAuthProviders: ['basic'], loginSelectorEnabled: false, - httpAuthSchemes: ['apikey'], + httpAuthSchemes: ['apikey', 'bearer'], sessionIdleTimeoutInMinutes: 60, sessionLifespanInMinutes: 43200, sessionCleanupInMinutes: 60, diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 3c1cdd5790f3c..f7b978c2b58bd 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -54,6 +54,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/security_api_integration/session_lifespan.config.ts'), require.resolve('../test/security_api_integration/login_selector.config.ts'), require.resolve('../test/security_api_integration/audit.config.ts'), + require.resolve('../test/security_api_integration/http_bearer.config.ts'), require.resolve('../test/security_api_integration/kerberos.config.ts'), require.resolve('../test/security_api_integration/kerberos_anonymous_access.config.ts'), require.resolve('../test/security_api_integration/pki.config.ts'), diff --git a/x-pack/test/security_api_integration/http_bearer.config.ts b/x-pack/test/security_api_integration/http_bearer.config.ts new file mode 100644 index 0000000000000..b0a9f4a920347 --- /dev/null +++ b/x-pack/test/security_api_integration/http_bearer.config.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrConfigProviderContext } from '@kbn/test'; +import { services } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const xPackAPITestsConfig = await readConfigFile(require.resolve('../api_integration/config.ts')); + + return { + testFiles: [require.resolve('./tests/http_bearer')], + servers: xPackAPITestsConfig.get('servers'), + security: { disableTestUser: true }, + services, + junit: { + reportName: 'X-Pack Security API Integration Tests (HTTP Bearer)', + }, + + esTestCluster: { + ...xPackAPITestsConfig.get('esTestCluster'), + serverArgs: [ + ...xPackAPITestsConfig.get('esTestCluster.serverArgs'), + 'xpack.security.authc.token.enabled=true', + 'xpack.security.authc.token.timeout=15s', + ], + }, + + kbnTestServer: { + ...xPackAPITestsConfig.get('kbnTestServer'), + }, + }; +} diff --git a/x-pack/test/security_api_integration/tests/http_bearer/header.ts b/x-pack/test/security_api_integration/tests/http_bearer/header.ts new file mode 100644 index 0000000000000..f7ebef4f16d09 --- /dev/null +++ b/x-pack/test/security_api_integration/tests/http_bearer/header.ts @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { adminTestUser } from '@kbn/test'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const es = getService('es'); + + async function createToken() { + const { + body: { access_token: accessToken, authentication }, + } = await es.security.getToken({ + body: { + grant_type: 'password', + ...adminTestUser, + }, + }); + + return { + accessToken, + expectedUser: { + ...authentication, + authentication_provider: { name: '__http__', type: 'http' }, + authentication_type: 'token', + }, + }; + } + + describe('header', () => { + it('accepts valid access token via authorization Bearer header', async () => { + const { accessToken, expectedUser } = await createToken(); + + const response = await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', `Bearer ${accessToken}`) + .expect(200, expectedUser); + + // Make sure we don't automatically create a session + expect(response.headers['set-cookie']).to.be(undefined); + }); + + it('accepts multiple requests for a single valid access token', async () => { + const { accessToken, expectedUser } = await createToken(); + + // try it once + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', `Bearer ${accessToken}`) + .expect(200, expectedUser); + + // try it again to verity it isn't invalidated after a single request + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', `Bearer ${accessToken}`) + .expect(200, expectedUser); + }); + + it('rejects invalid access token via authorization Bearer header', async () => { + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', 'Bearer notreal') + .expect(401); + }); + + it('rejects invalidated access token via authorization Bearer header', async () => { + const { accessToken } = await createToken(); + await es.security.invalidateToken({ body: { token: accessToken } }); + + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', `Bearer ${accessToken}`) + .expect(401); + }); + + it('rejects expired access token via authorization Bearer header', async function () { + this.timeout(40000); + + const { accessToken } = await createToken(); + + // Access token expiration is set to 15s for API integration tests. + // Let's wait for 20s to make sure token expires. + await new Promise((resolve) => setTimeout(resolve, 20000)); + + await supertest + .get('/internal/security/me') + .set('kbn-xsrf', 'true') + .set('authorization', `Bearer ${accessToken}`) + .expect(401); + }); + }); +} diff --git a/x-pack/test/security_api_integration/tests/http_bearer/index.ts b/x-pack/test/security_api_integration/tests/http_bearer/index.ts new file mode 100644 index 0000000000000..4dbad2660ebaa --- /dev/null +++ b/x-pack/test/security_api_integration/tests/http_bearer/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('security APIs - HTTP Bearer', function () { + this.tags('ciGroup6'); + loadTestFile(require.resolve('./header')); + }); +} From c8a01082696a94453060ded28ee67a32a2487e0d Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Mon, 11 Oct 2021 10:53:51 -0600 Subject: [PATCH 32/33] [Stack Monitoring] Adding alerts to react app (#114029) * [Stack Monitoring] Adding alerts to react app * Fixing global state context path * adding alerts to pages; adding alerts model to cluster_overview; removing loadAlerts from page template * Fixing request for enable alerts * remove loadAlerts from page template * Adding request error handlers * removing redundent error handling * Changing useRequestErrorHandler function to be async due to error.response.json call * removing old comment * Fixing contexts paths * Converting ajaxRequestErrorHandler to useRequestErrorHandler * Refactoring error handler for page template and setup mode * Removing unnecessary async/await * Removing unnecessary async/await in useClusters * adding alertTypeIds to each page * fixing instance count * Adding alertTypeIds to index page * Adding alert filters for specific pages * Adding alerts to Logstash nodes Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../plugins/monitoring/common/types/alerts.ts | 1 + .../public/alerts/alerts_dropdown.tsx | 5 +- .../external_config_context.tsx | 0 .../{ => contexts}/global_state_context.tsx | 8 +- .../contexts/header_action_menu_context.tsx | 15 + .../application/hooks/use_alerts_modal.ts | 10 +- .../public/application/hooks/use_clusters.ts | 6 +- .../application/hooks/use_monitoring_time.ts | 2 +- .../hooks/use_request_error_handler.tsx | 79 +++ .../monitoring/public/application/index.tsx | 467 +++++++++--------- .../public/application/pages/apm/instance.tsx | 2 +- .../application/pages/apm/instances.tsx | 2 +- .../public/application/pages/apm/overview.tsx | 2 +- .../application/pages/beats/instance.tsx | 2 +- .../application/pages/beats/instances.tsx | 2 +- .../application/pages/beats/overview.tsx | 2 +- .../pages/cluster/overview_page.tsx | 49 +- .../pages/elasticsearch/ccr_page.tsx | 41 +- .../pages/elasticsearch/ccr_shard_page.tsx | 47 +- .../elasticsearch/index_advanced_page.tsx | 46 +- .../pages/elasticsearch/index_page.tsx | 58 ++- .../pages/elasticsearch/indices_page.tsx | 51 +- .../pages/elasticsearch/ml_jobs_page.tsx | 2 +- .../elasticsearch/node_advanced_page.tsx | 63 ++- .../pages/elasticsearch/node_page.tsx | 70 ++- .../pages/elasticsearch/nodes_page.tsx | 66 ++- .../pages/elasticsearch/overview.tsx | 2 +- .../pages/home/cluster_listing.tsx | 32 +- .../application/pages/kibana/instance.tsx | 41 +- .../application/pages/kibana/instances.tsx | 43 +- .../application/pages/kibana/overview.tsx | 2 +- .../public/application/pages/license_page.tsx | 2 +- .../application/pages/logstash/advanced.tsx | 41 +- .../application/pages/logstash/node.tsx | 41 +- .../pages/logstash/node_pipelines.tsx | 2 +- .../application/pages/logstash/nodes.tsx | 40 +- .../application/pages/logstash/overview.tsx | 2 +- .../application/pages/logstash/pipeline.tsx | 4 +- .../application/pages/logstash/pipelines.tsx | 2 +- .../pages/no_data/no_data_page.tsx | 6 +- .../application/pages/page_template.tsx | 44 +- .../public/application/route_init.tsx | 2 +- .../application/setup_mode/setup_mode.tsx | 15 +- .../setup_mode/setup_mode_renderer.js | 16 +- .../public/components/action_menu/index.tsx | 34 ++ .../public/components/shared/toolbar.tsx | 2 +- .../monitoring/public/lib/fetch_alerts.ts | 36 ++ 47 files changed, 990 insertions(+), 517 deletions(-) rename x-pack/plugins/monitoring/public/application/{ => contexts}/external_config_context.tsx (100%) rename x-pack/plugins/monitoring/public/application/{ => contexts}/global_state_context.tsx (90%) create mode 100644 x-pack/plugins/monitoring/public/application/contexts/header_action_menu_context.tsx create mode 100644 x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.tsx create mode 100644 x-pack/plugins/monitoring/public/components/action_menu/index.tsx create mode 100644 x-pack/plugins/monitoring/public/lib/fetch_alerts.ts diff --git a/x-pack/plugins/monitoring/common/types/alerts.ts b/x-pack/plugins/monitoring/common/types/alerts.ts index 1f68b0c55a046..bbd217169469d 100644 --- a/x-pack/plugins/monitoring/common/types/alerts.ts +++ b/x-pack/plugins/monitoring/common/types/alerts.ts @@ -32,6 +32,7 @@ export interface CommonAlertState { export interface CommonAlertFilter { nodeUuid?: string; shardId?: string; + shardIndex?: string; } export interface CommonAlertParamDetail { diff --git a/x-pack/plugins/monitoring/public/alerts/alerts_dropdown.tsx b/x-pack/plugins/monitoring/public/alerts/alerts_dropdown.tsx index 261685a532882..976569f39de4c 100644 --- a/x-pack/plugins/monitoring/public/alerts/alerts_dropdown.tsx +++ b/x-pack/plugins/monitoring/public/alerts/alerts_dropdown.tsx @@ -14,13 +14,12 @@ import { import { i18n } from '@kbn/i18n'; import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; -import { Legacy } from '../legacy_shims'; import { useKibana } from '../../../../../src/plugins/kibana_react/public'; import { MonitoringStartPluginDependencies } from '../types'; +import { useAlertsModal } from '../application/hooks/use_alerts_modal'; export const AlertsDropdown: React.FC<{}> = () => { - const $injector = Legacy.shims.getAngularInjector(); - const alertsEnableModalProvider: any = $injector.get('enableAlertsModal'); + const alertsEnableModalProvider = useAlertsModal(); const { navigateToApp } = useKibana().services.application; diff --git a/x-pack/plugins/monitoring/public/application/external_config_context.tsx b/x-pack/plugins/monitoring/public/application/contexts/external_config_context.tsx similarity index 100% rename from x-pack/plugins/monitoring/public/application/external_config_context.tsx rename to x-pack/plugins/monitoring/public/application/contexts/external_config_context.tsx diff --git a/x-pack/plugins/monitoring/public/application/global_state_context.tsx b/x-pack/plugins/monitoring/public/application/contexts/global_state_context.tsx similarity index 90% rename from x-pack/plugins/monitoring/public/application/global_state_context.tsx rename to x-pack/plugins/monitoring/public/application/contexts/global_state_context.tsx index 6c952f80eff57..e6638b4c4fede 100644 --- a/x-pack/plugins/monitoring/public/application/global_state_context.tsx +++ b/x-pack/plugins/monitoring/public/application/contexts/global_state_context.tsx @@ -5,10 +5,10 @@ * 2.0. */ import React, { createContext } from 'react'; -import { GlobalState } from '../url_state'; -import { MonitoringStartPluginDependencies } from '../types'; -import { TimeRange, RefreshInterval } from '../../../../../src/plugins/data/public'; -import { Legacy } from '../legacy_shims'; +import { GlobalState } from '../../url_state'; +import { MonitoringStartPluginDependencies } from '../../types'; +import { TimeRange, RefreshInterval } from '../../../../../../src/plugins/data/public'; +import { Legacy } from '../../legacy_shims'; interface GlobalStateProviderProps { query: MonitoringStartPluginDependencies['data']['query']; diff --git a/x-pack/plugins/monitoring/public/application/contexts/header_action_menu_context.tsx b/x-pack/plugins/monitoring/public/application/contexts/header_action_menu_context.tsx new file mode 100644 index 0000000000000..88862d9e6a807 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/contexts/header_action_menu_context.tsx @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { AppMountParameters } from 'kibana/public'; + +interface ContextProps { + setHeaderActionMenu?: AppMountParameters['setHeaderActionMenu']; +} + +export const HeaderActionMenuContext = React.createContext({}); diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts b/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts index 9a2a2b80cc40f..123dd39f7b54d 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_alerts_modal.ts @@ -6,10 +6,11 @@ */ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { showAlertsToast } from '../../alerts/lib/alerts_toast'; -import { ajaxErrorHandlersProvider } from '../../lib/ajax_error_handler'; +import { useRequestErrorHandler } from './use_request_error_handler'; export const useAlertsModal = () => { const { services } = useKibana(); + const handleRequestError = useRequestErrorHandler(); function shouldShowAlertsModal(alerts: {}) { const modalHasBeenShown = @@ -28,12 +29,11 @@ export const useAlertsModal = () => { async function enableAlerts() { try { - const { data } = await services.http?.post('../api/monitoring/v1/alerts/enable', {}); + const response = await services.http?.post('../api/monitoring/v1/alerts/enable', {}); window.localStorage.setItem('ALERTS_MODAL_DECISION_MADE', 'true'); - showAlertsToast(data); + showAlertsToast(response); } catch (err) { - const ajaxErrorHandlers = ajaxErrorHandlersProvider(); - return ajaxErrorHandlers(err); + await handleRequestError(err); } } diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts b/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts index b4b8c21ca4d40..1961bd53b909f 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_clusters.ts @@ -7,6 +7,7 @@ import { useState, useEffect } from 'react'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { fetchClusters } from '../../lib/fetch_clusters'; +import { useRequestErrorHandler } from './use_request_error_handler'; export function useClusters(clusterUuid?: string | null, ccs?: any, codePaths?: string[]) { const { services } = useKibana<{ data: any }>(); @@ -17,6 +18,7 @@ export function useClusters(clusterUuid?: string | null, ccs?: any, codePaths?: const [clusters, setClusters] = useState([] as any); const [loaded, setLoaded] = useState(false); + const handleRequestError = useRequestErrorHandler(); useEffect(() => { async function makeRequest() { @@ -34,13 +36,13 @@ export function useClusters(clusterUuid?: string | null, ccs?: any, codePaths?: setClusters(response); } } catch (e) { - // TODO: Handle errors + handleRequestError(e); } finally { setLoaded(true); } } makeRequest(); - }, [clusterUuid, ccs, services.http, codePaths, min, max]); + }, [handleRequestError, clusterUuid, ccs, services.http, codePaths, min, max]); return { clusters, loaded }; } diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_monitoring_time.ts b/x-pack/plugins/monitoring/public/application/hooks/use_monitoring_time.ts index 3054714ec3aa6..e8973ce18232c 100644 --- a/x-pack/plugins/monitoring/public/application/hooks/use_monitoring_time.ts +++ b/x-pack/plugins/monitoring/public/application/hooks/use_monitoring_time.ts @@ -8,7 +8,7 @@ import { useCallback, useState, useContext, useEffect } from 'react'; import createContainer from 'constate'; import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; import { Legacy } from '../../legacy_shims'; -import { GlobalStateContext } from '../../application/global_state_context'; +import { GlobalStateContext } from '../contexts/global_state_context'; interface TimeOptions { from: string; diff --git a/x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.tsx b/x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.tsx new file mode 100644 index 0000000000000..3a64531844451 --- /dev/null +++ b/x-pack/plugins/monitoring/public/application/hooks/use_request_error_handler.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import React, { useCallback } from 'react'; +import { includes } from 'lodash'; +import { IHttpFetchError } from 'kibana/public'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiSpacer, EuiText } from '@elastic/eui'; +import { formatMsg } from '../../../../../../src/plugins/kibana_legacy/public'; +import { toMountPoint, useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { MonitoringStartPluginDependencies } from '../../types'; + +export function formatMonitoringError(err: IHttpFetchError) { + if (err.response?.status && err.response?.status !== -1) { + return ( + +

{err.body?.message}

+ + + +
+ ); + } + + return formatMsg(err); +} + +export const useRequestErrorHandler = () => { + const { services } = useKibana(); + return useCallback( + (err: IHttpFetchError) => { + if (err.response?.status === 403) { + // redirect to error message view + history.replaceState(null, '', '#/access-denied'); + } else if (err.response?.status === 404 && !includes(window.location.hash, 'no-data')) { + // pass through if this is a 404 and we're already on the no-data page + const formattedError = formatMonitoringError(err); + services.notifications?.toasts.addDanger({ + title: toMountPoint( + + ), + text: toMountPoint( +
+ {formattedError} + + window.location.reload()}> + + +
+ ), + }); + } else { + services.notifications?.toasts.addDanger({ + title: toMountPoint( + + ), + text: toMountPoint(formatMonitoringError(err)), + }); + } + }, + [services.notifications] + ); +}; diff --git a/x-pack/plugins/monitoring/public/application/index.tsx b/x-pack/plugins/monitoring/public/application/index.tsx index bc81dd826f849..7b4c73475338f 100644 --- a/x-pack/plugins/monitoring/public/application/index.tsx +++ b/x-pack/plugins/monitoring/public/application/index.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import { CoreStart, AppMountParameters } from 'kibana/public'; +import { CoreStart, AppMountParameters, MountPoint } from 'kibana/public'; import React from 'react'; import ReactDOM from 'react-dom'; import { Route, Switch, Redirect, Router } from 'react-router-dom'; @@ -15,8 +15,8 @@ import { LicensePage } from './pages/license_page'; import { ClusterOverview } from './pages/cluster/overview_page'; import { ClusterListing } from './pages/home/cluster_listing'; import { MonitoringStartPluginDependencies } from '../types'; -import { GlobalStateProvider } from './global_state_context'; -import { ExternalConfigContext, ExternalConfig } from './external_config_context'; +import { GlobalStateProvider } from './contexts/global_state_context'; +import { ExternalConfigContext, ExternalConfig } from './contexts/external_config_context'; import { createPreserveQueryHistory } from './preserve_query_history'; import { RouteInit } from './route_init'; import { NoDataPage } from './pages/no_data'; @@ -45,6 +45,7 @@ import { ElasticsearchCcrPage } from './pages/elasticsearch/ccr_page'; import { ElasticsearchCcrShardPage } from './pages/elasticsearch/ccr_shard_page'; import { MonitoringTimeContainer } from './hooks/use_monitoring_time'; import { BreadcrumbContainer } from './hooks/use_breadcrumbs'; +import { HeaderActionMenuContext } from './contexts/header_action_menu_context'; import { LogStashOverviewPage } from './pages/logstash/overview'; import { LogStashNodesPage } from './pages/logstash/nodes'; import { LogStashPipelinesPage } from './pages/logstash/pipelines'; @@ -58,11 +59,16 @@ import { LogStashNodePipelinesPage } from './pages/logstash/node_pipelines'; export const renderApp = ( core: CoreStart, plugins: MonitoringStartPluginDependencies, - { element }: AppMountParameters, + { element, setHeaderActionMenu }: AppMountParameters, externalConfig: ExternalConfig ) => { ReactDOM.render( - , + , element ); @@ -75,236 +81,239 @@ const MonitoringApp: React.FC<{ core: CoreStart; plugins: MonitoringStartPluginDependencies; externalConfig: ExternalConfig; -}> = ({ core, plugins, externalConfig }) => { + setHeaderActionMenu: (element: MountPoint | undefined) => void; +}> = ({ core, plugins, externalConfig, setHeaderActionMenu }) => { const history = createPreserveQueryHistory(); return ( - - - - - - - - - - - {/* ElasticSearch Views */} - - - - - - - - - - - - - - - - - - - - - {/* Kibana Views */} - - - - - - - {/* Beats Views */} - - - - - - - {/* Logstash Routes */} - - - - - - - - - - - - - - - {/* APM Views */} - - - - - - - - - - - + + + + + + + + + + + + {/* ElasticSearch Views */} + + + + + + + + + + + + + + + + + + + + + {/* Kibana Views */} + + + + + + + {/* Beats Views */} + + + + + + + {/* Logstash Routes */} + + + + + + + + + + + + + + + {/* APM Views */} + + + + + + + + + + + + diff --git a/x-pack/plugins/monitoring/public/application/pages/apm/instance.tsx b/x-pack/plugins/monitoring/public/application/pages/apm/instance.tsx index dc55ecb22b61a..3fa7819c5e417 100644 --- a/x-pack/plugins/monitoring/public/application/pages/apm/instance.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/apm/instance.tsx @@ -10,7 +10,7 @@ import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useCharts } from '../../hooks/use_charts'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; diff --git a/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx index bc60f26cdbfad..fedb07fa65a40 100644 --- a/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/apm/instances.tsx @@ -9,7 +9,7 @@ import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useTable } from '../../hooks/use_table'; import { ApmTemplate } from './apm_template'; diff --git a/x-pack/plugins/monitoring/public/application/pages/apm/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/apm/overview.tsx index cca31c0a7e65d..516c293c53546 100644 --- a/x-pack/plugins/monitoring/public/application/pages/apm/overview.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/apm/overview.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; import { ApmTemplate } from './apm_template'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useCharts } from '../../hooks/use_charts'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx index f7ff03898fda6..4c66bbba631fb 100644 --- a/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/beats/instance.tsx @@ -10,7 +10,7 @@ import { useParams } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useCharts } from '../../hooks/use_charts'; // @ts-ignore diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx index 18f941c398af0..489ad110c40fd 100644 --- a/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/beats/instances.tsx @@ -9,7 +9,7 @@ import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useTable } from '../../hooks/use_table'; import { BeatsTemplate } from './beats_template'; diff --git a/x-pack/plugins/monitoring/public/application/pages/beats/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/beats/overview.tsx index 8d28119c4ec1b..1fa37a2c7b3e6 100644 --- a/x-pack/plugins/monitoring/public/application/pages/beats/overview.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/beats/overview.tsx @@ -10,7 +10,7 @@ import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; import { BeatsTemplate } from './beats_template'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useCharts } from '../../hooks/use_charts'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; // @ts-ignore diff --git a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx index 3a717036396e9..b78df27cd12c4 100644 --- a/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/cluster/overview_page.tsx @@ -10,14 +10,17 @@ import { i18n } from '@kbn/i18n'; import { CODE_PATH_ALL } from '../../../../common/constants'; import { PageTemplate } from '../page_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { TabMenuItem } from '../page_template'; import { Overview } from '../../../components/cluster/overview'; -import { ExternalConfigContext } from '../../external_config_context'; +import { ExternalConfigContext } from '../../contexts/external_config_context'; import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; import { fetchClusters } from '../../../lib/fetch_clusters'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { EnableAlertsModal } from '../../../alerts/enable_alerts_modal'; const CODE_PATHS = [CODE_PATH_ALL]; @@ -28,6 +31,7 @@ export const ClusterOverview: React.FC<{}> = () => { const clusterUuid = state.cluster_uuid; const ccs = state.ccs; const [clusters, setClusters] = useState([] as any); + const [alerts, setAlerts] = useState({}); const [loaded, setLoaded] = useState(false); const { generate: generateBreadcrumbs } = useContext(BreadcrumbContainer.Context); @@ -54,23 +58,27 @@ export const ClusterOverview: React.FC<{}> = () => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); - try { - if (services.http?.fetch) { - const response = await fetchClusters({ - fetch: services.http.fetch, - timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), - }, - ccs, - clusterUuid, - codePaths: CODE_PATHS, - }); - setClusters(response); - } - } catch (err) { - // TODO: handle errors - } finally { + if (services.http?.fetch && clusterUuid) { + const response = await fetchClusters({ + fetch: services.http.fetch, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + ccs, + clusterUuid, + codePaths: CODE_PATHS, + }); + setClusters(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + clusterUuid, + timeRange: { + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), + }, + }); + setAlerts(alertsResponse); setLoaded(true); } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); @@ -89,7 +97,7 @@ export const ClusterOverview: React.FC<{}> = () => { {flyoutComponent} @@ -98,6 +106,7 @@ export const ClusterOverview: React.FC<{}> = () => { )} /> + ); }; diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx index 294aeade5e38b..8a9a736286c3f 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_page.tsx @@ -9,13 +9,15 @@ import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ElasticsearchTemplate } from './elasticsearch_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; // @ts-ignore import { Ccr } from '../../../components/elasticsearch/ccr'; import { ComponentProps } from '../../route_init'; import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { ELASTICSEARCH_SYSTEM_ID } from '../../../../common/constants'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { ELASTICSEARCH_SYSTEM_ID, RULE_CCR_READ_EXCEPTIONS } from '../../../../common/constants'; interface SetupModeProps { setupMode: any; @@ -33,6 +35,7 @@ export const ElasticsearchCcrPage: React.FC = ({ clusters }) => }) as any; const ccs = globalState.ccs; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.ccr.title', { defaultMessage: 'Elasticsearch - Ccr', @@ -46,18 +49,30 @@ export const ElasticsearchCcrPage: React.FC = ({ clusters }) => const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/ccr`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_CCR_READ_EXCEPTIONS], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); return ( @@ -73,7 +88,7 @@ export const ElasticsearchCcrPage: React.FC = ({ clusters }) => render={({ flyoutComponent, bottomBarComponent }: SetupModeProps) => ( {flyoutComponent} - + {bottomBarComponent} )} diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx index bec2f278f1774..21f9fd10f0806 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/ccr_shard_page.tsx @@ -10,13 +10,15 @@ import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import { PageTemplate } from '../page_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; // @ts-ignore import { CcrShardReact } from '../../../components/elasticsearch/ccr_shard'; import { ComponentProps } from '../../route_init'; import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; -import { ELASTICSEARCH_SYSTEM_ID } from '../../../../common/constants'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { ELASTICSEARCH_SYSTEM_ID, RULE_CCR_READ_EXCEPTIONS } from '../../../../common/constants'; interface SetupModeProps { setupMode: any; @@ -24,7 +26,7 @@ interface SetupModeProps { bottomBarComponent: any; } -export const ElasticsearchCcrShardPage: React.FC = ({ clusters }) => { +export const ElasticsearchCcrShardPage: React.FC = () => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); const { index, shardId }: { index: string; shardId: string } = useParams(); @@ -32,6 +34,7 @@ export const ElasticsearchCcrShardPage: React.FC = ({ clusters } const clusterUuid = globalState.cluster_uuid; const ccs = globalState.ccs; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.ccr.shard.title', { defaultMessage: 'Elasticsearch - Ccr - Shard', @@ -57,18 +60,34 @@ export const ElasticsearchCcrShardPage: React.FC = ({ clusters } const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/ccr/${index}/shard/${shardId}`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_CCR_READ_EXCEPTIONS], + clusterUuid, + filters: [ + { + shardId, + }, + ], timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http, index, shardId]); return ( @@ -84,7 +103,7 @@ export const ElasticsearchCcrShardPage: React.FC = ({ clusters } render={({ flyoutComponent, bottomBarComponent }: SetupModeProps) => ( {flyoutComponent} - + {bottomBarComponent} )} diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx index a635d98fcbbb0..86dba4e2f921c 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/index_advanced_page.tsx @@ -8,7 +8,7 @@ import React, { useContext, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { useParams } from 'react-router-dom'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; @@ -16,15 +16,18 @@ import { useCharts } from '../../hooks/use_charts'; import { ItemTemplate } from './item_template'; // @ts-ignore import { AdvancedIndex } from '../../../components/elasticsearch/index/advanced'; -import { ELASTICSEARCH_SYSTEM_ID } from '../../../../common/constants'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { ELASTICSEARCH_SYSTEM_ID, RULE_LARGE_SHARD_SIZE } from '../../../../common/constants'; -export const ElasticsearchIndexAdvancedPage: React.FC = ({ clusters }) => { +export const ElasticsearchIndexAdvancedPage: React.FC = () => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); const { index }: { index: string } = useParams(); const { zoomInfo, onBrush } = useCharts(); const clusterUuid = globalState.cluster_uuid; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.index.advanced.title', { defaultMessage: 'Elasticsearch - Indices - {indexName} - Advanced', @@ -36,17 +39,34 @@ export const ElasticsearchIndexAdvancedPage: React.FC = ({ clust const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices/${index}`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: true, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_LARGE_SHARD_SIZE], + filters: [ + { + shardIndex: index, + }, + ], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: true, - }), - }); - setData(response); + }); + setAlerts(alertsResponse); + } }, [clusterUuid, services.data?.query.timefilter.timefilter, services.http, index]); return ( @@ -58,7 +78,7 @@ export const ElasticsearchIndexAdvancedPage: React.FC = ({ clust {flyoutComponent} = ({ clusters }) => { +export const ElasticsearchIndexPage: React.FC = () => { const globalState = useContext(GlobalStateContext); const { services } = useKibana<{ data: any }>(); const { index }: { index: string } = useParams(); @@ -31,6 +33,7 @@ export const ElasticsearchIndexPage: React.FC = ({ clusters }) = const [data, setData] = useState({} as any); const [indexLabel, setIndexLabel] = useState(labels.index as any); const [nodesByIndicesData, setNodesByIndicesData] = useState([]); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.index.overview.title', { defaultMessage: 'Elasticsearch - Indices - {indexName} - Overview', @@ -49,23 +52,40 @@ export const ElasticsearchIndexPage: React.FC = ({ clusters }) = const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices/${index}`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: false, + }), + }); + setData(response); + const transformer = indicesByNodes(); + setNodesByIndicesData(transformer(response.shards, response.nodes)); + + const shards = response.shards; + if (shards.some((shard: any) => shard.state === 'UNASSIGNED')) { + setIndexLabel(labels.indexWithUnassigned); + } + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_LARGE_SHARD_SIZE], + filters: [ + { + shardIndex: index, + }, + ], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: false, - }), - }); - setData(response); - const transformer = indicesByNodes(); - setNodesByIndicesData(transformer(response.shards, response.nodes)); - - const shards = response.shards; - if (shards.some((shard: any) => shard.state === 'UNASSIGNED')) { - setIndexLabel(labels.indexWithUnassigned); + }); + setAlerts(alertsResponse); } }, [clusterUuid, services.data?.query.timefilter.timefilter, services.http, index]); @@ -85,7 +105,7 @@ export const ElasticsearchIndexPage: React.FC = ({ clusters }) = = ({ clusters }) => { const globalState = useContext(GlobalStateContext); @@ -32,6 +34,7 @@ export const ElasticsearchIndicesPage: React.FC = ({ clusters }) 'showSystemIndices', false ); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.indices.routeTitle', { defaultMessage: 'Elasticsearch - Indices', @@ -49,26 +52,38 @@ export const ElasticsearchIndicesPage: React.FC = ({ clusters }) const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/indices`; - const response = await services.http?.fetch(url, { - method: 'POST', - query: { - show_system_indices: showSystemIndices, - }, - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + query: { + show_system_indices: showSystemIndices, + }, + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + clusterUuid, + alertTypeIds: [RULE_LARGE_SHARD_SIZE], timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - setData(response); + }); + setAlerts(alertsResponse); + } }, [ - ccs, - showSystemIndices, - clusterUuid, services.data?.query.timefilter.timefilter, services.http, + clusterUuid, + showSystemIndices, + ccs, ]); return ( @@ -88,7 +103,7 @@ export const ElasticsearchIndicesPage: React.FC = ({ clusters }) = ({ clusters }) => { +export const ElasticsearchNodeAdvancedPage: React.FC = () => { const globalState = useContext(GlobalStateContext); const { zoomInfo, onBrush } = useCharts(); @@ -25,6 +35,7 @@ export const ElasticsearchNodeAdvancedPage: React.FC = ({ cluste const clusterUuid = globalState.cluster_uuid; const ccs = globalState.ccs; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.node.advanced.title', { defaultMessage: 'Elasticsearch - Nodes - {nodeName} - Advanced', @@ -43,20 +54,42 @@ export const ElasticsearchNodeAdvancedPage: React.FC = ({ cluste const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/nodes/${node}`; - - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: true, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + clusterUuid, + alertTypeIds: [ + RULE_CPU_USAGE, + RULE_THREAD_POOL_SEARCH_REJECTIONS, + RULE_THREAD_POOL_WRITE_REJECTIONS, + RULE_MISSING_MONITORING_DATA, + RULE_DISK_USAGE, + RULE_MEMORY_USAGE, + ], + filters: [ + { + nodeUuid: node, + }, + ], timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: true, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http, node]); return ( @@ -69,7 +102,7 @@ export const ElasticsearchNodeAdvancedPage: React.FC = ({ cluste > = ({ clusters }) => { +export const ElasticsearchNodePage: React.FC = () => { const globalState = useContext(GlobalStateContext); const { zoomInfo, onBrush } = useCharts(); const [showSystemIndices, setShowSystemIndices] = useLocalStorage( 'showSystemIndices', false ); + const [alerts, setAlerts] = useState({}); const { node }: { node: string } = useParams(); const { services } = useKibana<{ data: any }>(); @@ -54,30 +65,49 @@ export const ElasticsearchNodePage: React.FC = ({ clusters }) => const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/nodes/${node}`; + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + showSystemIndices, + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: false, + }), + }); - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - showSystemIndices, - ccs, + setData(response); + const transformer = nodesByIndices(); + setNodesByIndicesData(transformer(response.shards, response.nodes)); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [ + RULE_CPU_USAGE, + RULE_THREAD_POOL_SEARCH_REJECTIONS, + RULE_THREAD_POOL_WRITE_REJECTIONS, + RULE_MISSING_MONITORING_DATA, + RULE_DISK_USAGE, + RULE_MEMORY_USAGE, + ], + filters: [{ nodeUuid: node }], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: false, - }), - }); - - setData(response); - const transformer = nodesByIndices(); - setNodesByIndicesData(transformer(response.shards, response.nodes)); + }); + setAlerts(alertsResponse); + } }, [ - ccs, - clusterUuid, services.data?.query.timefilter.timefilter, services.http, + clusterUuid, node, showSystemIndices, + ccs, ]); const toggleShowSystemIndices = useCallback(() => { @@ -98,7 +128,7 @@ export const ElasticsearchNodePage: React.FC = ({ clusters }) => {flyoutComponent} = ({ clusters }) => { const globalState = useContext(GlobalStateContext); @@ -32,6 +42,7 @@ export const ElasticsearchNodesPage: React.FC = ({ clusters }) = cluster_uuid: clusterUuid, }) as any; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.elasticsearch.nodes.routeTitle', { defaultMessage: 'Elasticsearch - Nodes', @@ -52,25 +63,44 @@ export const ElasticsearchNodesPage: React.FC = ({ clusters }) = const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/elasticsearch/nodes`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + ...getPaginationRouteOptions(), + }), + }); + + setData(response); + updateTotalItemCount(response.totalNodeCount); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + clusterUuid, + alertTypeIds: [ + RULE_CPU_USAGE, + RULE_DISK_USAGE, + RULE_THREAD_POOL_SEARCH_REJECTIONS, + RULE_THREAD_POOL_WRITE_REJECTIONS, + RULE_MEMORY_USAGE, + RULE_MISSING_MONITORING_DATA, + ], timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - ...getPaginationRouteOptions(), - }), - }); - - setData(response); - updateTotalItemCount(response.totalNodeCount); + }); + setAlerts(alertsResponse); + } }, [ - ccs, - clusterUuid, services.data?.query.timefilter.timefilter, services.http, + clusterUuid, + ccs, getPaginationRouteOptions, updateTotalItemCount, ]); @@ -94,7 +124,7 @@ export const ElasticsearchNodesPage: React.FC = ({ clusters }) = clusterUuid={globalState.cluster_uuid} setupMode={setupMode} nodes={data.nodes} - alerts={{}} + alerts={alerts} showCgroupMetricsElasticsearch={showCgroupMetricsElasticsearch} {...getPaginationTableProps()} /> diff --git a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx index 3334c7e7b880a..c58aaa5dffb04 100644 --- a/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/elasticsearch/overview.tsx @@ -9,7 +9,7 @@ import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ElasticsearchTemplate } from './elasticsearch_template'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ElasticsearchOverview } from '../../../components/elasticsearch'; import { ComponentProps } from '../../route_init'; import { useCharts } from '../../hooks/use_charts'; diff --git a/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx b/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx index 906db1b57f0f5..a31f2bc317fa6 100644 --- a/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/home/cluster_listing.tsx @@ -12,8 +12,8 @@ import { useKibana } from '../../../../../../../src/plugins/kibana_react/public' // @ts-ignore import { Listing } from '../../../components/cluster/listing'; import { EnableAlertsModal } from '../../../alerts/enable_alerts_modal'; -import { GlobalStateContext } from '../../global_state_context'; -import { ExternalConfigContext } from '../../external_config_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; +import { ExternalConfigContext } from '../../contexts/external_config_context'; import { ComponentProps } from '../../route_init'; import { useTable } from '../../hooks/use_table'; import { PageTemplate, TabMenuItem } from '../page_template'; @@ -69,23 +69,19 @@ export const ClusterListing: React.FC = () => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); - try { - if (services.http?.fetch) { - const response = await fetchClusters({ - fetch: services.http.fetch, - timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), - }, - ccs: globalState.ccs, - codePaths: ['all'], - }); - setClusters(response); - } - } catch (err) { - // TODO: handle errors + if (services.http?.fetch) { + const response = await fetchClusters({ + fetch: services.http.fetch, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + ccs: globalState.ccs, + codePaths: ['all'], + }); + setClusters(response); } - }, [globalState, services.data?.query.timefilter.timefilter, services.http]); + }, [globalState.ccs, services.data?.query.timefilter.timefilter, services.http]); if (globalState.save && clusters.length === 1) { globalState.cluster_uuid = clusters[0].cluster_uuid; diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx index 8b88fc47a9007..444794d118b0f 100644 --- a/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/instance.tsx @@ -19,7 +19,7 @@ import { EuiPanel, } from '@elastic/eui'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useCharts } from '../../hooks/use_charts'; // @ts-ignore @@ -30,6 +30,9 @@ import { MonitoringTimeseriesContainer } from '../../../components/chart'; import { DetailStatus } from '../../../components/kibana/detail_status'; import { PageTemplate } from '../page_template'; import { AlertsCallout } from '../../../alerts/callout'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { RULE_KIBANA_VERSION_MISMATCH } from '../../../../common/constants'; const KibanaInstance = ({ data, alerts }: { data: any; alerts: any }) => { const { zoomInfo, onBrush } = useCharts(); @@ -112,6 +115,7 @@ export const KibanaInstancePage: React.FC = ({ clusters }) => { }) as any; const [data, setData] = useState({} as any); const [instanceName, setInstanceName] = useState(''); + const [alerts, setAlerts] = useState({}); const title = `Kibana - ${instanceName}`; const pageTitle = i18n.translate('xpack.monitoring.kibana.instance.pageTitle', { @@ -133,19 +137,30 @@ export const KibanaInstancePage: React.FC = ({ clusters }) => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/kibana/${instance}`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + setData(response); + setInstanceName(response.kibanaSummary.name); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_KIBANA_VERSION_MISMATCH], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - - setData(response); - setInstanceName(response.kibanaSummary.name); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, instance, services.data?.query.timefilter.timefilter, services.http]); return ( @@ -156,7 +171,7 @@ export const KibanaInstancePage: React.FC = ({ clusters }) => { data-test-subj="kibanaInstancePage" >
- +
); diff --git a/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx b/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx index 436a1a72b2fdb..ae0237ea40472 100644 --- a/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/kibana/instances.tsx @@ -9,7 +9,7 @@ import React, { useContext, useState, useCallback, useEffect } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { ComponentProps } from '../../route_init'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { useTable } from '../../hooks/use_table'; import { KibanaTemplate } from './kibana_template'; @@ -19,7 +19,9 @@ import { KibanaInstances } from '../../../components/kibana/instances'; import { SetupModeRenderer, SetupModeProps } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; -import { KIBANA_SYSTEM_ID } from '../../../../common/constants'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { KIBANA_SYSTEM_ID, RULE_KIBANA_VERSION_MISMATCH } from '../../../../common/constants'; export const KibanaInstancesPage: React.FC = ({ clusters }) => { const { cluster_uuid: clusterUuid, ccs } = useContext(GlobalStateContext); @@ -30,6 +32,7 @@ export const KibanaInstancesPage: React.FC = ({ clusters }) => { cluster_uuid: clusterUuid, }) as any; const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.kibana.instances.routeTitle', { defaultMessage: 'Kibana - Instances', @@ -50,19 +53,31 @@ export const KibanaInstancesPage: React.FC = ({ clusters }) => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/kibana/instances`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + updateTotalItemCount(response.kibanas.length); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_KIBANA_VERSION_MISMATCH], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - - setData(response); - updateTotalItemCount(response.stats.total); + }); + setAlerts(alertsResponse); + } }, [ ccs, clusterUuid, @@ -85,7 +100,7 @@ export const KibanaInstancesPage: React.FC = ({ clusters }) => { {flyoutComponent} = ({ clusters }) => { const globalState = useContext(GlobalStateContext); @@ -42,6 +45,7 @@ export const LogStashNodeAdvancedPage: React.FC = ({ clusters }) }); const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const title = i18n.translate('xpack.monitoring.logstash.node.advanced.routeTitle', { defaultMessage: 'Logstash - {nodeName} - Advanced', @@ -60,19 +64,30 @@ export const LogStashNodeAdvancedPage: React.FC = ({ clusters }) const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/node/${match.params.uuid}`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: true, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_LOGSTASH_VERSION_MISMATCH], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: true, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ ccs, clusterUuid, @@ -105,7 +120,7 @@ export const LogStashNodeAdvancedPage: React.FC = ({ clusters }) {data.nodeSummary && } - + {metricsToShow.map((metric, index) => ( diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx index 301d3c45dedb5..1163a619dd84b 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/node.tsx @@ -18,7 +18,7 @@ import { EuiFlexItem, } from '@elastic/eui'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; // @ts-ignore import { List } from '../../../components/logstash/pipeline_viewer/models/list'; @@ -30,6 +30,9 @@ import { DetailStatus } from '../../../components/logstash/detail_status'; import { MonitoringTimeseriesContainer } from '../../../components/chart'; import { AlertsCallout } from '../../../alerts/callout'; import { useCharts } from '../../hooks/use_charts'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; +import { RULE_LOGSTASH_VERSION_MISMATCH } from '../../../../common/constants'; export const LogStashNodePage: React.FC = ({ clusters }) => { const match = useRouteMatch<{ uuid: string | undefined }>(); @@ -41,6 +44,7 @@ export const LogStashNodePage: React.FC = ({ clusters }) => { cluster_uuid: clusterUuid, }); const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const { zoomInfo, onBrush } = useCharts(); const title = i18n.translate('xpack.monitoring.logstash.node.routeTitle', { defaultMessage: 'Logstash - {nodeName}', @@ -59,19 +63,30 @@ export const LogStashNodePage: React.FC = ({ clusters }) => { const getPageData = useCallback(async () => { const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/node/${match.params.uuid}`; const bounds = services.data?.query.timefilter.timefilter.getBounds(); - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + is_advanced: false, + }), + }); + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_LOGSTASH_VERSION_MISMATCH], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - is_advanced: false, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http, match.params]); const metricsToShow = useMemo(() => { @@ -99,7 +114,7 @@ export const LogStashNodePage: React.FC = ({ clusters }) => { {data.nodeSummary && } - + {metricsToShow.map((metric, index) => ( diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx index 1c956603f99bd..e09850eaad5c9 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/node_pipelines.tsx @@ -12,7 +12,7 @@ import { useRouteMatch } from 'react-router-dom'; // @ts-ignore import { isPipelineMonitoringSupportedInVersion } from '../../../lib/logstash/pipelines'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; // @ts-ignore import { Listing } from '../../../components/logstash/listing'; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx index 09a97925c56f5..0fd10a93bcd83 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/nodes.tsx @@ -8,7 +8,7 @@ import React, { useContext, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; // @ts-ignore import { Listing } from '../../../components/logstash/listing'; @@ -16,7 +16,9 @@ import { LogstashTemplate } from './logstash_template'; import { SetupModeRenderer } from '../../setup_mode/setup_mode_renderer'; import { SetupModeContext } from '../../../components/setup_mode/setup_mode_context'; import { useTable } from '../../hooks/use_table'; -import { LOGSTASH_SYSTEM_ID } from '../../../../common/constants'; +import { LOGSTASH_SYSTEM_ID, RULE_LOGSTASH_VERSION_MISMATCH } from '../../../../common/constants'; +import { AlertsByName } from '../../../alerts/types'; +import { fetchAlerts } from '../../../lib/fetch_alerts'; interface SetupModeProps { setupMode: any; @@ -33,6 +35,7 @@ export const LogStashNodesPage: React.FC = ({ clusters }) => { cluster_uuid: clusterUuid, }); const [data, setData] = useState({} as any); + const [alerts, setAlerts] = useState({}); const { getPaginationTableProps } = useTable('logstash.nodes'); const title = i18n.translate('xpack.monitoring.logstash.nodes.routeTitle', { @@ -46,18 +49,30 @@ export const LogStashNodesPage: React.FC = ({ clusters }) => { const getPageData = useCallback(async () => { const bounds = services.data?.query.timefilter.timefilter.getBounds(); const url = `../api/monitoring/v1/clusters/${clusterUuid}/logstash/nodes`; - const response = await services.http?.fetch(url, { - method: 'POST', - body: JSON.stringify({ - ccs, + if (services.http?.fetch && clusterUuid) { + const response = await services.http?.fetch(url, { + method: 'POST', + body: JSON.stringify({ + ccs, + timeRange: { + min: bounds.min.toISOString(), + max: bounds.max.toISOString(), + }, + }), + }); + + setData(response); + const alertsResponse = await fetchAlerts({ + fetch: services.http.fetch, + alertTypeIds: [RULE_LOGSTASH_VERSION_MISMATCH], + clusterUuid, timeRange: { - min: bounds.min.toISOString(), - max: bounds.max.toISOString(), + min: bounds.min.valueOf(), + max: bounds.max.valueOf(), }, - }), - }); - - setData(response); + }); + setAlerts(alertsResponse); + } }, [ccs, clusterUuid, services.data?.query.timefilter.timefilter, services.http]); return ( @@ -78,6 +93,7 @@ export const LogStashNodesPage: React.FC = ({ clusters }) => { metrics={data.metrics} data={data.nodes} setupMode={setupMode} + alerts={alerts} {...getPaginationTableProps()} /> {bottomBarComponent} diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/overview.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/overview.tsx index 1edbe5cf71e7d..339b9e9395569 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/overview.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/overview.tsx @@ -8,7 +8,7 @@ import React, { useContext, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; import { useCharts } from '../../hooks/use_charts'; // @ts-ignore diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx index abff0ab17b992..20f1caee2b1d8 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/pipeline.tsx @@ -10,7 +10,7 @@ import { find } from 'lodash'; import moment from 'moment'; import { useRouteMatch } from 'react-router-dom'; import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; // @ts-ignore import { List } from '../../../components/logstash/pipeline_viewer/models/list'; @@ -24,7 +24,7 @@ import { PipelineState } from '../../../components/logstash/pipeline_viewer/mode import { vertexFactory } from '../../../components/logstash/pipeline_viewer/models/graph/vertex_factory'; import { LogstashTemplate } from './logstash_template'; import { useTable } from '../../hooks/use_table'; -import { ExternalConfigContext } from '../../external_config_context'; +import { ExternalConfigContext } from '../../contexts/external_config_context'; import { formatTimestampToDuration } from '../../../../common'; import { CALCULATE_DURATION_SINCE } from '../../../../common/constants'; import { getSafeForExternalLink } from '../../../lib/get_safe_for_external_link'; diff --git a/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx b/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx index 5f4fe634177de..ac750ff81ddaa 100644 --- a/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/logstash/pipelines.tsx @@ -8,7 +8,7 @@ import React, { useContext, useState, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { find } from 'lodash'; import { useKibana, useUiSetting } from '../../../../../../../src/plugins/kibana_react/public'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; import { ComponentProps } from '../../route_init'; import { useCharts } from '../../hooks/use_charts'; // @ts-ignore diff --git a/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx b/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx index b05bd783b2ff2..26072f53f4752 100644 --- a/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/no_data/no_data_page.tsx @@ -18,7 +18,8 @@ import { Legacy } from '../../../legacy_shims'; import { Enabler } from './enabler'; import { BreadcrumbContainer } from '../../hooks/use_breadcrumbs'; import { initSetupModeState } from '../../setup_mode/setup_mode'; -import { GlobalStateContext } from '../../global_state_context'; +import { GlobalStateContext } from '../../contexts/global_state_context'; +import { useRequestErrorHandler } from '../../hooks/use_request_error_handler'; const CODE_PATHS = [CODE_PATH_LICENSE]; @@ -77,7 +78,8 @@ export const NoDataPage = () => { ]); const globalState = useContext(GlobalStateContext); - initSetupModeState(globalState, services.http); + const handleRequestError = useRequestErrorHandler(); + initSetupModeState(globalState, services.http, handleRequestError); // From x-pack/plugins/monitoring/public/views/no_data/model_updater.js const updateModel = useCallback( diff --git a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx index 927c464552087..5c030814d9cdf 100644 --- a/x-pack/plugins/monitoring/public/application/pages/page_template.tsx +++ b/x-pack/plugins/monitoring/public/application/pages/page_template.tsx @@ -6,8 +6,9 @@ */ import { EuiTab, EuiTabs } from '@elastic/eui'; -import React, { useContext, useState, useEffect } from 'react'; +import React, { useContext, useState, useEffect, useCallback } from 'react'; import { useHistory } from 'react-router-dom'; +import { IHttpFetchError } from 'kibana/public'; import { useTitle } from '../hooks/use_title'; import { MonitoringToolbar } from '../../components/shared/toolbar'; import { MonitoringTimeContainer } from '../hooks/use_monitoring_time'; @@ -18,6 +19,9 @@ import { updateSetupModeData, } from '../setup_mode/setup_mode'; import { SetupModeFeature } from '../../../common/enums'; +import { AlertsDropdown } from '../../alerts/alerts_dropdown'; +import { ActionMenu } from '../../components/action_menu'; +import { useRequestErrorHandler } from '../hooks/use_request_error_handler'; export interface TabMenuItem { id: string; @@ -46,34 +50,52 @@ export const PageTemplate: React.FC = ({ const { currentTimerange } = useContext(MonitoringTimeContainer.Context); const [loaded, setLoaded] = useState(false); const history = useHistory(); + const [hasError, setHasError] = useState(false); + const handleRequestError = useRequestErrorHandler(); + + const getPageDataResponseHandler = useCallback( + (result: any) => { + setHasError(false); + return result; + }, + [setHasError] + ); useEffect(() => { getPageData?.() - .catch((err) => { - // TODO: handle errors + .then(getPageDataResponseHandler) + .catch((err: IHttpFetchError) => { + handleRequestError(err); + setHasError(true); }) .finally(() => { setLoaded(true); }); - }, [getPageData, currentTimerange]); + }, [getPageData, currentTimerange, getPageDataResponseHandler, handleRequestError]); const onRefresh = () => { - const requests = [getPageData?.()]; + getPageData?.().then(getPageDataResponseHandler).catch(handleRequestError); + if (isSetupModeFeatureEnabled(SetupModeFeature.MetricbeatMigration)) { - requests.push(updateSetupModeData()); + updateSetupModeData(); } - - Promise.allSettled(requests).then((results) => { - // TODO: handle errors - }); }; const createHref = (route: string) => history.createHref({ pathname: route }); const isTabSelected = (route: string) => history.location.pathname === route; + const renderContent = () => { + if (hasError) return null; + if (getPageData && !loaded) return ; + return children; + }; + return (
+ + + {tabs && ( @@ -93,7 +115,7 @@ export const PageTemplate: React.FC = ({ })} )} -
{!getPageData ? children : loaded ? children : }
+
{renderContent()}
); }; diff --git a/x-pack/plugins/monitoring/public/application/route_init.tsx b/x-pack/plugins/monitoring/public/application/route_init.tsx index 8a9a906dbd563..8a11df3de50ae 100644 --- a/x-pack/plugins/monitoring/public/application/route_init.tsx +++ b/x-pack/plugins/monitoring/public/application/route_init.tsx @@ -7,7 +7,7 @@ import React, { useContext } from 'react'; import { Route, Redirect, useLocation } from 'react-router-dom'; import { useClusters } from './hooks/use_clusters'; -import { GlobalStateContext } from './global_state_context'; +import { GlobalStateContext } from './contexts/global_state_context'; import { getClusterFromClusters } from '../lib/get_cluster_from_clusters'; export interface ComponentProps { diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx index 70932e5177337..bfdf96ef5b2c1 100644 --- a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx +++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { render } from 'react-dom'; import { get, includes } from 'lodash'; import { i18n } from '@kbn/i18n'; -import { HttpStart } from 'kibana/public'; +import { HttpStart, IHttpFetchError } from 'kibana/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { Legacy } from '../../legacy_shims'; import { SetupModeEnterButton } from '../../components/setup_mode/enter_button'; import { SetupModeFeature } from '../../../common/enums'; import { ISetupModeContext } from '../../components/setup_mode/setup_mode_context'; -import { State as GlobalState } from '../../application/global_state_context'; +import { State as GlobalState } from '../contexts/global_state_context'; function isOnPage(hash: string) { return includes(window.location.hash, hash); @@ -23,6 +23,7 @@ function isOnPage(hash: string) { let globalState: GlobalState; let httpService: HttpStart; +let errorHandler: (error: IHttpFetchError) => void; interface ISetupModeState { enabled: boolean; @@ -65,8 +66,8 @@ export const fetchCollectionData = async (uuid?: string, fetchWithoutClusterUuid }); return response; } catch (err) { - // TODO: handle errors - throw new Error(err); + errorHandler(err); + throw err; } }; @@ -122,8 +123,8 @@ export const disableElasticsearchInternalCollection = async () => { const response = await httpService.post(url); return response; } catch (err) { - // TODO: handle errors - throw new Error(err); + errorHandler(err); + throw err; } }; @@ -161,10 +162,12 @@ export const setSetupModeMenuItem = () => { export const initSetupModeState = async ( state: GlobalState, http: HttpStart, + handleErrors: (error: IHttpFetchError) => void, callback?: () => void ) => { globalState = state; httpService = http; + errorHandler = handleErrors; if (callback) { setupModeState.callback = callback; } diff --git a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js index 337dacd4ecae9..a9ee2464cd423 100644 --- a/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js +++ b/x-pack/plugins/monitoring/public/application/setup_mode/setup_mode_renderer.js @@ -27,8 +27,9 @@ import { import { findNewUuid } from '../../components/renderers/lib/find_new_uuid'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { GlobalStateContext } from '../../application/global_state_context'; +import { GlobalStateContext } from '../../application/contexts/global_state_context'; import { withKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { useRequestErrorHandler } from '../hooks/use_request_error_handler'; class WrappedSetupModeRenderer extends React.Component { globalState; @@ -42,8 +43,8 @@ class WrappedSetupModeRenderer extends React.Component { UNSAFE_componentWillMount() { this.globalState = this.context; - const { kibana } = this.props; - initSetupModeState(this.globalState, kibana.services.http, (_oldData) => { + const { kibana, onHttpError } = this.props; + initSetupModeState(this.globalState, kibana.services.http, onHttpError, (_oldData) => { const newState = { renderState: true }; const { productName } = this.props; @@ -213,5 +214,12 @@ class WrappedSetupModeRenderer extends React.Component { } } +function withErrorHandler(Component) { + return function WrappedComponent(props) { + const handleRequestError = useRequestErrorHandler(); + return ; + }; +} + WrappedSetupModeRenderer.contextType = GlobalStateContext; -export const SetupModeRenderer = withKibana(WrappedSetupModeRenderer); +export const SetupModeRenderer = withKibana(withErrorHandler(WrappedSetupModeRenderer)); diff --git a/x-pack/plugins/monitoring/public/components/action_menu/index.tsx b/x-pack/plugins/monitoring/public/components/action_menu/index.tsx new file mode 100644 index 0000000000000..1348ac170395e --- /dev/null +++ b/x-pack/plugins/monitoring/public/components/action_menu/index.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useContext, useEffect } from 'react'; +import { + KibanaContextProvider, + toMountPoint, + useKibana, +} from '../../../../../../src/plugins/kibana_react/public'; +import { HeaderActionMenuContext } from '../../application/contexts/header_action_menu_context'; + +export const ActionMenu: React.FC<{}> = ({ children }) => { + const { services } = useKibana(); + const { setHeaderActionMenu } = useContext(HeaderActionMenuContext); + useEffect(() => { + if (setHeaderActionMenu) { + setHeaderActionMenu((element) => { + const mount = toMountPoint( + {children} + ); + return mount(element); + }); + return () => { + setHeaderActionMenu(undefined); + }; + } + }, [children, setHeaderActionMenu, services]); + + return null; +}; diff --git a/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx b/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx index 32bbdd6ecbeda..6a1ed1dd16f48 100644 --- a/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx +++ b/x-pack/plugins/monitoring/public/components/shared/toolbar.tsx @@ -14,7 +14,7 @@ import { } from '@elastic/eui'; import React, { useContext, useCallback } from 'react'; import { MonitoringTimeContainer } from '../../application/hooks/use_monitoring_time'; -import { GlobalStateContext } from '../../application/global_state_context'; +import { GlobalStateContext } from '../../application/contexts/global_state_context'; import { Legacy } from '../../legacy_shims'; interface MonitoringToolbarProps { diff --git a/x-pack/plugins/monitoring/public/lib/fetch_alerts.ts b/x-pack/plugins/monitoring/public/lib/fetch_alerts.ts new file mode 100644 index 0000000000000..c0ce7ed260889 --- /dev/null +++ b/x-pack/plugins/monitoring/public/lib/fetch_alerts.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import { HttpHandler } from 'kibana/public'; +import { CommonAlertFilter } from '../../common/types/alerts'; +import { AlertsByName } from '../alerts/types'; + +interface FetchAlertsParams { + alertTypeIds?: string[]; + filters?: CommonAlertFilter[]; + timeRange: { min: number; max: number }; + clusterUuid: string; + fetch: HttpHandler; +} + +export const fetchAlerts = async ({ + alertTypeIds, + filters, + timeRange, + clusterUuid, + fetch, +}: FetchAlertsParams): Promise => { + const url = `../api/monitoring/v1/alert/${clusterUuid}/status`; + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify({ + alertTypeIds, + filters, + timeRange, + }), + }); + return response as unknown as AlertsByName; +}; From badc77828ec21960708531e4470952ae2d051040 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Mon, 11 Oct 2021 12:55:06 -0400 Subject: [PATCH 33/33] [Cases][Observability] Do not sync alerts status with case status (#114318) * set sync status according to disable alerts * Adding test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../components/create/form_context.test.tsx | 23 +++++++++++++++++++ .../public/components/create/form_context.tsx | 10 +++++++- .../cases/public/components/create/index.tsx | 2 ++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/cases/public/components/create/form_context.test.tsx b/x-pack/plugins/cases/public/components/create/form_context.test.tsx index b988f13ee34ce..b55542499fbe4 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.test.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.test.tsx @@ -239,6 +239,29 @@ describe('Create case', () => { ); }); + it('should set sync alerts to false when the sync setting is passed in as false and alerts are disabled', async () => { + useConnectorsMock.mockReturnValue({ + ...sampleConnectorData, + connectors: connectorsMock, + }); + + const wrapper = mount( + + + + + + + ); + + fillForm(wrapper); + wrapper.find(`[data-test-subj="create-case-submit"]`).first().simulate('click'); + + await waitFor(() => + expect(postCase).toBeCalledWith({ ...sampleData, settings: { syncAlerts: false } }) + ); + }); + it('it should select the default connector set in the configuration', async () => { useCaseConfigureMock.mockImplementation(() => ({ ...useCaseConfigureResponse, diff --git a/x-pack/plugins/cases/public/components/create/form_context.tsx b/x-pack/plugins/cases/public/components/create/form_context.tsx index f59e1822c70be..03d8ec56fb0ae 100644 --- a/x-pack/plugins/cases/public/components/create/form_context.tsx +++ b/x-pack/plugins/cases/public/components/create/form_context.tsx @@ -34,6 +34,7 @@ interface Props { children?: JSX.Element | JSX.Element[]; hideConnectorServiceNowSir?: boolean; onSuccess?: (theCase: Case) => Promise; + syncAlertsDefaultValue?: boolean; } export const FormContext: React.FC = ({ @@ -42,6 +43,7 @@ export const FormContext: React.FC = ({ children, hideConnectorServiceNowSir, onSuccess, + syncAlertsDefaultValue = true, }) => { const { connectors, loading: isLoadingConnectors } = useConnectors(); const owner = useOwnerContext(); @@ -51,7 +53,12 @@ export const FormContext: React.FC = ({ const submitCase = useCallback( async ( - { connectorId: dataConnectorId, fields, syncAlerts = true, ...dataWithoutConnectorId }, + { + connectorId: dataConnectorId, + fields, + syncAlerts = syncAlertsDefaultValue, + ...dataWithoutConnectorId + }, isValid ) => { if (isValid) { @@ -94,6 +101,7 @@ export const FormContext: React.FC = ({ onSuccess, postComment, pushCaseToExternalService, + syncAlertsDefaultValue, ] ); diff --git a/x-pack/plugins/cases/public/components/create/index.tsx b/x-pack/plugins/cases/public/components/create/index.tsx index 7f8b8f664529e..d3eaba1ea0bc4 100644 --- a/x-pack/plugins/cases/public/components/create/index.tsx +++ b/x-pack/plugins/cases/public/components/create/index.tsx @@ -58,6 +58,8 @@ const CreateCaseComponent = ({ caseType={caseType} hideConnectorServiceNowSir={hideConnectorServiceNowSir} onSuccess={onSuccess} + // if we are disabling alerts, then we should not sync alerts + syncAlertsDefaultValue={!disableAlerts} >

Gf@Nj*<=XI0eiIXyCH7m81Z`*>F)aO7nyuEDMC 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 { if (spacesApi && resolveResult.outcome === 'aliasMatch') { // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash const newObjectId = resolveResult.alias_target_id!; // This is always defined if outcome === 'aliasMatch' - const newPath = http.basePath.prepend( - `path/to/this/page/${newObjectId}${window.location.hash}` - ); + const newPath = `/this/page/${newObjectId}${window.location.hash}`; // Use the *local* path within this app (do not include the "/app/appId" prefix) await spacesApi.ui.redirectLegacyUrl(newPath, OBJECT_NOUN); return; } @@ -255,9 +253,7 @@ const getLegacyUrlConflictCallout = () => { // callout with a warning for the user, and provide a way for them to navigate to the other object. const currentObjectId = savedObject.id; const otherObjectId = resolveResult.alias_target_id!; // This is always defined if outcome === 'conflict' - const otherObjectPath = http.basePath.prepend( - `path/to/this/page/${otherObjectId}${window.location.hash}` - ); + const otherObjectPath = `/this/page/${otherObjectId}${window.location.hash}`; // Use the *local* path within this app (do not include the "/app/appId" prefix) return ( <> {spacesApi.ui.components.getLegacyUrlConflict({ @@ -391,6 +387,13 @@ These should be handled on a case-by-case basis at the plugin owner's discretion * Any "secondary" objects on the page may handle the outcomes differently. If the secondary object ID is not important (for example, it just functions as a page anchor), it may make more sense to ignore the different outcomes. If the secondary object _is_ important but it is not directly represented in the UI, it may make more sense to throw a descriptive error when a `'conflict'` outcome is encountered. + - Embeddables should use `spacesApi.ui.components.getEmbeddableLegacyUrlConflict` to render conflict errors: ++ +image::images/sharing-saved-objects-faq-multiple-deep-link-objects-1.png["Sharing Saved Objects embeddable legacy URL conflict"] +Viewing details shows the user how to disable the alias and fix the problem using the +<>: ++ +image::images/sharing-saved-objects-faq-multiple-deep-link-objects-2.png["Sharing Saved Objects embeddable legacy URL conflict (showing details)"] - If the secondary object is resolved by an external service (such as the index pattern service), the service should simply make the full outcome available to consumers. From 30aeb8106c9c9861f1d845b032d90a7c108f3bc6 Mon Sep 17 00:00:00 2001 From: Khristinin Nikita Date: Sun, 10 Oct 2021 20:45:30 +0200 Subject: [PATCH 06/33] Make host card overview space aware (#113983) * Make host card overview space aware * Add cypress test * Move getHostRiskIndex to helpers * Fix cypress test Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../security_solution/common/constants.ts | 2 +- .../overview/risky_hosts_panel.spec.ts | 14 +++++++++++- .../cypress/screens/kibana_navigation.ts | 4 ++++ .../cypress/tasks/api_calls/spaces.ts | 22 +++++++++++++++++++ .../cypress/tasks/kibana_navigation.ts | 12 +++++++++- .../security_solution/public/helpers.test.ts | 8 ++++++- .../security_solution/public/helpers.ts | 13 ++++++++++- .../use_hosts_risk_score.ts | 21 ++++++++++-------- .../plugins/security_solution/public/types.ts | 2 ++ .../es_archives/risky_hosts/data.json | 2 +- .../es_archives/risky_hosts/mappings.json | 6 ++--- 11 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 x-pack/plugins/security_solution/cypress/tasks/api_calls/spaces.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 52c82f57d9ec3..d2120faf09dfb 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -334,7 +334,7 @@ export const ELASTIC_NAME = 'estc'; export const METADATA_TRANSFORM_STATS_URL = `/api/transform/transforms/${METADATA_TRANSFORMS_PATTERN}/_stats`; -export const HOST_RISK_SCORES_INDEX = 'ml_host_risk_score_latest'; +export const RISKY_HOSTS_INDEX_PREFIX = 'ml_host_risk_score_latest_'; export const TRANSFORM_STATES = { ABORTING: 'aborting', diff --git a/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts b/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts index df57f7cc8d050..1c55a38b32495 100644 --- a/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/overview/risky_hosts_panel.spec.ts @@ -17,8 +17,12 @@ import { import { loginAndWaitForPage } from '../../tasks/login'; import { OVERVIEW_URL } from '../../urls/navigation'; import { cleanKibana } from '../../tasks/common'; +import { changeSpace } from '../../tasks/kibana_navigation'; +import { createSpace, removeSpace } from '../../tasks/api_calls/spaces'; import { esArchiverLoad, esArchiverUnload } from '../../tasks/es_archiver'; +const testSpaceName = 'test'; + describe('Risky Hosts Link Panel', () => { before(() => { cleanKibana(); @@ -40,10 +44,12 @@ describe('Risky Hosts Link Panel', () => { describe('enabled module', () => { before(() => { esArchiverLoad('risky_hosts'); + createSpace(testSpaceName); }); after(() => { esArchiverUnload('risky_hosts'); + removeSpace(testSpaceName); }); it('renders disabled dashboard module as expected when there are no hosts in the selected time period', () => { @@ -57,13 +63,19 @@ describe('Risky Hosts Link Panel', () => { cy.get(`${OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 0 hosts'); }); - it('renders dashboard module as expected when there are hosts in the selected time period', () => { + it('renders space aware dashboard module as expected when there are hosts in the selected time period', () => { loginAndWaitForPage(OVERVIEW_URL); cy.get( `${OVERVIEW_RISKY_HOSTS_LINKS} ${OVERVIEW_RISKY_HOSTS_LINKS_WARNING_INNER_PANEL}` ).should('not.exist'); cy.get(`${OVERVIEW_RISKY_HOSTS_VIEW_DASHBOARD_BUTTON}`).should('be.disabled'); cy.get(`${OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 1 host'); + + changeSpace(testSpaceName); + cy.visit(`/s/${testSpaceName}${OVERVIEW_URL}`); + cy.get(`${OVERVIEW_RISKY_HOSTS_VIEW_DASHBOARD_BUTTON}`).should('be.disabled'); + cy.get(`${OVERVIEW_RISKY_HOSTS_TOTAL_EVENT_COUNT}`).should('have.text', 'Showing: 0 hosts'); + cy.get(`${OVERVIEW_RISKY_HOSTS_ENABLE_MODULE_BUTTON}`).should('exist'); }); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts b/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts index 36b870598eff4..c20f4bd054a7c 100644 --- a/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts +++ b/x-pack/plugins/security_solution/cypress/screens/kibana_navigation.ts @@ -25,3 +25,7 @@ export const OVERVIEW_PAGE = export const TIMELINES_PAGE = '[data-test-subj="collapsibleNavGroup-securitySolution"] [title="Timelines"]'; + +export const SPACES_BUTTON = '[data-test-subj="spacesNavSelector"]'; + +export const getGoToSpaceMenuItem = (space: string) => `[data-test-subj="${space}-gotoSpace"]`; diff --git a/x-pack/plugins/security_solution/cypress/tasks/api_calls/spaces.ts b/x-pack/plugins/security_solution/cypress/tasks/api_calls/spaces.ts new file mode 100644 index 0000000000000..cd12fab70a891 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/api_calls/spaces.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. + */ + +export const createSpace = (id: string) => { + cy.request({ + method: 'POST', + url: 'api/spaces/space', + body: { + id, + name: id, + }, + headers: { 'kbn-xsrf': 'cypress-creds' }, + }); +}; + +export const removeSpace = (id: string) => { + cy.request(`/api/spaces/space/${id}`); +}; diff --git a/x-pack/plugins/security_solution/cypress/tasks/kibana_navigation.ts b/x-pack/plugins/security_solution/cypress/tasks/kibana_navigation.ts index 3b3fc0c6da4e4..43630e63ebfe2 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/kibana_navigation.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/kibana_navigation.ts @@ -5,7 +5,11 @@ * 2.0. */ -import { KIBANA_NAVIGATION_TOGGLE } from '../screens/kibana_navigation'; +import { + KIBANA_NAVIGATION_TOGGLE, + SPACES_BUTTON, + getGoToSpaceMenuItem, +} from '../screens/kibana_navigation'; export const navigateFromKibanaCollapsibleTo = (page: string) => { cy.get(page).click(); @@ -14,3 +18,9 @@ export const navigateFromKibanaCollapsibleTo = (page: string) => { export const openKibanaNavigation = () => { cy.get(KIBANA_NAVIGATION_TOGGLE).click(); }; + +export const changeSpace = (space: string) => { + cy.get(`${SPACES_BUTTON}`).click(); + cy.get(getGoToSpaceMenuItem(space)).click(); + cy.get(`[data-test-subj="space-avatar-${space}"]`).should('exist'); +}; diff --git a/x-pack/plugins/security_solution/public/helpers.test.ts b/x-pack/plugins/security_solution/public/helpers.test.ts index eaaf518d486ad..f2e37cd995a4f 100644 --- a/x-pack/plugins/security_solution/public/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/helpers.test.ts @@ -5,7 +5,7 @@ * 2.0. */ -import { parseRoute } from './helpers'; +import { parseRoute, getHostRiskIndex } from './helpers'; describe('public helpers parseRoute', () => { it('should properly parse hash route', () => { @@ -54,3 +54,9 @@ describe('public helpers parseRoute', () => { }); }); }); + +describe('public helpers export getHostRiskIndex', () => { + it('should properly return index if space is specified', () => { + expect(getHostRiskIndex('testName')).toEqual('ml_host_risk_score_latest_testName'); + }); +}); diff --git a/x-pack/plugins/security_solution/public/helpers.ts b/x-pack/plugins/security_solution/public/helpers.ts index 9d842e0c0a128..aba46cffee193 100644 --- a/x-pack/plugins/security_solution/public/helpers.ts +++ b/x-pack/plugins/security_solution/public/helpers.ts @@ -9,7 +9,14 @@ import { isEmpty } from 'lodash/fp'; import { matchPath } from 'react-router-dom'; import { CoreStart } from '../../../../src/core/public'; -import { ALERTS_PATH, APP_ID, EXCEPTIONS_PATH, RULES_PATH, UEBA_PATH } from '../common/constants'; +import { + ALERTS_PATH, + APP_ID, + EXCEPTIONS_PATH, + RULES_PATH, + UEBA_PATH, + RISKY_HOSTS_INDEX_PREFIX, +} from '../common/constants'; import { FactoryQueryTypes, StrategyResponseType, @@ -147,3 +154,7 @@ export const isDetectionsPath = (pathname: string): boolean => { strict: false, }); }; + +export const getHostRiskIndex = (spaceId: string): string => { + return `${RISKY_HOSTS_INDEX_PREFIX}${spaceId}`; +}; diff --git a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts index 75cf51194ab65..af663bb74f54a 100644 --- a/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts +++ b/x-pack/plugins/security_solution/public/overview/containers/overview_risky_host_links/use_hosts_risk_score.ts @@ -12,12 +12,11 @@ import { useDispatch } from 'react-redux'; import { useAppToasts } from '../../../common/hooks/use_app_toasts'; import { useKibana } from '../../../common/lib/kibana'; import { inputsActions } from '../../../common/store/actions'; - -import { HOST_RISK_SCORES_INDEX } from '../../../../common/constants'; import { isIndexNotFoundError } from '../../../common/utils/exceptions'; import { HostsRiskScore } from '../../../../common'; import { useHostsRiskScoreComplete } from './use_hosts_risk_score_complete'; import { useIsExperimentalFeatureEnabled } from '../../../common/hooks/use_experimental_features'; +import { getHostRiskIndex } from '../../../helpers'; export const QUERY_ID = 'host_risk_score'; const noop = () => {}; @@ -50,7 +49,7 @@ export const useHostsRiskScore = ({ const [loading, setLoading] = useState(riskyHostsFeatureEnabled); const { addError } = useAppToasts(); - const { data } = useKibana().services; + const { data, spaces } = useKibana().services; const dispatch = useDispatch(); @@ -99,14 +98,18 @@ export const useHostsRiskScore = ({ useEffect(() => { if (riskyHostsFeatureEnabled && (hostName || timerange)) { - start({ - data, - timerange: timerange ? { to: timerange.to, from: timerange.from, interval: '' } : undefined, - hostName, - defaultIndex: [HOST_RISK_SCORES_INDEX], + spaces.getActiveSpace().then((space) => { + start({ + data, + timerange: timerange + ? { to: timerange.to, from: timerange.from, interval: '' } + : undefined, + hostName, + defaultIndex: [getHostRiskIndex(space.id)], + }); }); } - }, [start, data, timerange, hostName, riskyHostsFeatureEnabled]); + }, [start, data, timerange, hostName, riskyHostsFeatureEnabled, spaces]); if ((!hostName && !timerange) || !riskyHostsFeatureEnabled) { return null; diff --git a/x-pack/plugins/security_solution/public/types.ts b/x-pack/plugins/security_solution/public/types.ts index 3294d95bf909b..1cec87fd35d1f 100644 --- a/x-pack/plugins/security_solution/public/types.ts +++ b/x-pack/plugins/security_solution/public/types.ts @@ -9,6 +9,7 @@ import { CoreStart } from '../../../../src/core/public'; import { HomePublicPluginSetup } from '../../../../src/plugins/home/public'; import { DataPublicPluginStart } from '../../../../src/plugins/data/public'; import { EmbeddableStart } from '../../../../src/plugins/embeddable/public'; +import { SpacesPluginStart } from '../../../plugins/spaces/public'; import { LensPublicStart } from '../../../plugins/lens/public'; import { NewsfeedPublicPluginStart } from '../../../../src/plugins/newsfeed/public'; import { Start as InspectorStart } from '../../../../src/plugins/inspector/public'; @@ -67,6 +68,7 @@ export interface StartPlugins { timelines: TimelinesUIStart; uiActions: UiActionsStart; ml?: MlPluginStart; + spaces: SpacesPluginStart; } export type StartServices = CoreStart & diff --git a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json index 7327f0fc76897..e42a13ab8d8a8 100644 --- a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json +++ b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/data.json @@ -2,7 +2,7 @@ "type":"doc", "value":{ "id":"a4cf452c1e0375c3d4412cb550bd1783358468a3b3b777da4829d72c7d6fb74f", - "index":"ml_host_risk_score_latest", + "index":"ml_host_risk_score_latest_default", "source":{ "@timestamp":"2021-03-10T14:51:05.766Z", "risk_score":21, diff --git a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json index 211c50f6baee2..f71c9cf8ed4c2 100644 --- a/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json +++ b/x-pack/test/security_solution_cypress/es_archives/risky_hosts/mappings.json @@ -1,7 +1,7 @@ { "type": "index", "value": { - "index": "ml_host_risk_score_latest", + "index": "ml_host_risk_score_latest_default", "mappings": { "properties": { "@timestamp": { @@ -40,8 +40,8 @@ "settings": { "index": { "lifecycle": { - "name": "ml_host_risk_score_latest", - "rollover_alias": "ml_host_risk_score_latest" + "name": "ml_host_risk_score_latest_default", + "rollover_alias": "ml_host_risk_score_latest_default" }, "mapping": { "total_fields": { From c73dc234e39f707b386309e95ed742b7bf6559d4 Mon Sep 17 00:00:00 2001 From: Kyle Pollich Date: Sun, 10 Oct 2021 23:47:55 -0400 Subject: [PATCH 07/33] [Fleet] Display upgrade integration button instead of save for upgrades (#114314) * Display upgrade integration button instead of save for upgrades * Skip endpoint tests * Revert "Skip endpoint tests" This reverts commit 3cfd1001716b27cbd7589f24f821e6dc0e86ad99. --- .../edit_package_policy_page/index.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx index 35092cb67f7ef..7a2f46247d14a 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/edit_package_policy_page/index.tsx @@ -584,10 +584,17 @@ export const EditPackagePolicyForm = memo<{ fill data-test-subj="saveIntegration" > - + {isUpgrade ? ( + + ) : ( + + )} From 62658899e98c691224ad65a407724beaf9ada9c3 Mon Sep 17 00:00:00 2001 From: Mikhail Shustov Date: Mon, 11 Oct 2021 10:12:56 +0300 Subject: [PATCH 08/33] Bump hapi to a version with aborted request fix (#114414) * bump hapi to a version with aborted request fix * enable apm functional tests --- package.json | 4 ++-- vars/tasks.groovy | 15 +++++++-------- yarn.lock | 15 ++++++++++----- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 585d6489551bf..705d902d6afef 100644 --- a/package.json +++ b/package.json @@ -117,8 +117,8 @@ "@hapi/boom": "^9.1.4", "@hapi/cookie": "^11.0.2", "@hapi/h2o2": "^9.1.0", - "@hapi/hapi": "^20.2.0", - "@hapi/hoek": "^9.2.0", + "@hapi/hapi": "^20.2.1", + "@hapi/hoek": "^9.2.1", "@hapi/inert": "^6.0.4", "@hapi/wreck": "^17.1.0", "@kbn/ace": "link:bazel-bin/packages/kbn-ace", diff --git a/vars/tasks.groovy b/vars/tasks.groovy index 5c8f133331e55..1842e278282b1 100644 --- a/vars/tasks.groovy +++ b/vars/tasks.groovy @@ -146,14 +146,13 @@ def functionalXpack(Map params = [:]) { } } - //temporarily disable apm e2e test since it's breaking due to a version upgrade. - // whenChanged([ - // 'x-pack/plugins/apm/', - // ]) { - // if (githubPr.isPr()) { - // task(kibanaPipeline.functionalTestProcess('xpack-APMCypress', './test/scripts/jenkins_apm_cypress.sh')) - // } - // } + whenChanged([ + 'x-pack/plugins/apm/', + ]) { + if (githubPr.isPr()) { + task(kibanaPipeline.functionalTestProcess('xpack-APMCypress', './test/scripts/jenkins_apm_cypress.sh')) + } + } whenChanged([ 'x-pack/plugins/uptime/', diff --git a/yarn.lock b/yarn.lock index 66aeb28080311..defff7a1df42c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2968,10 +2968,10 @@ "@hapi/validate" "1.x.x" "@hapi/wreck" "17.x.x" -"@hapi/hapi@^20.2.0": - version "20.2.0" - resolved "https://registry.yarnpkg.com/@hapi/hapi/-/hapi-20.2.0.tgz#bf0eca9cc591e83f3d72d06a998d31be35d044a1" - integrity sha512-yPH/z8KvlSLV8lI4EuId9z595fKKk5n6YA7H9UddWYWsBXMcnCyoFmHtYq0PCV4sNgKLD6QW9e27R9V9Z9aqqw== +"@hapi/hapi@^20.2.1": + version "20.2.1" + resolved "https://registry.yarnpkg.com/@hapi/hapi/-/hapi-20.2.1.tgz#7482bc28757cb4671623a61bdb5ce920bffc8a2f" + integrity sha512-OXAU+yWLwkMfPFic+KITo+XPp6Oxpgc9WUH+pxXWcTIuvWbgco5TC/jS8UDvz+NFF5IzRgF2CL6UV/KLdQYUSQ== dependencies: "@hapi/accept" "^5.0.1" "@hapi/ammo" "^5.0.1" @@ -3001,11 +3001,16 @@ "@hapi/hoek" "9.x.x" "@hapi/validate" "1.x.x" -"@hapi/hoek@9.x.x", "@hapi/hoek@^9.0.0", "@hapi/hoek@^9.0.4", "@hapi/hoek@^9.2.0": +"@hapi/hoek@9.x.x", "@hapi/hoek@^9.0.0", "@hapi/hoek@^9.0.4": version "9.2.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.0.tgz#f3933a44e365864f4dad5db94158106d511e8131" integrity sha512-sqKVVVOe5ivCaXDWivIJYVSaEgdQK9ul7a4Kity5Iw7u9+wBAPbX1RMSnLLmp7O4Vzj0WOWwMAJsTL00xwaNug== +"@hapi/hoek@^9.2.1": + version "9.2.1" + resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.2.1.tgz#9551142a1980503752536b5050fd99f4a7f13b17" + integrity sha512-gfta+H8aziZsm8pZa0vj04KO6biEiisppNgA1kbJvFrrWu9Vm7eaUEy76DIxsuTaWvti5fkJVhllWc6ZTE+Mdw== + "@hapi/inert@^6.0.4": version "6.0.4" resolved "https://registry.yarnpkg.com/@hapi/inert/-/inert-6.0.4.tgz#0544221eabc457110a426818358d006e70ff1f41" From ab7e3e8d39b62709f09366f65dc7abf001cbe9f2 Mon Sep 17 00:00:00 2001 From: Jen Huang Date: Mon, 11 Oct 2021 01:36:24 -0700 Subject: [PATCH 09/33] [Fleet] De-dupe setting kibana.version constraint in get package request to EPR (#114376) --- .../plugins/fleet/server/services/epm/registry/index.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 2dff3f43f3ec1..aa2c3f1d4da3c 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -96,16 +96,12 @@ export async function fetchList(params?: SearchParams): Promise { const registryUrl = getRegistryUrl(); - const kibanaVersion = appContextService.getKibanaVersion().split('-')[0]; // may be x.y.z-SNAPSHOT - const kibanaBranch = appContextService.getKibanaBranch(); const url = new URL( `${registryUrl}/search?package=${packageName}&internal=true&experimental=true` ); - // on master, request all packages regardless of version - if (kibanaVersion && kibanaBranch !== 'master') { - url.searchParams.set('kibana.version', kibanaVersion); - } + setKibanaVersion(url); + const res = await fetchUrl(url.toString()); const searchResults = JSON.parse(res); if (searchResults.length) { From 2dece3d446b9f7233ac13810bc8e2ccb392189f7 Mon Sep 17 00:00:00 2001 From: Giorgos Bamparopoulos Date: Mon, 11 Oct 2021 09:39:49 +0100 Subject: [PATCH 10/33] Update APM queries development doc (#114268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add links to field references and GET requests to the examples * Add troubleshooting info for failed requests * Add data model and running examples section * Add GET requests for query examples * Add `metricset` possible values Co-authored-by: Søren Louv-Jansen * Add transaction based and metric based queries Co-authored-by: Søren Louv-Jansen --- x-pack/plugins/apm/dev_docs/apm_queries.md | 157 +++++++++++++++++---- 1 file changed, 126 insertions(+), 31 deletions(-) diff --git a/x-pack/plugins/apm/dev_docs/apm_queries.md b/x-pack/plugins/apm/dev_docs/apm_queries.md index 7ca28f2e273ca..8508e5a173c85 100644 --- a/x-pack/plugins/apm/dev_docs/apm_queries.md +++ b/x-pack/plugins/apm/dev_docs/apm_queries.md @@ -1,3 +1,9 @@ +# Data model +Elastic APM agents capture different types of information from within their instrumented applications. These are known as events, and can be spans, transactions, errors, or metrics. You can find more information [here](https://www.elastic.co/guide/en/apm/get-started/current/apm-data-model.html). + +# Running examples +You can run the example queries on the [edge cluster](https://edge-oblt.elastic.dev/) or any another cluster that contains APM data. + # Transactions Transactions are stored in two different formats: @@ -34,6 +40,8 @@ A pre-aggregated document where `_doc_count` is the number of transaction events } ``` +You can find all the APM transaction fields [here](https://www.elastic.co/guide/en/apm/server/current/exported-fields-apm-transaction.html). + The decision to use aggregated transactions or not is determined in [`getSearchAggregatedTransactions`](https://github.com/elastic/kibana/blob/a2ac439f56313b7a3fc4708f54a4deebf2615136/x-pack/plugins/apm/server/lib/helpers/aggregated_transactions/index.ts#L53-L79) and then used to specify [the transaction index](https://github.com/elastic/kibana/blob/a2ac439f56313b7a3fc4708f54a4deebf2615136/x-pack/plugins/apm/server/lib/suggestions/get_suggestions.ts#L30-L32) and [the latency field](https://github.com/elastic/kibana/blob/a2ac439f56313b7a3fc4708f54a4deebf2615136/x-pack/plugins/apm/server/lib/alerts/chart_preview/get_transaction_duration.ts#L62-L65) ### Latency @@ -45,6 +53,7 @@ Noteworthy fields: `transaction.duration.us`, `transaction.duration.histogram` #### Transaction-based latency ```json +GET apm-*-transaction-*,traces-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -61,6 +70,7 @@ Noteworthy fields: `transaction.duration.us`, `transaction.duration.histogram` #### Metric-based latency ```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -85,14 +95,64 @@ Throughput is the number of transactions per minute. This can be calculated usin Noteworthy fields: None (based on `doc_count`) -```js +#### Transaction-based throughput + +```json +GET apm-*-transaction-*,traces-apm*/_search?terminate_after=1000 +{ + "size": 0, + "query": { + "bool": { + "filter": [{ "terms": { "processor.event": ["transaction"] } }] + } + }, + "aggs": { + "timeseries": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "60s" + }, + "aggs": { + "throughput": { + "rate": { + "unit": "minute" + } + } + } + } + } +} +``` + + +#### Metric-based throughput + +```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { - // same filters as for latency + "bool": { + "filter": [ + { "terms": { "processor.event": ["metric"] } }, + { "term": { "metricset.name": "transaction" } } + ] + } }, "aggs": { - "throughput": { "rate": { "unit": "minute" } } + "timeseries": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "60s" + }, + "aggs": { + "throughput": { + "rate": { + "unit": "minute" + } + } + } + } } } ``` @@ -102,11 +162,41 @@ Noteworthy fields: None (based on `doc_count`) Failed transaction rate is the number of transactions with `event.outcome=failure` per minute. Noteworthy fields: `event.outcome` -```js +#### Transaction-based failed transaction rate + + ```json +GET apm-*-transaction-*,traces-apm*/_search?terminate_after=1000 { "size": 0, "query": { - // same filters as for latency + "bool": { + "filter": [{ "terms": { "processor.event": ["transaction"] } }] + } + }, + "aggs": { + "outcomes": { + "terms": { + "field": "event.outcome", + "include": ["failure", "success"] + } + } + } +} +``` + +#### Metric-based failed transaction rate + +```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 +{ + "size": 0, + "query": { + "bool": { + "filter": [ + { "terms": { "processor.event": ["metric"] } }, + { "term": { "metricset.name": "transaction" } } + ] + } }, "aggs": { "outcomes": { @@ -121,7 +211,7 @@ Noteworthy fields: `event.outcome` # System metrics -System metrics are captured periodically (every 60 seconds by default). +System metrics are captured periodically (every 60 seconds by default). You can find all the System Metrics fields [here](https://www.elastic.co/guide/en/apm/server/current/exported-fields-system.html). ### CPU @@ -146,6 +236,7 @@ Noteworthy fields: `system.cpu.total.norm.pct`, `system.process.cpu.total.norm.p #### Query ```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -185,18 +276,17 @@ Noteworthy fields: `system.memory.actual.free`, `system.memory.total`, #### Query -```js +```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { "bool": { "filter": [ { "terms": { "processor.event": ["metric"] }}, - { "terms": { "metricset.name": ["app"] }} - - // ensure the memory fields exists + { "terms": { "metricset.name": ["app"] }}, { "exists": { "field": "system.memory.actual.free" }}, - { "exists": { "field": "system.memory.total" }}, + { "exists": { "field": "system.memory.total" }} ] } }, @@ -213,7 +303,7 @@ Noteworthy fields: `system.memory.actual.free`, `system.memory.total`, } ``` -Above example is overly simplified. In reality [we do a bit more](https://github.com/elastic/kibana/blob/fe9b5332e157fd456f81aecfd4ffa78d9e511a66/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts#L51-L71) to properly calculate memory usage inside containers +The above example is overly simplified. In reality [we do a bit more](https://github.com/elastic/kibana/blob/fe9b5332e157fd456f81aecfd4ffa78d9e511a66/x-pack/plugins/apm/server/lib/metrics/by_agent/shared/memory/index.ts#L51-L71) to properly calculate memory usage inside containers. Please note that an [Exists Query](https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-exists-query.html) is used in the filter context in the query to ensure that the memory fields exist. @@ -268,6 +358,7 @@ Noteworthy fields: `transaction.name`, `transaction.type`, `span.type`, `span.su #### Query ```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -330,6 +421,7 @@ A pre-aggregated document with 73 span requests from opbeans-ruby to elasticsear The latency between a service and an (external) endpoint ```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -360,6 +452,7 @@ Captures the number of requests made from a service to an (external) endpoint #### Query ```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "size": 0, "query": { @@ -372,10 +465,17 @@ Captures the number of requests made from a service to an (external) endpoint } }, "aggs": { - "throughput": { - "rate": { - "field": "span.destination.service.response_time.count", - "unit": "minute" + "timeseries": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "60s" + }, + "aggs": { + "throughput": { + "rate": { + "unit": "minute" + } + } } } } @@ -390,27 +490,17 @@ Most Elasticsearch queries will need to have one or more filters. There are a co - stability: Running an aggregation on unrelated documents could cause the entire query to fail - performance: limiting the number of documents will make the query faster -```js +```json +GET apm-*-metric-*,metrics-apm*/_search?terminate_after=1000 { "query": { "bool": { "filter": [ - // service name { "term": { "service.name": "opbeans-go" }}, - - // service environment - { "term": { "service.environment": "testing" }} - - // transaction type - { "term": { "transaction.type": "request" }} - - // event type (possible values : transaction, span, metric, error) + { "term": { "service.environment": "testing" }}, + { "term": { "transaction.type": "request" }}, { "terms": { "processor.event": ["metric"] }}, - - // metric set is a subtype of `processor.event: metric` { "terms": { "metricset.name": ["transaction"] }}, - - // time range { "range": { "@timestamp": { @@ -422,5 +512,10 @@ Most Elasticsearch queries will need to have one or more filters. There are a co } ] } - }, + } +} ``` + +Possible values for `processor.event` are: `transaction`, `span`, `metric`, `error`. + +`metricset` is a subtype of `processor.event: metric`. Possible values are: `transaction`, `span_breakdown`, `transaction_breakdown`, `app`, `service_destination`, `agent_config` From 41c813bac438530a579b7896f6848dd56089145a Mon Sep 17 00:00:00 2001 From: Uladzislau Lasitsa Date: Mon, 11 Oct 2021 11:56:36 +0300 Subject: [PATCH 11/33] [Visualization] Get rid of saved object loader and use savedObjectClient resolve (#113121) * First step: create saved_visualize_utils, starting use new get/save methods * Use new util methods in embeddable * move findListItem in utils * some clean up * clean up * Some fixes * Fix saved object tags * Some types fixes * Fix unit tests * Clean up code * Add unit tests for new utils * Fix lint * Fix tagging * Add unit tests * Some fixes * Clean up code * Fix lint * Fix types * put new methods in start contract * Fix imports * Fix lint * Fix comments * Fix lint * Fix CI * use local url instead of full path * Fix unit test * Some clean up * Fix nits * fix types Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- src/plugins/visualizations/kibana.json | 2 +- .../create_vis_embeddable_from_object.ts | 20 +- .../public/embeddable/visualize_embeddable.ts | 15 +- .../visualize_embeddable_factory.tsx | 63 ++- src/plugins/visualizations/public/index.ts | 2 + src/plugins/visualizations/public/mocks.ts | 7 + src/plugins/visualizations/public/plugin.ts | 51 +- .../public/saved_visualizations/_saved_vis.ts | 42 +- .../saved_visualizations.ts | 1 + src/plugins/visualizations/public/types.ts | 35 +- .../controls_references.ts | 0 .../saved_visualization_references/index.ts | 0 .../saved_visualization_references.test.ts | 0 .../saved_visualization_references.ts | 0 .../timeseries_references.ts | 0 .../utils/saved_visualize_utils.test.ts | 507 ++++++++++++++++++ .../public/utils/saved_visualize_utils.ts | 403 ++++++++++++++ src/plugins/visualizations/tsconfig.json | 1 + src/plugins/visualize/kibana.json | 3 +- .../visualize_editor_common.test.tsx | 112 ++++ .../components/visualize_editor_common.tsx | 56 +- .../components/visualize_listing.tsx | 9 +- .../visualize/public/application/types.ts | 3 +- .../application/utils/get_top_nav_config.tsx | 31 +- .../utils/get_visualization_instance.test.ts | 27 +- .../utils/get_visualization_instance.ts | 9 +- .../public/application/utils/mocks.ts | 1 - .../utils/use/use_saved_vis_instance.test.ts | 5 - .../utils/use/use_saved_vis_instance.ts | 3 - src/plugins/visualize/public/plugin.ts | 4 +- src/plugins/visualize/tsconfig.json | 3 +- 31 files changed, 1291 insertions(+), 124 deletions(-) rename src/plugins/visualizations/public/{saved_visualizations => utils}/saved_visualization_references/controls_references.ts (100%) rename src/plugins/visualizations/public/{saved_visualizations => utils}/saved_visualization_references/index.ts (100%) rename src/plugins/visualizations/public/{saved_visualizations => utils}/saved_visualization_references/saved_visualization_references.test.ts (100%) rename src/plugins/visualizations/public/{saved_visualizations => utils}/saved_visualization_references/saved_visualization_references.ts (100%) rename src/plugins/visualizations/public/{saved_visualizations => utils}/saved_visualization_references/timeseries_references.ts (100%) create mode 100644 src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts create mode 100644 src/plugins/visualizations/public/utils/saved_visualize_utils.ts create mode 100644 src/plugins/visualize/public/application/components/visualize_editor_common.test.tsx diff --git a/src/plugins/visualizations/kibana.json b/src/plugins/visualizations/kibana.json index 0afbec24c7c3f..32430c9d4e4fd 100644 --- a/src/plugins/visualizations/kibana.json +++ b/src/plugins/visualizations/kibana.json @@ -11,7 +11,7 @@ "inspector", "savedObjects" ], - "optionalPlugins": ["usageCollection"], + "optionalPlugins": ["usageCollection", "spaces", "savedObjectsTaggingOss"], "requiredBundles": ["kibanaUtils", "discover"], "extraPublicDirs": ["common/constants", "common/prepare_log_table", "common/expression_functions"], "owner": { diff --git a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts index cfa871f17b0e0..c72b8618dc199 100644 --- a/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts +++ b/src/plugins/visualizations/public/embeddable/create_vis_embeddable_from_object.ts @@ -20,16 +20,10 @@ import { AttributeService, } from '../../../../plugins/embeddable/public'; import { DisabledLabEmbeddable } from './disabled_lab_embeddable'; -import { - getSavedVisualizationsLoader, - getUISettings, - getHttp, - getTimeFilter, - getCapabilities, -} from '../services'; +import { getUISettings, getHttp, getTimeFilter, getCapabilities } from '../services'; +import { urlFor } from '../utils/saved_visualize_utils'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants'; -import { SavedVisualizationsLoader } from '../saved_visualizations'; import { IndexPattern } from '../../../data/public'; import { createVisualizeEmbeddableAsync } from './visualize_embeddable_async'; @@ -38,7 +32,6 @@ export const createVisEmbeddableFromObject = async ( vis: Vis, input: Partial & { id: string }, - savedVisualizationsLoader?: SavedVisualizationsLoader, attributeService?: AttributeService< VisualizeSavedObjectAttributes, VisualizeByValueInput, @@ -46,16 +39,12 @@ export const createVisEmbeddableFromObject = >, parent?: IContainer ): Promise => { - const savedVisualizations = getSavedVisualizationsLoader(); - try { const visId = vis.id as string; - const editPath = visId ? savedVisualizations.urlFor(visId) : '#/edit_by_value'; + const editPath = visId ? urlFor(visId) : '#/edit_by_value'; - const editUrl = visId - ? getHttp().basePath.prepend(`/app/visualize${savedVisualizations.urlFor(visId)}`) - : ''; + const editUrl = visId ? getHttp().basePath.prepend(`/app/visualize${urlFor(visId)}`) : ''; const isLabsEnabled = getUISettings().get(VISUALIZE_ENABLE_LABS_SETTING); if (!isLabsEnabled && vis.type.stage === 'experimental') { @@ -87,7 +76,6 @@ export const createVisEmbeddableFromObject = }, input, attributeService, - savedVisualizationsLoader, parent ); } catch (e) { diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index f5a7349b633eb..0c7d58453db69 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -39,7 +39,7 @@ import { getExpressions, getUiActions } from '../services'; import { VIS_EVENT_TO_TRIGGER } from './events'; import { VisualizeEmbeddableFactoryDeps } from './visualize_embeddable_factory'; import { SavedObjectAttributes } from '../../../../core/types'; -import { SavedVisualizationsLoader } from '../saved_visualizations'; +import { getSavedVisualization } from '../utils/saved_visualize_utils'; import { VisSavedObject } from '../types'; import { toExpressionAst } from './to_ast'; @@ -108,7 +108,6 @@ export class VisualizeEmbeddable VisualizeByValueInput, VisualizeByReferenceInput >; - private savedVisualizationsLoader?: SavedVisualizationsLoader; constructor( timefilter: TimefilterContract, @@ -119,7 +118,6 @@ export class VisualizeEmbeddable VisualizeByValueInput, VisualizeByReferenceInput >, - savedVisualizationsLoader?: SavedVisualizationsLoader, parent?: IContainer ) { super( @@ -144,7 +142,6 @@ export class VisualizeEmbeddable this.vis.uiState.on('change', this.uiStateChangeHandler); this.vis.uiState.on('reload', this.reload); this.attributeService = attributeService; - this.savedVisualizationsLoader = savedVisualizationsLoader; if (this.attributeService) { const isByValue = !this.inputIsRefType(initialInput); @@ -455,7 +452,15 @@ export class VisualizeEmbeddable }; getInputAsRefType = async (): Promise => { - const savedVis = await this.savedVisualizationsLoader?.get({}); + const { savedObjectsClient, data, spaces, savedObjectsTaggingOss } = await this.deps.start() + .plugins; + const savedVis = await getSavedVisualization({ + savedObjectsClient, + search: data.search, + dataViews: data.dataViews, + spaces, + savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + }); if (!savedVis) { throw new Error('Error creating a saved vis object'); } diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx index 9e22b33bdee9d..9b1af5bea3fce 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable_factory.tsx @@ -33,20 +33,20 @@ import type { import { VISUALIZE_EMBEDDABLE_TYPE } from './constants'; import type { SerializedVis, Vis } from '../vis'; import { createVisAsync } from '../vis_async'; -import { - getCapabilities, - getTypes, - getUISettings, - getSavedVisualizationsLoader, -} from '../services'; +import { getCapabilities, getTypes, getUISettings } from '../services'; import { showNewVisModal } from '../wizard'; -import { convertToSerializedVis } from '../saved_visualizations/_saved_vis'; +import { + convertToSerializedVis, + getSavedVisualization, + saveVisualization, + getFullPath, +} from '../utils/saved_visualize_utils'; import { extractControlsReferences, extractTimeSeriesReferences, injectTimeSeriesReferences, injectControlsReferences, -} from '../saved_visualizations/saved_visualization_references'; +} from '../utils/saved_visualization_references'; import { createVisEmbeddableFromObject } from './create_vis_embeddable_from_object'; import { VISUALIZE_ENABLE_LABS_SETTING } from '../../common/constants'; import { checkForDuplicateTitle } from '../../../saved_objects/public'; @@ -59,7 +59,15 @@ interface VisualizationAttributes extends SavedObjectAttributes { export interface VisualizeEmbeddableFactoryDeps { start: StartServicesGetter< - Pick + Pick< + VisualizationsStartDeps, + | 'inspector' + | 'embeddable' + | 'savedObjectsClient' + | 'data' + | 'savedObjectsTaggingOss' + | 'spaces' + > >; } @@ -147,17 +155,36 @@ export class VisualizeEmbeddableFactory input: Partial & { id: string }, parent?: IContainer ): Promise { - const savedVisualizations = getSavedVisualizationsLoader(); + const startDeps = await this.deps.start(); try { - const savedObject = await savedVisualizations.get(savedObjectId); + const savedObject = await getSavedVisualization( + { + savedObjectsClient: startDeps.core.savedObjects.client, + search: startDeps.plugins.data.search, + dataViews: startDeps.plugins.data.dataViews, + spaces: startDeps.plugins.spaces, + savedObjectsTagging: startDeps.plugins.savedObjectsTaggingOss?.getTaggingApi(), + }, + savedObjectId + ); + + if (savedObject.sharingSavedObjectProps?.outcome === 'conflict') { + return new ErrorEmbeddable( + i18n.translate('visualizations.embeddable.legacyURLConflict.errorMessage', { + defaultMessage: `This visualization has the same URL as a legacy alias. Disable the alias to resolve this error : {json}`, + values: { json: savedObject.sharingSavedObjectProps?.errorJSON }, + }), + input, + parent + ); + } const visState = convertToSerializedVis(savedObject); const vis = await createVisAsync(savedObject.visState.type, visState); return createVisEmbeddableFromObject(this.deps)( vis, input, - savedVisualizations, await this.getAttributeService(), parent ); @@ -173,11 +200,9 @@ export class VisualizeEmbeddableFactory if (input.savedVis) { const visState = input.savedVis; const vis = await createVisAsync(visState.type, visState); - const savedVisualizations = getSavedVisualizationsLoader(); return createVisEmbeddableFromObject(this.deps)( vis, input, - savedVisualizations, await this.getAttributeService(), parent ); @@ -201,9 +226,9 @@ export class VisualizeEmbeddableFactory confirmOverwrite: false, returnToOrigin: true, isTitleDuplicateConfirmed: true, + copyOnSave: false, }; savedVis.title = title; - savedVis.copyOnSave = false; savedVis.description = ''; savedVis.searchSourceFields = visObj?.data.searchSource?.getSerializedFields(); const serializedVis = (visObj as unknown as Vis).serialize(); @@ -217,7 +242,12 @@ export class VisualizeEmbeddableFactory if (visObj) { savedVis.uiStateJSON = visObj?.uiState.toString(); } - const id = await savedVis.save(saveOptions); + const { core, plugins } = await this.deps.start(); + const id = await saveVisualization(savedVis, saveOptions, { + savedObjectsClient: core.savedObjects.client, + overlays: core.overlays, + savedObjectsTagging: plugins.savedObjectsTaggingOss?.getTaggingApi(), + }); if (!id || id === '') { throw new Error( i18n.translate('visualizations.savingVisualizationFailed.errorMsg', { @@ -225,6 +255,7 @@ export class VisualizeEmbeddableFactory }) ); } + core.chrome.recentlyAccessed.add(getFullPath(id), savedVis.title, String(id)); return { id }; } catch (error) { throw error; diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 0886f230d101f..e6ea3cd489556 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -38,6 +38,7 @@ export { VisToExpressionAst, VisToExpressionAstParams, VisEditorOptionsProps, + GetVisOptions, } from './types'; export { VisualizationListItem, VisualizationStage } from './vis_types/vis_type_alias_registry'; export { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; @@ -49,3 +50,4 @@ export { FakeParams, HistogramParams, } from '../common/expression_functions/xy_dimension'; +export { urlFor, getFullPath } from './utils/saved_visualize_utils'; diff --git a/src/plugins/visualizations/public/mocks.ts b/src/plugins/visualizations/public/mocks.ts index 901593626a945..9b2d6bfe25b32 100644 --- a/src/plugins/visualizations/public/mocks.ts +++ b/src/plugins/visualizations/public/mocks.ts @@ -10,6 +10,7 @@ import { PluginInitializerContext } from '../../../core/public'; import { Schema, VisualizationsSetup, VisualizationsStart } from './'; import { Schemas } from './vis_types'; import { VisualizationsPlugin } from './plugin'; +import { spacesPluginMock } from '../../../../x-pack/plugins/spaces/public/mocks'; import { coreMock, applicationServiceMock } from '../../../core/public/mocks'; import { embeddablePluginMock } from '../../../plugins/embeddable/public/mocks'; import { expressionsPluginMock } from '../../../plugins/expressions/public/mocks'; @@ -18,6 +19,7 @@ import { usageCollectionPluginMock } from '../../../plugins/usage_collection/pub import { uiActionsPluginMock } from '../../../plugins/ui_actions/public/mocks'; import { inspectorPluginMock } from '../../../plugins/inspector/public/mocks'; import { savedObjectsPluginMock } from '../../../plugins/saved_objects/public/mocks'; +import { savedObjectTaggingOssPluginMock } from '../../saved_objects_tagging_oss/public/mocks'; const createSetupContract = (): VisualizationsSetup => ({ createBaseVisualization: jest.fn(), @@ -34,6 +36,9 @@ const createStartContract = (): VisualizationsStart => ({ savedVisualizationsLoader: { get: jest.fn(), } as any, + getSavedVisualization: jest.fn(), + saveVisualization: jest.fn(), + findListItems: jest.fn(), showNewVisModal: jest.fn(), createVis: jest.fn(), convertFromSerializedVis: jest.fn(), @@ -61,9 +66,11 @@ const createInstance = async () => { uiActions: uiActionsPluginMock.createStartContract(), application: applicationServiceMock.createStartContract(), embeddable: embeddablePluginMock.createStartContract(), + spaces: spacesPluginMock.createStartContract(), getAttributeService: jest.fn(), savedObjectsClient: coreMock.createStart().savedObjects.client, savedObjects: savedObjectsPluginMock.createStartContract(), + savedObjectsTaggingOss: savedObjectTaggingOssPluginMock.createStart(), }); return { diff --git a/src/plugins/visualizations/public/plugin.ts b/src/plugins/visualizations/public/plugin.ts index ee3e914aa4bc6..47f544ce2f5d3 100644 --- a/src/plugins/visualizations/public/plugin.ts +++ b/src/plugins/visualizations/public/plugin.ts @@ -5,6 +5,8 @@ * in compliance with, at your election, the Elastic License 2.0 or the Server * Side Public License, v 1. */ + +import type { SavedObjectsFindOptionsReference } from 'kibana/public'; import { setUISettings, setTypes, @@ -30,6 +32,7 @@ import { VisualizeEmbeddableFactory, createVisEmbeddableFromObject, } from './embeddable'; +import type { SpacesPluginStart } from '../../../../x-pack/plugins/spaces/public'; import { TypesService } from './vis_types/types_service'; import { range as rangeExpressionFunction } from '../common/expression_functions/range'; import { visDimension as visDimensionExpressionFunction } from '../common/expression_functions/vis_dimension'; @@ -43,7 +46,10 @@ import { showNewVisModal } from './wizard'; import { convertFromSerializedVis, convertToSerializedVis, -} from './saved_visualizations/_saved_vis'; + getSavedVisualization, + saveVisualization, + findListItems, +} from './utils/saved_visualize_utils'; import { createSavedSearchesLoader } from '../../discover/public'; @@ -66,7 +72,9 @@ import type { import type { DataPublicPluginSetup, DataPublicPluginStart } from '../../../plugins/data/public'; import type { ExpressionsSetup, ExpressionsStart } from '../../expressions/public'; import type { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; +import type { SavedObjectTaggingOssPluginStart } from '../../saved_objects_tagging_oss/public'; import { createVisAsync } from './vis_async'; +import type { VisSavedObject, SaveVisOptions, GetVisOptions } from './types'; /** * Interface for this plugin's returned setup/start contracts. @@ -82,6 +90,13 @@ export interface VisualizationsStart extends TypesStart { convertToSerializedVis: typeof convertToSerializedVis; convertFromSerializedVis: typeof convertFromSerializedVis; showNewVisModal: typeof showNewVisModal; + getSavedVisualization: (opts?: GetVisOptions | string) => Promise; + saveVisualization: (savedVis: VisSavedObject, saveOptions: SaveVisOptions) => Promise; + findListItems: ( + searchTerm: string, + listingLimit: number, + references?: SavedObjectsFindOptionsReference[] + ) => Promise<{ hits: Array>; total: number }>; __LEGACY: { createVisEmbeddableFromObject: ReturnType }; } @@ -103,6 +118,8 @@ export interface VisualizationsStartDeps { getAttributeService: EmbeddableStart['getAttributeService']; savedObjects: SavedObjectsStart; savedObjectsClient: SavedObjectsClientContract; + spaces?: SpacesPluginStart; + savedObjectsTaggingOss?: SavedObjectTaggingOssPluginStart; } /** @@ -149,7 +166,15 @@ export class VisualizationsPlugin public start( core: CoreStart, - { data, expressions, uiActions, embeddable, savedObjects }: VisualizationsStartDeps + { + data, + expressions, + uiActions, + embeddable, + savedObjects, + spaces, + savedObjectsTaggingOss, + }: VisualizationsStartDeps ): VisualizationsStart { const types = this.types.start(); setTypes(types); @@ -181,6 +206,28 @@ export class VisualizationsPlugin return { ...types, showNewVisModal, + getSavedVisualization: async (opts) => { + return getSavedVisualization( + { + search: data.search, + savedObjectsClient: core.savedObjects.client, + dataViews: data.dataViews, + spaces, + savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + }, + opts + ); + }, + saveVisualization: async (savedVis, saveOptions) => { + return saveVisualization(savedVis, saveOptions, { + savedObjectsClient: core.savedObjects.client, + overlays: core.overlays, + savedObjectsTagging: savedObjectsTaggingOss?.getTaggingApi(), + }); + }, + findListItems: async (searchTerm, listingLimit, references) => { + return findListItems(core.savedObjects.client, types, searchTerm, listingLimit, references); + }, /** * creates new instance of Vis * @param {IndexPattern} indexPattern - index pattern to use diff --git a/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts b/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts index fb6c99ac8ef02..fbd8e414c2738 100644 --- a/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts +++ b/src/plugins/visualizations/public/saved_visualizations/_saved_vis.ts @@ -16,11 +16,11 @@ import type { SavedObjectsStart, SavedObject } from '../../../../plugins/saved_objects/public'; // @ts-ignore import { updateOldState } from '../legacy/vis_update_state'; -import { extractReferences, injectReferences } from './saved_visualization_references'; +import { extractReferences, injectReferences } from '../utils/saved_visualization_references'; import { createSavedSearchesLoader } from '../../../discover/public'; import type { SavedObjectsClientContract } from '../../../../core/public'; import type { IndexPatternsContract } from '../../../../plugins/data/public'; -import type { ISavedVis, SerializedVis } from '../types'; +import type { ISavedVis } from '../types'; export interface SavedVisServices { savedObjectsClient: SavedObjectsClientContract; @@ -28,43 +28,7 @@ export interface SavedVisServices { indexPatterns: IndexPatternsContract; } -export const convertToSerializedVis = (savedVis: ISavedVis): SerializedVis => { - const { id, title, description, visState, uiStateJSON, searchSourceFields } = savedVis; - - const aggs = searchSourceFields && searchSourceFields.index ? visState.aggs || [] : visState.aggs; - - return { - id, - title, - type: visState.type, - description, - params: visState.params, - uiState: JSON.parse(uiStateJSON || '{}'), - data: { - aggs, - searchSource: searchSourceFields!, - savedSearchId: savedVis.savedSearchId, - }, - }; -}; - -export const convertFromSerializedVis = (vis: SerializedVis): ISavedVis => { - return { - id: vis.id, - title: vis.title, - description: vis.description, - visState: { - title: vis.title, - type: vis.type, - aggs: vis.data.aggs, - params: vis.params, - }, - uiStateJSON: JSON.stringify(vis.uiState), - searchSourceFields: vis.data.searchSource, - savedSearchId: vis.data.savedSearchId, - }; -}; - +/** @deprecated **/ export function createSavedVisClass(services: SavedVisServices) { const savedSearch = createSavedSearchesLoader(services); diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts b/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts index d07d28b393dcc..cec65b8f988b3 100644 --- a/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts +++ b/src/plugins/visualizations/public/saved_visualizations/saved_visualizations.ts @@ -22,6 +22,7 @@ export interface FindListItemsOptions { references?: SavedObjectsFindOptionsReference[]; } +/** @deprecated **/ export function createSavedVisLoader(services: SavedVisServicesWithVisualizations) { const { savedObjectsClient, visualizationTypes } = services; diff --git a/src/plugins/visualizations/public/types.ts b/src/plugins/visualizations/public/types.ts index d68599c0724f6..5be8f49e9cdc7 100644 --- a/src/plugins/visualizations/public/types.ts +++ b/src/plugins/visualizations/public/types.ts @@ -6,13 +6,14 @@ * Side Public License, v 1. */ -import { SavedObject } from '../../../plugins/saved_objects/public'; +import type { SavedObjectsMigrationVersion } from 'kibana/public'; import { IAggConfigs, SearchSourceFields, TimefilterContract, AggConfigSerialized, } from '../../../plugins/data/public'; +import type { ISearchSource } from '../../data/common'; import { ExpressionAstExpression } from '../../expressions/public'; import type { SerializedVis, Vis } from './vis'; @@ -36,9 +37,39 @@ export interface ISavedVis { uiStateJSON?: string; savedSearchRefName?: string; savedSearchId?: string; + sharingSavedObjectProps?: { + outcome?: 'aliasMatch' | 'exactMatch' | 'conflict'; + aliasTargetId?: string; + errorJSON?: string; + }; } -export interface VisSavedObject extends SavedObject, ISavedVis {} +export interface VisSavedObject extends ISavedVis { + lastSavedTitle: string; + getEsType: () => string; + getDisplayName?: () => string; + displayName: string; + migrationVersion?: SavedObjectsMigrationVersion; + searchSource?: ISearchSource; + version?: string; + tags?: string[]; +} + +export interface SaveVisOptions { + confirmOverwrite?: boolean; + isTitleDuplicateConfirmed?: boolean; + onTitleDuplicate?: () => void; + copyOnSave?: boolean; +} + +export interface GetVisOptions { + id?: string; + searchSource?: boolean; + migrationVersion?: SavedObjectsMigrationVersion; + savedSearchId?: string; + type?: string; + indexPattern?: string; +} export interface VisToExpressionAstParams { timefilter: TimefilterContract; diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts similarity index 100% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/controls_references.ts rename to src/plugins/visualizations/public/utils/saved_visualization_references/controls_references.ts diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/index.ts similarity index 100% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/index.ts rename to src/plugins/visualizations/public/utils/saved_visualization_references/index.ts diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/saved_visualization_references.test.ts similarity index 100% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.test.ts rename to src/plugins/visualizations/public/utils/saved_visualization_references/saved_visualization_references.test.ts diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/saved_visualization_references.ts similarity index 100% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/saved_visualization_references.ts rename to src/plugins/visualizations/public/utils/saved_visualization_references/saved_visualization_references.ts diff --git a/src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts b/src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts similarity index 100% rename from src/plugins/visualizations/public/saved_visualizations/saved_visualization_references/timeseries_references.ts rename to src/plugins/visualizations/public/utils/saved_visualization_references/timeseries_references.ts diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts new file mode 100644 index 0000000000000..83b16026de391 --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.test.ts @@ -0,0 +1,507 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { ISearchSource } from '../../../data/common'; +import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/public'; +import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; +import { coreMock } from '../../../../core/public/mocks'; +import { dataPluginMock } from '../../../data/public/mocks'; +import { SavedObjectsClientContract } from '../../../../core/public'; +import { + findListItems, + getSavedVisualization, + saveVisualization, + SAVED_VIS_TYPE, +} from './saved_visualize_utils'; +import { VisTypeAlias, TypesStart } from '../vis_types'; +import type { VisSavedObject } from '../types'; + +let visTypes = [] as VisTypeAlias[]; +const mockGetAliases = jest.fn(() => visTypes); +const mockGetTypes = jest.fn((type: string) => type) as unknown as TypesStart['get']; +jest.mock('../services', () => ({ + getSpaces: jest.fn(() => ({ + getActiveSpace: () => ({ + id: 'test', + }), + })), +})); + +const mockParseSearchSourceJSON = jest.fn(); +const mockInjectSearchSourceReferences = jest.fn(); +const mockExtractSearchSourceReferences = jest.fn((...args) => [{}, []]); + +jest.mock('../../../../plugins/data/public', () => ({ + extractSearchSourceReferences: jest.fn((...args) => mockExtractSearchSourceReferences(...args)), + injectSearchSourceReferences: jest.fn((...args) => mockInjectSearchSourceReferences(...args)), + parseSearchSourceJSON: jest.fn((...args) => mockParseSearchSourceJSON(...args)), +})); + +const mockInjectReferences = jest.fn(); +const mockExtractReferences = jest.fn(() => ({ references: [], attributes: {} })); +jest.mock('./saved_visualization_references', () => ({ + injectReferences: jest.fn((...args) => mockInjectReferences(...args)), + extractReferences: jest.fn(() => mockExtractReferences()), +})); + +let isTitleDuplicateConfirmed = true; +const mockCheckForDuplicateTitle = jest.fn(() => { + if (!isTitleDuplicateConfirmed) { + throw new Error(); + } +}); +const mockSaveWithConfirmation = jest.fn(() => ({ id: 'test-after-confirm' })); +jest.mock('../../../../plugins/saved_objects/public', () => ({ + checkForDuplicateTitle: jest.fn(() => mockCheckForDuplicateTitle()), + saveWithConfirmation: jest.fn(() => mockSaveWithConfirmation()), + isErrorNonFatal: jest.fn(() => true), +})); + +describe('saved_visualize_utils', () => { + const { overlays, savedObjects } = coreMock.createStart(); + const savedObjectsClient = savedObjects.client as jest.Mocked; + (savedObjectsClient.resolve as jest.Mock).mockImplementation(() => ({ + saved_object: { + references: [ + { + id: 'test', + type: 'index-pattern', + }, + ], + attributes: { + visState: JSON.stringify({ type: 'area' }), + kibanaSavedObjectMeta: { + searchSourceJSON: '{filter: []}', + }, + }, + _version: '1', + }, + outcome: 'exact', + alias_target_id: null, + })); + (savedObjectsClient.create as jest.Mock).mockImplementation(() => ({ id: 'test' })); + const { dataViews, search } = dataPluginMock.createStartContract(); + + describe('getSavedVisualization', () => { + beforeEach(() => { + mockParseSearchSourceJSON.mockClear(); + mockInjectSearchSourceReferences.mockClear(); + mockInjectReferences.mockClear(); + }); + it('should return object with defaults if was not provided id', async () => { + const savedVis = await getSavedVisualization({ + savedObjectsClient, + search, + dataViews, + spaces: Promise.resolve({ + getActiveSpace: () => ({ + id: 'test', + }), + }) as unknown as SpacesPluginStart, + }); + expect(savedVis).toBeDefined(); + expect(savedVis.title).toBe(''); + expect(savedVis.displayName).toBe(SAVED_VIS_TYPE); + }); + + it('should create search source if saved object has searchSourceJSON', async () => { + await getSavedVisualization( + { + savedObjectsClient, + search, + dataViews, + spaces: Promise.resolve({ + getActiveSpace: () => ({ + id: 'test', + }), + }) as unknown as SpacesPluginStart, + }, + { id: 'test', searchSource: true } + ); + expect(mockParseSearchSourceJSON).toHaveBeenCalledWith('{filter: []}'); + expect(mockInjectSearchSourceReferences).toHaveBeenCalled(); + expect(search.searchSource.create).toHaveBeenCalled(); + }); + + it('should inject references if saved object has references', async () => { + await getSavedVisualization( + { + savedObjectsClient, + search, + dataViews, + spaces: Promise.resolve({ + getActiveSpace: () => ({ + id: 'test', + }), + }) as unknown as SpacesPluginStart, + }, + { id: 'test', searchSource: true } + ); + expect(mockInjectReferences.mock.calls[0][1]).toEqual([ + { + id: 'test', + type: 'index-pattern', + }, + ]); + }); + + it('should call getTagIdsFromReferences if we provide savedObjectsTagging service', async () => { + const mockGetTagIdsFromReferences = jest.fn(() => ['test']); + await getSavedVisualization( + { + savedObjectsClient, + search, + dataViews, + spaces: Promise.resolve({ + getActiveSpace: () => ({ + id: 'test', + }), + }) as unknown as SpacesPluginStart, + savedObjectsTagging: { + ui: { + getTagIdsFromReferences: mockGetTagIdsFromReferences, + }, + } as unknown as SavedObjectsTaggingApi, + }, + { id: 'test', searchSource: true } + ); + expect(mockGetTagIdsFromReferences).toHaveBeenCalled(); + }); + }); + + describe('saveVisualization', () => { + let vis: VisSavedObject; + beforeEach(() => { + mockExtractSearchSourceReferences.mockClear(); + mockExtractReferences.mockClear(); + mockSaveWithConfirmation.mockClear(); + savedObjectsClient.create.mockClear(); + vis = { + visState: { + type: 'area', + }, + title: 'test', + uiStateJSON: '{}', + version: '1', + __tags: [], + lastSavedTitle: 'test', + displayName: 'test', + getEsType: () => 'vis', + } as unknown as VisSavedObject; + }); + + it('should return id after save', async () => { + const savedVisId = await saveVisualization(vis, {}, { savedObjectsClient, overlays }); + expect(savedObjectsClient.create).toHaveBeenCalled(); + expect(mockExtractReferences).toHaveBeenCalled(); + expect(savedVisId).toBe('test'); + }); + + it('should call extractSearchSourceReferences if we new vis has searchSourceFields', async () => { + vis.searchSourceFields = { fields: [] }; + await saveVisualization(vis, {}, { savedObjectsClient, overlays }); + expect(mockExtractSearchSourceReferences).toHaveBeenCalledWith(vis.searchSourceFields); + }); + + it('should serialize searchSource', async () => { + vis.searchSource = { + serialize: jest.fn(() => ({ searchSourceJSON: '{}', references: [] })), + } as unknown as ISearchSource; + await saveVisualization(vis, {}, { savedObjectsClient, overlays }); + expect(vis.searchSource?.serialize).toHaveBeenCalled(); + }); + + it('should call updateTagsReferences if we provide savedObjectsTagging service', async () => { + const mockUpdateTagsReferences = jest.fn(() => []); + await saveVisualization( + vis, + {}, + { + savedObjectsClient, + overlays, + savedObjectsTagging: { + ui: { + updateTagsReferences: mockUpdateTagsReferences, + }, + } as unknown as SavedObjectsTaggingApi, + } + ); + expect(mockUpdateTagsReferences).toHaveBeenCalled(); + }); + + describe('confirmOverwrite', () => { + it('as false we should not call saveWithConfirmation and just do create', async () => { + const savedVisId = await saveVisualization( + vis, + { confirmOverwrite: false }, + { savedObjectsClient, overlays } + ); + expect(savedObjectsClient.create).toHaveBeenCalled(); + expect(mockExtractReferences).toHaveBeenCalled(); + expect(mockSaveWithConfirmation).not.toHaveBeenCalled(); + expect(savedVisId).toBe('test'); + }); + + it('as true we should call saveWithConfirmation', async () => { + const savedVisId = await saveVisualization( + vis, + { confirmOverwrite: true }, + { savedObjectsClient, overlays } + ); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + expect(mockSaveWithConfirmation).toHaveBeenCalled(); + expect(savedVisId).toBe('test-after-confirm'); + }); + }); + + describe('isTitleDuplicateConfirmed', () => { + it('as false we should not save vis with duplicated title', async () => { + isTitleDuplicateConfirmed = false; + const savedVisId = await saveVisualization( + vis, + { isTitleDuplicateConfirmed }, + { savedObjectsClient, overlays } + ); + expect(savedObjectsClient.create).not.toHaveBeenCalled(); + expect(mockSaveWithConfirmation).not.toHaveBeenCalled(); + expect(mockCheckForDuplicateTitle).toHaveBeenCalled(); + expect(savedVisId).toBe(''); + expect(vis.id).toBeUndefined(); + }); + + it('as true we should save vis with duplicated title', async () => { + isTitleDuplicateConfirmed = true; + const savedVisId = await saveVisualization( + vis, + { isTitleDuplicateConfirmed }, + { savedObjectsClient, overlays } + ); + expect(mockCheckForDuplicateTitle).toHaveBeenCalled(); + expect(savedObjectsClient.create).toHaveBeenCalled(); + expect(savedVisId).toBe('test'); + expect(vis.id).toBe('test'); + }); + }); + }); + + describe('findListItems', () => { + function testProps() { + (savedObjectsClient.find as jest.Mock).mockImplementation(() => ({ + total: 0, + savedObjects: [], + })); + return { + savedObjectsClient, + search: '', + size: 10, + }; + } + + beforeEach(() => { + savedObjectsClient.find.mockClear(); + }); + + it('searches visualization title and description', async () => { + const props = testProps(); + const { find } = props.savedObjectsClient; + await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size + ); + expect(find.mock.calls).toMatchObject([ + [ + { + type: ['visualization'], + searchFields: ['title^3', 'description'], + }, + ], + ]); + }); + + it('searches searchFields and types specified by app extensions', async () => { + const props = testProps(); + visTypes = [ + { + appExtensions: { + visualizations: { + docTypes: ['bazdoc', 'etc'], + searchFields: ['baz', 'bing'], + }, + }, + } as VisTypeAlias, + ]; + const { find } = props.savedObjectsClient; + await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size + ); + expect(find.mock.calls).toMatchObject([ + [ + { + type: ['bazdoc', 'etc', 'visualization'], + searchFields: ['baz', 'bing', 'title^3', 'description'], + }, + ], + ]); + }); + + it('deduplicates types and search fields', async () => { + const props = testProps(); + visTypes = [ + { + appExtensions: { + visualizations: { + docTypes: ['bazdoc', 'bar'], + searchFields: ['baz', 'bing', 'barfield'], + }, + }, + } as VisTypeAlias, + { + appExtensions: { + visualizations: { + docTypes: ['visualization', 'foo', 'bazdoc'], + searchFields: ['baz', 'bing', 'foofield'], + }, + }, + } as VisTypeAlias, + ]; + const { find } = props.savedObjectsClient; + await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size + ); + expect(find.mock.calls).toMatchObject([ + [ + { + type: ['bazdoc', 'bar', 'visualization', 'foo'], + searchFields: ['baz', 'bing', 'barfield', 'foofield', 'title^3', 'description'], + }, + ], + ]); + }); + + it('searches the search term prefix', async () => { + const props = { + ...testProps(), + search: 'ahoythere', + }; + const { find } = props.savedObjectsClient; + await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size + ); + expect(find.mock.calls).toMatchObject([ + [ + { + search: 'ahoythere*', + }, + ], + ]); + }); + + it('searches with references', async () => { + const props = { + ...testProps(), + references: [ + { type: 'foo', id: 'hello' }, + { type: 'bar', id: 'dolly' }, + ], + }; + const { find } = props.savedObjectsClient; + await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size, + props.references + ); + expect(find.mock.calls).toMatchObject([ + [ + { + hasReference: [ + { type: 'foo', id: 'hello' }, + { type: 'bar', id: 'dolly' }, + ], + }, + ], + ]); + }); + + it('uses type-specific toListItem function, if available', async () => { + const props = testProps(); + + visTypes = [ + { + appExtensions: { + visualizations: { + docTypes: ['wizard'], + toListItem(savedObject) { + return { + id: savedObject.id, + title: `${(savedObject.attributes as { label: string }).label} THE GRAY`, + }; + }, + }, + }, + } as VisTypeAlias, + ]; + (props.savedObjectsClient.find as jest.Mock).mockImplementationOnce(async () => ({ + total: 2, + savedObjects: [ + { + id: 'lotr', + type: 'wizard', + attributes: { label: 'Gandalf' }, + }, + { + id: 'wat', + type: 'visualization', + attributes: { title: 'WATEVER', typeName: 'test' }, + }, + ], + })); + + const items = await findListItems( + props.savedObjectsClient, + { get: mockGetTypes, getAliases: mockGetAliases }, + props.search, + props.size + ); + expect(items).toEqual({ + total: 2, + hits: [ + { + id: 'lotr', + references: undefined, + title: 'Gandalf THE GRAY', + }, + { + id: 'wat', + references: undefined, + icon: undefined, + savedObjectType: 'visualization', + editUrl: '/edit/wat', + type: 'test', + typeName: 'test', + typeTitle: undefined, + title: 'WATEVER', + url: '#/edit/wat', + }, + ], + }); + }); + }); +}); diff --git a/src/plugins/visualizations/public/utils/saved_visualize_utils.ts b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts new file mode 100644 index 0000000000000..a28ee9486c4d2 --- /dev/null +++ b/src/plugins/visualizations/public/utils/saved_visualize_utils.ts @@ -0,0 +1,403 @@ +/* + * 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 _ from 'lodash'; +import type { + SavedObjectsFindOptionsReference, + SavedObjectsFindOptions, + SavedObjectsClientContract, + SavedObjectAttributes, + SavedObjectReference, +} from 'kibana/public'; +import type { OverlayStart } from '../../../../core/public'; +import { SavedObjectNotFound } from '../../../kibana_utils/public'; +import { + extractSearchSourceReferences, + injectSearchSourceReferences, + parseSearchSourceJSON, + DataPublicPluginStart, +} from '../../../../plugins/data/public'; +import { + checkForDuplicateTitle, + saveWithConfirmation, + isErrorNonFatal, +} from '../../../../plugins/saved_objects/public'; +import type { SavedObjectsTaggingApi } from '../../../saved_objects_tagging_oss/public'; +import type { SpacesPluginStart } from '../../../../../x-pack/plugins/spaces/public'; +import { VisualizationsAppExtension } from '../vis_types/vis_type_alias_registry'; +import type { + VisSavedObject, + SerializedVis, + ISavedVis, + SaveVisOptions, + GetVisOptions, +} from '../types'; +import type { TypesStart, BaseVisType } from '../vis_types'; +// @ts-ignore +import { updateOldState } from '../legacy/vis_update_state'; +import { injectReferences, extractReferences } from './saved_visualization_references'; + +export const SAVED_VIS_TYPE = 'visualization'; + +const getDefaults = (opts: GetVisOptions) => ({ + title: '', + visState: !opts.type ? null : { type: opts.type }, + uiStateJSON: '{}', + description: '', + savedSearchId: opts.savedSearchId, + version: 1, +}); + +export function getFullPath(id: string) { + return `/app/visualize#/edit/${id}`; +} + +export function urlFor(id: string) { + return `#/edit/${encodeURIComponent(id)}`; +} + +export function mapHitSource( + visTypes: Pick, + { + attributes, + id, + references, + }: { + attributes: SavedObjectAttributes; + id: string; + references: SavedObjectReference[]; + } +) { + const newAttributes: { + id: string; + references: SavedObjectReference[]; + url: string; + savedObjectType?: string; + editUrl?: string; + type?: BaseVisType; + icon?: BaseVisType['icon']; + image?: BaseVisType['image']; + typeTitle?: BaseVisType['title']; + error?: string; + } = { + id, + references, + url: urlFor(id), + ...attributes, + }; + + let typeName = attributes.typeName; + if (attributes.visState) { + try { + typeName = JSON.parse(String(attributes.visState)).type; + } catch (e) { + /* missing typename handled below */ + } + } + + if (!typeName || !visTypes.get(typeName as string)) { + newAttributes.error = 'Unknown visualization type'; + return newAttributes; + } + + newAttributes.type = visTypes.get(typeName as string); + newAttributes.savedObjectType = 'visualization'; + newAttributes.icon = newAttributes.type?.icon; + newAttributes.image = newAttributes.type?.image; + newAttributes.typeTitle = newAttributes.type?.title; + newAttributes.editUrl = `/edit/${id}`; + + return newAttributes; +} + +export const convertToSerializedVis = (savedVis: VisSavedObject): SerializedVis => { + const { id, title, description, visState, uiStateJSON, searchSourceFields } = savedVis; + + const aggs = searchSourceFields && searchSourceFields.index ? visState.aggs || [] : visState.aggs; + + return { + id, + title, + type: visState.type, + description, + params: visState.params, + uiState: JSON.parse(uiStateJSON || '{}'), + data: { + aggs, + searchSource: searchSourceFields!, + savedSearchId: savedVis.savedSearchId, + }, + }; +}; + +export const convertFromSerializedVis = (vis: SerializedVis): ISavedVis => { + return { + id: vis.id, + title: vis.title, + description: vis.description, + visState: { + title: vis.title, + type: vis.type, + aggs: vis.data.aggs, + params: vis.params, + }, + uiStateJSON: JSON.stringify(vis.uiState), + searchSourceFields: vis.data.searchSource, + savedSearchId: vis.data.savedSearchId, + }; +}; + +export async function findListItems( + savedObjectsClient: SavedObjectsClientContract, + visTypes: Pick, + search: string, + size: number, + references?: SavedObjectsFindOptionsReference[] +) { + const visAliases = visTypes.getAliases(); + const extensions = visAliases + .map((v) => v.appExtensions?.visualizations) + .filter(Boolean) as VisualizationsAppExtension[]; + const extensionByType = extensions.reduce((acc, m) => { + return m!.docTypes.reduce((_acc, type) => { + acc[type] = m; + return acc; + }, acc); + }, {} as { [visType: string]: VisualizationsAppExtension }); + const searchOption = (field: string, ...defaults: string[]) => + _(extensions).map(field).concat(defaults).compact().flatten().uniq().value() as string[]; + const searchOptions: SavedObjectsFindOptions = { + type: searchOption('docTypes', 'visualization'), + searchFields: searchOption('searchFields', 'title^3', 'description'), + search: search ? `${search}*` : undefined, + perPage: size, + page: 1, + defaultSearchOperator: 'AND' as 'AND', + hasReference: references, + }; + + const { total, savedObjects } = await savedObjectsClient.find( + searchOptions + ); + + return { + total, + hits: savedObjects.map((savedObject) => { + const config = extensionByType[savedObject.type]; + + if (config) { + return { + ...config.toListItem(savedObject), + references: savedObject.references, + }; + } else { + return mapHitSource(visTypes, savedObject); + } + }), + }; +} + +export async function getSavedVisualization( + services: { + savedObjectsClient: SavedObjectsClientContract; + search: DataPublicPluginStart['search']; + dataViews: DataPublicPluginStart['dataViews']; + spaces?: SpacesPluginStart; + savedObjectsTagging?: SavedObjectsTaggingApi; + }, + opts?: GetVisOptions | string +): Promise { + if (typeof opts !== 'object') { + opts = { id: opts } as GetVisOptions; + } + + const id = (opts.id as string) || ''; + const savedObject = { + id, + migrationVersion: opts.migrationVersion, + displayName: SAVED_VIS_TYPE, + getEsType: () => SAVED_VIS_TYPE, + getDisplayName: () => SAVED_VIS_TYPE, + searchSource: opts.searchSource ? services.search.searchSource.createEmpty() : undefined, + } as VisSavedObject; + const defaultsProps = getDefaults(opts); + + if (!id) { + Object.assign(savedObject, defaultsProps); + return savedObject; + } + + const { + saved_object: resp, + outcome, + alias_target_id: aliasTargetId, + } = await services.savedObjectsClient.resolve(SAVED_VIS_TYPE, id); + + if (!resp._version) { + throw new SavedObjectNotFound(SAVED_VIS_TYPE, id || ''); + } + + const attributes = _.cloneDeep(resp.attributes); + + if (attributes.visState && typeof attributes.visState === 'string') { + attributes.visState = JSON.parse(attributes.visState); + } + + // assign the defaults to the response + _.defaults(attributes, defaultsProps); + + Object.assign(savedObject, attributes); + savedObject.lastSavedTitle = savedObject.title; + + savedObject.sharingSavedObjectProps = { + aliasTargetId, + outcome, + errorJSON: + outcome === 'conflict' && services.spaces + ? JSON.stringify({ + targetType: SAVED_VIS_TYPE, + sourceId: id, + targetSpace: (await services.spaces.getActiveSpace()).id, + }) + : undefined, + }; + + const meta = (attributes.kibanaSavedObjectMeta || {}) as SavedObjectAttributes; + + if (meta.searchSourceJSON) { + try { + let searchSourceValues = parseSearchSourceJSON(meta.searchSourceJSON as string); + + if (opts.searchSource) { + searchSourceValues = injectSearchSourceReferences( + searchSourceValues as any, + resp.references + ); + savedObject.searchSource = await services.search.searchSource.create(searchSourceValues); + } else { + savedObject.searchSourceFields = searchSourceValues; + } + } catch (error: any) { + throw error; + } + } + + if (resp.references && resp.references.length > 0) { + injectReferences(savedObject, resp.references); + } + + if (services.savedObjectsTagging) { + savedObject.tags = services.savedObjectsTagging.ui.getTagIdsFromReferences(resp.references); + } + + savedObject.visState = await updateOldState(savedObject.visState); + if (savedObject.searchSourceFields?.index) { + await services.dataViews.get(savedObject.searchSourceFields.index as any); + } + + return savedObject; +} + +export async function saveVisualization( + savedObject: VisSavedObject, + { + confirmOverwrite = false, + isTitleDuplicateConfirmed = false, + onTitleDuplicate, + copyOnSave = false, + }: SaveVisOptions, + services: { + savedObjectsClient: SavedObjectsClientContract; + overlays: OverlayStart; + savedObjectsTagging?: SavedObjectsTaggingApi; + } +) { + // Save the original id in case the save fails. + const originalId = savedObject.id; + // Read https://github.com/elastic/kibana/issues/9056 and + // https://github.com/elastic/kibana/issues/9012 for some background into why this copyOnSave variable + // exists. + // The goal is to move towards a better rename flow, but since our users have been conditioned + // to expect a 'save as' flow during a rename, we are keeping the logic the same until a better + // UI/UX can be worked out. + if (copyOnSave) { + delete savedObject.id; + } + + const attributes: SavedObjectAttributes = { + visState: JSON.stringify(savedObject.visState), + title: savedObject.title, + uiStateJSON: savedObject.uiStateJSON, + description: savedObject.description, + savedSearchId: savedObject.savedSearchId, + version: savedObject.version, + }; + let references: SavedObjectReference[] = []; + + if (savedObject.searchSource) { + const { searchSourceJSON, references: searchSourceReferences } = + savedObject.searchSource.serialize(); + attributes.kibanaSavedObjectMeta = { searchSourceJSON }; + references.push(...searchSourceReferences); + } + + if (savedObject.searchSourceFields) { + const [searchSourceFields, searchSourceReferences] = extractSearchSourceReferences( + savedObject.searchSourceFields + ); + const searchSourceJSON = JSON.stringify(searchSourceFields); + attributes.kibanaSavedObjectMeta = { searchSourceJSON }; + references.push(...searchSourceReferences); + } + + if (services.savedObjectsTagging) { + references = services.savedObjectsTagging.ui.updateTagsReferences( + references, + savedObject.tags || [] + ); + } + + const extractedRefs = extractReferences({ attributes, references }); + + if (!extractedRefs.references) { + throw new Error('References not returned from extractReferences'); + } + + try { + await checkForDuplicateTitle( + { + ...savedObject, + copyOnSave, + } as any, + isTitleDuplicateConfirmed, + onTitleDuplicate, + services as any + ); + const createOpt = { + id: savedObject.id, + migrationVersion: savedObject.migrationVersion, + references: extractedRefs.references, + }; + const resp = confirmOverwrite + ? await saveWithConfirmation(attributes, savedObject, createOpt, services) + : await services.savedObjectsClient.create(SAVED_VIS_TYPE, extractedRefs.attributes, { + ...createOpt, + overwrite: true, + }); + + savedObject.id = resp.id; + savedObject.lastSavedTitle = savedObject.title; + return savedObject.id; + } catch (err: any) { + savedObject.id = originalId; + if (isErrorNonFatal(err)) { + return ''; + } + return Promise.reject(err); + } +} diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json index 65ab83d5e0bae..eeaed655c3e73 100644 --- a/src/plugins/visualizations/tsconfig.json +++ b/src/plugins/visualizations/tsconfig.json @@ -23,5 +23,6 @@ { "path": "../usage_collection/tsconfig.json" }, { "path": "../kibana_utils/tsconfig.json" }, { "path": "../discover/tsconfig.json" }, + { "path": "../../../x-pack/plugins/spaces/tsconfig.json" }, ] } diff --git a/src/plugins/visualize/kibana.json b/src/plugins/visualize/kibana.json index afa9e3ce055b2..bfb23bec2111c 100644 --- a/src/plugins/visualize/kibana.json +++ b/src/plugins/visualize/kibana.json @@ -17,7 +17,8 @@ "home", "share", "savedObjectsTaggingOss", - "usageCollection" + "usageCollection", + "spaces" ], "requiredBundles": [ "kibanaUtils", diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.test.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.test.tsx new file mode 100644 index 0000000000000..2c8478492005f --- /dev/null +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.test.tsx @@ -0,0 +1,112 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { VisualizeEditorCommon } from './visualize_editor_common'; +import { VisualizeEditorVisInstance } from '../types'; + +const mockGetLegacyUrlConflict = jest.fn(); +const mockRedirectLegacyUrl = jest.fn(() => Promise.resolve()); +jest.mock('../../../../kibana_react/public', () => ({ + useKibana: jest.fn(() => ({ + services: { + spaces: { + ui: { + redirectLegacyUrl: mockRedirectLegacyUrl, + components: { + getLegacyUrlConflict: mockGetLegacyUrlConflict, + }, + }, + }, + history: { + location: { + search: '?_g=test', + }, + }, + http: { + basePath: { + prepend: (url: string) => url, + }, + }, + }, + })), + withKibana: jest.fn((comp) => comp), +})); + +describe('VisualizeEditorCommon', () => { + it('should display a conflict callout if saved object conflicts', async () => { + shallow( + {}} + hasUnappliedChanges={false} + isEmbeddableRendered={false} + onAppLeave={() => {}} + visEditorRef={React.createRef()} + visInstance={ + { + savedVis: { + id: 'test', + sharingSavedObjectProps: { + outcome: 'conflict', + aliasTargetId: 'alias_id', + }, + }, + vis: { + type: { + title: 'TSVB', + }, + }, + } as VisualizeEditorVisInstance + } + /> + ); + expect(mockGetLegacyUrlConflict).toHaveBeenCalledWith({ + currentObjectId: 'test', + objectNoun: 'TSVB visualization', + otherObjectId: 'alias_id', + otherObjectPath: '#/edit/alias_id?_g=test', + }); + }); + + it('should redirect to new id if saved object aliasMatch', async () => { + mount( + {}} + hasUnappliedChanges={false} + isEmbeddableRendered={false} + onAppLeave={() => {}} + visEditorRef={React.createRef()} + visInstance={ + { + savedVis: { + id: 'test', + sharingSavedObjectProps: { + outcome: 'aliasMatch', + aliasTargetId: 'alias_id', + }, + }, + vis: { + type: { + title: 'TSVB', + }, + }, + } as VisualizeEditorVisInstance + } + /> + ); + expect(mockRedirectLegacyUrl).toHaveBeenCalledWith( + '#/edit/alias_id?_g=test', + 'TSVB visualization' + ); + }); +}); diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx index a03073e61f59c..6268ba5c936ef 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx @@ -7,15 +7,19 @@ */ import './visualize_editor.scss'; -import React, { RefObject } from 'react'; +import React, { RefObject, useCallback, useEffect } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; import { EuiScreenReaderOnly } from '@elastic/eui'; import { AppMountParameters } from 'kibana/public'; import { VisualizeTopNav } from './visualize_top_nav'; import { ExperimentalVisInfo } from './experimental_vis_info'; +import { useKibana } from '../../../../kibana_react/public'; +import { urlFor } from '../../../../visualizations/public'; import { SavedVisInstance, VisualizeAppState, + VisualizeServices, VisualizeAppStateContainer, VisualizeEditorVisInstance, } from '../types'; @@ -53,6 +57,55 @@ export const VisualizeEditorCommon = ({ embeddableId, visEditorRef, }: VisualizeEditorCommonProps) => { + const { services } = useKibana(); + + useEffect(() => { + async function aliasMatchRedirect() { + const sharingSavedObjectProps = visInstance?.savedVis.sharingSavedObjectProps; + if (services.spaces && sharingSavedObjectProps?.outcome === 'aliasMatch') { + // We found this object by a legacy URL alias from its old ID; redirect the user to the page with its new ID, preserving any URL hash + const newObjectId = sharingSavedObjectProps?.aliasTargetId; // This is always defined if outcome === 'aliasMatch' + const newPath = `${urlFor(newObjectId!)}${services.history.location.search}`; + await services.spaces.ui.redirectLegacyUrl( + newPath, + i18n.translate('visualize.legacyUrlConflict.objectNoun', { + defaultMessage: '{visName} visualization', + values: { + visName: visInstance?.vis?.type.title, + }, + }) + ); + return; + } + } + + aliasMatchRedirect(); + }, [visInstance?.savedVis.sharingSavedObjectProps, visInstance?.vis?.type.title, services]); + + const getLegacyUrlConflictCallout = useCallback(() => { + // This function returns a callout component *if* we have encountered a "legacy URL conflict" scenario + const currentObjectId = visInstance?.savedVis.id; + const sharingSavedObjectProps = visInstance?.savedVis.sharingSavedObjectProps; + if (services.spaces && sharingSavedObjectProps?.outcome === 'conflict' && currentObjectId) { + // We have resolved to one object, but another object has a legacy URL alias associated with this ID/page. We should display a + // callout with a warning for the user, and provide a way for them to navigate to the other object. + const otherObjectId = sharingSavedObjectProps?.aliasTargetId!; // This is always defined if outcome === 'conflict' + const otherObjectPath = `${urlFor(otherObjectId)}${services.history.location.search}`; + return services.spaces.ui.components.getLegacyUrlConflict({ + objectNoun: i18n.translate('visualize.legacyUrlConflict.objectNoun', { + defaultMessage: '{visName} visualization', + values: { + visName: visInstance?.vis?.type.title, + }, + }), + currentObjectId, + otherObjectId, + otherObjectPath, + }); + } + return null; + }, [visInstance?.savedVis, services, visInstance?.vis?.type.title]); + return (