diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 9a4f2b71da1ff..acfb7307f49c4 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -132,6 +132,9 @@ /x-pack/test/alerting_api_integration @elastic/kibana-alerting-services /x-pack/test/plugin_api_integration/plugins/task_manager @elastic/kibana-alerting-services /x-pack/test/plugin_api_integration/test_suites/task_manager @elastic/kibana-alerting-services +/x-pack/legacy/plugins/triggers_actions_ui/ @elastic/kibana-alerting-services +/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/ @elastic/kibana-alerting-services +/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/ @elastic/kibana-alerting-services # Design **/*.scss @elastic/kibana-design diff --git a/docs/development/core/server/kibana-plugin-server.basepath.get.md b/docs/development/core/server/kibana-plugin-server.basepath.get.md index 6ef7022f10e62..a20bc1a4e3174 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.get.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.get.md @@ -9,5 +9,5 @@ returns `basePath` value, specific for an incoming request. Signature: ```typescript -get: (request: KibanaRequest | LegacyRequest) => string; +get: (request: LegacyRequest | KibanaRequest) => string; ``` diff --git a/docs/development/core/server/kibana-plugin-server.basepath.md b/docs/development/core/server/kibana-plugin-server.basepath.md index 50a30f7c43fe6..63aeb7f711d97 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.md @@ -20,9 +20,9 @@ The constructor for this class is marked as internal. Third-party code should no | Property | Modifiers | Type | Description | | --- | --- | --- | --- | -| [get](./kibana-plugin-server.basepath.get.md) | | (request: KibanaRequest<unknown, unknown, unknown, any> | LegacyRequest) => string | returns basePath value, specific for an incoming request. | +| [get](./kibana-plugin-server.basepath.get.md) | | (request: LegacyRequest | KibanaRequest<unknown, unknown, unknown, any>) => string | returns basePath value, specific for an incoming request. | | [prepend](./kibana-plugin-server.basepath.prepend.md) | | (path: string) => string | Prepends path with the basePath. | | [remove](./kibana-plugin-server.basepath.remove.md) | | (path: string) => string | Removes the prepended basePath from the path. | | [serverBasePath](./kibana-plugin-server.basepath.serverbasepath.md) | | string | returns the server's basePathSee [BasePath.get](./kibana-plugin-server.basepath.get.md) for getting the basePath value for a specific request | -| [set](./kibana-plugin-server.basepath.set.md) | | (request: KibanaRequest<unknown, unknown, unknown, any> | LegacyRequest, requestSpecificBasePath: string) => void | sets basePath value, specific for an incoming request. | +| [set](./kibana-plugin-server.basepath.set.md) | | (request: LegacyRequest | KibanaRequest<unknown, unknown, unknown, any>, requestSpecificBasePath: string) => void | sets basePath value, specific for an incoming request. | diff --git a/docs/development/core/server/kibana-plugin-server.basepath.set.md b/docs/development/core/server/kibana-plugin-server.basepath.set.md index 56a7f644d34cc..ac08baa0bb99e 100644 --- a/docs/development/core/server/kibana-plugin-server.basepath.set.md +++ b/docs/development/core/server/kibana-plugin-server.basepath.set.md @@ -9,5 +9,5 @@ sets `basePath` value, specific for an incoming request. Signature: ```typescript -set: (request: KibanaRequest | LegacyRequest, requestSpecificBasePath: string) => void; +set: (request: LegacyRequest | KibanaRequest, requestSpecificBasePath: string) => void; ``` diff --git a/docs/development/core/server/kibana-plugin-server.uisettingsparams.deprecation.md b/docs/development/core/server/kibana-plugin-server.uisettingsparams.deprecation.md new file mode 100644 index 0000000000000..7ad26b85bf81c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.uisettingsparams.deprecation.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [UiSettingsParams](./kibana-plugin-server.uisettingsparams.md) > [deprecation](./kibana-plugin-server.uisettingsparams.deprecation.md) + +## UiSettingsParams.deprecation property + +optional deprecation information. Used to generate a deprecation warning. + +Signature: + +```typescript +deprecation?: DeprecationSettings; +``` diff --git a/docs/development/core/server/kibana-plugin-server.uisettingsparams.md b/docs/development/core/server/kibana-plugin-server.uisettingsparams.md index a38499e8f37dd..fc2f8038f973f 100644 --- a/docs/development/core/server/kibana-plugin-server.uisettingsparams.md +++ b/docs/development/core/server/kibana-plugin-server.uisettingsparams.md @@ -17,6 +17,7 @@ export interface UiSettingsParams | Property | Type | Description | | --- | --- | --- | | [category](./kibana-plugin-server.uisettingsparams.category.md) | string[] | used to group the configured setting in the UI | +| [deprecation](./kibana-plugin-server.uisettingsparams.deprecation.md) | DeprecationSettings | optional deprecation information. Used to generate a deprecation warning. | | [description](./kibana-plugin-server.uisettingsparams.description.md) | string | description provided to a user in UI | | [name](./kibana-plugin-server.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-server.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | @@ -24,5 +25,6 @@ export interface UiSettingsParams | [readonly](./kibana-plugin-server.uisettingsparams.readonly.md) | boolean | a flag indicating that value cannot be changed | | [requiresPageReload](./kibana-plugin-server.uisettingsparams.requirespagereload.md) | boolean | a flag indicating whether new value applying requires page reloading | | [type](./kibana-plugin-server.uisettingsparams.type.md) | UiSettingsType | defines a type of UI element [UiSettingsType](./kibana-plugin-server.uisettingstype.md) | +| [validation](./kibana-plugin-server.uisettingsparams.validation.md) | ImageValidation | StringValidation | | | [value](./kibana-plugin-server.uisettingsparams.value.md) | SavedObjectAttribute | default value to fall back to if a user doesn't provide any | diff --git a/docs/development/core/server/kibana-plugin-server.uisettingsparams.validation.md b/docs/development/core/server/kibana-plugin-server.uisettingsparams.validation.md new file mode 100644 index 0000000000000..f097f36e999ba --- /dev/null +++ b/docs/development/core/server/kibana-plugin-server.uisettingsparams.validation.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-server](./kibana-plugin-server.md) > [UiSettingsParams](./kibana-plugin-server.uisettingsparams.md) > [validation](./kibana-plugin-server.uisettingsparams.validation.md) + +## UiSettingsParams.validation property + +Signature: + +```typescript +validation?: ImageValidation | StringValidation; +``` diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index a674b49a8e134..09ea1afe35766 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -127,7 +127,7 @@ export class ChromeService { ) ) ); - this.isVisible$ = combineLatest(this.appHidden$, this.toggleHidden$).pipe( + this.isVisible$ = combineLatest([this.appHidden$, this.toggleHidden$]).pipe( map(([appHidden, toggleHidden]) => !(appHidden || toggleHidden)), takeUntil(this.stop$) ); diff --git a/src/core/public/doc_links/doc_links_service.ts b/src/core/public/doc_links/doc_links_service.ts index 44dc76bfe6e32..36b220f16f395 100644 --- a/src/core/public/doc_links/doc_links_service.ts +++ b/src/core/public/doc_links/doc_links_service.ts @@ -115,6 +115,9 @@ export class DocLinksService { date: { dateMath: `${ELASTICSEARCH_DOCS}common-options.html#date-math`, }, + management: { + kibanaSearchSettings: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/advanced-options.html#kibana-search-settings`, + }, }, }); } diff --git a/src/core/public/rendering/app_containers.test.tsx b/src/core/public/rendering/app_containers.test.tsx new file mode 100644 index 0000000000000..746e37b1214d9 --- /dev/null +++ b/src/core/public/rendering/app_containers.test.tsx @@ -0,0 +1,105 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { BehaviorSubject } from 'rxjs'; +import { act } from 'react-dom/test-utils'; +import { mount } from 'enzyme'; +import React from 'react'; + +import { AppWrapper, AppContainer } from './app_containers'; + +describe('AppWrapper', () => { + it('toggles the `hidden-chrome` class depending on the chrome visibility state', () => { + const chromeVisible$ = new BehaviorSubject(true); + + const component = mount(app-content); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => chromeVisible$.next(false)); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => chromeVisible$.next(true)); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + }); +}); + +describe('AppContainer', () => { + it('adds classes supplied by chrome', () => { + const appClasses$ = new BehaviorSubject([]); + + const component = mount(app-content); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => appClasses$.next(['classA', 'classB'])); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => appClasses$.next(['classC'])); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + + act(() => appClasses$.next([])); + component.update(); + expect(component.getDOMNode()).toMatchInlineSnapshot(` +
+ app-content +
+ `); + }); +}); diff --git a/src/core/public/rendering/app_containers.tsx b/src/core/public/rendering/app_containers.tsx new file mode 100644 index 0000000000000..72faaeac588be --- /dev/null +++ b/src/core/public/rendering/app_containers.tsx @@ -0,0 +1,37 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { Observable } from 'rxjs'; +import useObservable from 'react-use/lib/useObservable'; +import classNames from 'classnames'; + +export const AppWrapper: React.FunctionComponent<{ + chromeVisible$: Observable; +}> = ({ chromeVisible$, children }) => { + const visible = useObservable(chromeVisible$); + return
{children}
; +}; + +export const AppContainer: React.FunctionComponent<{ + classes$: Observable; +}> = ({ classes$, children }) => { + const classes = useObservable(classes$); + return
{children}
; +}; diff --git a/src/core/public/rendering/rendering_service.test.tsx b/src/core/public/rendering/rendering_service.test.tsx index ed835574a32f9..437a602a3d447 100644 --- a/src/core/public/rendering/rendering_service.test.tsx +++ b/src/core/public/rendering/rendering_service.test.tsx @@ -18,72 +18,129 @@ */ import React from 'react'; +import { act } from 'react-dom/test-utils'; -import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { RenderingService } from './rendering_service'; -import { InternalApplicationStart } from '../application'; +import { applicationServiceMock } from '../application/application_service.mock'; +import { chromeServiceMock } from '../chrome/chrome_service.mock'; import { injectedMetadataServiceMock } from '../injected_metadata/injected_metadata_service.mock'; import { overlayServiceMock } from '../overlays/overlay_service.mock'; +import { BehaviorSubject } from 'rxjs'; describe('RenderingService#start', () => { - const getService = ({ legacyMode = false }: { legacyMode?: boolean } = {}) => { - const rendering = new RenderingService(); - const application = { - getComponent: () =>
Hello application!
, - } as InternalApplicationStart; - const chrome = chromeServiceMock.createStartContract(); + let application: ReturnType; + let chrome: ReturnType; + let overlays: ReturnType; + let injectedMetadata: ReturnType; + let targetDomElement: HTMLDivElement; + let rendering: RenderingService; + + beforeEach(() => { + application = applicationServiceMock.createInternalStartContract(); + application.getComponent.mockReturnValue(
Hello application!
); + + chrome = chromeServiceMock.createStartContract(); chrome.getHeaderComponent.mockReturnValue(
Hello chrome!
); - const overlays = overlayServiceMock.createStartContract(); + + overlays = overlayServiceMock.createStartContract(); overlays.banners.getComponent.mockReturnValue(
I'm a banner!
); - const injectedMetadata = injectedMetadataServiceMock.createStartContract(); - injectedMetadata.getLegacyMode.mockReturnValue(legacyMode); - const targetDomElement = document.createElement('div'); - const start = rendering.start({ + injectedMetadata = injectedMetadataServiceMock.createStartContract(); + + targetDomElement = document.createElement('div'); + + rendering = new RenderingService(); + }); + + const startService = () => { + return rendering.start({ application, chrome, injectedMetadata, overlays, targetDomElement, }); - return { start, targetDomElement }; }; - it('renders application service into provided DOM element', () => { - const { targetDomElement } = getService(); - expect(targetDomElement.querySelector('div.application')).toMatchInlineSnapshot(` -
-
- Hello application! -
-
- `); - }); + describe('standard mode', () => { + beforeEach(() => { + injectedMetadata.getLegacyMode.mockReturnValue(false); + }); - it('contains wrapper divs', () => { - const { targetDomElement } = getService(); - expect(targetDomElement.querySelector('div.app-wrapper')).toBeDefined(); - expect(targetDomElement.querySelector('div.app-wrapper-pannel')).toBeDefined(); - }); + it('renders application service into provided DOM element', () => { + startService(); + expect(targetDomElement.querySelector('div.application')).toMatchInlineSnapshot(` +
+
+ Hello application! +
+
+ `); + }); + + it('adds the `chrome-hidden` class to the AppWrapper when chrome is hidden', () => { + const isVisible$ = new BehaviorSubject(true); + chrome.getIsVisible$.mockReturnValue(isVisible$); + startService(); + + const appWrapper = targetDomElement.querySelector('div.app-wrapper')!; + expect(appWrapper.className).toEqual('app-wrapper'); + + act(() => isVisible$.next(false)); + expect(appWrapper.className).toEqual('app-wrapper hidden-chrome'); - it('renders the banner UI', () => { - const { targetDomElement } = getService(); - expect(targetDomElement.querySelector('#globalBannerList')).toMatchInlineSnapshot(` -
-
- I'm a banner! -
-
- `); + act(() => isVisible$.next(true)); + expect(appWrapper.className).toEqual('app-wrapper'); + }); + + it('adds the application classes to the AppContainer', () => { + const applicationClasses$ = new BehaviorSubject([]); + chrome.getApplicationClasses$.mockReturnValue(applicationClasses$); + startService(); + + const appContainer = targetDomElement.querySelector('div.application')!; + expect(appContainer.className).toEqual('application'); + + act(() => applicationClasses$.next(['classA', 'classB'])); + expect(appContainer.className).toEqual('application classA classB'); + + act(() => applicationClasses$.next(['classC'])); + expect(appContainer.className).toEqual('application classC'); + + act(() => applicationClasses$.next([])); + expect(appContainer.className).toEqual('application'); + }); + + it('contains wrapper divs', () => { + startService(); + expect(targetDomElement.querySelector('div.app-wrapper')).toBeDefined(); + expect(targetDomElement.querySelector('div.app-wrapper-pannel')).toBeDefined(); + }); + + it('renders the banner UI', () => { + startService(); + expect(targetDomElement.querySelector('#globalBannerList')).toMatchInlineSnapshot(` +
+
+ I'm a banner! +
+
+ `); + }); }); - describe('legacyMode', () => { + describe('legacy mode', () => { + beforeEach(() => { + injectedMetadata.getLegacyMode.mockReturnValue(true); + }); + it('renders into provided DOM element', () => { - const { targetDomElement } = getService({ legacyMode: true }); + startService(); + expect(targetDomElement).toMatchInlineSnapshot(`
{ }); it('returns a div for the legacy service to render into', () => { - const { - start: { legacyTargetDomElement }, - targetDomElement, - } = getService({ legacyMode: true }); + const { legacyTargetDomElement } = startService(); + expect(targetDomElement.contains(legacyTargetDomElement!)).toBe(true); }); }); diff --git a/src/core/public/rendering/rendering_service.tsx b/src/core/public/rendering/rendering_service.tsx index 7a747faa2673f..58b8c1921e333 100644 --- a/src/core/public/rendering/rendering_service.tsx +++ b/src/core/public/rendering/rendering_service.tsx @@ -25,6 +25,7 @@ import { InternalChromeStart } from '../chrome'; import { InternalApplicationStart } from '../application'; import { InjectedMetadataStart } from '../injected_metadata'; import { OverlayStart } from '../overlays'; +import { AppWrapper, AppContainer } from './app_containers'; interface StartDeps { application: InternalApplicationStart; @@ -65,12 +66,12 @@ export class RenderingService { {chromeUi} {!legacyMode && ( -
+
{bannerUi}
-
{appUi}
+ {appUi}
-
+ )} {legacyMode &&
} diff --git a/src/core/server/mocks.ts b/src/core/server/mocks.ts index 073d380d3aa67..c7082d46313ae 100644 --- a/src/core/server/mocks.ts +++ b/src/core/server/mocks.ts @@ -37,6 +37,7 @@ export { elasticsearchServiceMock } from './elasticsearch/elasticsearch_service. export { httpServiceMock } from './http/http_service.mock'; export { loggingServiceMock } from './logging/logging_service.mock'; export { savedObjectsClientMock } from './saved_objects/service/saved_objects_client.mock'; +export { savedObjectsRepositoryMock } from './saved_objects/service/lib/repository.mock'; export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { uuidServiceMock } from './uuid/uuid_service.mock'; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index bf7dc14c73265..7f3a960571012 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1928,6 +1928,8 @@ export type SharedGlobalConfig = RecursiveReadonly_2<{ // @public export interface UiSettingsParams { category?: string[]; + // Warning: (ae-forgotten-export) The symbol "DeprecationSettings" needs to be exported by the entry point index.d.ts + deprecation?: DeprecationSettings; description?: string; name?: string; optionLabels?: Record; @@ -1935,6 +1937,11 @@ export interface UiSettingsParams { readonly?: boolean; requiresPageReload?: boolean; type?: UiSettingsType; + // Warning: (ae-forgotten-export) The symbol "ImageValidation" needs to be exported by the entry point index.d.ts + // Warning: (ae-forgotten-export) The symbol "StringValidation" needs to be exported by the entry point index.d.ts + // + // (undocumented) + validation?: ImageValidation | StringValidation; value?: SavedObjectAttribute; } diff --git a/src/core/server/types.ts b/src/core/server/types.ts index 9919c7f0386b5..2433aad1a2be5 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -23,4 +23,3 @@ export * from './saved_objects/types'; export * from './ui_settings/types'; export * from './legacy/types'; export { EnvironmentMode, PackageInfo } from './config/types'; -export { ICspConfig } from './csp'; diff --git a/src/core/server/ui_settings/types.ts b/src/core/server/ui_settings/types.ts index 5e3f0a4fbb6bd..14eb71a22cefc 100644 --- a/src/core/server/ui_settings/types.ts +++ b/src/core/server/ui_settings/types.ts @@ -73,6 +73,15 @@ export interface UserProvidedValues { isOverridden?: boolean; } +/** + * UiSettings deprecation field options. + * @public + * */ +export interface DeprecationSettings { + message: string; + docLinksKey: string; +} + /** * UI element type to represent the settings. * @public @@ -102,6 +111,25 @@ export interface UiSettingsParams { readonly?: boolean; /** defines a type of UI element {@link UiSettingsType} */ type?: UiSettingsType; + /** optional deprecation information. Used to generate a deprecation warning. */ + deprecation?: DeprecationSettings; + /* + * Allows defining a custom validation applicable to value change on the client. + * @deprecated + */ + validation?: ImageValidation | StringValidation; +} + +export interface StringValidation { + regexString: string; + message: string; +} + +export interface ImageValidation { + maxSize: { + length: number; + description: string; + }; } /** @internal */ diff --git a/src/dev/sass/build_sass.js b/src/dev/sass/build_sass.js index 14f03a7a116a6..1ff7c700d0386 100644 --- a/src/dev/sass/build_sass.js +++ b/src/dev/sass/build_sass.js @@ -19,6 +19,7 @@ import { resolve } from 'path'; +import * as Rx from 'rxjs'; import { toArray } from 'rxjs/operators'; import { createFailError } from '@kbn/dev-utils'; @@ -61,9 +62,11 @@ export async function buildSass({ log, kibanaDir, watch }) { const scanDirs = [resolve(kibanaDir, 'src/legacy/core_plugins')]; const paths = [resolve(kibanaDir, 'x-pack')]; - const { spec$ } = findPluginSpecs({ plugins: { scanDirs, paths } }); - const enabledPlugins = await spec$.pipe(toArray()).toPromise(); - const uiExports = collectUiExports(enabledPlugins); + const { spec$, disabledSpec$ } = findPluginSpecs({ plugins: { scanDirs, paths } }); + const allPlugins = await Rx.merge(spec$, disabledSpec$) + .pipe(toArray()) + .toPromise(); + const uiExports = collectUiExports(allPlugins); const { styleSheetPaths } = uiExports; log.info('%s %d styleSheetPaths', watch ? 'watching' : 'found', styleSheetPaths.length); diff --git a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts index 6262c304e307b..8301daa675b5c 100644 --- a/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts +++ b/src/legacy/core_plugins/console/public/np_ready/application/models/legacy_core_editor/legacy_core_editor.ts @@ -303,7 +303,9 @@ export class LegacyCoreEditor implements CoreEditor { const maxLineLength = this.getWrapLimit() - 5; const isWrapping = firstLine.length > maxLineLength; const getScreenCoords = (line: number) => - this.editor.renderer.textToScreenCoordinates(line - 1, startColumn).pageY - offsetFromPage; + this.editor.renderer.textToScreenCoordinates(line - 1, startColumn).pageY - + offsetFromPage + + (window.pageYOffset || 0); const topOfReq = getScreenCoords(startLine); if (topOfReq >= 0) { diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap b/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap index 10d165d0d69c4..eef8f3fc93d90 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap @@ -60,6 +60,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": Array [ "default_value", ], + "deprecation": undefined, "description": "Description for Test array setting", "displayName": "Test array setting", "isCustom": undefined, @@ -79,6 +80,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "elasticsearch", ], "defVal": true, + "deprecation": undefined, "description": "Description for Test boolean setting", "displayName": "Test boolean setting", "isCustom": undefined, @@ -100,6 +102,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test custom string setting", "displayName": "Test custom string setting", "isCustom": undefined, @@ -119,6 +122,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test image setting", "displayName": "Test image setting", "isCustom": undefined, @@ -140,6 +144,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": "{ \\"foo\\": \\"bar\\" }", + "deprecation": undefined, "description": "Description for overridden json", "displayName": "An overridden json", "isCustom": undefined, @@ -159,6 +164,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 1234, + "deprecation": undefined, "description": "Description for overridden number", "displayName": "An overridden number", "isCustom": undefined, @@ -178,6 +184,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for overridden select setting", "displayName": "Test overridden select setting", "isCustom": undefined, @@ -201,6 +208,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "foo", + "deprecation": undefined, "description": "Description for overridden string", "displayName": "An overridden string", "isCustom": undefined, @@ -220,6 +228,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "{\\"foo\\": \\"bar\\"}", + "deprecation": undefined, "description": "Description for Test json setting", "displayName": "Test json setting", "isCustom": undefined, @@ -239,6 +248,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "", + "deprecation": undefined, "description": "Description for Test markdown setting", "displayName": "Test markdown setting", "isCustom": undefined, @@ -258,6 +268,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 5, + "deprecation": undefined, "description": "Description for Test number setting", "displayName": "Test number setting", "isCustom": undefined, @@ -277,6 +288,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for Test select setting", "displayName": "Test select setting", "isCustom": undefined, @@ -300,6 +312,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -345,6 +358,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": Array [ "default_value", ], + "deprecation": undefined, "description": "Description for Test array setting", "displayName": "Test array setting", "isCustom": undefined, @@ -364,6 +378,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "elasticsearch", ], "defVal": true, + "deprecation": undefined, "description": "Description for Test boolean setting", "displayName": "Test boolean setting", "isCustom": undefined, @@ -385,6 +400,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test custom string setting", "displayName": "Test custom string setting", "isCustom": undefined, @@ -404,6 +420,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test image setting", "displayName": "Test image setting", "isCustom": undefined, @@ -425,6 +442,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "defVal": "{ \\"foo\\": \\"bar\\" }", + "deprecation": undefined, "description": "Description for overridden json", "displayName": "An overridden json", "isCustom": undefined, @@ -444,6 +462,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 1234, + "deprecation": undefined, "description": "Description for overridden number", "displayName": "An overridden number", "isCustom": undefined, @@ -463,6 +482,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for overridden select setting", "displayName": "Test overridden select setting", "isCustom": undefined, @@ -486,6 +506,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "foo", + "deprecation": undefined, "description": "Description for overridden string", "displayName": "An overridden string", "isCustom": undefined, @@ -505,6 +526,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "{\\"foo\\": \\"bar\\"}", + "deprecation": undefined, "description": "Description for Test json setting", "displayName": "Test json setting", "isCustom": undefined, @@ -524,6 +546,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "", + "deprecation": undefined, "description": "Description for Test markdown setting", "displayName": "Test markdown setting", "isCustom": undefined, @@ -543,6 +566,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": 5, + "deprecation": undefined, "description": "Description for Test number setting", "displayName": "Test number setting", "isCustom": undefined, @@ -562,6 +586,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": "orange", + "deprecation": undefined, "description": "Description for Test select setting", "displayName": "Test select setting", "isCustom": undefined, @@ -585,6 +610,7 @@ exports[`AdvancedSettings should render normally 1`] = ` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -705,6 +731,7 @@ exports[`AdvancedSettings should render read-only when saving is disabled 1`] = "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -748,6 +775,7 @@ exports[`AdvancedSettings should render read-only when saving is disabled 1`] = "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -886,6 +914,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, @@ -929,6 +958,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` "general", ], "defVal": null, + "deprecation": undefined, "description": "Description for Test string setting", "displayName": "Test string setting", "isCustom": undefined, diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js index 939dc8c20e465..a2f201cf757f5 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.js @@ -19,12 +19,14 @@ import React, { PureComponent, Fragment } from 'react'; import PropTypes from 'prop-types'; +import { npStart } from 'ui/new_platform'; import 'brace/theme/textmate'; import 'brace/mode/markdown'; import { toastNotifications } from 'ui/notify'; import { + EuiBadge, EuiButton, EuiButtonEmpty, EuiCode, @@ -41,6 +43,7 @@ import { EuiImage, EuiLink, EuiSpacer, + EuiToolTip, EuiText, EuiSelect, EuiSwitch, @@ -224,7 +227,7 @@ export class Field extends PureComponent { } const file = files[0]; - const { maxSize } = this.props.setting.options; + const { maxSize } = this.props.setting.validation; try { const base64Image = await this.getImageAsBase64(file); const isInvalid = !!(maxSize && maxSize.length && base64Image.length > maxSize.length); @@ -565,6 +568,36 @@ export class Field extends PureComponent { renderDescription(setting) { let description; + let deprecation; + + if (setting.deprecation) { + const { links } = npStart.core.docLinks; + + deprecation = ( + <> + + { + window.open(links.management[setting.deprecation.docLinksKey], '_blank'); + }} + onClickAriaLabel={i18n.translate( + 'kbn.management.settings.field.deprecationClickAreaLabel', + { + defaultMessage: 'Click to view deprecation documentation for {settingName}.', + values: { + settingName: setting.name, + }, + } + )} + > + Deprecated + + + + + ); + } if (React.isValidElement(setting.description)) { description = setting.description; @@ -582,6 +615,7 @@ export class Field extends PureComponent { return ( + {deprecation} {description} {this.renderDefaultValue(setting)} diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js index 74bb0e25ff52e..07ce6f84d2bb6 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/components/field/field.test.js @@ -72,10 +72,9 @@ const settings = { defVal: null, isCustom: false, isOverridden: false, - options: { + validation: { maxSize: { length: 1000, - displayName: '1 kB', description: 'Description for 1 kB', }, }, diff --git a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js index 791f9e400b407..6efb89cfba2b2 100644 --- a/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js +++ b/src/legacy/core_plugins/kibana/public/management/sections/settings/lib/to_editable_config.js @@ -43,12 +43,14 @@ export function toEditableConfig({ def, name, value, isCustom, isOverridden }) { defVal: def.value, type: getValType(def, value), description: def.description, - validation: def.validation - ? { - regex: new RegExp(def.validation.regexString), - message: def.validation.message, - } - : undefined, + deprecation: def.deprecation, + validation: + def.validation && def.validation.regexString + ? { + regex: new RegExp(def.validation.regexString), + message: def.validation.message, + } + : def.validation, options: def.options, optionLabels: def.optionLabels, requiresPageReload: !!def.requiresPageReload, diff --git a/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts b/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts index fc91742c53cca..b7a3a0f000d72 100644 --- a/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts +++ b/src/legacy/core_plugins/kibana/public/visualize_embeddable/visualize_embeddable.ts @@ -379,6 +379,7 @@ export class VisualizeEmbeddable extends Embeddable ({}), + mappings: { + 'tsvb-validation-telemetry': { + properties: { + failedRequests: { + type: 'long', + }, + }, + }, + }, + savedObjectSchemas: { + 'tsvb-validation-telemetry': { + isNamespaceAgnostic: true, + }, + }, }, init: (server: Legacy.Server) => { const visTypeTimeSeriesPlugin = server.newPlatform.setup.plugins diff --git a/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts b/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts index 8740f84dab3b9..225d81b71b8e0 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts +++ b/src/legacy/core_plugins/vis_type_timeseries/public/metrics_fn.ts @@ -31,6 +31,7 @@ type Context = KibanaContext | null; interface Arguments { params: string; uiState: string; + savedObjectId: string | null; } type VisParams = Required; @@ -64,10 +65,16 @@ export const createMetricsFn = (): ExpressionFunction { +export const metricsRequestHandler = async ({ + uiState, + timeRange, + filters, + query, + visParams, + savedObjectId, +}) => { const config = getUISettings(); const timezone = timezoneProvider(config)(); const uiStateObj = uiState.get(visParams.type, {}); @@ -49,6 +56,7 @@ export const metricsRequestHandler = async ({ uiState, timeRange, filters, query filters, panels: [visParams], state: uiStateObj, + savedObjectId: savedObjectId || 'unsaved', }), }); diff --git a/src/legacy/core_plugins/vis_type_timeseries/server/init.ts b/src/legacy/core_plugins/vis_type_timeseries/server/init.ts index 7b42ae8098016..ae6eebc00fc1b 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/server/init.ts +++ b/src/legacy/core_plugins/vis_type_timeseries/server/init.ts @@ -26,12 +26,17 @@ import { SearchStrategiesRegister } from './lib/search_strategies/search_strateg // @ts-ignore import { getVisData } from './lib/get_vis_data'; import { Framework } from '../../../../plugins/vis_type_timeseries/server'; +import { ValidationTelemetryServiceSetup } from '../../../../plugins/vis_type_timeseries/server'; -export const init = async (framework: Framework, __LEGACY: any) => { +export const init = async ( + framework: Framework, + __LEGACY: any, + validationTelemetry: ValidationTelemetryServiceSetup +) => { const { core } = framework; const router = core.http.createRouter(); - visDataRoutes(router, framework); + visDataRoutes(router, framework, validationTelemetry); // [LEGACY_TODO] fieldsRoutes(__LEGACY.server); diff --git a/src/legacy/core_plugins/vis_type_timeseries/server/routes/post_vis_schema.ts b/src/legacy/core_plugins/vis_type_timeseries/server/routes/post_vis_schema.ts new file mode 100644 index 0000000000000..3aca50b5b4710 --- /dev/null +++ b/src/legacy/core_plugins/vis_type_timeseries/server/routes/post_vis_schema.ts @@ -0,0 +1,247 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import Joi from 'joi'; +const stringOptionalNullable = Joi.string() + .allow('', null) + .optional(); +const stringRequired = Joi.string() + .allow('') + .required(); +const arrayNullable = Joi.array().allow(null); +const numberIntegerOptional = Joi.number() + .integer() + .optional(); +const numberIntegerRequired = Joi.number() + .integer() + .required(); +const numberOptional = Joi.number().optional(); +const numberRequired = Joi.number().required(); +const queryObject = Joi.object({ + language: Joi.string().allow(''), + query: Joi.string().allow(''), +}); + +const annotationsItems = Joi.object({ + color: stringOptionalNullable, + fields: stringOptionalNullable, + hidden: Joi.boolean().optional(), + icon: stringOptionalNullable, + id: stringOptionalNullable, + ignore_global_filters: numberIntegerOptional, + ignore_panel_filters: numberIntegerOptional, + index_pattern: stringOptionalNullable, + query_string: queryObject.optional(), + template: stringOptionalNullable, + time_field: stringOptionalNullable, +}); + +const backgroundColorRulesItems = Joi.object({ + value: Joi.number() + .allow(null) + .optional(), + id: stringOptionalNullable, + background_color: stringOptionalNullable, + color: stringOptionalNullable, +}); + +const gaugeColorRulesItems = Joi.object({ + gauge: stringOptionalNullable, + id: stringOptionalNullable, + operator: stringOptionalNullable, + value: Joi.number(), +}); +const metricsItems = Joi.object({ + field: stringOptionalNullable, + id: stringRequired, + metric_agg: stringOptionalNullable, + numerator: stringOptionalNullable, + denominator: stringOptionalNullable, + sigma: stringOptionalNullable, + function: stringOptionalNullable, + script: stringOptionalNullable, + variables: Joi.array() + .items( + Joi.object({ + field: stringOptionalNullable, + id: stringRequired, + name: stringOptionalNullable, + }) + ) + .optional(), + type: stringRequired, + value: stringOptionalNullable, + values: Joi.array() + .items(Joi.string().allow('', null)) + .allow(null) + .optional(), +}); + +const splitFiltersItems = Joi.object({ + id: stringOptionalNullable, + color: stringOptionalNullable, + filter: Joi.object({ + language: Joi.string().allow(''), + query: Joi.string().allow(''), + }).optional(), + label: stringOptionalNullable, +}); + +const seriesItems = Joi.object({ + aggregate_by: stringOptionalNullable, + aggregate_function: stringOptionalNullable, + axis_position: stringRequired, + axis_max: stringOptionalNullable, + axis_min: stringOptionalNullable, + chart_type: stringRequired, + color: stringRequired, + color_rules: Joi.array() + .items( + Joi.object({ + value: numberOptional, + id: stringRequired, + text: stringOptionalNullable, + operator: stringOptionalNullable, + }) + ) + .optional(), + fill: numberOptional, + filter: Joi.object({ + query: stringRequired, + language: stringOptionalNullable, + }).optional(), + formatter: stringRequired, + hide_in_legend: numberIntegerOptional, + hidden: Joi.boolean().optional(), + id: stringRequired, + label: stringOptionalNullable, + line_width: numberOptional, + metrics: Joi.array().items(metricsItems), + offset_time: stringOptionalNullable, + override_index_pattern: numberOptional, + point_size: numberRequired, + separate_axis: numberIntegerOptional, + seperate_axis: numberIntegerOptional, + series_index_pattern: stringOptionalNullable, + series_time_field: stringOptionalNullable, + series_interval: stringOptionalNullable, + series_drop_last_bucket: numberIntegerOptional, + split_color_mode: stringOptionalNullable, + split_filters: Joi.array() + .items(splitFiltersItems) + .optional(), + split_mode: stringRequired, + stacked: stringRequired, + steps: numberIntegerOptional, + terms_field: stringOptionalNullable, + terms_order_by: stringOptionalNullable, + terms_size: stringOptionalNullable, + terms_direction: stringOptionalNullable, + terms_include: stringOptionalNullable, + terms_exclude: stringOptionalNullable, + time_range_mode: stringOptionalNullable, + trend_arrows: numberOptional, + type: stringOptionalNullable, + value_template: stringOptionalNullable, + var_name: stringOptionalNullable, +}); + +export const visPayloadSchema = Joi.object({ + filters: arrayNullable, + panels: Joi.array().items( + Joi.object({ + annotations: Joi.array() + .items(annotationsItems) + .optional(), + axis_formatter: stringRequired, + axis_position: stringRequired, + axis_scale: stringRequired, + axis_min: stringOptionalNullable, + axis_max: stringOptionalNullable, + bar_color_rules: arrayNullable.optional(), + background_color: stringOptionalNullable, + background_color_rules: Joi.array() + .items(backgroundColorRulesItems) + .optional(), + default_index_pattern: stringOptionalNullable, + default_timefield: stringOptionalNullable, + drilldown_url: stringOptionalNullable, + drop_last_bucket: numberIntegerOptional, + filter: Joi.alternatives( + stringOptionalNullable, + Joi.object({ + language: stringOptionalNullable, + query: stringOptionalNullable, + }) + ), + gauge_color_rules: Joi.array() + .items(gaugeColorRulesItems) + .optional(), + gauge_width: [stringOptionalNullable, numberOptional], + gauge_inner_color: stringOptionalNullable, + gauge_inner_width: Joi.alternatives(stringOptionalNullable, numberIntegerOptional), + gauge_style: stringOptionalNullable, + gauge_max: stringOptionalNullable, + id: stringRequired, + ignore_global_filters: numberOptional, + ignore_global_filter: numberOptional, + index_pattern: stringRequired, + interval: stringRequired, + isModelInvalid: Joi.boolean().optional(), + legend_position: stringOptionalNullable, + markdown: stringOptionalNullable, + markdown_scrollbars: numberIntegerOptional, + markdown_openLinksInNewTab: numberIntegerOptional, + markdown_vertical_align: stringOptionalNullable, + markdown_less: stringOptionalNullable, + markdown_css: stringOptionalNullable, + pivot_id: stringOptionalNullable, + pivot_label: stringOptionalNullable, + pivot_type: stringOptionalNullable, + pivot_rows: stringOptionalNullable, + series: Joi.array() + .items(seriesItems) + .required(), + show_grid: numberIntegerRequired, + show_legend: numberIntegerRequired, + time_field: stringOptionalNullable, + time_range_mode: stringOptionalNullable, + type: stringRequired, + }) + ), + // general + query: Joi.array() + .items(queryObject) + .allow(null) + .required(), + state: Joi.object({ + sort: Joi.object({ + column: stringRequired, + order: Joi.string() + .valid(['asc', 'desc']) + .required(), + }).optional(), + }).required(), + savedObjectId: Joi.string().optional(), + timerange: Joi.object({ + timezone: stringRequired, + min: stringRequired, + max: stringRequired, + }).required(), +}); diff --git a/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.js b/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.ts similarity index 59% rename from src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.js rename to src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.ts index d2ded81309ffa..32e87f5a3f666 100644 --- a/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.js +++ b/src/legacy/core_plugins/vis_type_timeseries/server/routes/vis.ts @@ -17,12 +17,22 @@ * under the License. */ +import { IRouter } from 'kibana/server'; import { schema } from '@kbn/config-schema'; import { getVisData } from '../lib/get_vis_data'; +import { visPayloadSchema } from './post_vis_schema'; +import { + Framework, + ValidationTelemetryServiceSetup, +} from '../../../../../plugins/vis_type_timeseries/server'; const escapeHatch = schema.object({}, { allowUnknowns: true }); -export const visDataRoutes = (router, framework) => { +export const visDataRoutes = ( + router: IRouter, + framework: Framework, + { logFailedValidation }: ValidationTelemetryServiceSetup +) => { router.post( { path: '/api/metrics/vis/data', @@ -31,6 +41,16 @@ export const visDataRoutes = (router, framework) => { }, }, async (requestContext, request, response) => { + const { error: validationError } = visPayloadSchema.validate(request.body); + if (validationError) { + logFailedValidation(); + const savedObjectId = + (typeof request.body === 'object' && (request.body as any).savedObjectId) || + 'unavailable'; + framework.logger.warn( + `Request validation error: ${validationError.message} (saved object id: ${savedObjectId}). This most likely means your TSVB visualization contains outdated configuration. You can report this problem under https://github.com/elastic/kibana/issues/new?template=Bug_report.md` + ); + } try { const results = await getVisData(requestContext, request.body, framework); return response.ok({ body: results }); diff --git a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts index cc2ab133941db..ab1664d612b35 100644 --- a/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts +++ b/src/legacy/core_plugins/visualizations/public/np_ready/public/legacy/build_pipeline.ts @@ -59,7 +59,12 @@ export interface Schemas { [key: string]: any[] | undefined; } -type buildVisFunction = (visState: VisState, schemas: Schemas, uiState: any) => string; +type buildVisFunction = ( + visState: VisState, + schemas: Schemas, + uiState: any, + meta?: { savedObjectId?: string } +) => string; type buildVisConfigFunction = (schemas: Schemas, visParams?: VisParams) => VisParams; interface BuildPipelineVisFunction { @@ -248,11 +253,13 @@ export const buildPipelineVisFunction: BuildPipelineVisFunction = { input_control_vis: visState => { return `input_control_vis ${prepareJson('visConfig', visState.params)}`; }, - metrics: (visState, schemas, uiState = {}) => { + metrics: (visState, schemas, uiState = {}, meta) => { const paramsJson = prepareJson('params', visState.params); const uiStateJson = prepareJson('uiState', uiState); + const savedObjectIdParam = prepareString('savedObjectId', meta?.savedObjectId); - return `tsvb ${paramsJson} ${uiStateJson}`; + const params = [paramsJson, uiStateJson, savedObjectIdParam].filter(param => Boolean(param)); + return `tsvb ${params.join(' ')}`; }, timelion: visState => { const expression = prepareString('expression', visState.params.expression); @@ -488,6 +495,7 @@ export const buildPipeline = async ( params: { searchSource: ISearchSource; timeRange?: any; + savedObjectId?: string; } ) => { const { searchSource } = params; @@ -521,7 +529,9 @@ export const buildPipeline = async ( const schemas = getSchemas(vis, params.timeRange); if (buildPipelineVisFunction[vis.type.name]) { - pipeline += buildPipelineVisFunction[vis.type.name](visState, schemas, uiState); + pipeline += buildPipelineVisFunction[vis.type.name](visState, schemas, uiState, { + savedObjectId: params.savedObjectId, + }); } else if (vislibCharts.includes(vis.type.name)) { const visConfig = visState.params; visConfig.dimensions = await buildVislibDimensions(vis, params); diff --git a/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap b/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap index 7ab7d7653eb5e..4ec29ca409b80 100644 --- a/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap +++ b/src/plugins/data/public/ui/query_string_input/__snapshots__/language_switcher.test.tsx.snap @@ -170,6 +170,9 @@ exports[`LanguageSwitcher should toggle off if language is lucene 1`] = ` "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -460,6 +463,9 @@ exports[`LanguageSwitcher should toggle on if language is kuery 1`] = ` "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, diff --git a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap index 6f5f9b3956187..15e74e98920e2 100644 --- a/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap +++ b/src/plugins/data/public/ui/query_string_input/__snapshots__/query_string_input.test.tsx.snap @@ -276,6 +276,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -896,6 +899,9 @@ exports[`QueryStringInput Should disable autoFocus on EuiFieldText when disableA "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -1504,6 +1510,9 @@ exports[`QueryStringInput Should pass the query language to the language switche "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -2121,6 +2130,9 @@ exports[`QueryStringInput Should pass the query language to the language switche "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -2729,6 +2741,9 @@ exports[`QueryStringInput Should render the given query 1`] = ` "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, @@ -3346,6 +3361,9 @@ exports[`QueryStringInput Should render the given query 1`] = ` "logstash": Object { "base": "https://www.elastic.co/guide/en/logstash/mocked-test-branch", }, + "management": Object { + "kibanaSearchSettings": "https://www.elastic.co/guide/en/kibana/mocked-test-branch/advanced-options.html#kibana-search-settings", + }, "metricbeat": Object { "base": "https://www.elastic.co/guide/en/beats/metricbeat/mocked-test-branch", }, diff --git a/src/plugins/vis_type_timeseries/kibana.json b/src/plugins/vis_type_timeseries/kibana.json index f9a368e85ed49..d77f4ac92da16 100644 --- a/src/plugins/vis_type_timeseries/kibana.json +++ b/src/plugins/vis_type_timeseries/kibana.json @@ -2,5 +2,6 @@ "id": "metrics", "version": "8.0.0", "kibanaVersion": "kibana", - "server": true -} \ No newline at end of file + "server": true, + "optionalPlugins": ["usageCollection"] +} diff --git a/src/plugins/vis_type_timeseries/server/index.ts b/src/plugins/vis_type_timeseries/server/index.ts index 599726612a936..dfb2394af237b 100644 --- a/src/plugins/vis_type_timeseries/server/index.ts +++ b/src/plugins/vis_type_timeseries/server/index.ts @@ -30,6 +30,8 @@ export const config = { export type VisTypeTimeseriesConfig = TypeOf; +export { ValidationTelemetryServiceSetup } from './validation_telemetry'; + export function plugin(initializerContext: PluginInitializerContext) { return new VisTypeTimeseriesPlugin(initializerContext); } diff --git a/src/plugins/vis_type_timeseries/server/plugin.ts b/src/plugins/vis_type_timeseries/server/plugin.ts index f508aa250454f..dcd0cd500bbc3 100644 --- a/src/plugins/vis_type_timeseries/server/plugin.ts +++ b/src/plugins/vis_type_timeseries/server/plugin.ts @@ -35,11 +35,17 @@ import { GetVisData, GetVisDataOptions, } from '../../../legacy/core_plugins/vis_type_timeseries/server'; +import { ValidationTelemetryService } from './validation_telemetry/validation_telemetry_service'; +import { UsageCollectionSetup } from '../../usage_collection/server'; export interface LegacySetup { server: Server; } +interface VisTypeTimeseriesPluginSetupDependencies { + usageCollection?: UsageCollectionSetup; +} + export interface VisTypeTimeseriesSetup { /** @deprecated */ __legacy: { @@ -61,11 +67,14 @@ export interface Framework { } export class VisTypeTimeseriesPlugin implements Plugin { + private validationTelementryService: ValidationTelemetryService; + constructor(private readonly initializerContext: PluginInitializerContext) { this.initializerContext = initializerContext; + this.validationTelementryService = new ValidationTelemetryService(); } - public setup(core: CoreSetup, plugins: any) { + public setup(core: CoreSetup, plugins: VisTypeTimeseriesPluginSetupDependencies) { const logger = this.initializerContext.logger.get('visTypeTimeseries'); const config$ = this.initializerContext.config.create(); // Global config contains things like the ES shard timeout @@ -82,8 +91,13 @@ export class VisTypeTimeseriesPlugin implements Plugin { return { __legacy: { config$, - registerLegacyAPI: once((__LEGACY: LegacySetup) => { - init(framework, __LEGACY); + registerLegacyAPI: once(async (__LEGACY: LegacySetup) => { + const validationTelemetrySetup = await this.validationTelementryService.setup(core, { + ...plugins, + globalConfig$, + }); + + await init(framework, __LEGACY, validationTelemetrySetup); }), }, getVisData: async (requestContext: RequestHandlerContext, options: GetVisDataOptions) => { diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/index.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/index.ts new file mode 100644 index 0000000000000..140f61fa2f3fd --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/index.ts @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './validation_telemetry_service'; diff --git a/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts new file mode 100644 index 0000000000000..136f5b9e5cfad --- /dev/null +++ b/src/plugins/vis_type_timeseries/server/validation_telemetry/validation_telemetry_service.ts @@ -0,0 +1,84 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { APICaller, CoreSetup, Plugin, PluginInitializerContext } from 'kibana/server'; +import { UsageCollectionSetup } from '../../../usage_collection/server'; + +export interface ValidationTelemetryServiceSetup { + logFailedValidation: () => void; +} + +export class ValidationTelemetryService implements Plugin { + private kibanaIndex: string = ''; + async setup( + core: CoreSetup, + { + usageCollection, + globalConfig$, + }: { + usageCollection?: UsageCollectionSetup; + globalConfig$: PluginInitializerContext['config']['legacy']['globalConfig$']; + } + ) { + globalConfig$.subscribe(config => { + this.kibanaIndex = config.kibana.index; + }); + if (usageCollection) { + usageCollection.registerCollector( + usageCollection.makeUsageCollector({ + type: 'tsvb-validation', + isReady: () => this.kibanaIndex !== '', + fetch: async (callCluster: APICaller) => { + try { + const response = await callCluster('get', { + index: this.kibanaIndex, + id: 'tsvb-validation-telemetry:tsvb-validation-telemetry', + ignore: [404], + }); + return { + failed_validations: + response?._source?.['tsvb-validation-telemetry']?.failedRequests || 0, + }; + } catch (err) { + return { + failed_validations: 0, + }; + } + }, + }) + ); + } + const internalRepository = core.savedObjects.createInternalRepository(); + + return { + logFailedValidation: async () => { + try { + await internalRepository.incrementCounter( + 'tsvb-validation-telemetry', + 'tsvb-validation-telemetry', + 'failedRequests' + ); + } catch (e) { + // swallow error, validation telemetry shouldn't fail anything else + } + }, + }; + } + start() {} +} diff --git a/test/plugin_functional/test_suites/core_plugins/applications.ts b/test/plugin_functional/test_suites/core_plugins/applications.ts index a3c9d9d63e353..231458fad155b 100644 --- a/test/plugin_functional/test_suites/core_plugins/applications.ts +++ b/test/plugin_functional/test_suites/core_plugins/applications.ts @@ -27,12 +27,18 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider const browser = getService('browser'); const appsMenu = getService('appsMenu'); const testSubjects = getService('testSubjects'); + const find = getService('find'); const loadingScreenNotShown = async () => expect(await testSubjects.exists('kbnLoadingMessage')).to.be(false); const loadingScreenShown = () => testSubjects.existOrFail('kbnLoadingMessage'); + const getAppWrapperWidth = async () => { + const wrapper = await find.byClassName('app-wrapper'); + return (await wrapper.getSize()).width; + }; + const getKibanaUrl = (pathname?: string, search?: string) => url.format({ protocol: 'http:', @@ -99,12 +105,20 @@ export default function({ getService, getPageObjects }: PluginFunctionalProvider await PageObjects.common.navigateToApp('chromeless'); await loadingScreenNotShown(); expect(await testSubjects.exists('headerGlobalNav')).to.be(false); + + const wrapperWidth = await getAppWrapperWidth(); + const windowWidth = (await browser.getWindowSize()).width; + expect(wrapperWidth).to.eql(windowWidth); }); it('navigating away from chromeless application shows chrome', async () => { await PageObjects.common.navigateToApp('foo'); await loadingScreenNotShown(); expect(await testSubjects.exists('headerGlobalNav')).to.be(true); + + const wrapperWidth = await getAppWrapperWidth(); + const windowWidth = (await browser.getWindowSize()).width; + expect(wrapperWidth).to.be.below(windowWidth); }); it.skip('can navigate from NP apps to legacy apps', async () => { diff --git a/x-pack/.i18nrc.json b/x-pack/.i18nrc.json index 7e86d2f1dc435..71e3bdd6c8c84 100644 --- a/x-pack/.i18nrc.json +++ b/x-pack/.i18nrc.json @@ -4,6 +4,7 @@ "xpack.actions": "legacy/plugins/actions", "xpack.advancedUiActions": "plugins/advanced_ui_actions", "xpack.alerting": "legacy/plugins/alerting", + "xpack.triggersActionsUI": "legacy/plugins/triggers_actions_ui", "xpack.apm": "legacy/plugins/apm", "xpack.beatsManagement": "legacy/plugins/beats_management", "xpack.canvas": "legacy/plugins/canvas", diff --git a/x-pack/index.js b/x-pack/index.js index 56547f89b1e90..83a7b5540334f 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -42,6 +42,7 @@ import { transform } from './legacy/plugins/transform'; import { actions } from './legacy/plugins/actions'; import { alerting } from './legacy/plugins/alerting'; import { lens } from './legacy/plugins/lens'; +import { triggersActionsUI } from './legacy/plugins/triggers_actions_ui'; module.exports = function(kibana) { return [ @@ -83,5 +84,6 @@ module.exports = function(kibana) { snapshotRestore(kibana), actions(kibana), alerting(kibana), + triggersActionsUI(kibana), ]; }; diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts index 2f15ae1c0a2b3..63f1b545179c7 100644 --- a/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.test.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { ActionTypeRegistry } from './action_type_registry'; import { ExecutorType } from './types'; import { ActionExecutor, ExecutorError, TaskRunnerFactory } from './lib'; import { configUtilsMock } from './actions_config.mock'; -const mockTaskManager = taskManagerMock.create(); +const mockTaskManager = taskManagerMock.setup(); const actionTypeRegistryParams = { taskManager: mockTaskManager, taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor()), diff --git a/x-pack/legacy/plugins/actions/server/action_type_registry.ts b/x-pack/legacy/plugins/actions/server/action_type_registry.ts index f66d1947c2b8b..351c1add7b451 100644 --- a/x-pack/legacy/plugins/actions/server/action_type_registry.ts +++ b/x-pack/legacy/plugins/actions/server/action_type_registry.ts @@ -6,11 +6,11 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; -import { TaskManagerSetupContract } from './shim'; -import { RunContext } from '../../task_manager/server'; +import { RunContext, TaskManagerSetupContract } from '../../../../plugins/task_manager/server'; import { ExecutorError, TaskRunnerFactory } from './lib'; import { ActionType } from './types'; import { ActionsConfigurationUtilities } from './actions_config'; + interface ConstructorOptions { taskManager: TaskManagerSetupContract; taskRunnerFactory: TaskRunnerFactory; diff --git a/x-pack/legacy/plugins/actions/server/actions_client.test.ts b/x-pack/legacy/plugins/actions/server/actions_client.test.ts index 9e75248c56cae..dfbd2db4b6842 100644 --- a/x-pack/legacy/plugins/actions/server/actions_client.test.ts +++ b/x-pack/legacy/plugins/actions/server/actions_client.test.ts @@ -10,7 +10,7 @@ import { ActionTypeRegistry } from './action_type_registry'; import { ActionsClient } from './actions_client'; import { ExecutorType } from './types'; import { ActionExecutor, TaskRunnerFactory } from './lib'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { configUtilsMock } from './actions_config.mock'; import { getActionsConfigurationUtilities } from './actions_config'; @@ -23,7 +23,7 @@ const defaultKibanaIndex = '.kibana'; const savedObjectsClient = savedObjectsClientMock.create(); const scopedClusterClient = elasticsearchServiceMock.createScopedClusterClient(); -const mockTaskManager = taskManagerMock.create(); +const mockTaskManager = taskManagerMock.setup(); const actionTypeRegistryParams = { taskManager: mockTaskManager, diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts index 4aaecc8e9d7df..74263c603c11e 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.test.ts @@ -49,7 +49,7 @@ beforeEach(() => { describe('actionTypeRegistry.get() works', () => { test('action type static data is as expected', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('email'); + expect(actionType.name).toEqual('Email'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts index dd2bd328ce53f..94d7852e76fad 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/email.ts @@ -118,7 +118,9 @@ export function getActionType(params: GetActionTypeParams): ActionType { const { logger, configurationUtilities } = params; return { id: '.email', - name: 'email', + name: i18n.translate('xpack.actions.builtin.emailTitle', { + defaultMessage: 'Email', + }), validate: { config: schema.object(ConfigSchemaProps, { validate: curry(validateConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts index 1da8b06e1587a..dbac84ef681f1 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.test.ts @@ -37,7 +37,7 @@ beforeEach(() => { describe('actionTypeRegistry.get() works', () => { test('action type static data is as expected', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('index'); + expect(actionType.name).toEqual('Index'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts index 0e9fe0483ee1e..ddf33ba63f71a 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/es_index.ts @@ -38,7 +38,9 @@ const ParamsSchema = schema.object({ export function getActionType({ logger }: { logger: Logger }): ActionType { return { id: '.index', - name: 'index', + name: i18n.translate('xpack.actions.builtin.esIndexTitle', { + defaultMessage: 'Index', + }), validate: { config: ConfigSchema, params: ParamsSchema, diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts index 3a0c9f415cc2b..5fcf39c2e8fdd 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/index.test.ts @@ -6,7 +6,7 @@ import { ActionExecutor, TaskRunnerFactory } from '../lib'; import { ActionTypeRegistry } from '../action_type_registry'; -import { taskManagerMock } from '../../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../../plugins/task_manager/server/task_manager.mock'; import { registerBuiltInActionTypes } from './index'; import { Logger } from '../../../../../../src/core/server'; import { loggingServiceMock } from '../../../../../../src/core/server/mocks'; @@ -20,7 +20,7 @@ export function createActionTypeRegistry(): { } { const logger = loggingServiceMock.create().get() as jest.Mocked; const actionTypeRegistry = new ActionTypeRegistry({ - taskManager: taskManagerMock.create(), + taskManager: taskManagerMock.setup(), taskRunnerFactory: new TaskRunnerFactory(new ActionExecutor()), actionsConfigUtils: configUtilsMock, }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts index cb3548524ebbb..f60fdf7fef95e 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.test.ts @@ -38,7 +38,7 @@ beforeAll(() => { describe('get()', () => { test('should return correct action type', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('pagerduty'); + expect(actionType.name).toEqual('PagerDuty'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts index 250c169278c57..b26621702cf5b 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/pagerduty.ts @@ -96,7 +96,9 @@ export function getActionType({ }): ActionType { return { id: '.pagerduty', - name: 'pagerduty', + name: i18n.translate('xpack.actions.builtin.pagerdutyTitle', { + defaultMessage: 'PagerDuty', + }), validate: { config: schema.object(configSchemaProps, { validate: curry(valdiateActionTypeConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts index c59ddf97017fd..8f28b9e8f5125 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.test.ts @@ -25,7 +25,7 @@ beforeAll(() => { describe('get()', () => { test('returns action type', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('server-log'); + expect(actionType.name).toEqual('Server log'); }); }); @@ -98,6 +98,6 @@ describe('execute()', () => { config: {}, secrets: {}, }); - expect(mockedLogger.info).toHaveBeenCalledWith('server-log: message text here'); + expect(mockedLogger.info).toHaveBeenCalledWith('Server log: message text here'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts index 0edf409e4d46c..34b8602eeba36 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/server_log.ts @@ -12,7 +12,7 @@ import { Logger } from '../../../../../../src/core/server'; import { ActionType, ActionTypeExecutorOptions, ActionTypeExecutorResult } from '../types'; import { withoutControlCharacters } from './lib/string_utils'; -const ACTION_NAME = 'server-log'; +const ACTION_NAME = 'Server log'; // params definition diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts index a2b0db8bdb70f..aebc9c4993599 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.test.ts @@ -29,7 +29,7 @@ beforeAll(() => { describe('action registeration', () => { test('returns action type', () => { expect(actionType.id).toEqual(ACTION_TYPE_ID); - expect(actionType.name).toEqual('slack'); + expect(actionType.name).toEqual('Slack'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts index 92611d6f162ff..b8989e59a2257 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/slack.ts @@ -49,7 +49,9 @@ export function getActionType({ }): ActionType { return { id: '.slack', - name: 'slack', + name: i18n.translate('xpack.actions.builtin.slackTitle', { + defaultMessage: 'Slack', + }), validate: { secrets: schema.object(secretsSchemaProps, { validate: curry(valdiateActionTypeConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts index 64dd3a485f8e2..b95fef97ac7b9 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.test.ts @@ -25,7 +25,7 @@ beforeAll(() => { describe('actionType', () => { test('exposes the action as `webhook` on its Id and Name', () => { expect(actionType.id).toEqual('.webhook'); - expect(actionType.name).toEqual('webhook'); + expect(actionType.name).toEqual('Webhook'); }); }); diff --git a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts index 06fe2fb0e591c..fa88d3c72c163 100644 --- a/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts +++ b/x-pack/legacy/plugins/actions/server/builtin_action_types/webhook.ts @@ -56,7 +56,9 @@ export function getActionType({ }): ActionType { return { id: '.webhook', - name: 'webhook', + name: i18n.translate('xpack.actions.builtin.webhookTitle', { + defaultMessage: 'Webhook', + }), validate: { config: schema.object(configSchemaProps, { validate: curry(valdiateActionTypeConfig)(configurationUtilities), diff --git a/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts b/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts index 6de446ee2da76..7dbcfce5ee335 100644 --- a/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts +++ b/x-pack/legacy/plugins/actions/server/create_execute_function.test.ts @@ -4,11 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { createExecuteFunction } from './create_execute_function'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -const mockTaskManager = taskManagerMock.create(); +const mockTaskManager = taskManagerMock.start(); const savedObjectsClient = savedObjectsClientMock.create(); const getBasePath = jest.fn(); diff --git a/x-pack/legacy/plugins/actions/server/create_execute_function.ts b/x-pack/legacy/plugins/actions/server/create_execute_function.ts index 8ff12b8c3fa4b..ddd8b1df2327b 100644 --- a/x-pack/legacy/plugins/actions/server/create_execute_function.ts +++ b/x-pack/legacy/plugins/actions/server/create_execute_function.ts @@ -5,7 +5,7 @@ */ import { SavedObjectsClientContract } from 'src/core/server'; -import { TaskManagerStartContract } from './shim'; +import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; import { GetBasePathFunction } from './types'; interface CreateExecuteFunctionOptions { diff --git a/x-pack/legacy/plugins/actions/server/init.ts b/x-pack/legacy/plugins/actions/server/init.ts index 5eab3418467bc..6f221b08c4bc5 100644 --- a/x-pack/legacy/plugins/actions/server/init.ts +++ b/x-pack/legacy/plugins/actions/server/init.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Legacy } from 'kibana'; import { Plugin } from './plugin'; -import { shim, Server } from './shim'; +import { shim } from './shim'; import { ActionsPlugin } from './types'; -export async function init(server: Server) { +export async function init(server: Legacy.Server) { const { initializerContext, coreSetup, coreStart, pluginsSetup, pluginsStart } = shim(server); const plugin = new Plugin(initializerContext); diff --git a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts index 5b60696c42d52..ad2b74da0d7d4 100644 --- a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts +++ b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.test.ts @@ -7,7 +7,7 @@ import sinon from 'sinon'; import { ExecutorError } from './executor_error'; import { ActionExecutor } from './action_executor'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager/server'; +import { ConcreteTaskInstance, TaskStatus } from '../../../../../plugins/task_manager/server'; import { TaskRunnerFactory } from './task_runner_factory'; import { actionTypeRegistryMock } from '../action_type_registry.mock'; import { actionExecutorMock } from './action_executor.mock'; diff --git a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts index ca6a726f40e14..2dc3d1161399e 100644 --- a/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts +++ b/x-pack/legacy/plugins/actions/server/lib/task_runner_factory.ts @@ -6,7 +6,7 @@ import { ActionExecutorContract } from './action_executor'; import { ExecutorError } from './executor_error'; -import { RunContext } from '../../../task_manager/server'; +import { RunContext } from '../../../../../plugins/task_manager/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; import { ActionTaskParams, GetBasePathFunction, SpaceIdToNamespaceFunction } from '../types'; diff --git a/x-pack/legacy/plugins/actions/server/plugin.ts b/x-pack/legacy/plugins/actions/server/plugin.ts index 48f99ba5135b7..ffc4a9cf90e54 100644 --- a/x-pack/legacy/plugins/actions/server/plugin.ts +++ b/x-pack/legacy/plugins/actions/server/plugin.ts @@ -93,7 +93,7 @@ export class Plugin { const actionsConfigUtils = getActionsConfigurationUtilities(config as ActionsConfigType); const actionTypeRegistry = new ActionTypeRegistry({ taskRunnerFactory, - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, actionsConfigUtils, }); this.taskRunnerFactory = taskRunnerFactory; @@ -164,7 +164,7 @@ export class Plugin { }); const executeFn = createExecuteFunction({ - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, getScopedSavedObjectsClient: core.savedObjects.getScopedSavedObjectsClient, getBasePath, }); diff --git a/x-pack/legacy/plugins/actions/server/shim.ts b/x-pack/legacy/plugins/actions/server/shim.ts index f8aa9b8d7a25c..8077dc67c92c4 100644 --- a/x-pack/legacy/plugins/actions/server/shim.ts +++ b/x-pack/legacy/plugins/actions/server/shim.ts @@ -8,7 +8,11 @@ import Hapi from 'hapi'; import { Legacy } from 'kibana'; import * as Rx from 'rxjs'; import { ActionsConfigType } from './types'; -import { TaskManager } from '../../task_manager/server'; +import { + TaskManagerStartContract, + TaskManagerSetupContract, +} from '../../../../plugins/task_manager/server'; +import { getTaskManagerSetup, getTaskManagerStart } from '../../task_manager/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../spaces'; @@ -24,16 +28,6 @@ import { } from '../../../../../src/core/server'; import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; -// Extend PluginProperties to indicate which plugins are guaranteed to exist -// due to being marked as dependencies -interface Plugins extends Hapi.PluginProperties { - task_manager: TaskManager; -} - -export interface Server extends Legacy.Server { - plugins: Plugins; -} - export interface KibanaConfig { index: string; } @@ -41,14 +35,9 @@ export interface KibanaConfig { /** * Shim what we're thinking setup and start contracts will look like */ -export type TaskManagerStartContract = Pick; export type XPackMainPluginSetupContract = Pick; export type SecurityPluginSetupContract = Pick; export type SecurityPluginStartContract = Pick; -export type TaskManagerSetupContract = Pick< - TaskManager, - 'addMiddleware' | 'registerTaskDefinitions' ->; /** * New platform interfaces @@ -74,7 +63,7 @@ export interface ActionsCoreStart { } export interface ActionsPluginsSetup { security?: SecurityPluginSetupContract; - task_manager: TaskManagerSetupContract; + taskManager: TaskManagerSetupContract; xpack_main: XPackMainPluginSetupContract; encryptedSavedObjects: EncryptedSavedObjectsSetupContract; licensing: LicensingPluginSetup; @@ -83,7 +72,7 @@ export interface ActionsPluginsStart { security?: SecurityPluginStartContract; spaces: () => SpacesPluginStartContract | undefined; encryptedSavedObjects: EncryptedSavedObjectsStartContract; - task_manager: TaskManagerStartContract; + taskManager: TaskManagerStartContract; } /** @@ -92,7 +81,7 @@ export interface ActionsPluginsStart { * @param server Hapi server instance */ export function shim( - server: Server + server: Legacy.Server ): { initializerContext: ActionsPluginInitializerContext; coreSetup: ActionsCoreSetup; @@ -132,7 +121,7 @@ export function shim( const pluginsSetup: ActionsPluginsSetup = { security: newPlatform.setup.plugins.security as SecurityPluginSetupContract | undefined, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerSetup(server)!, xpack_main: server.plugins.xpack_main, encryptedSavedObjects: newPlatform.setup.plugins .encryptedSavedObjects as EncryptedSavedObjectsSetupContract, @@ -146,7 +135,7 @@ export function shim( spaces: () => server.plugins.spaces, encryptedSavedObjects: newPlatform.start.plugins .encryptedSavedObjects as EncryptedSavedObjectsStartContract, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerStart(server)!, }; return { diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts index 8e96ad8dae31c..e1a05d6460e25 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.test.ts @@ -6,10 +6,9 @@ import { TaskRunnerFactory } from './task_runner'; import { AlertTypeRegistry } from './alert_type_registry'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; - -const taskManager = taskManagerMock.create(); +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; +const taskManager = taskManagerMock.setup(); const alertTypeRegistryParams = { taskManager, taskRunnerFactory: new TaskRunnerFactory(), diff --git a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts index 2003e810a05b5..1e9007202c452 100644 --- a/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts +++ b/x-pack/legacy/plugins/alerting/server/alert_type_registry.ts @@ -6,9 +6,8 @@ import Boom from 'boom'; import { i18n } from '@kbn/i18n'; +import { RunContext, TaskManagerSetupContract } from '../../../../plugins/task_manager/server'; import { TaskRunnerFactory } from './task_runner'; -import { RunContext } from '../../task_manager'; -import { TaskManagerSetupContract } from './shim'; import { AlertType } from './types'; interface ConstructorOptions { diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts index 32293d9755a2a..2af66059d9fed 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.test.ts @@ -7,14 +7,14 @@ import uuid from 'uuid'; import { schema } from '@kbn/config-schema'; import { AlertsClient } from './alerts_client'; import { savedObjectsClientMock, loggingServiceMock } from '../../../../../src/core/server/mocks'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; -import { TaskStatus } from '../../task_manager/server'; +import { TaskStatus } from '../../../../plugins/task_manager/server'; import { IntervalSchedule } from './types'; import { resolvable } from './test_utils'; import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks'; -const taskManager = taskManagerMock.create(); +const taskManager = taskManagerMock.start(); const alertTypeRegistry = alertTypeRegistryMock.create(); const savedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createStart(); diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client.ts b/x-pack/legacy/plugins/alerting/server/alerts_client.ts index 33a6b716e9b8a..fe96a233b8663 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client.ts @@ -22,7 +22,6 @@ import { AlertType, IntervalSchedule, } from './types'; -import { TaskManagerStartContract } from './shim'; import { validateAlertTypeParams } from './lib'; import { InvalidateAPIKeyParams, @@ -30,6 +29,7 @@ import { InvalidateAPIKeyResult as SecurityPluginInvalidateAPIKeyResult, } from '../../../../plugins/security/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../plugins/encrypted_saved_objects/server'; +import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; type NormalizedAlertAction = Omit; export type CreateAPIKeyResult = diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts index 519001d07e089..754e02a3f1e5e 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.test.ts @@ -7,7 +7,7 @@ import { Request } from 'hapi'; import { AlertsClientFactory, ConstructorOpts } from './alerts_client_factory'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; -import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; +import { taskManagerMock } from '../../../../plugins/task_manager/server/task_manager.mock'; import { KibanaRequest } from '../../../../../src/core/server'; import { loggingServiceMock } from '../../../../../src/core/server/mocks'; import { encryptedSavedObjectsMock } from '../../../../plugins/encrypted_saved_objects/server/mocks'; @@ -23,7 +23,7 @@ const securityPluginSetup = { }; const alertsClientFactoryParams: jest.Mocked = { logger: loggingServiceMock.create().get(), - taskManager: taskManagerMock.create(), + taskManager: taskManagerMock.start(), alertTypeRegistry: alertTypeRegistryMock.create(), getSpaceId: jest.fn(), spaceIdToNamespace: jest.fn(), diff --git a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts index 94a396fbaa806..eab1cc3ce627b 100644 --- a/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts +++ b/x-pack/legacy/plugins/alerting/server/alerts_client_factory.ts @@ -8,10 +8,11 @@ import Hapi from 'hapi'; import uuid from 'uuid'; import { AlertsClient } from './alerts_client'; import { AlertTypeRegistry, SpaceIdToNamespaceFunction } from './types'; -import { SecurityPluginStartContract, TaskManagerStartContract } from './shim'; +import { SecurityPluginStartContract } from './shim'; import { KibanaRequest, Logger } from '../../../../../src/core/server'; import { InvalidateAPIKeyParams } from '../../../../plugins/security/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../plugins/encrypted_saved_objects/server'; +import { TaskManagerStartContract } from '../../../../plugins/task_manager/server'; export interface ConstructorOpts { logger: Logger; diff --git a/x-pack/legacy/plugins/alerting/server/plugin.ts b/x-pack/legacy/plugins/alerting/server/plugin.ts index fb16f579d4c70..357db9e3df97e 100644 --- a/x-pack/legacy/plugins/alerting/server/plugin.ts +++ b/x-pack/legacy/plugins/alerting/server/plugin.ts @@ -79,7 +79,7 @@ export class Plugin { }); const alertTypeRegistry = new AlertTypeRegistry({ - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, taskRunnerFactory: this.taskRunnerFactory, }); this.alertTypeRegistry = alertTypeRegistry; @@ -116,7 +116,7 @@ export class Plugin { const alertsClientFactory = new AlertsClientFactory({ alertTypeRegistry: this.alertTypeRegistry!, logger: this.logger, - taskManager: plugins.task_manager, + taskManager: plugins.taskManager, securityPluginSetup: plugins.security, encryptedSavedObjectsPlugin: plugins.encryptedSavedObjects, spaceIdToNamespace, diff --git a/x-pack/legacy/plugins/alerting/server/shim.ts b/x-pack/legacy/plugins/alerting/server/shim.ts index ae29048d83dd9..ccc10f929e123 100644 --- a/x-pack/legacy/plugins/alerting/server/shim.ts +++ b/x-pack/legacy/plugins/alerting/server/shim.ts @@ -7,7 +7,11 @@ import Hapi from 'hapi'; import { Legacy } from 'kibana'; import { LegacySpacesPlugin as SpacesPluginStartContract } from '../../spaces'; -import { TaskManager } from '../../task_manager/server'; +import { + TaskManagerStartContract, + TaskManagerSetupContract, +} from '../../../../plugins/task_manager/server'; +import { getTaskManagerSetup, getTaskManagerStart } from '../../task_manager/server'; import { XPackMainPlugin } from '../../xpack_main/server/xpack_main'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; import { @@ -31,7 +35,6 @@ import { LicensingPluginSetup } from '../../../../plugins/licensing/server'; // due to being marked as dependencies interface Plugins extends Hapi.PluginProperties { actions: ActionsPlugin; - task_manager: TaskManager; } export interface Server extends Legacy.Server { @@ -41,17 +44,9 @@ export interface Server extends Legacy.Server { /** * Shim what we're thinking setup and start contracts will look like */ -export type TaskManagerStartContract = Pick< - TaskManager, - 'schedule' | 'fetch' | 'remove' | 'runNow' ->; export type SecurityPluginSetupContract = Pick; export type SecurityPluginStartContract = Pick; export type XPackMainPluginSetupContract = Pick; -export type TaskManagerSetupContract = Pick< - TaskManager, - 'addMiddleware' | 'registerTaskDefinitions' ->; /** * New platform interfaces @@ -73,7 +68,7 @@ export interface AlertingCoreStart { } export interface AlertingPluginsSetup { security?: SecurityPluginSetupContract; - task_manager: TaskManagerSetupContract; + taskManager: TaskManagerSetupContract; actions: ActionsPluginSetupContract; xpack_main: XPackMainPluginSetupContract; encryptedSavedObjects: EncryptedSavedObjectsSetupContract; @@ -84,7 +79,7 @@ export interface AlertingPluginsStart { security?: SecurityPluginStartContract; spaces: () => SpacesPluginStartContract | undefined; encryptedSavedObjects: EncryptedSavedObjectsStartContract; - task_manager: TaskManagerStartContract; + taskManager: TaskManagerStartContract; } /** @@ -121,7 +116,7 @@ export function shim( const pluginsSetup: AlertingPluginsSetup = { security: newPlatform.setup.plugins.security as SecurityPluginSetupContract | undefined, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerSetup(server)!, actions: server.plugins.actions.setup, xpack_main: server.plugins.xpack_main, encryptedSavedObjects: newPlatform.setup.plugins @@ -137,7 +132,7 @@ export function shim( spaces: () => server.plugins.spaces, encryptedSavedObjects: newPlatform.start.plugins .encryptedSavedObjects as EncryptedSavedObjectsStartContract, - task_manager: server.plugins.task_manager, + taskManager: getTaskManagerStart(server)!, }; return { diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts index 670e348379c56..394c13e1bd24f 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.test.ts @@ -7,7 +7,7 @@ import sinon from 'sinon'; import { schema } from '@kbn/config-schema'; import { AlertExecutorOptions } from '../types'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager'; +import { ConcreteTaskInstance, TaskStatus } from '../../../../../plugins/task_manager/server'; import { TaskRunnerContext } from './task_runner_factory'; import { TaskRunner } from './task_runner'; import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts index c6f1a02da8dcd..0f643e3d3121c 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner.ts @@ -8,7 +8,7 @@ import { pick, mapValues, omit } from 'lodash'; import { Logger } from '../../../../../../src/core/server'; import { SavedObject } from '../../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; -import { ConcreteTaskInstance } from '../../../task_manager'; +import { ConcreteTaskInstance } from '../../../../../plugins/task_manager/server'; import { createExecutionHandler } from './create_execution_handler'; import { AlertInstance, createAlertInstanceFactory } from '../alert_instance'; import { getNextRunAt } from './get_next_run_at'; diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts index 2ea1256352bec..543b9e7d32e12 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.test.ts @@ -5,7 +5,7 @@ */ import sinon from 'sinon'; -import { ConcreteTaskInstance, TaskStatus } from '../../../task_manager'; +import { ConcreteTaskInstance, TaskStatus } from '../../../../../plugins/task_manager/server'; import { TaskRunnerContext, TaskRunnerFactory } from './task_runner_factory'; import { encryptedSavedObjectsMock } from '../../../../../plugins/encrypted_saved_objects/server/mocks'; import { diff --git a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts index 7186e1e729bda..7178fa4f01282 100644 --- a/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts +++ b/x-pack/legacy/plugins/alerting/server/task_runner/task_runner_factory.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { Logger } from '../../../../../../src/core/server'; -import { RunContext } from '../../../task_manager'; +import { RunContext } from '../../../../../plugins/task_manager/server'; import { PluginStartContract as EncryptedSavedObjectsStartContract } from '../../../../../plugins/encrypted_saved_objects/server'; import { PluginStartContract as ActionsPluginStartContract } from '../../../actions'; import { diff --git a/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index e345ca3552e5a..8f87b3473b2e4 100644 --- a/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/legacy/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -4,6 +4,8 @@ exports[`Error CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Error CONTAINER_ID 1`] = `undefined`; +exports[`Error DESTINATION_ADDRESS 1`] = `undefined`; + exports[`Error ERROR_CULPRIT 1`] = `"handleOopsie"`; exports[`Error ERROR_EXC_HANDLED 1`] = `undefined`; @@ -112,6 +114,8 @@ exports[`Span CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Span CONTAINER_ID 1`] = `undefined`; +exports[`Span DESTINATION_ADDRESS 1`] = `undefined`; + exports[`Span ERROR_CULPRIT 1`] = `undefined`; exports[`Span ERROR_EXC_HANDLED 1`] = `undefined`; @@ -220,6 +224,8 @@ exports[`Transaction CLIENT_GEO_COUNTRY_ISO_CODE 1`] = `undefined`; exports[`Transaction CONTAINER_ID 1`] = `"container1234567890abcdef"`; +exports[`Transaction DESTINATION_ADDRESS 1`] = `undefined`; + exports[`Transaction ERROR_CULPRIT 1`] = `undefined`; exports[`Transaction ERROR_EXC_HANDLED 1`] = `undefined`; diff --git a/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts index 0d7ff3114e73f..ce2db4964a412 100644 --- a/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/legacy/plugins/apm/common/elasticsearch_fieldnames.ts @@ -14,6 +14,8 @@ export const HTTP_REQUEST_METHOD = 'http.request.method'; export const USER_ID = 'user.id'; export const USER_AGENT_NAME = 'user_agent.name'; +export const DESTINATION_ADDRESS = 'destination.address'; + export const OBSERVER_VERSION_MAJOR = 'observer.version_major'; export const OBSERVER_LISTENING = 'observer.listening'; export const PROCESSOR_EVENT = 'processor.event'; diff --git a/x-pack/legacy/plugins/apm/common/service_map.ts b/x-pack/legacy/plugins/apm/common/service_map.ts new file mode 100644 index 0000000000000..fbaa489c45039 --- /dev/null +++ b/x-pack/legacy/plugins/apm/common/service_map.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface ServiceConnectionNode { + 'service.name': string; + 'service.environment': string | null; + 'agent.name': string; +} +export interface ExternalConnectionNode { + 'destination.address': string; + 'span.type': string; + 'span.subtype': string; +} + +export type ConnectionNode = ServiceConnectionNode | ExternalConnectionNode; + +export interface Connection { + source: ConnectionNode; + destination: ConnectionNode; +} diff --git a/x-pack/legacy/plugins/apm/index.ts b/x-pack/legacy/plugins/apm/index.ts index cf2cbd2507215..0934cb0019f44 100644 --- a/x-pack/legacy/plugins/apm/index.ts +++ b/x-pack/legacy/plugins/apm/index.ts @@ -71,7 +71,8 @@ export const apm: LegacyPluginInitializer = kibana => { autocreateApmIndexPattern: Joi.boolean().default(true), // service map - serviceMapEnabled: Joi.boolean().default(false) + serviceMapEnabled: Joi.boolean().default(false), + serviceMapInitialTimeRange: Joi.number().default(60 * 1000 * 60) // last 1 hour }).default(); }, diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx index 238158c5bf224..bc020815cc9cb 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/Cytoscape.tsx @@ -73,6 +73,7 @@ export function Cytoscape({ cy.on('data', event => { // Add the "primary" class to the node if its id matches the serviceName. if (cy.nodes().length > 0 && serviceName) { + cy.nodes().removeClass('primary'); cy.getElementById(serviceName).addClass('primary'); } diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx new file mode 100644 index 0000000000000..efafdbcecd41c --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/LoadingOverlay.tsx @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import theme from '@elastic/eui/dist/eui_theme_light.json'; +import React from 'react'; +import { EuiProgress, EuiText, EuiSpacer } from '@elastic/eui'; +import styled from 'styled-components'; +import { i18n } from '@kbn/i18n'; + +const Container = styled.div` + position: relative; +`; + +const Overlay = styled.div` + position: absolute; + top: 0; + z-index: 1; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + padding: ${theme.gutterTypes.gutterMedium}; +`; + +const ProgressBarContainer = styled.div` + width: 50%; + max-width: 600px; +`; + +interface Props { + children: React.ReactNode; + isLoading: boolean; + percentageLoaded: number; +} + +export const LoadingOverlay = ({ + children, + isLoading, + percentageLoaded +}: Props) => ( + + {isLoading && ( + + + + + + + {i18n.translate('xpack.apm.loadingServiceMap', { + defaultMessage: + 'Loading service map... This might take a short while.' + })} + + + )} + {children} + +); diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 03ae9d0c287e5..d4e792ccf761b 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -8,17 +8,13 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { icons, defaultIcon } from './icons'; const layout = { - animate: true, - animationEasing: theme.euiAnimSlightBounce as cytoscape.Css.TransitionTimingFunction, - animationDuration: parseInt(theme.euiAnimSpeedFast, 10), name: 'dagre', nodeDimensionsIncludeLabels: true, - rankDir: 'LR', - spacingFactor: 2 + rankDir: 'LR' }; function isDatabaseOrExternal(agentName: string) { - return agentName === 'database' || agentName === 'external'; + return !agentName; } const style: cytoscape.Stylesheet[] = [ @@ -47,7 +43,7 @@ const style: cytoscape.Stylesheet[] = [ 'font-family': 'Inter UI, Segoe UI, Helvetica, Arial, sans-serif', 'font-size': theme.euiFontSizeXS, height: theme.avatarSizing.l.size, - label: 'data(id)', + label: 'data(label)', 'min-zoomed-font-size': theme.euiSizeL, 'overlay-opacity': 0, shape: (el: cytoscape.NodeSingular) => @@ -76,7 +72,18 @@ const style: cytoscape.Stylesheet[] = [ // // @ts-ignore 'target-distance-from-node': theme.paddingSizes.xs, - width: 2 + width: 1, + 'source-arrow-shape': 'none' + } + }, + { + selector: 'edge[bidirectional]', + style: { + 'source-arrow-shape': 'triangle', + 'target-arrow-shape': 'triangle', + // @ts-ignore + 'source-distance-from-node': theme.paddingSizes.xs, + 'target-distance-from-node': theme.paddingSizes.xs } } ]; diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts new file mode 100644 index 0000000000000..c9caa27af41c5 --- /dev/null +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/get_cytoscape_elements.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { ValuesType } from 'utility-types'; +import { sortBy, isEqual } from 'lodash'; +import { Connection, ConnectionNode } from '../../../../common/service_map'; +import { ServiceMapAPIResponse } from '../../../../server/lib/service_map/get_service_map'; +import { getAPMHref } from '../../shared/Links/apm/APMLink'; + +function getConnectionNodeId(node: ConnectionNode): string { + if ('destination.address' in node) { + // use a prefix to distinguish exernal destination ids from services + return `>${node['destination.address']}`; + } + return node['service.name']; +} + +function getConnectionId(connection: Connection) { + return `${getConnectionNodeId(connection.source)}~${getConnectionNodeId( + connection.destination + )}`; +} +export function getCytoscapeElements( + responses: ServiceMapAPIResponse[], + search: string +) { + const discoveredServices = responses.flatMap( + response => response.discoveredServices + ); + + const serviceNodes = responses + .flatMap(response => response.services) + .map(service => ({ + ...service, + id: service['service.name'] + })); + + // maps destination.address to service.name if possible + function getConnectionNode(node: ConnectionNode) { + let mappedNode: ConnectionNode | undefined; + + if ('destination.address' in node) { + mappedNode = discoveredServices.find(map => isEqual(map.from, node))?.to; + } + + if (!mappedNode) { + mappedNode = node; + } + + return { + ...mappedNode, + id: getConnectionNodeId(mappedNode) + }; + } + + // build connections with mapped nodes + const connections = responses + .flatMap(response => response.connections) + .map(connection => { + const source = getConnectionNode(connection.source); + const destination = getConnectionNode(connection.destination); + + return { + source, + destination, + id: getConnectionId({ source, destination }) + }; + }) + .filter(connection => connection.source.id !== connection.destination.id); + + const nodes = connections + .flatMap(connection => [connection.source, connection.destination]) + .concat(serviceNodes); + + type ConnectionWithId = ValuesType; + type ConnectionNodeWithId = ValuesType; + + const connectionsById = connections.reduce((connectionMap, connection) => { + return { + ...connectionMap, + [connection.id]: connection + }; + }, {} as Record); + + const nodesById = nodes.reduce((nodeMap, node) => { + return { + ...nodeMap, + [node.id]: node + }; + }, {} as Record); + + const cyNodes = (Object.values(nodesById) as ConnectionNodeWithId[]).map( + node => { + let data = {}; + + if ('service.name' in node) { + data = { + href: getAPMHref( + `/services/${node['service.name']}/service-map`, + search + ), + agentName: node['agent.name'] || node['agent.name'] + }; + } + + return { + group: 'nodes' as const, + data: { + id: node.id, + label: + 'service.name' in node + ? node['service.name'] + : node['destination.address'], + ...data + } + }; + } + ); + + // instead of adding connections in two directions, + // we add a `bidirectional` flag to use in styling + const dedupedConnections = (sortBy( + Object.values(connectionsById), + // make sure that order is stable + 'id' + ) as ConnectionWithId[]).reduce< + Array + >((prev, connection) => { + const reversedConnection = prev.find( + c => + c.destination.id === connection.source.id && + c.source.id === connection.destination.id + ); + + if (reversedConnection) { + reversedConnection.bidirectional = true; + return prev; + } + + return prev.concat(connection); + }, []); + + const cyEdges = dedupedConnections.map(connection => { + return { + group: 'edges' as const, + data: { + id: connection.id, + source: connection.source.id, + target: connection.destination.id, + bidirectional: connection.bidirectional ? true : undefined + } + }; + }, []); + + return [...cyNodes, ...cyEdges]; +} diff --git a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx index cc09975a344b5..d3cc2b14e2c68 100644 --- a/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx +++ b/x-pack/legacy/plugins/apm/public/components/app/ServiceMap/index.tsx @@ -5,13 +5,30 @@ */ import theme from '@elastic/eui/dist/eui_theme_light.json'; -import React from 'react'; -import { useFetcher } from '../../../hooks/useFetcher'; +import React, { + useMemo, + useEffect, + useState, + useRef, + useCallback +} from 'react'; +import { find, isEqual } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { EuiButton } from '@elastic/eui'; +import { ElementDefinition } from 'cytoscape'; +import { toMountPoint } from '../../../../../../../../src/plugins/kibana_react/public'; +import { ServiceMapAPIResponse } from '../../../../server/lib/service_map/get_service_map'; import { useLicense } from '../../../hooks/useLicense'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { Controls } from './Controls'; import { Cytoscape } from './Cytoscape'; import { PlatinumLicensePrompt } from './PlatinumLicensePrompt'; +import { useCallApmApi } from '../../../hooks/useCallApmApi'; +import { useDeepObjectIdentity } from '../../../hooks/useDeepObjectIdentity'; +import { useLocation } from '../../../hooks/useLocation'; +import { LoadingOverlay } from './LoadingOverlay'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { getCytoscapeElements } from './get_cytoscape_elements'; interface ServiceMapProps { serviceName?: string; @@ -37,37 +54,159 @@ ${theme.euiColorLightShade}`, margin: `-${theme.gutterTypes.gutterLarge}` }; +const MAX_REQUESTS = 5; + export function ServiceMap({ serviceName }: ServiceMapProps) { - const { - urlParams: { start, end } - } = useUrlParams(); + const callApmApi = useCallApmApi(); + const license = useLicense(); + const { search } = useLocation(); + const { urlParams, uiFilters } = useUrlParams(); + const { notifications } = useApmPluginContext().core; + const params = useDeepObjectIdentity({ + start: urlParams.start, + end: urlParams.end, + environment: urlParams.environment, + serviceName, + uiFilters: { + ...uiFilters, + environment: undefined + } + }); + + const renderedElements = useRef([]); + const openToast = useRef(null); + + const [responses, setResponses] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [percentageLoaded, setPercentageLoaded] = useState(0); + const [, _setUnusedState] = useState(false); + + const elements = useMemo(() => getCytoscapeElements(responses, search), [ + responses, + search + ]); + + const forceUpdate = useCallback(() => _setUnusedState(value => !value), []); + + const getNext = useCallback( + async (input: { reset?: boolean; after?: string | undefined }) => { + const { start, end, uiFilters: strippedUiFilters, ...query } = params; + + if (input.reset) { + renderedElements.current = []; + setResponses([]); + } - const { data } = useFetcher( - callApmApi => { if (start && end) { - return callApmApi({ - pathname: '/api/apm/service-map', - params: { query: { start, end } } - }); + setIsLoading(true); + try { + const data = await callApmApi({ + pathname: '/api/apm/service-map', + params: { + query: { + ...query, + start, + end, + uiFilters: JSON.stringify(strippedUiFilters), + after: input.after + } + } + }); + setResponses(resp => resp.concat(data)); + setIsLoading(false); + + const shouldGetNext = + responses.length + 1 < MAX_REQUESTS && data.after; + + if (shouldGetNext) { + setPercentageLoaded(value => value + 30); // increase loading bar 30% + await getNext({ after: data.after }); + } + } catch (error) { + setIsLoading(false); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.apm.errorServiceMapData', { + defaultMessage: `Error loading service connections` + }) + }); + } } }, - [start, end] + [callApmApi, params, responses.length, notifications.toasts] ); - const elements = Array.isArray(data) ? data : []; - const license = useLicense(); + useEffect(() => { + const loadServiceMaps = async () => { + setPercentageLoaded(5); + await getNext({ reset: true }); + setPercentageLoaded(100); + }; + + loadServiceMaps(); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [params]); + + useEffect(() => { + if (renderedElements.current.length === 0) { + renderedElements.current = elements; + return; + } + + const newElements = elements.filter(element => { + return !find(renderedElements.current, el => isEqual(el, element)); + }); + + const updateMap = () => { + renderedElements.current = elements; + if (openToast.current) { + notifications.toasts.remove(openToast.current); + } + forceUpdate(); + }; + + if (newElements.length > 0 && percentageLoaded === 100) { + openToast.current = notifications.toasts.add({ + title: i18n.translate('xpack.apm.newServiceMapData', { + defaultMessage: `Newly discovered connections are available.` + }), + onClose: () => { + openToast.current = null; + }, + toastLifeTimeMs: 24 * 60 * 60 * 1000, + text: toMountPoint( + + {i18n.translate('xpack.apm.updateServiceMap', { + defaultMessage: 'Update map' + })} + + ) + }).id; + } + + return () => { + if (openToast.current) { + notifications.toasts.remove(openToast.current); + } + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elements, percentageLoaded]); + const isValidPlatinumLicense = license?.isActive && (license?.type === 'platinum' || license?.type === 'trial'); return isValidPlatinumLicense ? ( - - - + + + + + ) : ( ); diff --git a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx b/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx index 84c2801a45049..51056fae50360 100644 --- a/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx +++ b/x-pack/legacy/plugins/apm/public/components/shared/Stacktrace/FrameHeading.tsx @@ -35,9 +35,13 @@ const FrameHeading: React.FC = ({ stackframe, isLibraryFrame }) => { ? LibraryFrameFileDetail : AppFrameFileDetail; const lineNumber = stackframe.line.number; + + const name = + 'filename' in stackframe ? stackframe.filename : stackframe.classname; + return ( - {stackframe.filename} in{' '} + {name} in{' '} {stackframe.function} {lineNumber > 0 && ( diff --git a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts index aeeb39733b5db..737eeac95516e 100644 --- a/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts +++ b/x-pack/legacy/plugins/apm/server/lib/helpers/es_client.ts @@ -11,7 +11,7 @@ import { IndicesDeleteParams, IndicesCreateParams } from 'elasticsearch'; -import { merge } from 'lodash'; +import { merge, uniqueId } from 'lodash'; import { cloneDeep, isString } from 'lodash'; import { KibanaRequest } from 'src/core/server'; import { OBSERVER_VERSION_MAJOR } from '../../../common/elasticsearch_fieldnames'; @@ -127,6 +127,23 @@ export function getESClient( ? callAsInternalUser : callAsCurrentUser; + const debug = context.params.query._debug; + + function withTime( + fn: (log: typeof console.log) => Promise + ): Promise { + const log = console.log.bind(console, uniqueId()); + if (!debug) { + return fn(log); + } + const time = process.hrtime(); + return fn(log).then(data => { + const now = process.hrtime(time); + log(`took: ${Math.round(now[0] * 1000 + now[1] / 1e6)}ms`); + return data; + }); + } + return { search: async < TDocument = unknown, @@ -141,27 +158,29 @@ export function getESClient( apmOptions ); - if (context.params.query._debug) { - console.log(`--DEBUG ES QUERY--`); - console.log( - `${request.url.pathname} ${JSON.stringify(context.params.query)}` - ); - console.log(`GET ${nextParams.index}/_search`); - console.log(JSON.stringify(nextParams.body, null, 2)); - } + return withTime(log => { + if (context.params.query._debug) { + log(`--DEBUG ES QUERY--`); + log( + `${request.url.pathname} ${JSON.stringify(context.params.query)}` + ); + log(`GET ${nextParams.index}/_search`); + log(JSON.stringify(nextParams.body, null, 2)); + } - return (callMethod('search', nextParams) as unknown) as Promise< - ESSearchResponse - >; + return (callMethod('search', nextParams) as unknown) as Promise< + ESSearchResponse + >; + }); }, index: (params: APMIndexDocumentParams) => { - return callMethod('index', params); + return withTime(() => callMethod('index', params)); }, delete: (params: IndicesDeleteParams) => { - return callMethod('delete', params); + return withTime(() => callMethod('delete', params)); }, indicesCreate: (params: IndicesCreateParams) => { - return callMethod('indices.create', params); + return withTime(() => callMethod('indices.create', params)); } }; } diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map.ts new file mode 100644 index 0000000000000..04e2a43a4b8f1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map.ts @@ -0,0 +1,129 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PromiseReturnType } from '../../../typings/common'; +import { + Setup, + SetupTimeRange, + SetupUIFilters +} from '../helpers/setup_request'; +import { getServiceMapFromTraceIds } from './get_service_map_from_trace_ids'; +import { getTraceSampleIds } from './get_trace_sample_ids'; +import { getServicesProjection } from '../../../common/projections/services'; +import { mergeProjection } from '../../../common/projections/util/merge_projection'; +import { + SERVICE_AGENT_NAME, + SERVICE_NAME +} from '../../../common/elasticsearch_fieldnames'; + +export interface IEnvOptions { + setup: Setup & SetupTimeRange & SetupUIFilters; + serviceName?: string; + environment?: string; + after?: string; +} + +async function getConnectionData({ + setup, + serviceName, + environment, + after +}: IEnvOptions) { + const { traceIds, after: nextAfter } = await getTraceSampleIds({ + setup, + serviceName, + environment, + after + }); + + const serviceMapData = traceIds.length + ? await getServiceMapFromTraceIds({ + setup, + serviceName, + environment, + traceIds + }) + : { connections: [], discoveredServices: [] }; + + return { + after: nextAfter, + ...serviceMapData + }; +} + +async function getServicesData(options: IEnvOptions) { + // only return services on the first request for the global service map + if (options.after) { + return []; + } + + const { setup } = options; + + const projection = getServicesProjection({ setup }); + + const { filter } = projection.body.query.bool; + + const params = mergeProjection(projection, { + body: { + size: 0, + query: { + bool: { + ...projection.body.query.bool, + filter: options.serviceName + ? filter.concat({ + term: { + [SERVICE_NAME]: options.serviceName + } + }) + : filter + } + }, + aggs: { + services: { + terms: { + field: projection.body.aggs.services.terms.field, + size: 500 + }, + aggs: { + agent_name: { + terms: { + field: SERVICE_AGENT_NAME + } + } + } + } + } + } + }); + + const { client } = setup; + + const response = await client.search(params); + + return ( + response.aggregations?.services.buckets.map(bucket => { + return { + 'service.name': bucket.key as string, + 'agent.name': + (bucket.agent_name.buckets[0]?.key as string | undefined) || '', + 'service.environment': options.environment || null + }; + }) || [] + ); +} + +export type ServiceMapAPIResponse = PromiseReturnType; +export async function getServiceMap(options: IEnvOptions) { + const [connectionData, servicesData] = await Promise.all([ + getConnectionData(options), + getServicesData(options) + ]); + + return { + ...connectionData, + services: servicesData + }; +} diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts new file mode 100644 index 0000000000000..ea9af12ac7f9a --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_service_map_from_trace_ids.ts @@ -0,0 +1,280 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { uniq, find } from 'lodash'; +import { Setup } from '../helpers/setup_request'; +import { + TRACE_ID, + PROCESSOR_EVENT +} from '../../../common/elasticsearch_fieldnames'; +import { + Connection, + ServiceConnectionNode, + ConnectionNode, + ExternalConnectionNode +} from '../../../common/service_map'; + +export async function getServiceMapFromTraceIds({ + setup, + traceIds, + serviceName, + environment +}: { + setup: Setup; + traceIds: string[]; + serviceName?: string; + environment?: string; +}) { + const { indices, client } = setup; + + const serviceMapParams = { + index: [ + indices['apm_oss.spanIndices'], + indices['apm_oss.transactionIndices'] + ], + body: { + size: 0, + query: { + bool: { + filter: [ + { + terms: { + [PROCESSOR_EVENT]: ['span', 'transaction'] + } + }, + { + terms: { + [TRACE_ID]: traceIds + } + } + ] + } + }, + aggs: { + service_map: { + scripted_metric: { + init_script: { + lang: 'painless', + source: `state.eventsById = new HashMap(); + + String[] fieldsToCopy = new String[] { + 'parent.id', + 'service.name', + 'service.environment', + 'destination.address', + 'trace.id', + 'processor.event', + 'span.type', + 'span.subtype', + 'agent.name' + }; + state.fieldsToCopy = fieldsToCopy;` + }, + map_script: { + lang: 'painless', + source: `def id; + if (!doc['span.id'].empty) { + id = doc['span.id'].value; + } else { + id = doc['transaction.id'].value; + } + + def copy = new HashMap(); + copy.id = id; + + for(key in state.fieldsToCopy) { + if (!doc[key].empty) { + copy[key] = doc[key].value; + } + } + + state.eventsById[id] = copy` + }, + combine_script: { + lang: 'painless', + source: `return state.eventsById;` + }, + reduce_script: { + lang: 'painless', + source: ` + def getDestination ( def event ) { + def destination = new HashMap(); + destination['destination.address'] = event['destination.address']; + destination['span.type'] = event['span.type']; + destination['span.subtype'] = event['span.subtype']; + return destination; + } + + def processAndReturnEvent(def context, def eventId) { + if (context.processedEvents[eventId] != null) { + return context.processedEvents[eventId]; + } + + def event = context.eventsById[eventId]; + + if (event == null) { + return null; + } + + def service = new HashMap(); + service['service.name'] = event['service.name']; + service['service.environment'] = event['service.environment']; + service['agent.name'] = event['agent.name']; + + def basePath = new ArrayList(); + + def parentId = event['parent.id']; + def parent; + + if (parentId != null && parentId != event['id']) { + parent = processAndReturnEvent(context, parentId); + if (parent != null) { + /* copy the path from the parent */ + basePath.addAll(parent.path); + /* flag parent path for removal, as it has children */ + context.locationsToRemove.add(parent.path); + + /* if the parent has 'destination.address' set, and the service is different, + we've discovered a service */ + + if (parent['destination.address'] != null + && parent['destination.address'] != "" + && (parent['span.type'] == 'external' + || parent['span.type'] == 'messaging') + && (parent['service.name'] != event['service.name'] + || parent['service.environment'] != event['service.environment'] + ) + ) { + def parentDestination = getDestination(parent); + context.externalToServiceMap.put(parentDestination, service); + } + } + } + + def lastLocation = basePath.size() > 0 ? basePath[basePath.size() - 1] : null; + + def currentLocation = service; + + /* only add the current location to the path if it's different from the last one*/ + if (lastLocation == null || !lastLocation.equals(currentLocation)) { + basePath.add(currentLocation); + } + + /* if there is an outgoing span, create a new path */ + if (event['span.type'] == 'external' || event['span.type'] == 'messaging') { + def outgoingLocation = getDestination(event); + def outgoingPath = new ArrayList(basePath); + outgoingPath.add(outgoingLocation); + context.paths.add(outgoingPath); + } + + event.path = basePath; + + context.processedEvents[eventId] = event; + return event; + } + + def context = new HashMap(); + + context.processedEvents = new HashMap(); + context.eventsById = new HashMap(); + + context.paths = new HashSet(); + context.externalToServiceMap = new HashMap(); + context.locationsToRemove = new HashSet(); + + for (state in states) { + context.eventsById.putAll(state); + } + + for (entry in context.eventsById.entrySet()) { + processAndReturnEvent(context, entry.getKey()); + } + + def paths = new HashSet(); + + for(foundPath in context.paths) { + if (!context.locationsToRemove.contains(foundPath)) { + paths.add(foundPath); + } + } + + def response = new HashMap(); + response.paths = paths; + + def discoveredServices = new HashSet(); + + for(entry in context.externalToServiceMap.entrySet()) { + def map = new HashMap(); + map.from = entry.getKey(); + map.to = entry.getValue(); + discoveredServices.add(map); + } + response.discoveredServices = discoveredServices; + + return response;` + } + } + } + } + } + }; + + const serviceMapResponse = await client.search(serviceMapParams); + + const scriptResponse = serviceMapResponse.aggregations?.service_map.value as { + paths: ConnectionNode[][]; + discoveredServices: Array<{ + from: ExternalConnectionNode; + to: ServiceConnectionNode; + }>; + }; + + let paths = scriptResponse.paths; + + if (serviceName || environment) { + paths = paths.filter(path => { + return path.some(node => { + let matches = true; + if (serviceName) { + matches = + matches && + 'service.name' in node && + node['service.name'] === serviceName; + } + if (environment) { + matches = + matches && + 'service.environment' in node && + node['service.environment'] === environment; + } + return matches; + }); + }); + } + + const connections = uniq( + paths.flatMap(path => { + return path.reduce((conns, location, index) => { + const prev = path[index - 1]; + if (prev) { + return conns.concat({ + source: prev, + destination: location + }); + } + return conns; + }, [] as Connection[]); + }, [] as Connection[]), + (value, index, array) => { + return find(array, value); + } + ); + + return { + connections, + discoveredServices: scriptResponse.discoveredServices + }; +} diff --git a/x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts b/x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts new file mode 100644 index 0000000000000..acf113b426608 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/lib/service_map/get_trace_sample_ids.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { uniq, take, sortBy } from 'lodash'; +import { + Setup, + SetupUIFilters, + SetupTimeRange +} from '../helpers/setup_request'; +import { rangeFilter } from '../helpers/range_filter'; +import { ESFilter } from '../../../typings/elasticsearch'; +import { + PROCESSOR_EVENT, + SERVICE_NAME, + SERVICE_ENVIRONMENT, + SPAN_TYPE, + SPAN_SUBTYPE, + DESTINATION_ADDRESS, + TRACE_ID +} from '../../../common/elasticsearch_fieldnames'; + +const MAX_TRACES_TO_INSPECT = 1000; + +export async function getTraceSampleIds({ + after, + serviceName, + environment, + setup +}: { + after?: string; + serviceName?: string; + environment?: string; + setup: Setup & SetupTimeRange & SetupUIFilters; +}) { + const isTop = !after; + + const { start, end, client, indices, config } = setup; + + const rangeEnd = end; + const rangeStart = isTop + ? rangeEnd - config['xpack.apm.serviceMapInitialTimeRange'] + : start; + + const rangeQuery = { range: rangeFilter(rangeStart, rangeEnd) }; + + const query = { + bool: { + filter: [ + { + term: { + [PROCESSOR_EVENT]: 'span' + } + }, + { + exists: { + field: DESTINATION_ADDRESS + } + }, + rangeQuery + ] as ESFilter[] + } + } as { bool: { filter: ESFilter[]; must_not?: ESFilter[] | ESFilter } }; + + if (serviceName) { + query.bool.filter.push({ term: { [SERVICE_NAME]: serviceName } }); + } + + if (environment) { + query.bool.filter.push({ term: { [SERVICE_ENVIRONMENT]: environment } }); + } + + const afterObj = + after && after !== 'top' + ? { after: JSON.parse(Buffer.from(after, 'base64').toString()) } + : {}; + + const params = { + index: [indices['apm_oss.spanIndices']], + body: { + size: 0, + query, + aggs: { + connections: { + composite: { + size: 1000, + ...afterObj, + sources: [ + { [SERVICE_NAME]: { terms: { field: SERVICE_NAME } } }, + { + [SERVICE_ENVIRONMENT]: { + terms: { field: SERVICE_ENVIRONMENT, missing_bucket: true } + } + }, + { + [SPAN_TYPE]: { + terms: { field: SPAN_TYPE, missing_bucket: true } + } + }, + { + [SPAN_SUBTYPE]: { + terms: { field: SPAN_SUBTYPE, missing_bucket: true } + } + }, + { + [DESTINATION_ADDRESS]: { + terms: { field: DESTINATION_ADDRESS } + } + } + ] + }, + aggs: { + sample: { + sampler: { + shard_size: 30 + }, + aggs: { + trace_ids: { + terms: { + field: TRACE_ID, + execution_hint: 'map' as const, + // remove bias towards large traces by sorting on trace.id + // which will be random-esque + order: { + _key: 'desc' as const + } + } + } + } + } + } + } + } + } + }; + + const tracesSampleResponse = await client.search< + { trace: { id: string } }, + typeof params + >(params); + + let nextAfter: string | undefined; + + const receivedAfterKey = + tracesSampleResponse.aggregations?.connections.after_key; + + if (!after) { + nextAfter = 'top'; + } else if (receivedAfterKey) { + nextAfter = Buffer.from(JSON.stringify(receivedAfterKey)).toString( + 'base64' + ); + } + + // make sure at least one trace per composite/connection bucket + // is queried + const traceIdsWithPriority = + tracesSampleResponse.aggregations?.connections.buckets.flatMap(bucket => + bucket.sample.trace_ids.buckets.map((sampleDocBucket, index) => ({ + traceId: sampleDocBucket.key as string, + priority: index + })) + ) || []; + + const traceIds = take( + uniq( + sortBy(traceIdsWithPriority, 'priority').map(({ traceId }) => traceId) + ), + MAX_TRACES_TO_INSPECT + ); + + return { + after: nextAfter, + traceIds + }; +} diff --git a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts index e98842151da84..a9a8241da39d1 100644 --- a/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/legacy/plugins/apm/server/routes/create_apm_api.ts @@ -58,7 +58,7 @@ import { uiFiltersEnvironmentsRoute } from './ui_filters'; import { createApi } from './create_api'; -import { serviceMapRoute } from './services'; +import { serviceMapRoute } from './service_map'; const createApmApi = () => { const api = createApi() @@ -118,10 +118,12 @@ const createApmApi = () => { .add(transactionsLocalFiltersRoute) .add(serviceNodesLocalFiltersRoute) .add(uiFiltersEnvironmentsRoute) - .add(serviceMapRoute) // Transaction - .add(transactionByTraceIdRoute); + .add(transactionByTraceIdRoute) + + // Service map + .add(serviceMapRoute); return api; }; diff --git a/x-pack/legacy/plugins/apm/server/routes/service_map.ts b/x-pack/legacy/plugins/apm/server/routes/service_map.ts new file mode 100644 index 0000000000000..94b176147f7a1 --- /dev/null +++ b/x-pack/legacy/plugins/apm/server/routes/service_map.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import Boom from 'boom'; +import { setupRequest } from '../lib/helpers/setup_request'; +import { createRoute } from './create_route'; +import { uiFiltersRt, rangeRt } from './default_api_types'; +import { getServiceMap } from '../lib/service_map/get_service_map'; + +export const serviceMapRoute = createRoute(() => ({ + path: '/api/apm/service-map', + params: { + query: t.intersection([ + t.partial({ environment: t.string, serviceName: t.string }), + uiFiltersRt, + rangeRt, + t.partial({ after: t.string }) + ]) + }, + handler: async ({ context, request }) => { + if (!context.config['xpack.apm.serviceMapEnabled']) { + throw Boom.notFound(); + } + const setup = await setupRequest(context, request); + const { + query: { serviceName, environment, after } + } = context.params; + return getServiceMap({ setup, serviceName, environment, after }); + } +})); diff --git a/x-pack/legacy/plugins/apm/server/routes/services.ts b/x-pack/legacy/plugins/apm/server/routes/services.ts index 78cb092b85db6..18777183ea1de 100644 --- a/x-pack/legacy/plugins/apm/server/routes/services.ts +++ b/x-pack/legacy/plugins/apm/server/routes/services.ts @@ -5,7 +5,6 @@ */ import * as t from 'io-ts'; -import Boom from 'boom'; import { AgentName } from '../../typings/es_schemas/ui/fields/Agent'; import { createApmTelementry, @@ -18,7 +17,6 @@ import { getServiceTransactionTypes } from '../lib/services/get_service_transact import { getServiceNodeMetadata } from '../lib/services/get_service_node_metadata'; import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; -import { getServiceMap } from '../lib/services/map'; import { getServiceAnnotations } from '../lib/services/annotations'; export const servicesRoute = createRoute(() => ({ @@ -87,19 +85,6 @@ export const serviceNodeMetadataRoute = createRoute(() => ({ } })); -export const serviceMapRoute = createRoute(() => ({ - path: '/api/apm/service-map', - params: { - query: rangeRt - }, - handler: async ({ context }) => { - if (context.config['xpack.apm.serviceMapEnabled']) { - return getServiceMap(); - } - return new Boom('Not found', { statusCode: 404 }); - } -})); - export const serviceAnnotationsRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/annotations', params: { diff --git a/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts index 74a9436d7a4bc..6d3620f11a87b 100644 --- a/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/legacy/plugins/apm/typings/elasticsearch/aggregations.ts @@ -36,6 +36,19 @@ interface MetricsAggregationResponsePart { value: number | null; } +type GetCompositeKeys< + TAggregationOptionsMap extends AggregationOptionsMap +> = TAggregationOptionsMap extends { + composite: { sources: Array }; +} + ? keyof Source + : never; + +type CompositeOptionsSource = Record< + string, + { terms: { field: string; missing_bucket?: boolean } } | undefined +>; + export interface AggregationOptionsByType { terms: { field: string; @@ -97,6 +110,22 @@ export interface AggregationOptionsByType { buckets_path: BucketsPath; script?: Script; }; + composite: { + size?: number; + sources: CompositeOptionsSource[]; + after?: Record; + }; + diversified_sampler: { + shard_size?: number; + max_docs_per_value?: number; + } & ({ script: Script } | { field: string }); // TODO use MetricsAggregationOptions if possible + scripted_metric: { + params?: Record; + init_script?: Script; + map_script: Script; + combine_script: Script; + reduce_script: Script; + }; } type AggregationType = keyof AggregationOptionsByType; @@ -229,6 +258,24 @@ interface AggregationResponsePart< value: number | null; } | undefined; + composite: { + after_key: Record, number>; + buckets: Array< + { + key: Record, number>; + doc_count: number; + } & BucketSubAggregationResponse< + TAggregationOptionsMap['aggs'], + TDocument + > + >; + }; + diversified_sampler: { + doc_count: number; + } & AggregationResponseMap; + scripted_metric: { + value: unknown; + }; } // Type for debugging purposes. If you see an error in AggregationResponseMap diff --git a/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts b/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts index eff39838bd957..064b684cf9aa6 100644 --- a/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts +++ b/x-pack/legacy/plugins/apm/typings/elasticsearch/index.ts @@ -56,6 +56,7 @@ export interface ESFilter { | string | string[] | number + | boolean | Record | ESFilter[]; }; diff --git a/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts b/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts index a1b1a8198bb35..993fac46ad7cb 100644 --- a/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts +++ b/x-pack/legacy/plugins/apm/typings/es_schemas/raw/fields/Stackframe.ts @@ -4,8 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -interface IStackframeBase { - filename: string; +type IStackframeBase = { function?: string; library_frame?: boolean; exclude_from_grouping?: boolean; @@ -19,13 +18,13 @@ interface IStackframeBase { line: { number: number; }; -} +} & ({ classname: string } | { filename: string }); -export interface IStackframeWithLineContext extends IStackframeBase { +export type IStackframeWithLineContext = IStackframeBase & { line: { number: number; context: string; }; -} +}; export type IStackframe = IStackframeBase | IStackframeWithLineContext; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts index 063e69d1d2141..e728ea25f5504 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable.ts @@ -5,11 +5,11 @@ */ import { ExpressionType } from 'src/plugins/expressions/public'; -import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; +import { EmbeddableInput } from '../../../../../../src/plugins/embeddable/public'; import { EmbeddableTypes } from './embeddable_types'; export const EmbeddableExpressionType = 'embeddable'; -export { EmbeddableTypes }; +export { EmbeddableTypes, EmbeddableInput }; export interface EmbeddableExpression { type: typeof EmbeddableExpressionType; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts index 3669bd3e08201..8f5ad859d28ba 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/expression_types/embeddable_types.ts @@ -9,7 +9,7 @@ import { MAP_SAVED_OBJECT_TYPE } from '../../../maps/common/constants'; import { VISUALIZE_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/visualize_embeddable/constants'; import { SEARCH_EMBEDDABLE_TYPE } from '../../../../../../src/legacy/core_plugins/kibana/public/discover/np_ready/embeddable/constants'; -export const EmbeddableTypes = { +export const EmbeddableTypes: { map: string; search: string; visualization: string } = { map: MAP_SAVED_OBJECT_TYPE, search: SEARCH_EMBEDDABLE_TYPE, visualization: VISUALIZE_EMBEDDABLE_TYPE, diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts index 097aef69d4b4c..48b50930d563e 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/index.ts @@ -32,6 +32,7 @@ import { image } from './image'; import { joinRows } from './join_rows'; import { lt } from './lt'; import { lte } from './lte'; +import { mapCenter } from './map_center'; import { mapColumn } from './mapColumn'; import { math } from './math'; import { metric } from './metric'; @@ -57,6 +58,7 @@ import { staticColumn } from './staticColumn'; import { string } from './string'; import { table } from './table'; import { tail } from './tail'; +import { timerange } from './time_range'; import { timefilter } from './timefilter'; import { timefilterControl } from './timefilterControl'; import { switchFn } from './switch'; @@ -91,6 +93,7 @@ export const functions = [ lt, lte, joinRows, + mapCenter, mapColumn, math, metric, @@ -118,6 +121,7 @@ export const functions = [ tail, timefilter, timefilterControl, + timerange, switchFn, caseFn, ]; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/map_center.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/map_center.ts new file mode 100644 index 0000000000000..21f9e9fe3148d --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/map_center.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFunction } from 'src/plugins/expressions/common'; +import { getFunctionHelp } from '../../../i18n/functions'; +import { MapCenter } from '../../../types'; + +interface Args { + lat: number; + lon: number; + zoom: number; +} + +export function mapCenter(): ExpressionFunction<'mapCenter', null, Args, MapCenter> { + const { help, args: argHelp } = getFunctionHelp().mapCenter; + return { + name: 'mapCenter', + help, + type: 'mapCenter', + context: { + types: ['null'], + }, + args: { + lat: { + types: ['number'], + required: true, + help: argHelp.lat, + }, + lon: { + types: ['number'], + required: true, + help: argHelp.lon, + }, + zoom: { + types: ['number'], + required: true, + help: argHelp.zoom, + }, + }, + fn: (context, args) => { + return { + type: 'mapCenter', + ...args, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts index 25f035bbb6d8c..5b95886faa13d 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.test.ts @@ -5,7 +5,7 @@ */ jest.mock('ui/new_platform'); import { savedMap } from './saved_map'; -import { buildEmbeddableFilters } from '../../../server/lib/build_embeddable_filters'; +import { getQueryFilters } from '../../../server/lib/build_embeddable_filters'; const filterContext = { and: [ @@ -24,20 +24,22 @@ describe('savedMap', () => { const fn = savedMap().fn; const args = { id: 'some-id', + center: null, + title: null, + timerange: null, + hideLayer: [], }; it('accepts null context', () => { const expression = fn(null, args, {}); expect(expression.input.filters).toEqual([]); - expect(expression.input.timeRange).toBeUndefined(); }); it('accepts filter context', () => { const expression = fn(filterContext, args, {}); - const embeddableFilters = buildEmbeddableFilters(filterContext.and); + const embeddableFilters = getQueryFilters(filterContext.and); - expect(expression.input.filters).toEqual(embeddableFilters.filters); - expect(expression.input.timeRange).toEqual(embeddableFilters.timeRange); + expect(expression.input.filters).toEqual(embeddableFilters); }); }); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts index 460cb9c34efff..b6d88c06ed06d 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/saved_map.ts @@ -7,8 +7,8 @@ import { ExpressionFunction } from 'src/plugins/expressions/common/types'; import { TimeRange } from 'src/plugins/data/public'; import { EmbeddableInput } from 'src/legacy/core_plugins/embeddable_api/public/np_ready/public'; -import { buildEmbeddableFilters } from '../../../server/lib/build_embeddable_filters'; -import { Filter } from '../../../types'; +import { getQueryFilters } from '../../../server/lib/build_embeddable_filters'; +import { Filter, MapCenter, TimeRange as TimeRangeArg } from '../../../types'; import { EmbeddableTypes, EmbeddableExpressionType, @@ -19,19 +19,36 @@ import { esFilters } from '../../../../../../../src/plugins/data/public'; interface Arguments { id: string; + center: MapCenter | null; + hideLayer: string[]; + title: string | null; + timerange: TimeRangeArg | null; } // Map embeddable is missing proper typings, so type is just to document what we // are expecting to pass to the embeddable -interface SavedMapInput extends EmbeddableInput { +export type SavedMapInput = EmbeddableInput & { id: string; + isLayerTOCOpen: boolean; timeRange?: TimeRange; refreshConfig: { isPaused: boolean; interval: number; }; + hideFilterActions: true; filters: esFilters.Filter[]; -} + mapCenter?: { + lat: number; + lon: number; + zoom: number; + }; + hiddenLayers?: string[]; +}; + +const defaultTimeRange = { + from: 'now-15m', + to: 'now', +}; type Return = EmbeddableExpression; @@ -46,21 +63,56 @@ export function savedMap(): ExpressionFunction<'savedMap', Filter | null, Argume required: false, help: argHelp.id, }, + center: { + types: ['mapCenter'], + help: argHelp.center, + required: false, + }, + hideLayer: { + types: ['string'], + help: argHelp.hideLayer, + required: false, + multi: true, + }, + timerange: { + types: ['timerange'], + help: argHelp.timerange, + required: false, + }, + title: { + types: ['string'], + help: argHelp.title, + required: false, + }, }, type: EmbeddableExpressionType, - fn: (context, { id }) => { + fn: (context, args) => { const filters = context ? context.and : []; + const center = args.center + ? { + lat: args.center.lat, + lon: args.center.lon, + zoom: args.center.zoom, + } + : undefined; + return { type: EmbeddableExpressionType, input: { - id, - ...buildEmbeddableFilters(filters), - + id: args.id, + filters: getQueryFilters(filters), + timeRange: args.timerange || defaultTimeRange, refreshConfig: { isPaused: false, interval: 0, }, + + mapCenter: center, + hideFilterActions: true, + title: args.title ? args.title : undefined, + isLayerTOCOpen: false, + hiddenLayers: args.hideLayer || [], }, embeddableType: EmbeddableTypes.map, }; diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/time_range.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/time_range.ts new file mode 100644 index 0000000000000..716026279ccea --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/functions/common/time_range.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ExpressionFunction } from 'src/plugins/expressions/common'; +import { getFunctionHelp } from '../../../i18n/functions'; +import { TimeRange } from '../../../types'; + +interface Args { + from: string; + to: string; +} + +export function timerange(): ExpressionFunction<'timerange', null, Args, TimeRange> { + const { help, args: argHelp } = getFunctionHelp().timerange; + return { + name: 'timerange', + help, + type: 'timerange', + context: { + types: ['null'], + }, + args: { + from: { + types: ['string'], + required: true, + help: argHelp.from, + }, + to: { + types: ['string'], + required: true, + help: argHelp.to, + }, + }, + fn: (context, args) => { + return { + type: 'timerange', + ...args, + }; + }, + }; +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable.tsx b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx similarity index 74% rename from x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable.tsx rename to x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx index 5c7ef1a8c1799..8642ebd901bb4 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable.tsx +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.tsx @@ -10,32 +10,27 @@ import { I18nContext } from 'ui/i18n'; import { npStart } from 'ui/new_platform'; import { IEmbeddable, + EmbeddableFactory, EmbeddablePanel, EmbeddableFactoryNotFoundError, - EmbeddableInput, -} from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; -import { start } from '../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; -import { EmbeddableExpression } from '../expression_types/embeddable'; -import { RendererStrings } from '../../i18n'; +} from '../../../../../../../src/plugins/embeddable/public'; +import { start } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public/legacy'; +import { EmbeddableExpression } from '../../expression_types/embeddable'; +import { RendererStrings } from '../../../i18n'; import { SavedObjectFinderProps, SavedObjectFinderUi, -} from '../../../../../../src/plugins/kibana_react/public'; +} from '../../../../../../../src/plugins/kibana_react/public'; const { embeddable: strings } = RendererStrings; +import { embeddableInputToExpression } from './embeddable_input_to_expression'; +import { EmbeddableInput } from '../../expression_types'; +import { RendererHandlers } from '../../../types'; const embeddablesRegistry: { [key: string]: IEmbeddable; } = {}; -interface Handlers { - setFilter: (text: string) => void; - getFilter: () => string | null; - done: () => void; - onResize: (fn: () => void) => void; - onDestroy: (fn: () => void) => void; -} - const renderEmbeddable = (embeddableObject: IEmbeddable, domNode: HTMLElement) => { const SavedObjectFinder = (props: SavedObjectFinderProps) => ( ({ render: async ( domNode: HTMLElement, { input, embeddableType }: EmbeddableExpression, - handlers: Handlers + handlers: RendererHandlers ) => { if (!embeddablesRegistry[input.id]) { const factory = Array.from(start.getEmbeddableFactories()).find( embeddableFactory => embeddableFactory.type === embeddableType - ); + ) as EmbeddableFactory; if (!factory) { handlers.done(); @@ -86,8 +81,13 @@ const embeddable = () => ({ } const embeddableObject = await factory.createFromSavedObject(input.id, input); + embeddablesRegistry[input.id] = embeddableObject; + ReactDOM.unmountComponentAtNode(domNode); + const subscription = embeddableObject.getInput$().subscribe(function(updatedInput) { + handlers.onEmbeddableInputChange(embeddableInputToExpression(updatedInput, embeddableType)); + }); ReactDOM.render(renderEmbeddable(embeddableObject, domNode), domNode, () => handlers.done()); handlers.onResize(() => { @@ -97,7 +97,11 @@ const embeddable = () => ({ }); handlers.onDestroy(() => { + subscription.unsubscribe(); + handlers.onEmbeddableDestroyed(); + delete embeddablesRegistry[input.id]; + return ReactDOM.unmountComponentAtNode(domNode); }); } else { diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts new file mode 100644 index 0000000000000..93d747537c34c --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.test.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { embeddableInputToExpression } from './embeddable_input_to_expression'; +import { SavedMapInput } from '../../functions/common/saved_map'; +import { EmbeddableTypes } from '../../expression_types'; +import { fromExpression, Ast } from '@kbn/interpreter/common'; + +const baseSavedMapInput = { + id: 'embeddableId', + filters: [], + isLayerTOCOpen: false, + refreshConfig: { + isPaused: true, + interval: 0, + }, + hideFilterActions: true as true, +}; + +describe('input to expression', () => { + describe('Map Embeddable', () => { + it('converts to a savedMap expression', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + expect(ast.type).toBe('expression'); + expect(ast.chain[0].function).toBe('savedMap'); + + expect(ast.chain[0].arguments.id).toStrictEqual([input.id]); + + expect(ast.chain[0].arguments).not.toHaveProperty('title'); + expect(ast.chain[0].arguments).not.toHaveProperty('center'); + expect(ast.chain[0].arguments).not.toHaveProperty('timerange'); + }); + + it('includes optional input values', () => { + const input: SavedMapInput = { + ...baseSavedMapInput, + mapCenter: { + lat: 1, + lon: 2, + zoom: 3, + }, + title: 'title', + timeRange: { + from: 'now-1h', + to: 'now', + }, + }; + + const expression = embeddableInputToExpression(input, EmbeddableTypes.map); + const ast = fromExpression(expression); + + const centerExpression = ast.chain[0].arguments.center[0] as Ast; + + expect(centerExpression.chain[0].function).toBe('mapCenter'); + expect(centerExpression.chain[0].arguments.lat[0]).toEqual(input.mapCenter?.lat); + expect(centerExpression.chain[0].arguments.lon[0]).toEqual(input.mapCenter?.lon); + expect(centerExpression.chain[0].arguments.zoom[0]).toEqual(input.mapCenter?.zoom); + + const timerangeExpression = ast.chain[0].arguments.timerange[0] as Ast; + + expect(timerangeExpression.chain[0].function).toBe('timerange'); + expect(timerangeExpression.chain[0].arguments.from[0]).toEqual(input.timeRange?.from); + expect(timerangeExpression.chain[0].arguments.to[0]).toEqual(input.timeRange?.to); + }); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts new file mode 100644 index 0000000000000..a3cb53acebed2 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable_input_to_expression.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EmbeddableTypes, EmbeddableInput } from '../../expression_types'; +import { SavedMapInput } from '../../functions/common/saved_map'; + +/* + Take the input from an embeddable and the type of embeddable and convert it into an expression +*/ +export function embeddableInputToExpression( + input: EmbeddableInput, + embeddableType: string +): string { + const expressionParts: string[] = []; + + if (embeddableType === EmbeddableTypes.map) { + const mapInput = input as SavedMapInput; + + expressionParts.push('savedMap'); + + expressionParts.push(`id="${input.id}"`); + + if (input.title) { + expressionParts.push(`title="${input.title}"`); + } + + if (mapInput.mapCenter) { + expressionParts.push( + `center={mapCenter lat=${mapInput.mapCenter.lat} lon=${mapInput.mapCenter.lon} zoom=${mapInput.mapCenter.zoom}}` + ); + } + + if (mapInput.timeRange) { + expressionParts.push( + `timerange={timerange from="${mapInput.timeRange.from}" to="${mapInput.timeRange.to}"}` + ); + } + + if (mapInput.hiddenLayers && mapInput.hiddenLayers.length) { + for (const layerId of mapInput.hiddenLayers) { + expressionParts.push(`hideLayer="${layerId}"`); + } + } + } + + return expressionParts.join(' '); +} diff --git a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js index 50fa6943fc74a..48364be06e539 100644 --- a/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js +++ b/x-pack/legacy/plugins/canvas/canvas_plugin_src/renderers/index.js @@ -7,7 +7,7 @@ import { advancedFilter } from './advanced_filter'; import { debug } from './debug'; import { dropdownFilter } from './dropdown_filter'; -import { embeddable } from './embeddable'; +import { embeddable } from './embeddable/embeddable'; import { error } from './error'; import { image } from './image'; import { markdown } from './markdown'; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts new file mode 100644 index 0000000000000..3022ad07089d2 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/map_center.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { mapCenter } from '../../../canvas_plugin_src/functions/common/map_center'; +import { FunctionHelp } from '../'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.mapCenterHelpText', { + defaultMessage: `Returns an object with the center coordinates and zoom level of the map`, + }), + args: { + lat: i18n.translate('xpack.canvas.functions.mapCenter.args.latHelpText', { + defaultMessage: `Latitude for the center of the map`, + }), + lon: i18n.translate('xpack.canvas.functions.savedMap.args.lonHelpText', { + defaultMessage: `Longitude for the center of the map`, + }), + zoom: i18n.translate('xpack.canvas.functions.savedMap.args.zoomHelpText', { + defaultMessage: `The zoom level of the map`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts index d01b77e1cfd51..53bcd481f185f 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/saved_map.ts @@ -14,6 +14,20 @@ export const help: FunctionHelp> = { defaultMessage: `Returns an embeddable for a saved map object`, }), args: { - id: 'The id of the saved map object', + id: i18n.translate('xpack.canvas.functions.savedMap.args.idHelpText', { + defaultMessage: `The ID of the Saved Map Object`, + }), + center: i18n.translate('xpack.canvas.functions.savedMap.args.centerHelpText', { + defaultMessage: `The center and zoom level the map should have`, + }), + hideLayer: i18n.translate('xpack.canvas.functions.savedMap.args.hideLayer', { + defaultMessage: `The IDs of map layers that should be hidden`, + }), + timerange: i18n.translate('xpack.canvas.functions.savedMap.args.timerangeHelpText', { + defaultMessage: `The timerange of data that should be included`, + }), + title: i18n.translate('xpack.canvas.functions.savedMap.args.titleHelpText', { + defaultMessage: `The title for the map`, + }), }, }; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/dict/time_range.ts b/x-pack/legacy/plugins/canvas/i18n/functions/dict/time_range.ts new file mode 100644 index 0000000000000..476a9978800df --- /dev/null +++ b/x-pack/legacy/plugins/canvas/i18n/functions/dict/time_range.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { timerange } from '../../../canvas_plugin_src/functions/common/time_range'; +import { FunctionHelp } from '../function_help'; +import { FunctionFactory } from '../../../types'; + +export const help: FunctionHelp> = { + help: i18n.translate('xpack.canvas.functions.timerangeHelpText', { + defaultMessage: `An object that represents a span of time`, + }), + args: { + from: i18n.translate('xpack.canvas.functions.timerange.args.fromHelpText', { + defaultMessage: `The start of the time range`, + }), + to: i18n.translate('xpack.canvas.functions.timerange.args.toHelpText', { + defaultMessage: `The end of the time range`, + }), + }, +}; diff --git a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts index f6b3c451c6fbb..94d7e6f43326f 100644 --- a/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts +++ b/x-pack/legacy/plugins/canvas/i18n/functions/function_help.ts @@ -44,6 +44,7 @@ import { help as joinRows } from './dict/join_rows'; import { help as location } from './dict/location'; import { help as lt } from './dict/lt'; import { help as lte } from './dict/lte'; +import { help as mapCenter } from './dict/map_center'; import { help as mapColumn } from './dict/map_column'; import { help as markdown } from './dict/markdown'; import { help as math } from './dict/math'; @@ -75,6 +76,7 @@ import { help as tail } from './dict/tail'; import { help as timefilter } from './dict/timefilter'; import { help as timefilterControl } from './dict/timefilter_control'; import { help as timelion } from './dict/timelion'; +import { help as timerange } from './dict/time_range'; import { help as to } from './dict/to'; import { help as urlparam } from './dict/urlparam'; @@ -196,6 +198,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ location, lt, lte, + mapCenter, mapColumn, markdown, math, @@ -227,6 +230,7 @@ export const getFunctionHelp = (): FunctionHelpDict => ({ timefilter, timefilterControl, timelion, + timerange, to, urlparam, }); diff --git a/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js b/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js index 89c0b5b21c581..1926fb4aaa5eb 100644 --- a/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js +++ b/x-pack/legacy/plugins/canvas/public/components/element_content/element_content.js @@ -47,7 +47,14 @@ export const ElementContent = compose( pure, ...branches )(({ renderable, renderFunction, size, handlers }) => { - const { getFilter, setFilter, done, onComplete } = handlers; + const { + getFilter, + setFilter, + done, + onComplete, + onEmbeddableInputChange, + onEmbeddableDestroyed, + } = handlers; return Style.it( renderable.css, @@ -69,7 +76,7 @@ export const ElementContent = compose( config={renderable.value} css={renderable.css} // This is an actual CSS stylesheet string, it will be scoped by RenderElement size={size} // Size is only passed for the purpose of triggering the resize event, it isn't really used otherwise - handlers={{ getFilter, setFilter, done }} + handlers={{ getFilter, setFilter, done, onEmbeddableInputChange, onEmbeddableDestroyed }} />
diff --git a/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js b/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js index ce6791f2f88b6..e93cea597901f 100644 --- a/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js +++ b/x-pack/legacy/plugins/canvas/public/components/element_wrapper/lib/handlers.js @@ -6,6 +6,10 @@ import { isEqual } from 'lodash'; import { setFilter } from '../../../state/actions/elements'; +import { + updateEmbeddableExpression, + fetchEmbeddableRenderable, +} from '../../../state/actions/embeddable'; export const createHandlers = dispatch => { let isComplete = false; @@ -32,6 +36,14 @@ export const createHandlers = dispatch => { completeFn = fn; }, + onEmbeddableInputChange(embeddableExpression) { + dispatch(updateEmbeddableExpression({ elementId: element.id, embeddableExpression })); + }, + + onEmbeddableDestroyed() { + dispatch(fetchEmbeddableRenderable(element.id)); + }, + done() { // don't emit if the element is already done if (isComplete) { diff --git a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx index c54c56e1561ca..565ca5fa5bbd6 100644 --- a/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx +++ b/x-pack/legacy/plugins/canvas/public/components/embeddable_flyout/index.tsx @@ -19,14 +19,15 @@ import { withKibana } from '../../../../../../../src/plugins/kibana_react/public const allowedEmbeddables = { [EmbeddableTypes.map]: (id: string) => { - return `filters | savedMap id="${id}" | render`; + return `savedMap id="${id}" | render`; }, - [EmbeddableTypes.visualization]: (id: string) => { + // FIX: Only currently allow Map embeddables + /* [EmbeddableTypes.visualization]: (id: string) => { return `filters | savedVisualization id="${id}" | render`; }, [EmbeddableTypes.search]: (id: string) => { return `filters | savedSearch id="${id}" | render`; - }, + },*/ }; interface StateProps { diff --git a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js index 4ee3a65172a2e..b775524acf639 100644 --- a/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js +++ b/x-pack/legacy/plugins/canvas/public/components/workpad_page/workpad_interactive_page/index.js @@ -73,6 +73,32 @@ function closest(s) { return null; } +// If you interact with an embeddable panel, only the header should be draggable +// This function will determine if an element is an embeddable body or not +const isEmbeddableBody = element => { + const hasClosest = typeof element.closest === 'function'; + + if (hasClosest) { + return element.closest('.embeddable') && !element.closest('.embPanel__header'); + } else { + return closest.call(element, '.embeddable') && !closest.call(element, '.embPanel__header'); + } +}; + +// Some elements in an embeddable may be portaled out of the embeddable container. +// We do not want clicks on those to trigger drags, etc, in the workpad. This function +// will check to make sure the clicked item is actually in the container +const isInWorkpad = element => { + const hasClosest = typeof element.closest === 'function'; + const workpadContainerSelector = '.canvasWorkpadContainer'; + + if (hasClosest) { + return !!element.closest(workpadContainerSelector); + } else { + return !!closest.call(element, workpadContainerSelector); + } +}; + const componentLayoutState = ({ aeroStore, setAeroStore, @@ -209,6 +235,8 @@ export const InteractivePage = compose( withProps((...props) => ({ ...props, canDragElement: element => { + return !isEmbeddableBody(element) && isInWorkpad(element); + const hasClosest = typeof element.closest === 'function'; if (hasClosest) { diff --git a/x-pack/legacy/plugins/canvas/public/state/actions/embeddable.ts b/x-pack/legacy/plugins/canvas/public/state/actions/embeddable.ts new file mode 100644 index 0000000000000..3604d7e3c2141 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/actions/embeddable.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Dispatch } from 'redux'; +import { createAction } from 'redux-actions'; +// @ts-ignore Untyped +import { createThunk } from 'redux-thunks'; +// @ts-ignore Untyped Local +import { fetchRenderable } from './elements'; +import { State } from '../../../types'; + +export const UpdateEmbeddableExpressionActionType = 'updateEmbeddableExpression'; +export interface UpdateEmbeddableExpressionPayload { + embeddableExpression: string; + elementId: string; +} +export const updateEmbeddableExpression = createAction( + UpdateEmbeddableExpressionActionType +); + +export const fetchEmbeddableRenderable = createThunk( + 'fetchEmbeddableRenderable', + ({ dispatch, getState }: { dispatch: Dispatch; getState: () => State }, elementId: string) => { + const pageWithElement = getState().persistent.workpad.pages.find(page => { + return page.elements.find(element => element.id === elementId) !== undefined; + }); + + if (pageWithElement) { + const element = pageWithElement.elements.find(el => el.id === elementId); + dispatch(fetchRenderable(element)); + } + } +); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js b/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js index 10a5bdb5998ea..c7e8a5c2ff2d8 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/elements.js @@ -28,7 +28,7 @@ function getNodeIndexById(page, nodeId, location) { return page[location].findIndex(node => node.id === nodeId); } -function assignNodeProperties(workpadState, pageId, nodeId, props) { +export function assignNodeProperties(workpadState, pageId, nodeId, props) { const pageIndex = getPageIndexById(workpadState, pageId); const location = getLocationFromIds(workpadState, pageId, nodeId); const nodesPath = `pages.${pageIndex}.${location}`; diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/embeddable.ts b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddable.ts new file mode 100644 index 0000000000000..9969c38cfa767 --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddable.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fromExpression, toExpression } from '@kbn/interpreter/common'; +import { handleActions } from 'redux-actions'; +import { State } from '../../../types'; + +import { + UpdateEmbeddableExpressionActionType, + UpdateEmbeddableExpressionPayload, +} from '../actions/embeddable'; + +// @ts-ignore untyped local +import { assignNodeProperties } from './elements'; + +export const embeddableReducer = handleActions< + State['persistent']['workpad'], + UpdateEmbeddableExpressionPayload +>( + { + [UpdateEmbeddableExpressionActionType]: (workpadState, { payload }) => { + if (!payload) { + return workpadState; + } + + const { elementId, embeddableExpression } = payload; + + // Find the element + const pageWithElement = workpadState.pages.find(page => { + return page.elements.find(element => element.id === elementId) !== undefined; + }); + + if (!pageWithElement) { + return workpadState; + } + + const element = pageWithElement.elements.find(elem => elem.id === elementId); + + if (!element) { + return workpadState; + } + + const existingAst = fromExpression(element.expression); + const newAst = fromExpression(embeddableExpression); + const searchForFunction = newAst.chain[0].function; + + // Find the first matching function in the existing ASt + const existingAstFunction = existingAst.chain.find(f => f.function === searchForFunction); + + if (!existingAstFunction) { + return workpadState; + } + + existingAstFunction.arguments = newAst.chain[0].arguments; + + const updatedExpression = toExpression(existingAst); + + return assignNodeProperties(workpadState, pageWithElement.id, elementId, { + expression: updatedExpression, + }); + }, + }, + {} as State['persistent']['workpad'] +); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/embeddables.test.ts b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddables.test.ts new file mode 100644 index 0000000000000..5b1192630897a --- /dev/null +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/embeddables.test.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; + * you may not use this file except in compliance with the Elastic License. + */ +jest.mock('ui/new_platform'); +import { State } from '../../../types'; +import { updateEmbeddableExpression } from '../actions/embeddable'; +import { embeddableReducer } from './embeddable'; + +const elementId = 'element-1111'; +const embeddableId = '1234'; +const mockWorkpadState = { + pages: [ + { + elements: [ + { + id: elementId, + expression: `function1 | function2 id="${embeddableId}" change="start value" remove="remove"`, + }, + ], + }, + ], +} as State['persistent']['workpad']; + +describe('embeddables reducer', () => { + it('updates the functions expression', () => { + const updatedValue = 'updated value'; + + const action = updateEmbeddableExpression({ + elementId, + embeddableExpression: `function2 id="${embeddableId}" change="${updatedValue}" add="add"`, + }); + + const newState = embeddableReducer(mockWorkpadState, action); + + expect(newState.pages[0].elements[0].expression.replace(/\s/g, '')).toBe( + `function1 | ${action.payload!.embeddableExpression}`.replace(/\s/g, '') + ); + }); +}); diff --git a/x-pack/legacy/plugins/canvas/public/state/reducers/index.js b/x-pack/legacy/plugins/canvas/public/state/reducers/index.js index b60a0a3b32656..cec6f9dceef6d 100644 --- a/x-pack/legacy/plugins/canvas/public/state/reducers/index.js +++ b/x-pack/legacy/plugins/canvas/public/state/reducers/index.js @@ -16,6 +16,7 @@ import { pagesReducer } from './pages'; import { elementsReducer } from './elements'; import { assetsReducer } from './assets'; import { historyReducer } from './history'; +import { embeddableReducer } from './embeddable'; export function getRootReducer(initialState) { return combineReducers({ @@ -25,7 +26,7 @@ export function getRootReducer(initialState) { persistent: reduceReducers( historyReducer, combineReducers({ - workpad: reduceReducers(workpadReducer, pagesReducer, elementsReducer), + workpad: reduceReducers(workpadReducer, pagesReducer, elementsReducer, embeddableReducer), schemaVersion: (state = get(initialState, 'persistent.schemaVersion')) => state, }) ), diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts index d1632fc3eef28..b422a9451293f 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts +++ b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.test.ts @@ -23,10 +23,10 @@ const timeFilter: Filter = { }; describe('buildEmbeddableFilters', () => { - it('converts non time Canvas Filters to ES Filters ', () => { + it('converts all Canvas Filters to ES Filters ', () => { const filters = buildEmbeddableFilters([timeFilter, columnFilter, columnFilter]); - expect(filters.filters).toHaveLength(2); + expect(filters.filters).toHaveLength(3); }); it('converts time filter to time range', () => { diff --git a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts index 52fcc9813a93d..1a78a1e057016 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts +++ b/x-pack/legacy/plugins/canvas/server/lib/build_embeddable_filters.ts @@ -35,10 +35,8 @@ function getTimeRangeFromFilters(filters: Filter[]): TimeRange | undefined { : undefined; } -function getQueryFilters(filters: Filter[]): esFilters.Filter[] { - return buildBoolArray(filters.filter(filter => filter.type !== 'time')).map( - esFilters.buildQueryFilter - ); +export function getQueryFilters(filters: Filter[]): esFilters.Filter[] { + return buildBoolArray(filters).map(esFilters.buildQueryFilter); } export function buildEmbeddableFilters(filters: Filter[]): EmbeddableFilterInput { diff --git a/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js b/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js index 15d3dc52ee311..f7907e2cffb26 100644 --- a/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js +++ b/x-pack/legacy/plugins/canvas/server/lib/query_es_sql.js @@ -30,6 +30,19 @@ export const queryEsSQL = (elasticsearchClient, { count, query, filter, timezone }); const columnNames = map(columns, 'name'); const rows = res.rows.map(row => zipObject(columnNames, row)); + + if (!!res.cursor) { + elasticsearchClient('transport.request', { + path: '/_sql/close', + method: 'POST', + body: { + cursor: res.cursor, + }, + }).catch(e => { + throw new Error(`Unexpected error from Elasticsearch: ${e.message}`); + }); + } + return { type: 'datatable', columns, diff --git a/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx b/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx index 03b3e0df8a0cf..317a3417841b8 100644 --- a/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx +++ b/x-pack/legacy/plugins/canvas/shareable_runtime/components/rendered_element.tsx @@ -69,6 +69,8 @@ export class RenderedElementComponent extends PureComponent { onResize: () => {}, setFilter: () => {}, getFilter: () => '', + onEmbeddableInputChange: () => {}, + onEmbeddableDestroyed: () => {}, }); } catch (e) { // eslint-disable-next-line no-console diff --git a/x-pack/legacy/plugins/canvas/types/functions.ts b/x-pack/legacy/plugins/canvas/types/functions.ts index 6510c018f1ed4..773c9c3020a85 100644 --- a/x-pack/legacy/plugins/canvas/types/functions.ts +++ b/x-pack/legacy/plugins/canvas/types/functions.ts @@ -192,3 +192,16 @@ export interface AxisConfig { */ export const isAxisConfig = (axisConfig: any): axisConfig is AxisConfig => !!axisConfig && axisConfig.type === 'axisConfig'; + +export interface MapCenter { + type: 'mapCenter'; + lat: number; + lon: number; + zoom: number; +} + +export interface TimeRange { + type: 'timerange'; + from: string; + to: string; +} diff --git a/x-pack/legacy/plugins/canvas/types/renderers.ts b/x-pack/legacy/plugins/canvas/types/renderers.ts index 282a1c820e346..af1710e69c257 100644 --- a/x-pack/legacy/plugins/canvas/types/renderers.ts +++ b/x-pack/legacy/plugins/canvas/types/renderers.ts @@ -17,6 +17,10 @@ export interface RendererHandlers { getFilter: () => string; /** Sets the value of the filter property on the element object persisted on the workpad */ setFilter: (filter: string) => void; + /** Handler to invoke when the input to a function has changed internally */ + onEmbeddableInputChange: (expression: string) => void; + /** Handler to invoke when a rendered embeddable is destroyed */ + onEmbeddableDestroyed: () => void; } export interface RendererSpec { diff --git a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts index 1749421277719..d9ca9a96ffe51 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './log_entry_categories'; +export * from './log_entry_category_datasets'; export * from './log_entry_rate'; diff --git a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts new file mode 100644 index 0000000000000..66823c25237ac --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_categories.ts @@ -0,0 +1,109 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH = + '/api/infra/log_analysis/results/log_entry_categories'; + +/** + * request + */ + +const logEntryCategoriesHistogramParametersRT = rt.type({ + id: rt.string, + timeRange: timeRangeRT, + bucketCount: rt.number, +}); + +export type LogEntryCategoriesHistogramParameters = rt.TypeOf< + typeof logEntryCategoriesHistogramParametersRT +>; + +export const getLogEntryCategoriesRequestPayloadRT = rt.type({ + data: rt.intersection([ + rt.type({ + // the number of categories to fetch + categoryCount: rt.number, + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the categories from + timeRange: timeRangeRT, + // a list of histograms to create + histograms: rt.array(logEntryCategoriesHistogramParametersRT), + }), + rt.partial({ + // the datasets to filter for (optional, unfiltered if not present) + datasets: rt.array(rt.string), + }), + ]), +}); + +export type GetLogEntryCategoriesRequestPayload = rt.TypeOf< + typeof getLogEntryCategoriesRequestPayloadRT +>; + +/** + * response + */ + +export const logEntryCategoryHistogramBucketRT = rt.type({ + startTime: rt.number, + bucketDuration: rt.number, + logEntryCount: rt.number, +}); + +export type LogEntryCategoryHistogramBucket = rt.TypeOf; + +export const logEntryCategoryHistogramRT = rt.type({ + histogramId: rt.string, + buckets: rt.array(logEntryCategoryHistogramBucketRT), +}); + +export type LogEntryCategoryHistogram = rt.TypeOf; + +export const logEntryCategoryRT = rt.type({ + categoryId: rt.number, + datasets: rt.array(rt.string), + histograms: rt.array(logEntryCategoryHistogramRT), + logEntryCount: rt.number, + maximumAnomalyScore: rt.number, + regularExpression: rt.string, +}); + +export type LogEntryCategory = rt.TypeOf; + +export const getLogEntryCategoriesSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + categories: rt.array(logEntryCategoryRT), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryCategoriesSuccessResponsePayload = rt.TypeOf< + typeof getLogEntryCategoriesSuccessReponsePayloadRT +>; + +export const getLogEntryCategoriesResponsePayloadRT = rt.union([ + getLogEntryCategoriesSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryCategoriesReponsePayload = rt.TypeOf< + typeof getLogEntryCategoriesResponsePayloadRT +>; diff --git a/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_category_datasets.ts b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_category_datasets.ts new file mode 100644 index 0000000000000..934d1052fa29f --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/log_analysis/results/log_entry_category_datasets.ts @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { + badRequestErrorRT, + forbiddenErrorRT, + timeRangeRT, + routeTimingMetadataRT, +} from '../../shared'; + +export const LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH = + '/api/infra/log_analysis/results/log_entry_category_datasets'; + +/** + * request + */ + +export const getLogEntryCategoryDatasetsRequestPayloadRT = rt.type({ + data: rt.type({ + // the id of the source configuration + sourceId: rt.string, + // the time range to fetch the category datasets from + timeRange: timeRangeRT, + }), +}); + +export type GetLogEntryCategoryDatasetsRequestPayload = rt.TypeOf< + typeof getLogEntryCategoryDatasetsRequestPayloadRT +>; + +/** + * response + */ + +export const getLogEntryCategoryDatasetsSuccessReponsePayloadRT = rt.intersection([ + rt.type({ + data: rt.type({ + datasets: rt.array(rt.string), + }), + }), + rt.partial({ + timing: routeTimingMetadataRT, + }), +]); + +export type GetLogEntryCategoryDatasetsSuccessResponsePayload = rt.TypeOf< + typeof getLogEntryCategoryDatasetsSuccessReponsePayloadRT +>; + +export const getLogEntryCategoryDatasetsResponsePayloadRT = rt.union([ + getLogEntryCategoryDatasetsSuccessReponsePayloadRT, + badRequestErrorRT, + forbiddenErrorRT, +]); + +export type GetLogEntryCategoryDatasetsReponsePayload = rt.TypeOf< + typeof getLogEntryCategoryDatasetsResponsePayloadRT +>; diff --git a/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts b/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts index 1047ca2f2a01a..caeb1914cb8a2 100644 --- a/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts +++ b/x-pack/legacy/plugins/infra/common/http_api/shared/index.ts @@ -7,3 +7,4 @@ export * from './errors'; export * from './metric_statistics'; export * from './time_range'; +export * from './timing'; diff --git a/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts b/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts new file mode 100644 index 0000000000000..a208921c03d6f --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/http_api/shared/timing.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { tracingSpanRT } from '../../performance_tracing'; + +export const routeTimingMetadataRT = rt.type({ + spans: rt.array(tracingSpanRT), +}); diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/index.ts b/x-pack/legacy/plugins/infra/common/log_analysis/index.ts index 79913f829191d..22137e63ab7e7 100644 --- a/x-pack/legacy/plugins/infra/common/log_analysis/index.ts +++ b/x-pack/legacy/plugins/infra/common/log_analysis/index.ts @@ -5,4 +5,7 @@ */ export * from './log_analysis'; +export * from './log_analysis_results'; +export * from './log_entry_rate_analysis'; +export * from './log_entry_categories_analysis'; export * from './job_parameters'; diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts index 4a6f20d549799..9b2f1a55eb8c1 100644 --- a/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis.ts @@ -4,14 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as rt from 'io-ts'; - -export const jobTypeRT = rt.keyof({ - 'log-entry-rate': null, -}); - -export type JobType = rt.TypeOf; - // combines and abstracts job and datafeed status export type JobStatus = | 'unknown' diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis_results.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis_results.ts new file mode 100644 index 0000000000000..1dcd4a10fc4e3 --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_analysis_results.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ML_SEVERITY_SCORES = { + warning: 3, + minor: 25, + major: 50, + critical: 75, +}; + +export type MLSeverityScoreCategories = keyof typeof ML_SEVERITY_SCORES; + +export const ML_SEVERITY_COLORS = { + critical: 'rgb(228, 72, 72)', + major: 'rgb(229, 113, 0)', + minor: 'rgb(255, 221, 0)', + warning: 'rgb(125, 180, 226)', +}; + +export const getSeverityCategoryForScore = ( + score: number +): MLSeverityScoreCategories | undefined => { + if (score >= ML_SEVERITY_SCORES.critical) { + return 'critical'; + } else if (score >= ML_SEVERITY_SCORES.major) { + return 'major'; + } else if (score >= ML_SEVERITY_SCORES.minor) { + return 'minor'; + } else if (score >= ML_SEVERITY_SCORES.warning) { + return 'warning'; + } else { + // Category is too low to include + return undefined; + } +}; + +export const formatAnomalyScore = (score: number) => { + return Math.round(score); +}; + +export const getFriendlyNameForPartitionId = (partitionId: string) => { + return partitionId !== '' ? partitionId : 'unknown'; +}; diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts new file mode 100644 index 0000000000000..0957126ee52e3 --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_categories_analysis.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const logEntryCategoriesJobTypeRT = rt.keyof({ + 'log-entry-categories-count': null, +}); + +export type LogEntryCategoriesJobType = rt.TypeOf; + +export const logEntryCategoriesJobTypes: LogEntryCategoriesJobType[] = [ + 'log-entry-categories-count', +]; diff --git a/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_rate_analysis.ts b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_rate_analysis.ts new file mode 100644 index 0000000000000..7fd668dc4ebce --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/log_analysis/log_entry_rate_analysis.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const logEntryRateJobTypeRT = rt.keyof({ + 'log-entry-rate': null, +}); + +export type LogEntryRateJobType = rt.TypeOf; + +export const logEntryRateJobTypes: LogEntryRateJobType[] = ['log-entry-rate']; diff --git a/x-pack/legacy/plugins/infra/common/performance_tracing.ts b/x-pack/legacy/plugins/infra/common/performance_tracing.ts new file mode 100644 index 0000000000000..3e96f3c19d06d --- /dev/null +++ b/x-pack/legacy/plugins/infra/common/performance_tracing.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import uuid from 'uuid'; + +export const tracingSpanRT = rt.type({ + duration: rt.number, + id: rt.string, + name: rt.string, + start: rt.number, +}); + +export type TracingSpan = rt.TypeOf; + +export type ActiveTrace = (endTime?: number) => TracingSpan; + +export const startTracingSpan = (name: string): ActiveTrace => { + const initialState: TracingSpan = { + duration: Number.POSITIVE_INFINITY, + id: uuid.v4(), + name, + start: Date.now(), + }; + + return (endTime: number = Date.now()) => ({ + ...initialState, + duration: endTime - initialState.start, + }); +}; diff --git a/x-pack/legacy/plugins/infra/common/runtime_types.ts b/x-pack/legacy/plugins/infra/common/runtime_types.ts index 297743f9b3456..d5b858df38def 100644 --- a/x-pack/legacy/plugins/infra/common/runtime_types.ts +++ b/x-pack/legacy/plugins/infra/common/runtime_types.ts @@ -4,11 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Errors } from 'io-ts'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { Errors, Type } from 'io-ts'; import { failure } from 'io-ts/lib/PathReporter'; +type ErrorFactory = (message: string) => Error; + export const createPlainError = (message: string) => new Error(message); -export const throwErrors = (createError: (message: string) => Error) => (errors: Errors) => { +export const throwErrors = (createError: ErrorFactory) => (errors: Errors) => { throw createError(failure(errors).join('\n')); }; + +export const decodeOrThrow = ( + runtimeType: Type, + createError: ErrorFactory = createPlainError +) => (inputValue: I) => + pipe(runtimeType.decode(inputValue), fold(throwErrors(createError), identity)); diff --git a/x-pack/legacy/plugins/infra/public/apps/start_app.tsx b/x-pack/legacy/plugins/infra/public/apps/start_app.tsx index 8ccb051724ede..dbdc827478a45 100644 --- a/x-pack/legacy/plugins/infra/public/apps/start_app.tsx +++ b/x-pack/legacy/plugins/infra/public/apps/start_app.tsx @@ -27,6 +27,7 @@ import { KibanaContextProvider, } from '../../../../../../src/plugins/kibana_react/public'; import { ROOT_ELEMENT_ID } from '../app'; + // NP_TODO: Type plugins export async function startApp(libs: InfraFrontendLibs, core: CoreStart, plugins: any) { const history = createHashHistory(); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts index 06229a26afd19..e954cf21229ee 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/index.ts @@ -5,3 +5,4 @@ */ export * from './log_analysis_job_problem_indicator'; +export * from './recreate_job_button'; diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx index 018c5f5e0570d..8a16d819e12c2 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/log_analysis_job_problem_indicator.tsx @@ -17,13 +17,22 @@ export const LogAnalysisJobProblemIndicator: React.FC<{ onRecreateMlJobForReconfiguration: () => void; onRecreateMlJobForUpdate: () => void; }> = ({ jobStatus, setupStatus, onRecreateMlJobForReconfiguration, onRecreateMlJobForUpdate }) => { - if (jobStatus === 'stopped') { + if (isStopped(jobStatus)) { return ; - } else if (setupStatus === 'skippedButUpdatable') { + } else if (isUpdatable(setupStatus)) { return ; - } else if (setupStatus === 'skippedButReconfigurable') { + } else if (isReconfigurable(setupStatus)) { return ; } return null; // no problem to indicate }; + +const isStopped = (jobStatus: JobStatus) => jobStatus === 'stopped'; + +const isUpdatable = (setupStatus: SetupStatus) => setupStatus === 'skippedButUpdatable'; + +const isReconfigurable = (setupStatus: SetupStatus) => setupStatus === 'skippedButReconfigurable'; + +export const jobHasProblem = (jobStatus: JobStatus, setupStatus: SetupStatus) => + isStopped(jobStatus) || isUpdatable(setupStatus) || isReconfigurable(setupStatus); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx new file mode 100644 index 0000000000000..74e8d197ef455 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_button.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButton, PropsOf } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React from 'react'; + +export const RecreateJobButton: React.FunctionComponent> = props => ( + + + +); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx index b95054bbd6a9b..5b872d4ee5147 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_job_status/recreate_job_callout.tsx @@ -5,8 +5,9 @@ */ import React from 'react'; -import { EuiCallOut, EuiButton } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCallOut } from '@elastic/eui'; + +import { RecreateJobButton } from './recreate_job_button'; export const RecreateJobCallout: React.FC<{ onRecreateMlJob: () => void; @@ -14,11 +15,6 @@ export const RecreateJobCallout: React.FC<{ }> = ({ children, onRecreateMlJob, title }) => (

{children}

- - - +
); diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/first_use_callout.tsx b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/first_use_callout.tsx new file mode 100644 index 0000000000000..7fcdcc89a633a --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/first_use_callout.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +export const FirstUseCallout = () => { + return ( + +

+ {i18n.translate('xpack.infra.logs.analysis.onboardingSuccessContent', { + defaultMessage: + 'Please allow a few minutes for our machine learning robots to begin collecting data.', + })} +

+
+ ); +}; diff --git a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts index 8a4ceb70252a3..a3139124e6c9f 100644 --- a/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts +++ b/x-pack/legacy/plugins/infra/public/components/logging/log_analysis_results/index.ts @@ -5,3 +5,4 @@ */ export * from './analyze_in_ml_button'; +export * from './first_use_callout'; diff --git a/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts b/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts index 41c155e185c3a..a067285026e33 100644 --- a/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts +++ b/x-pack/legacy/plugins/infra/public/containers/logs/log_analysis/api/ml_get_jobs_summary_api.ts @@ -41,6 +41,7 @@ export type FetchJobStatusRequestPayload = rt.TypeOf ( jobSummaries .filter(jobSummary => jobSummary.id === jobId) .every( - jobSummary => - jobSummary.fullJob && - jobSummary.fullJob.custom_settings && - jobSummary.fullJob.custom_settings.job_revision && - jobSummary.fullJob.custom_settings.job_revision >= currentRevision + jobSummary => (jobSummary?.fullJob?.custom_settings?.job_revision ?? 0) >= currentRevision ); const isJobConfigurationConsistent = ( diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts index 5910dc54dfc90..be7547f2e74cb 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/module_descriptor.ts @@ -8,6 +8,8 @@ import { bucketSpan, categoriesMessageField, getJobId, + LogEntryCategoriesJobType, + logEntryCategoriesJobTypes, partitionField, } from '../../../../common/log_analysis'; @@ -21,22 +23,19 @@ import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; -const jobTypes = ['log-entry-categories-count']; const moduleId = 'logs_ui_categories'; -type JobType = typeof jobTypes[0]; - const getJobIds = (spaceId: string, sourceId: string) => - jobTypes.reduce( + logEntryCategoriesJobTypes.reduce( (accumulatedJobIds, jobType) => ({ ...accumulatedJobIds, [jobType]: getJobId(spaceId, sourceId, jobType), }), - {} as Record + {} as Record ); const getJobSummary = async (spaceId: string, sourceId: string) => { - const response = await callJobsSummaryAPI(spaceId, sourceId, jobTypes); + const response = await callJobsSummaryAPI(spaceId, sourceId, logEntryCategoriesJobTypes); const jobIds = Object.values(getJobIds(spaceId, sourceId)); return response.filter(jobSummary => jobIds.includes(jobSummary.id)); @@ -83,7 +82,7 @@ const setUpModule = async ( }; const cleanUpModule = async (spaceId: string, sourceId: string) => { - return await cleanUpJobsAndDatafeeds(spaceId, sourceId, jobTypes); + return await cleanUpJobsAndDatafeeds(spaceId, sourceId, logEntryCategoriesJobTypes); }; const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceConfiguration) => { @@ -103,9 +102,9 @@ const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceCon ]); }; -export const logEntryCategoriesModule: ModuleDescriptor = { +export const logEntryCategoriesModule: ModuleDescriptor = { moduleId, - jobTypes, + jobTypes: logEntryCategoriesJobTypes, bucketSpan, getJobIds, getJobSummary, diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx index 9a50acf622ee1..cc59d73055796 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_content.tsx @@ -14,6 +14,7 @@ import { MlUnavailablePrompt, } from '../../../components/logging/log_analysis_setup'; import { LogAnalysisCapabilities } from '../../../containers/logs/log_analysis'; +import { LogEntryCategoriesResultsContent } from './page_results_content'; import { LogEntryCategoriesSetupContent } from './page_setup_content'; import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; @@ -44,8 +45,7 @@ export const LogEntryCategoriesPageContent = () => { } else if (setupStatus === 'unknown') { return ; } else if (isSetupStatusWithResults(setupStatus)) { - return null; - // return ; + return ; } else { return ; } diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx new file mode 100644 index 0000000000000..ffffba0691749 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -0,0 +1,240 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import datemath from '@elastic/datemath'; +import { EuiFlexGroup, EuiFlexItem, EuiPage, EuiPanel, EuiSuperDatePicker } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import moment from 'moment'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useKibana } from '../../../../../../../../src/plugins/kibana_react/public'; +import euiStyled from '../../../../../../common/eui_styled_components'; +import { TimeRange } from '../../../../common/http_api/shared/time_range'; +import { + LogAnalysisJobProblemIndicator, + jobHasProblem, +} from '../../../components/logging/log_analysis_job_status'; +import { FirstUseCallout } from '../../../components/logging/log_analysis_results'; +import { useInterval } from '../../../hooks/use_interval'; +import { useTrackPageview } from '../../../hooks/use_track_metric'; +import { TopCategoriesSection } from './sections/top_categories'; +import { useLogEntryCategoriesModuleContext } from './use_log_entry_categories_module'; +import { useLogEntryCategoriesResults } from './use_log_entry_categories_results'; +import { + StringTimeRange, + useLogEntryCategoriesResultsUrlState, +} from './use_log_entry_categories_results_url_state'; + +const JOB_STATUS_POLLING_INTERVAL = 30000; + +export const LogEntryCategoriesResultsContent: React.FunctionComponent = () => { + useTrackPageview({ app: 'infra_logs', path: 'log_entry_categories_results' }); + useTrackPageview({ app: 'infra_logs', path: 'log_entry_categories_results', delay: 15000 }); + + const { + fetchJobStatus, + jobStatus, + setupStatus, + viewSetupForReconfiguration, + viewSetupForUpdate, + jobIds, + sourceConfiguration: { sourceId }, + } = useLogEntryCategoriesModuleContext(); + + const { + timeRange: selectedTimeRange, + setTimeRange: setSelectedTimeRange, + autoRefresh, + setAutoRefresh, + } = useLogEntryCategoriesResultsUrlState(); + + const [categoryQueryTimeRange, setCategoryQueryTimeRange] = useState<{ + lastChangedTime: number; + timeRange: TimeRange; + }>(() => ({ + lastChangedTime: Date.now(), + timeRange: stringToNumericTimeRange(selectedTimeRange), + })); + + const [categoryQueryDatasets, setCategoryQueryDatasets] = useState([]); + + const { services } = useKibana<{}>(); + + const showLoadDataErrorNotification = useCallback( + (error: Error) => { + // eslint-disable-next-line no-unused-expressions + services.notifications?.toasts.addError(error, { + title: loadDataErrorTitle, + }); + }, + [services.notifications] + ); + + const { + getLogEntryCategoryDatasets, + getTopLogEntryCategories, + isLoadingLogEntryCategoryDatasets, + isLoadingTopLogEntryCategories, + logEntryCategoryDatasets, + topLogEntryCategories, + } = useLogEntryCategoriesResults({ + categoriesCount: 25, + endTime: categoryQueryTimeRange.timeRange.endTime, + filteredDatasets: categoryQueryDatasets, + onGetTopLogEntryCategoriesError: showLoadDataErrorNotification, + sourceId, + startTime: categoryQueryTimeRange.timeRange.startTime, + }); + + const handleQueryTimeRangeChange = useCallback( + ({ start: startTime, end: endTime }: { start: string; end: string }) => { + setCategoryQueryTimeRange(previousQueryParameters => ({ + ...previousQueryParameters, + timeRange: stringToNumericTimeRange({ startTime, endTime }), + lastChangedTime: Date.now(), + })); + }, + [setCategoryQueryTimeRange] + ); + + const handleSelectedTimeRangeChange = useCallback( + (selectedTime: { start: string; end: string; isInvalid: boolean }) => { + if (selectedTime.isInvalid) { + return; + } + setSelectedTimeRange({ + startTime: selectedTime.start, + endTime: selectedTime.end, + }); + handleQueryTimeRangeChange(selectedTime); + }, + [setSelectedTimeRange, handleQueryTimeRangeChange] + ); + + const handleAutoRefreshChange = useCallback( + ({ isPaused, refreshInterval: interval }: { isPaused: boolean; refreshInterval: number }) => { + setAutoRefresh({ + isPaused, + interval, + }); + }, + [setAutoRefresh] + ); + + const isFirstUse = useMemo(() => setupStatus === 'hiddenAfterSuccess', [setupStatus]); + + const hasResults = useMemo(() => topLogEntryCategories.length > 0, [ + topLogEntryCategories.length, + ]); + + useEffect(() => { + getTopLogEntryCategories(); + }, [getTopLogEntryCategories, categoryQueryDatasets, categoryQueryTimeRange.lastChangedTime]); + + useEffect(() => { + getLogEntryCategoryDatasets(); + }, [getLogEntryCategoryDatasets, categoryQueryTimeRange.lastChangedTime]); + + useInterval(() => { + fetchJobStatus(); + }, JOB_STATUS_POLLING_INTERVAL); + + useInterval( + () => { + handleQueryTimeRangeChange({ + start: selectedTimeRange.startTime, + end: selectedTimeRange.endTime, + }); + }, + autoRefresh.isPaused ? null : autoRefresh.interval + ); + + return ( + + + + + + + + + + + + + {jobHasProblem(jobStatus['log-entry-categories-count'], setupStatus) ? ( + + + + ) : null} + {isFirstUse && !hasResults ? ( + + + + ) : null} + + + + + + + + ); +}; + +const stringToNumericTimeRange = (timeRange: StringTimeRange): TimeRange => ({ + startTime: moment( + datemath.parse(timeRange.startTime, { + momentInstance: moment, + }) + ).valueOf(), + endTime: moment( + datemath.parse(timeRange.endTime, { + momentInstance: moment, + roundUp: true, + }) + ).valueOf(), +}); + +// This is needed due to the flex-basis: 100% !important; rule that +// kicks in on small screens via media queries breaking when using direction="column" +export const ResultsContentPage = euiStyled(EuiPage)` + flex: 1 0 0%; + flex-direction: column; + + .euiFlexGroup--responsive > .euiFlexItem { + flex-basis: auto !important; + } +`; + +const loadDataErrorTitle = i18n.translate( + 'xpack.infra.logs.logEntryCategories.loadDataErrorTitle', + { + defaultMessage: 'Failed to load category data', + } +); diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx new file mode 100644 index 0000000000000..e50231316fb5a --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/anomaly_severity_indicator.tsx @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiHealth } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { + formatAnomalyScore, + getSeverityCategoryForScore, + ML_SEVERITY_COLORS, +} from '../../../../../../common/log_analysis'; + +export const AnomalySeverityIndicator: React.FunctionComponent<{ + anomalyScore: number; +}> = ({ anomalyScore }) => { + const severityColor = useMemo(() => getColorForAnomalyScore(anomalyScore), [anomalyScore]); + + return {formatAnomalyScore(anomalyScore)}; +}; + +const getColorForAnomalyScore = (anomalyScore: number) => { + const severityCategory = getSeverityCategoryForScore(anomalyScore); + + if (severityCategory != null && severityCategory in ML_SEVERITY_COLORS) { + return ML_SEVERITY_COLORS[severityCategory]; + } else { + return 'subdued'; + } +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx new file mode 100644 index 0000000000000..5c8b18528cae6 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/category_expression.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React, { memo } from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; + +export const RegularExpressionRepresentation: React.FunctionComponent<{ + maximumSegmentCount?: number; + regularExpression: string; +}> = memo(({ maximumSegmentCount = 30, regularExpression }) => { + const segments = regularExpression.split(collapsedRegularExpressionCharacters); + + return ( + + {segments + .slice(0, maximumSegmentCount) + .map((segment, segmentIndex) => [ + segmentIndex > 0 ? ( + + ) : null, + + {segment.replace(escapedRegularExpressionCharacters, '$1')} + , + ])} + {segments.length > maximumSegmentCount ? ( + + … + + ) : null} + + ); +}); + +const CategoryPattern = euiStyled.span` + font-family: ${props => props.theme.eui.euiCodeFontFamily}; + word-break: break-all; +`; + +const CategoryPatternWildcard = euiStyled.span` + color: ${props => props.theme.eui.euiColorMediumShade}; +`; + +const CategoryPatternSegment = euiStyled.span` + font-weight: bold; +`; + +const collapsedRegularExpressionCharacters = /\.[+*]\??/g; + +const escapedRegularExpressionCharacters = /\\([\\^$*+?.()\[\]])/g; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx new file mode 100644 index 0000000000000..c30612f54be00 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_list.tsx @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; + +export const DatasetsList: React.FunctionComponent<{ + datasets: string[]; +}> = ({ datasets }) => ( +
    + {datasets.sort().map(dataset => { + const datasetLabel = getFriendlyNameForPartitionId(dataset); + return
  • {datasetLabel}
  • ; + })} +
+); diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx new file mode 100644 index 0000000000000..9c22caa4b3465 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/datasets_selector.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiComboBox, EuiComboBoxOptionProps } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useMemo } from 'react'; + +import { getFriendlyNameForPartitionId } from '../../../../../../common/log_analysis'; + +type DatasetOptionProps = EuiComboBoxOptionProps; + +export const DatasetsSelector: React.FunctionComponent<{ + availableDatasets: string[]; + isLoading?: boolean; + onChangeDatasetSelection: (datasets: string[]) => void; + selectedDatasets: string[]; +}> = ({ availableDatasets, isLoading = false, onChangeDatasetSelection, selectedDatasets }) => { + const options = useMemo( + () => + availableDatasets.map(dataset => ({ + value: dataset, + label: getFriendlyNameForPartitionId(dataset), + })), + [availableDatasets] + ); + + const selectedOptions = useMemo( + () => options.filter(({ value }) => value != null && selectedDatasets.includes(value)), + [options, selectedDatasets] + ); + + const handleChange = useCallback( + (newSelectedOptions: DatasetOptionProps[]) => + onChangeDatasetSelection(newSelectedOptions.map(({ value }) => value).filter(isDefined)), + [onChangeDatasetSelection] + ); + + return ( + + ); +}; + +const datasetFilterPlaceholder = i18n.translate( + 'xpack.infra.logs.logEntryCategories.datasetFilterPlaceholder', + { + defaultMessage: 'Filter by datasets', + } +); + +const isDefined = (value: Value): value is NonNullable => value != null; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts new file mode 100644 index 0000000000000..e699bbf956f94 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './top_categories_section'; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx new file mode 100644 index 0000000000000..7a29ea9aa0ebc --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/log_entry_count_sparkline.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; + +import { LogEntryCategoryHistogram } from '../../../../../../common/http_api/log_analysis'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { SingleMetricComparison } from './single_metric_comparison'; +import { SingleMetricSparkline } from './single_metric_sparkline'; + +export const LogEntryCountSparkline: React.FunctionComponent<{ + currentCount: number; + histograms: LogEntryCategoryHistogram[]; + timeRange: TimeRange; +}> = ({ currentCount, histograms, timeRange }) => { + const metric = useMemo( + () => + histograms + .find(histogram => histogram.histogramId === 'history') + ?.buckets?.map(({ startTime: timestamp, logEntryCount: value }) => ({ + timestamp, + value, + })) ?? [], + [histograms] + ); + const referenceCount = useMemo( + () => + histograms.find(histogram => histogram.histogramId === 'reference')?.buckets?.[0] + ?.logEntryCount ?? 0, + [histograms] + ); + + const overallTimeRange = useMemo( + () => ({ + endTime: timeRange.endTime, + startTime: timeRange.startTime - (timeRange.endTime - timeRange.startTime), + }), + [timeRange.endTime, timeRange.startTime] + ); + + return ( + <> + + + + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_comparison.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_comparison.tsx new file mode 100644 index 0000000000000..1352afb60a505 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_comparison.tsx @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiIcon, EuiTextColor } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; + +export const SingleMetricComparison: React.FunctionComponent<{ + currentValue: number; + previousValue: number; +}> = ({ currentValue, previousValue }) => { + const changeFactor = currentValue / previousValue - 1; + + if (changeFactor < 0) { + return ( + + + {formatPercentage(changeFactor)} + + ); + } else if (changeFactor > 0 && Number.isFinite(changeFactor)) { + return ( + + + {formatPercentage(changeFactor)} + + ); + } else if (changeFactor > 0 && !Number.isFinite(changeFactor)) { + return ( + + + {newCategoryTrendLabel} + + ); + } + + return null; +}; + +const formatPercentage = (value: number) => numeral(value).format('+0,0 %'); + +const newCategoryTrendLabel = i18n.translate( + 'xpack.infra.logs.logEntryCategories.newCategoryTrendLabel', + { + defaultMessage: 'new', + } +); + +const NoWrapSpan = euiStyled.span` + white-space: nowrap; +`; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx new file mode 100644 index 0000000000000..5fb8e3380f23f --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/single_metric_sparkline.tsx @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { Chart, Settings, AreaSeries } from '@elastic/charts'; +import { + EUI_CHARTS_THEME_LIGHT, + EUI_SPARKLINE_THEME_PARTIAL, + EUI_CHARTS_THEME_DARK, +} from '@elastic/eui/dist/eui_charts_theme'; + +import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; +import { TimeRange } from '../../../../../../common/http_api/shared'; + +interface TimeSeriesPoint { + timestamp: number; + value: number; +} + +const timestampAccessor = 'timestamp'; +const valueAccessor = ['value']; +const sparklineSize = { + height: 20, + width: 100, +}; + +export const SingleMetricSparkline: React.FunctionComponent<{ + metric: TimeSeriesPoint[]; + timeRange: TimeRange; +}> = ({ metric, timeRange }) => { + const [isDarkMode] = useKibanaUiSetting('theme:darkMode'); + + const theme = useMemo( + () => [ + // localThemeOverride, + EUI_SPARKLINE_THEME_PARTIAL, + isDarkMode ? EUI_CHARTS_THEME_DARK.theme : EUI_CHARTS_THEME_LIGHT.theme, + ], + [isDarkMode] + ); + + const xDomain = useMemo( + () => ({ + max: timeRange.endTime, + min: timeRange.startTime, + }), + [timeRange] + ); + + return ( + + + + + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx new file mode 100644 index 0000000000000..0281615a59c78 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_section.tsx @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; + +import { LogEntryCategory } from '../../../../../../common/http_api/log_analysis'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; +import { RecreateJobButton } from '../../../../../components/logging/log_analysis_job_status'; +import { AnalyzeInMlButton } from '../../../../../components/logging/log_analysis_results'; +import { DatasetsSelector } from './datasets_selector'; +import { TopCategoriesTable } from './top_categories_table'; + +export const TopCategoriesSection: React.FunctionComponent<{ + availableDatasets: string[]; + isLoadingDatasets?: boolean; + isLoadingTopCategories?: boolean; + jobId: string; + onChangeDatasetSelection: (datasets: string[]) => void; + onRequestRecreateMlJob: () => void; + selectedDatasets: string[]; + timeRange: TimeRange; + topCategories: LogEntryCategory[]; +}> = ({ + availableDatasets, + isLoadingDatasets = false, + isLoadingTopCategories = false, + jobId, + onChangeDatasetSelection, + onRequestRecreateMlJob, + selectedDatasets, + timeRange, + topCategories, +}) => { + return ( + <> + + + +

{title}

+
+
+ + + + + + +
+ + + + } + > + + + + ); +}; + +const title = i18n.translate('xpack.infra.logs.logEntryCategories.topCategoriesSectionTitle', { + defaultMessage: 'Log message categories', +}); + +const loadingAriaLabel = i18n.translate( + 'xpack.infra.logs.logEntryCategories.topCategoriesSectionLoadingAriaLabel', + { defaultMessage: 'Loading message categories' } +); + +const LoadingOverlayContent = () => ; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx new file mode 100644 index 0000000000000..3d20aef03ff15 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/sections/top_categories/top_categories_table.tsx @@ -0,0 +1,106 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiBasicTable, EuiBasicTableColumn } from '@elastic/eui'; +import numeral from '@elastic/numeral'; +import { i18n } from '@kbn/i18n'; +import React, { useMemo } from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; +import { + LogEntryCategory, + LogEntryCategoryHistogram, +} from '../../../../../../common/http_api/log_analysis'; +import { TimeRange } from '../../../../../../common/http_api/shared'; +import { AnomalySeverityIndicator } from './anomaly_severity_indicator'; +import { RegularExpressionRepresentation } from './category_expression'; +import { DatasetsList } from './datasets_list'; +import { LogEntryCountSparkline } from './log_entry_count_sparkline'; + +export const TopCategoriesTable = euiStyled( + ({ + className, + timeRange, + topCategories, + }: { + className?: string; + timeRange: TimeRange; + topCategories: LogEntryCategory[]; + }) => { + const columns = useMemo(() => createColumns(timeRange), [timeRange]); + + return ( + + ); + } +)` + &.euiTableRow--topAligned .euiTableRowCell { + vertical-align: top; + } +`; + +const createColumns = (timeRange: TimeRange): Array> => [ + { + align: 'right', + field: 'logEntryCount', + name: i18n.translate('xpack.infra.logs.logEntryCategories.countColumnTitle', { + defaultMessage: 'Message count', + }), + render: (logEntryCount: number) => { + return numeral(logEntryCount).format('0,0'); + }, + width: '120px', + }, + { + field: 'histograms', + name: i18n.translate('xpack.infra.logs.logEntryCategories.trendColumnTitle', { + defaultMessage: 'Trend', + }), + render: (histograms: LogEntryCategoryHistogram[], item) => { + return ( + + ); + }, + width: '220px', + }, + { + field: 'regularExpression', + name: i18n.translate('xpack.infra.logs.logEntryCategories.categoryColumnTitle', { + defaultMessage: 'Category', + }), + truncateText: true, + render: (regularExpression: string) => ( + + ), + }, + { + field: 'datasets', + name: i18n.translate('xpack.infra.logs.logEntryCategories.datasetColumnTitle', { + defaultMessage: 'Datasets', + }), + render: (datasets: string[]) => , + width: '200px', + }, + { + align: 'right', + field: 'maximumAnomalyScore', + name: i18n.translate('xpack.infra.logs.logEntryCategories.maximumAnomalyScoreColumnTitle', { + defaultMessage: 'Maximum anomaly score', + }), + render: (maximumAnomalyScore: number) => ( + + ), + width: '160px', + }, +]; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_datasets.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_datasets.ts new file mode 100644 index 0000000000000..942ded4230e97 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_log_entry_category_datasets.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from 'ui/new_platform'; + +import { + getLogEntryCategoryDatasetsRequestPayloadRT, + getLogEntryCategoryDatasetsSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; + +export const callGetLogEntryCategoryDatasetsAPI = async ( + sourceId: string, + startTime: number, + endTime: number +) => { + const response = await npStart.core.http.fetch( + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, + { + method: 'POST', + body: JSON.stringify( + getLogEntryCategoryDatasetsRequestPayloadRT.encode({ + data: { + sourceId, + timeRange: { + startTime, + endTime, + }, + }, + }) + ), + } + ); + + return pipe( + getLogEntryCategoryDatasetsSuccessReponsePayloadRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts new file mode 100644 index 0000000000000..35d6f1ec4f893 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/service_calls/get_top_log_entry_categories.ts @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { identity } from 'fp-ts/lib/function'; +import { npStart } from 'ui/new_platform'; + +import { + getLogEntryCategoriesRequestPayloadRT, + getLogEntryCategoriesSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, +} from '../../../../../common/http_api/log_analysis'; +import { createPlainError, throwErrors } from '../../../../../common/runtime_types'; + +export const callGetTopLogEntryCategoriesAPI = async ( + sourceId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets?: string[] +) => { + const intervalDuration = endTime - startTime; + + const response = await npStart.core.http.fetch(LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, { + method: 'POST', + body: JSON.stringify( + getLogEntryCategoriesRequestPayloadRT.encode({ + data: { + sourceId, + timeRange: { + startTime, + endTime, + }, + categoryCount, + datasets, + histograms: [ + { + id: 'history', + timeRange: { + startTime: startTime - intervalDuration, + endTime, + }, + bucketCount: 10, + }, + { + id: 'reference', + timeRange: { + startTime: startTime - intervalDuration, + endTime: startTime, + }, + bucketCount: 1, + }, + ], + }, + }) + ), + }); + + return pipe( + getLogEntryCategoriesSuccessReponsePayloadRT.decode(response), + fold(throwErrors(createPlainError), identity) + ); +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts new file mode 100644 index 0000000000000..2282582dc2bd6 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results.ts @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useMemo, useState } from 'react'; + +import { + GetLogEntryCategoriesSuccessResponsePayload, + GetLogEntryCategoryDatasetsSuccessResponsePayload, +} from '../../../../common/http_api/log_analysis'; +import { useTrackedPromise, CanceledPromiseError } from '../../../utils/use_tracked_promise'; +import { callGetTopLogEntryCategoriesAPI } from './service_calls/get_top_log_entry_categories'; +import { callGetLogEntryCategoryDatasetsAPI } from './service_calls/get_log_entry_category_datasets'; + +type TopLogEntryCategories = GetLogEntryCategoriesSuccessResponsePayload['data']['categories']; +type LogEntryCategoryDatasets = GetLogEntryCategoryDatasetsSuccessResponsePayload['data']['datasets']; + +export const useLogEntryCategoriesResults = ({ + categoriesCount, + filteredDatasets: filteredDatasets, + endTime, + onGetLogEntryCategoryDatasetsError, + onGetTopLogEntryCategoriesError, + sourceId, + startTime, +}: { + categoriesCount: number; + filteredDatasets: string[]; + endTime: number; + onGetLogEntryCategoryDatasetsError?: (error: Error) => void; + onGetTopLogEntryCategoriesError?: (error: Error) => void; + sourceId: string; + startTime: number; +}) => { + const [topLogEntryCategories, setTopLogEntryCategories] = useState([]); + const [logEntryCategoryDatasets, setLogEntryCategoryDatasets] = useState< + LogEntryCategoryDatasets + >([]); + + const [getTopLogEntryCategoriesRequest, getTopLogEntryCategories] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetTopLogEntryCategoriesAPI( + sourceId, + startTime, + endTime, + categoriesCount, + filteredDatasets + ); + }, + onResolve: ({ data: { categories } }) => { + setTopLogEntryCategories(categories); + }, + onReject: error => { + if ( + error instanceof Error && + !(error instanceof CanceledPromiseError) && + onGetTopLogEntryCategoriesError + ) { + onGetTopLogEntryCategoriesError(error); + } + }, + }, + [categoriesCount, endTime, filteredDatasets, sourceId, startTime] + ); + + const [getLogEntryCategoryDatasetsRequest, getLogEntryCategoryDatasets] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: async () => { + return await callGetLogEntryCategoryDatasetsAPI(sourceId, startTime, endTime); + }, + onResolve: ({ data: { datasets } }) => { + setLogEntryCategoryDatasets(datasets); + }, + onReject: error => { + if ( + error instanceof Error && + !(error instanceof CanceledPromiseError) && + onGetLogEntryCategoryDatasetsError + ) { + onGetLogEntryCategoryDatasetsError(error); + } + }, + }, + [categoriesCount, endTime, sourceId, startTime] + ); + + const isLoadingTopLogEntryCategories = useMemo( + () => getTopLogEntryCategoriesRequest.state === 'pending', + [getTopLogEntryCategoriesRequest.state] + ); + + const isLoadingLogEntryCategoryDatasets = useMemo( + () => getLogEntryCategoryDatasetsRequest.state === 'pending', + [getLogEntryCategoryDatasetsRequest.state] + ); + + const isLoading = useMemo( + () => isLoadingTopLogEntryCategories || isLoadingLogEntryCategoryDatasets, + [isLoadingLogEntryCategoryDatasets, isLoadingTopLogEntryCategories] + ); + + return { + getLogEntryCategoryDatasets, + getTopLogEntryCategories, + isLoading, + isLoadingLogEntryCategoryDatasets, + isLoadingTopLogEntryCategories, + logEntryCategoryDatasets, + topLogEntryCategories, + }; +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results_url_state.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results_url_state.tsx new file mode 100644 index 0000000000000..bf30f96e4b741 --- /dev/null +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_categories/use_log_entry_categories_results_url_state.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { fold } from 'fp-ts/lib/Either'; +import { constant, identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; +import * as rt from 'io-ts'; + +import { useUrlState } from '../../../utils/use_url_state'; + +const autoRefreshRT = rt.union([ + rt.type({ + interval: rt.number, + isPaused: rt.boolean, + }), + rt.undefined, +]); + +export const stringTimeRangeRT = rt.type({ + startTime: rt.string, + endTime: rt.string, +}); +export type StringTimeRange = rt.TypeOf; + +const urlTimeRangeRT = rt.union([stringTimeRangeRT, rt.undefined]); + +const TIME_RANGE_URL_STATE_KEY = 'timeRange'; +const AUTOREFRESH_URL_STATE_KEY = 'autoRefresh'; + +export const useLogEntryCategoriesResultsUrlState = () => { + const [timeRange, setTimeRange] = useUrlState({ + defaultState: { + startTime: 'now-2w', + endTime: 'now', + }, + decodeUrlState: (value: unknown) => + pipe(urlTimeRangeRT.decode(value), fold(constant(undefined), identity)), + encodeUrlState: urlTimeRangeRT.encode, + urlStateKey: TIME_RANGE_URL_STATE_KEY, + writeDefaultState: true, + }); + + const [autoRefresh, setAutoRefresh] = useUrlState({ + defaultState: { + isPaused: false, + interval: 60000, + }, + decodeUrlState: (value: unknown) => + pipe(autoRefreshRT.decode(value), fold(constant(undefined), identity)), + encodeUrlState: autoRefreshRT.encode, + urlStateKey: AUTOREFRESH_URL_STATE_KEY, + writeDefaultState: true, + }); + + return { + timeRange, + setTimeRange, + autoRefresh, + setAutoRefresh, + }; +}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/first_use.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/first_use.tsx deleted file mode 100644 index 1ab9356a69e2a..0000000000000 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/first_use.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { i18n } from '@kbn/i18n'; -import { EuiCallOut, EuiSpacer } from '@elastic/eui'; - -export const FirstUseCallout = () => { - return ( - <> - -

- {i18n.translate('xpack.infra.logs.logsAnalysisResults.onboardingSuccessContent', { - defaultMessage: - 'Please allow a few minutes for our machine learning robots to begin collecting data.', - })} -

-
- - - ); -}; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts index 52be313264335..52ba3101dbc38 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/module_descriptor.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { bucketSpan, getJobId, partitionField } from '../../../../common/log_analysis'; +import { + bucketSpan, + getJobId, + LogEntryRateJobType, + logEntryRateJobTypes, + partitionField, +} from '../../../../common/log_analysis'; import { ModuleDescriptor, @@ -16,22 +22,19 @@ import { callGetMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml import { callSetupMlModuleAPI } from '../../../containers/logs/log_analysis/api/ml_setup_module_api'; import { callValidateIndicesAPI } from '../../../containers/logs/log_analysis/api/validate_indices'; -const jobTypes = ['log-entry-rate']; const moduleId = 'logs_ui_analysis'; -type JobType = typeof jobTypes[0]; - const getJobIds = (spaceId: string, sourceId: string) => - jobTypes.reduce( + logEntryRateJobTypes.reduce( (accumulatedJobIds, jobType) => ({ ...accumulatedJobIds, [jobType]: getJobId(spaceId, sourceId, jobType), }), - {} as Record + {} as Record ); const getJobSummary = async (spaceId: string, sourceId: string) => { - const response = await callJobsSummaryAPI(spaceId, sourceId, jobTypes); + const response = await callJobsSummaryAPI(spaceId, sourceId, logEntryRateJobTypes); const jobIds = Object.values(getJobIds(spaceId, sourceId)); return response.filter(jobSummary => jobIds.includes(jobSummary.id)); @@ -78,7 +81,7 @@ const setUpModule = async ( }; const cleanUpModule = async (spaceId: string, sourceId: string) => { - return await cleanUpJobsAndDatafeeds(spaceId, sourceId, jobTypes); + return await cleanUpJobsAndDatafeeds(spaceId, sourceId, logEntryRateJobTypes); }; const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceConfiguration) => { @@ -94,9 +97,9 @@ const validateSetupIndices = async ({ indices, timestampField }: ModuleSourceCon ]); }; -export const logEntryRateModule: ModuleDescriptor = { +export const logEntryRateModule: ModuleDescriptor = { moduleId, - jobTypes, + jobTypes: logEntryRateJobTypes, bucketSpan, getJobIds, getJobSummary, diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index b6ab8acdea5b2..693444c02ce5f 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -11,6 +11,7 @@ import { EuiFlexItem, EuiPage, EuiPanel, + EuiSpacer, EuiSuperDatePicker, EuiText, } from '@elastic/eui'; @@ -26,7 +27,6 @@ import { LoadingOverlayWrapper } from '../../../components/loading_overlay_wrapp import { useInterval } from '../../../hooks/use_interval'; import { useTrackPageview } from '../../../hooks/use_track_metric'; import { useKibanaUiSetting } from '../../../utils/use_kibana_ui_setting'; -import { FirstUseCallout } from './first_use'; import { AnomaliesResults } from './sections/anomalies'; import { LogRateResults } from './sections/log_rate'; import { useLogEntryRateModuleContext } from './use_log_entry_rate_module'; @@ -35,6 +35,7 @@ import { StringTimeRange, useLogAnalysisResultsUrlState, } from './use_log_entry_rate_results_url_state'; +import { FirstUseCallout } from '../../../components/logging/log_analysis_results'; const JOB_STATUS_POLLING_INTERVAL = 30000; @@ -196,7 +197,12 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { - {isFirstUse && !hasResults ? : null} + {isFirstUse && !hasResults ? ( + <> + + + + ) : null} { // This is needed due to the flex-basis: 100% !important; rule that // kicks in on small screens via media queries breaking when using direction="column" export const ResultsContentPage = euiStyled(EuiPage)` + flex: 1 0 0%; + .euiFlexGroup--responsive > .euiFlexItem { flex-basis: auto !important; } diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx index a75e6c50ab03f..1a3a7d9e2b572 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/chart.tsx @@ -22,8 +22,11 @@ import moment from 'moment'; import React, { useCallback, useMemo } from 'react'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { + MLSeverityScoreCategories, + ML_SEVERITY_COLORS, +} from '../../../../../../common/log_analysis'; import { useKibanaUiSetting } from '../../../../../utils/use_kibana_ui_setting'; -import { MLSeverityScoreCategories } from '../helpers/data_formatters'; export const AnomaliesChart: React.FunctionComponent<{ chartId: string; @@ -109,19 +112,19 @@ interface SeverityConfig { const severityConfigs: Record = { warning: { id: `anomalies-warning`, - style: { fill: 'rgb(125, 180, 226)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.warning, opacity: 0.7 }, }, minor: { id: `anomalies-minor`, - style: { fill: 'rgb(255, 221, 0)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.minor, opacity: 0.7 }, }, major: { id: `anomalies-major`, - style: { fill: 'rgb(229, 113, 0)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.major, opacity: 0.7 }, }, critical: { id: `anomalies-critical`, - style: { fill: 'rgb(228, 72, 72)', opacity: 0.7 }, + style: { fill: ML_SEVERITY_COLORS.critical, opacity: 0.7 }, }, }; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx index e5e719c2d69f6..4aff907cfad66 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/index.tsx @@ -12,7 +12,6 @@ import { EuiStat, EuiTitle, EuiLoadingSpinner, - EuiButton, } from '@elastic/eui'; import numeral from '@elastic/numeral'; import { i18n } from '@kbn/i18n'; @@ -21,16 +20,18 @@ import React, { useMemo } from 'react'; import euiStyled from '../../../../../../../../common/eui_styled_components'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; -import { JobStatus, SetupStatus } from '../../../../../../common/log_analysis'; +import { formatAnomalyScore, JobStatus, SetupStatus } from '../../../../../../common/log_analysis'; import { - formatAnomalyScore, getAnnotationsForAll, getLogEntryRateCombinedSeries, getTopAnomalyScoreAcrossAllPartitions, } from '../helpers/data_formatters'; import { AnomaliesChart } from './chart'; import { AnomaliesTable } from './table'; -import { LogAnalysisJobProblemIndicator } from '../../../../../components/logging/log_analysis_job_status'; +import { + LogAnalysisJobProblemIndicator, + RecreateJobButton, +} from '../../../../../components/logging/log_analysis_job_status'; import { AnalyzeInMlButton } from '../../../../../components/logging/log_analysis_results'; import { LoadingOverlayWrapper } from '../../../../../components/loading_overlay_wrapper'; @@ -99,9 +100,7 @@ export const AnomaliesResults: React.FunctionComponent<{ - - Recreate jobs - + diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx index 45893315c7361..3e86b45fadfdd 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/anomalies/table.tsx @@ -4,15 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo, useState, useCallback } from 'react'; import { EuiBasicTable, EuiButtonIcon } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import { RIGHT_ALIGNMENT } from '@elastic/eui/lib/services'; +import { i18n } from '@kbn/i18n'; +import React, { useCallback, useMemo, useState } from 'react'; + +import euiStyled from '../../../../../../../../common/eui_styled_components'; import { TimeRange } from '../../../../../../common/http_api/shared/time_range'; +import { + formatAnomalyScore, + getFriendlyNameForPartitionId, +} from '../../../../../../common/log_analysis'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; import { AnomaliesTableExpandedRow } from './expanded_row'; -import { formatAnomalyScore, getFriendlyNameForPartitionId } from '../helpers/data_formatters'; -import euiStyled from '../../../../../../../../common/eui_styled_components'; interface TableItem { id: string; diff --git a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx index f9b85fc4e20c2..e8e4c18e7420c 100644 --- a/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx +++ b/x-pack/legacy/plugins/infra/public/pages/logs/log_entry_rate/sections/helpers/data_formatters.tsx @@ -7,17 +7,14 @@ import { RectAnnotationDatum } from '@elastic/charts'; import { i18n } from '@kbn/i18n'; +import { + formatAnomalyScore, + getFriendlyNameForPartitionId, + getSeverityCategoryForScore, + MLSeverityScoreCategories, +} from '../../../../../../common/log_analysis'; import { LogEntryRateResults } from '../../use_log_entry_rate_results'; -const ML_SEVERITY_SCORES = { - warning: 3, - minor: 25, - major: 50, - critical: 75, -}; - -export type MLSeverityScoreCategories = keyof typeof ML_SEVERITY_SCORES; - export const getLogEntryRatePartitionedSeries = (results: LogEntryRateResults) => { return results.histogramBuckets.reduce>( (buckets, bucket) => { @@ -182,26 +179,3 @@ export const getTopAnomalyScoreAcrossAllPartitions = (results: LogEntryRateResul ); return Math.max(...allTopScores); }; - -const getSeverityCategoryForScore = (score: number): MLSeverityScoreCategories | undefined => { - if (score >= ML_SEVERITY_SCORES.critical) { - return 'critical'; - } else if (score >= ML_SEVERITY_SCORES.major) { - return 'major'; - } else if (score >= ML_SEVERITY_SCORES.minor) { - return 'minor'; - } else if (score >= ML_SEVERITY_SCORES.warning) { - return 'warning'; - } else { - // Category is too low to include - return undefined; - } -}; - -export const formatAnomalyScore = (score: number) => { - return Math.round(score); -}; - -export const getFriendlyNameForPartitionId = (partitionId: string) => { - return partitionId !== '' ? partitionId : 'unknown'; -}; diff --git a/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts b/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts index c23bab7026aaa..e9a966b97e4dd 100644 --- a/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts +++ b/x-pack/legacy/plugins/infra/public/utils/use_tracked_promise.ts @@ -248,7 +248,7 @@ interface CancelablePromise { promise: Promise; } -class CanceledPromiseError extends Error { +export class CanceledPromiseError extends Error { public isCanceled = true; constructor(message?: string) { @@ -257,6 +257,6 @@ class CanceledPromiseError extends Error { } } -class SilentCanceledPromiseError extends CanceledPromiseError {} +export class SilentCanceledPromiseError extends CanceledPromiseError {} const noOp = () => undefined; diff --git a/x-pack/legacy/plugins/infra/server/infra_server.ts b/x-pack/legacy/plugins/infra/server/infra_server.ts index f99589e1b52bd..4f290cb05f056 100644 --- a/x-pack/legacy/plugins/infra/server/infra_server.ts +++ b/x-pack/legacy/plugins/infra/server/infra_server.ts @@ -12,6 +12,8 @@ import { createSourceStatusResolvers } from './graphql/source_status'; import { createSourcesResolvers } from './graphql/sources'; import { InfraBackendLibs } from './lib/infra_types'; import { + initGetLogEntryCategoriesRoute, + initGetLogEntryCategoryDatasetsRoute, initGetLogEntryRateRoute, initValidateLogAnalysisIndicesRoute, } from './routes/log_analysis'; @@ -41,6 +43,8 @@ export const initInfraServer = (libs: InfraBackendLibs) => { libs.framework.registerGraphQLEndpoint('/graphql', schema); initIpToHostName(libs); + initGetLogEntryCategoriesRoute(libs); + initGetLogEntryCategoryDatasetsRoute(libs); initGetLogEntryRateRoute(libs); initSnapshotRoute(libs); initNodeDetailsRoute(libs); diff --git a/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts b/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts index 305841aa52d36..d8a39a6b9c16f 100644 --- a/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts +++ b/x-pack/legacy/plugins/infra/server/lib/compose/kibana.ts @@ -12,7 +12,7 @@ import { InfraFieldsDomain } from '../domains/fields_domain'; import { InfraLogEntriesDomain } from '../domains/log_entries_domain'; import { InfraMetricsDomain } from '../domains/metrics_domain'; import { InfraBackendLibs, InfraDomainLibs } from '../infra_types'; -import { InfraLogAnalysis } from '../log_analysis'; +import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from '../log_analysis'; import { InfraSnapshot } from '../snapshot'; import { InfraSourceStatus } from '../source_status'; import { InfraSources } from '../sources'; @@ -29,7 +29,8 @@ export function compose(core: CoreSetup, config: InfraConfig, plugins: InfraServ sources, }); const snapshot = new InfraSnapshot({ sources, framework }); - const logAnalysis = new InfraLogAnalysis({ framework }); + const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); + const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); // TODO: separate these out individually and do away with "domains" as a temporary group const domainLibs: InfraDomainLibs = { @@ -45,7 +46,8 @@ export function compose(core: CoreSetup, config: InfraConfig, plugins: InfraServ const libs: InfraBackendLibs = { configuration: config, // NP_TODO: Do we ever use this anywhere? framework, - logAnalysis, + logEntryCategoriesAnalysis, + logEntryRateAnalysis, snapshot, sources, sourceStatus, diff --git a/x-pack/legacy/plugins/infra/server/lib/infra_types.ts b/x-pack/legacy/plugins/infra/server/lib/infra_types.ts index 46d32885600df..d52416b39596b 100644 --- a/x-pack/legacy/plugins/infra/server/lib/infra_types.ts +++ b/x-pack/legacy/plugins/infra/server/lib/infra_types.ts @@ -8,7 +8,7 @@ import { InfraSourceConfiguration } from '../../public/graphql/types'; import { InfraFieldsDomain } from './domains/fields_domain'; import { InfraLogEntriesDomain } from './domains/log_entries_domain'; import { InfraMetricsDomain } from './domains/metrics_domain'; -import { InfraLogAnalysis } from './log_analysis/log_analysis'; +import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from './log_analysis'; import { InfraSnapshot } from './snapshot'; import { InfraSources } from './sources'; import { InfraSourceStatus } from './source_status'; @@ -31,7 +31,8 @@ export interface InfraDomainLibs { export interface InfraBackendLibs extends InfraDomainLibs { configuration: InfraConfig; framework: KibanaFramework; - logAnalysis: InfraLogAnalysis; + logEntryCategoriesAnalysis: LogEntryCategoriesAnalysis; + logEntryRateAnalysis: LogEntryRateAnalysis; snapshot: InfraSnapshot; sources: InfraSources; sourceStatus: InfraSourceStatus; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts index dc5c87c61fdce..d1c8316ad061b 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/errors.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export class NoLogRateResultsIndexError extends Error { +export class NoLogAnalysisResultsIndexError extends Error { constructor(message?: string) { super(message); Object.setPrototypeOf(this, new.target.prototype); diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts index 0b58c71c1db7b..44c2bafce4194 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/index.ts @@ -5,4 +5,5 @@ */ export * from './errors'; -export * from './log_analysis'; +export * from './log_entry_categories_analysis'; +export * from './log_entry_rate_analysis'; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts new file mode 100644 index 0000000000000..f2b6c468df69f --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_categories_analysis.ts @@ -0,0 +1,363 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest, RequestHandlerContext } from '../../../../../../../src/core/server'; +import { getJobId, logEntryCategoriesJobTypes } from '../../../common/log_analysis'; +import { startTracingSpan, TracingSpan } from '../../../common/performance_tracing'; +import { decodeOrThrow } from '../../../common/runtime_types'; +import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; +import { NoLogAnalysisResultsIndexError } from './errors'; +import { + createLogEntryCategoriesQuery, + logEntryCategoriesResponseRT, + LogEntryCategoryHit, +} from './queries/log_entry_categories'; +import { + createLogEntryCategoryHistogramsQuery, + logEntryCategoryHistogramsResponseRT, +} from './queries/log_entry_category_histograms'; +import { + CompositeDatasetKey, + createLogEntryDatasetsQuery, + LogEntryDatasetBucket, + logEntryDatasetsResponseRT, +} from './queries/log_entry_data_sets'; +import { + createTopLogEntryCategoriesQuery, + topLogEntryCategoriesResponseRT, +} from './queries/top_log_entry_categories'; + +const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; + +export class LogEntryCategoriesAnalysis { + constructor( + private readonly libs: { + framework: KibanaFramework; + } + ) {} + + public async getTopLogEntryCategories( + requestContext: RequestHandlerContext, + request: KibanaRequest, + sourceId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets: string[], + histograms: HistogramParameters[] + ) { + const finalizeTopLogEntryCategoriesSpan = startTracingSpan('get top categories'); + + const logEntryCategoriesCountJobId = getJobId( + this.libs.framework.getSpaceId(request), + sourceId, + logEntryCategoriesJobTypes[0] + ); + + const { + topLogEntryCategories, + timing: { spans: fetchTopLogEntryCategoriesAggSpans }, + } = await this.fetchTopLogEntryCategories( + requestContext, + logEntryCategoriesCountJobId, + startTime, + endTime, + categoryCount, + datasets + ); + + const categoryIds = topLogEntryCategories.map(({ categoryId }) => categoryId); + + const { + logEntryCategoriesById, + timing: { spans: fetchTopLogEntryCategoryPatternsSpans }, + } = await this.fetchLogEntryCategories( + requestContext, + logEntryCategoriesCountJobId, + categoryIds + ); + + const { + categoryHistogramsById, + timing: { spans: fetchTopLogEntryCategoryHistogramsSpans }, + } = await this.fetchTopLogEntryCategoryHistograms( + requestContext, + logEntryCategoriesCountJobId, + categoryIds, + histograms + ); + + const topLogEntryCategoriesSpan = finalizeTopLogEntryCategoriesSpan(); + + return { + data: topLogEntryCategories.map(topCategory => ({ + ...topCategory, + regularExpression: logEntryCategoriesById[topCategory.categoryId]?._source.regex ?? '', + histograms: categoryHistogramsById[topCategory.categoryId] ?? [], + })), + timing: { + spans: [ + topLogEntryCategoriesSpan, + ...fetchTopLogEntryCategoriesAggSpans, + ...fetchTopLogEntryCategoryPatternsSpans, + ...fetchTopLogEntryCategoryHistogramsSpans, + ], + }, + }; + } + + public async getLogEntryCategoryDatasets( + requestContext: RequestHandlerContext, + request: KibanaRequest, + sourceId: string, + startTime: number, + endTime: number + ) { + const finalizeLogEntryDatasetsSpan = startTracingSpan('get data sets'); + + const logEntryCategoriesCountJobId = getJobId( + this.libs.framework.getSpaceId(request), + sourceId, + logEntryCategoriesJobTypes[0] + ); + + let logEntryDatasetBuckets: LogEntryDatasetBucket[] = []; + let afterLatestBatchKey: CompositeDatasetKey | undefined; + let esSearchSpans: TracingSpan[] = []; + + while (true) { + const finalizeEsSearchSpan = startTracingSpan('fetch category dataset batch from ES'); + + const logEntryDatasetsResponse = decodeOrThrow(logEntryDatasetsResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'search', + createLogEntryDatasetsQuery( + logEntryCategoriesCountJobId, + startTime, + endTime, + COMPOSITE_AGGREGATION_BATCH_SIZE, + afterLatestBatchKey + ) + ) + ); + + if (logEntryDatasetsResponse._shards.total === 0) { + throw new NoLogAnalysisResultsIndexError( + `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` + ); + } + + const { + after_key: afterKey, + buckets: latestBatchBuckets, + } = logEntryDatasetsResponse.aggregations.dataset_buckets; + + logEntryDatasetBuckets = [...logEntryDatasetBuckets, ...latestBatchBuckets]; + afterLatestBatchKey = afterKey; + esSearchSpans = [...esSearchSpans, finalizeEsSearchSpan()]; + + if (latestBatchBuckets.length < COMPOSITE_AGGREGATION_BATCH_SIZE) { + break; + } + } + + const logEntryDatasetsSpan = finalizeLogEntryDatasetsSpan(); + + return { + data: logEntryDatasetBuckets.map(logEntryDatasetBucket => logEntryDatasetBucket.key.dataset), + timing: { + spans: [logEntryDatasetsSpan, ...esSearchSpans], + }, + }; + } + + private async fetchTopLogEntryCategories( + requestContext: RequestHandlerContext, + logEntryCategoriesCountJobId: string, + startTime: number, + endTime: number, + categoryCount: number, + datasets: string[] + ) { + const finalizeEsSearchSpan = startTracingSpan('Fetch top categories from ES'); + + const topLogEntryCategoriesResponse = decodeOrThrow(topLogEntryCategoriesResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'search', + createTopLogEntryCategoriesQuery( + logEntryCategoriesCountJobId, + startTime, + endTime, + categoryCount, + datasets + ) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + if (topLogEntryCategoriesResponse._shards.total === 0) { + throw new NoLogAnalysisResultsIndexError( + `Failed to find ml result index for job ${logEntryCategoriesCountJobId}.` + ); + } + + const topLogEntryCategories = topLogEntryCategoriesResponse.aggregations.terms_category_id.buckets.map( + topCategoryBucket => ({ + categoryId: parseCategoryId(topCategoryBucket.key), + logEntryCount: topCategoryBucket.filter_model_plot.sum_actual.value ?? 0, + datasets: topCategoryBucket.filter_model_plot.terms_dataset.buckets.map( + datasetBucket => datasetBucket.key + ), + maximumAnomalyScore: topCategoryBucket.filter_record.maximum_record_score.value ?? 0, + }) + ); + + return { + topLogEntryCategories, + timing: { + spans: [esSearchSpan], + }, + }; + } + + private async fetchLogEntryCategories( + requestContext: RequestHandlerContext, + logEntryCategoriesCountJobId: string, + categoryIds: number[] + ) { + if (categoryIds.length === 0) { + return { + logEntryCategoriesById: {}, + timing: { spans: [] }, + }; + } + + const finalizeEsSearchSpan = startTracingSpan('Fetch category patterns from ES'); + + const logEntryCategoriesResponse = decodeOrThrow(logEntryCategoriesResponseRT)( + await this.libs.framework.callWithRequest( + requestContext, + 'search', + createLogEntryCategoriesQuery(logEntryCategoriesCountJobId, categoryIds) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + const logEntryCategoriesById = logEntryCategoriesResponse.hits.hits.reduce< + Record + >( + (accumulatedCategoriesById, categoryHit) => ({ + ...accumulatedCategoriesById, + [categoryHit._source.category_id]: categoryHit, + }), + {} + ); + + return { + logEntryCategoriesById, + timing: { + spans: [esSearchSpan], + }, + }; + } + + private async fetchTopLogEntryCategoryHistograms( + requestContext: RequestHandlerContext, + logEntryCategoriesCountJobId: string, + categoryIds: number[], + histograms: HistogramParameters[] + ) { + if (categoryIds.length === 0 || histograms.length === 0) { + return { + categoryHistogramsById: {}, + timing: { spans: [] }, + }; + } + + const finalizeEsSearchSpan = startTracingSpan('Fetch category histograms from ES'); + + const categoryHistogramsReponses = await Promise.all( + histograms.map(({ bucketCount, endTime, id: histogramId, startTime }) => + this.libs.framework + .callWithRequest( + requestContext, + 'search', + createLogEntryCategoryHistogramsQuery( + logEntryCategoriesCountJobId, + categoryIds, + startTime, + endTime, + bucketCount + ) + ) + .then(decodeOrThrow(logEntryCategoryHistogramsResponseRT)) + .then(response => ({ + histogramId, + histogramBuckets: response.aggregations.filters_categories.buckets, + })) + ) + ); + + const esSearchSpan = finalizeEsSearchSpan(); + + const categoryHistogramsById = Object.values(categoryHistogramsReponses).reduce< + Record< + number, + Array<{ + histogramId: string; + buckets: Array<{ + bucketDuration: number; + logEntryCount: number; + startTime: number; + }>; + }> + > + >( + (outerAccumulatedHistograms, { histogramId, histogramBuckets }) => + Object.entries(histogramBuckets).reduce( + (innerAccumulatedHistograms, [categoryBucketKey, categoryBucket]) => { + const categoryId = parseCategoryId(categoryBucketKey); + return { + ...innerAccumulatedHistograms, + [categoryId]: [ + ...(innerAccumulatedHistograms[categoryId] ?? []), + { + histogramId, + buckets: categoryBucket.histogram_timestamp.buckets.map(bucket => ({ + bucketDuration: categoryBucket.histogram_timestamp.meta.bucketDuration, + logEntryCount: bucket.sum_actual.value, + startTime: bucket.key, + })), + }, + ], + }; + }, + outerAccumulatedHistograms + ), + {} + ); + + return { + categoryHistogramsById, + timing: { + spans: [esSearchSpan], + }, + }; + } +} + +const parseCategoryId = (rawCategoryId: string) => parseInt(rawCategoryId, 10); + +interface HistogramParameters { + id: string; + startTime: number; + endTime: number; + bucketCount: number; +} diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_analysis.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts similarity index 95% rename from x-pack/legacy/plugins/infra/server/lib/log_analysis/log_analysis.ts rename to x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts index fac49a7980f26..515856fa6be8a 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_analysis.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/log_entry_rate_analysis.ts @@ -10,7 +10,7 @@ import { identity } from 'fp-ts/lib/function'; import { getJobId } from '../../../common/log_analysis'; import { throwErrors, createPlainError } from '../../../common/runtime_types'; import { KibanaFramework } from '../adapters/framework/kibana_framework_adapter'; -import { NoLogRateResultsIndexError } from './errors'; +import { NoLogAnalysisResultsIndexError } from './errors'; import { logRateModelPlotResponseRT, createLogEntryRateQuery, @@ -21,7 +21,7 @@ import { RequestHandlerContext, KibanaRequest } from '../../../../../../../src/c const COMPOSITE_AGGREGATION_BATCH_SIZE = 1000; -export class InfraLogAnalysis { +export class LogEntryRateAnalysis { constructor( private readonly libs: { framework: KibanaFramework; @@ -36,11 +36,11 @@ export class InfraLogAnalysis { public async getLogEntryRateBuckets( requestContext: RequestHandlerContext, + request: KibanaRequest, sourceId: string, startTime: number, endTime: number, - bucketDuration: number, - request: KibanaRequest + bucketDuration: number ) { const logRateJobId = this.getJobIds(request, sourceId).logEntryRate; let mlModelPlotBuckets: LogRateModelPlotBucket[] = []; @@ -61,7 +61,7 @@ export class InfraLogAnalysis { ); if (mlModelPlotResponse._shards.total === 0) { - throw new NoLogRateResultsIndexError( + throw new NoLogAnalysisResultsIndexError( `Failed to find ml result index for job ${logRateJobId}.` ); } diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/common.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/common.ts new file mode 100644 index 0000000000000..92ef4fb4e35c9 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/common.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +const ML_ANOMALY_INDEX_PREFIX = '.ml-anomalies-'; + +export const getMlResultIndex = (jobId: string) => `${ML_ANOMALY_INDEX_PREFIX}${jobId}`; + +export const defaultRequestParameters = { + allowNoIndices: true, + ignoreUnavailable: true, + trackScores: false, + trackTotalHits: false, +}; + +export const createTimeRangeFilters = (startTime: number, endTime: number) => [ + { + range: { + timestamp: { + gte: startTime, + lte: endTime, + }, + }, + }, +]; + +export const createResultTypeFilters = (resultType: 'model_plot' | 'record') => [ + { + term: { + result_type: { + value: resultType, + }, + }, + }, +]; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts index 1749421277719..8c470acbf02fb 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/index.ts @@ -5,3 +5,4 @@ */ export * from './log_entry_rate'; +export * from './top_log_entry_categories'; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts new file mode 100644 index 0000000000000..63b3632f03784 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_categories.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { defaultRequestParameters, getMlResultIndex } from './common'; + +export const createLogEntryCategoriesQuery = ( + logEntryCategoriesJobId: string, + categoryIds: number[] +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + { + terms: { + category_id: categoryIds, + }, + }, + ], + }, + }, + _source: ['category_id', 'regex'], + }, + index: getMlResultIndex(logEntryCategoriesJobId), + size: categoryIds.length, +}); + +export const logEntryCategoryHitRT = rt.type({ + _source: rt.type({ + category_id: rt.number, + regex: rt.string, + }), +}); + +export type LogEntryCategoryHit = rt.TypeOf; + +export const logEntryCategoriesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + hits: rt.type({ + hits: rt.array(logEntryCategoryHitRT), + }), + }), +]); + +export type logEntryCategoriesResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts new file mode 100644 index 0000000000000..67087f3b4775b --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_category_histograms.ts @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { + createResultTypeFilters, + createTimeRangeFilters, + defaultRequestParameters, + getMlResultIndex, +} from './common'; + +export const createLogEntryCategoryHistogramsQuery = ( + logEntryCategoriesJobId: string, + categoryIds: number[], + startTime: number, + endTime: number, + bucketCount: number +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + ...createTimeRangeFilters(startTime, endTime), + ...createResultTypeFilters('model_plot'), + ...createCategoryFilters(categoryIds), + ], + }, + }, + aggs: { + filters_categories: { + filters: createCategoryFiltersAggregation(categoryIds), + aggs: { + histogram_timestamp: createHistogramAggregation(startTime, endTime, bucketCount), + }, + }, + }, + }, + index: getMlResultIndex(logEntryCategoriesJobId), + size: 0, +}); + +const createCategoryFilters = (categoryIds: number[]) => [ + { + terms: { + by_field_value: categoryIds, + }, + }, +]; + +const createCategoryFiltersAggregation = (categoryIds: number[]) => ({ + filters: categoryIds.reduce>( + (categoryFilters, categoryId) => ({ + ...categoryFilters, + [`${categoryId}`]: { + term: { + by_field_value: categoryId, + }, + }, + }), + {} + ), +}); + +const createHistogramAggregation = (startTime: number, endTime: number, bucketCount: number) => { + const bucketDuration = Math.round((endTime - startTime) / bucketCount); + + return { + histogram: { + field: 'timestamp', + interval: bucketDuration, + offset: startTime, + }, + meta: { + bucketDuration, + }, + aggs: { + sum_actual: { + sum: { + field: 'actual', + }, + }, + }, + }; +}; + +export const logEntryCategoryFilterBucketRT = rt.type({ + doc_count: rt.number, + histogram_timestamp: rt.type({ + meta: rt.type({ + bucketDuration: rt.number, + }), + buckets: rt.array( + rt.type({ + key: rt.number, + doc_count: rt.number, + sum_actual: rt.type({ + value: rt.number, + }), + }) + ), + }), +}); + +export type LogEntryCategoryFilterBucket = rt.TypeOf; + +export const logEntryCategoryHistogramsResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + aggregations: rt.type({ + filters_categories: rt.type({ + buckets: rt.record(rt.string, logEntryCategoryFilterBucketRT), + }), + }), + }), +]); + +export type LogEntryCategorHistogramsResponse = rt.TypeOf< + typeof logEntryCategoryHistogramsResponseRT +>; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts new file mode 100644 index 0000000000000..b41a21a21b6a6 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_data_sets.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { defaultRequestParameters, getMlResultIndex } from './common'; + +export const createLogEntryDatasetsQuery = ( + logEntryAnalysisJobId: string, + startTime: number, + endTime: number, + size: number, + afterKey?: CompositeDatasetKey +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + { + range: { + timestamp: { + gte: startTime, + lt: endTime, + }, + }, + }, + { + term: { + result_type: { + value: 'model_plot', + }, + }, + }, + ], + }, + }, + aggs: { + dataset_buckets: { + composite: { + after: afterKey, + size, + sources: [ + { + dataset: { + terms: { + field: 'partition_field_value', + order: 'asc', + }, + }, + }, + ], + }, + }, + }, + }, + index: getMlResultIndex(logEntryAnalysisJobId), + size: 0, +}); + +const compositeDatasetKeyRT = rt.type({ + dataset: rt.string, +}); + +export type CompositeDatasetKey = rt.TypeOf; + +const logEntryDatasetBucketRT = rt.type({ + key: compositeDatasetKeyRT, +}); + +export type LogEntryDatasetBucket = rt.TypeOf; + +export const logEntryDatasetsResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + aggregations: rt.type({ + dataset_buckets: rt.intersection([ + rt.type({ + buckets: rt.array(logEntryDatasetBucketRT), + }), + rt.partial({ + after_key: compositeDatasetKeyRT, + }), + ]), + }), + }), +]); + +export type LogEntryDatasetsResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts index 2dd0880cbf8cb..def7caf578b94 100644 --- a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/log_entry_rate.ts @@ -6,7 +6,7 @@ import * as rt from 'io-ts'; -const ML_ANOMALY_INDEX_PREFIX = '.ml-anomalies-'; +import { defaultRequestParameters, getMlResultIndex } from './common'; export const createLogEntryRateQuery = ( logRateJobId: string, @@ -16,7 +16,7 @@ export const createLogEntryRateQuery = ( size: number, afterKey?: CompositeTimestampPartitionKey ) => ({ - allowNoIndices: true, + ...defaultRequestParameters, body: { query: { bool: { @@ -118,11 +118,8 @@ export const createLogEntryRateQuery = ( }, }, }, - ignoreUnavailable: true, - index: `${ML_ANOMALY_INDEX_PREFIX}${logRateJobId}`, + index: getMlResultIndex(logRateJobId), size: 0, - trackScores: false, - trackTotalHits: false, }); const logRateMlRecordRT = rt.type({ diff --git a/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts new file mode 100644 index 0000000000000..22b0ef748f5f8 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/lib/log_analysis/queries/top_log_entry_categories.ts @@ -0,0 +1,160 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +import { commonSearchSuccessResponseFieldsRT } from '../../../utils/elasticsearch_runtime_types'; +import { + createResultTypeFilters, + createTimeRangeFilters, + defaultRequestParameters, + getMlResultIndex, +} from './common'; + +export const createTopLogEntryCategoriesQuery = ( + logEntryCategoriesJobId: string, + startTime: number, + endTime: number, + size: number, + datasets: string[], + sortDirection: 'asc' | 'desc' = 'desc' +) => ({ + ...defaultRequestParameters, + body: { + query: { + bool: { + filter: [ + ...createTimeRangeFilters(startTime, endTime), + ...createDatasetsFilters(datasets), + { + bool: { + should: [ + { + bool: { + filter: [ + ...createResultTypeFilters('model_plot'), + { + range: { + actual: { + gt: 0, + }, + }, + }, + ], + }, + }, + { + bool: { + filter: createResultTypeFilters('record'), + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + aggs: { + terms_category_id: { + terms: { + field: 'by_field_value', + size, + order: { + 'filter_model_plot>sum_actual': sortDirection, + }, + }, + aggs: { + filter_model_plot: { + filter: { + term: { + result_type: 'model_plot', + }, + }, + aggs: { + sum_actual: { + sum: { + field: 'actual', + }, + }, + terms_dataset: { + terms: { + field: 'partition_field_value', + size: 1000, + }, + }, + }, + }, + filter_record: { + filter: { + term: { + result_type: 'record', + }, + }, + aggs: { + maximum_record_score: { + max: { + field: 'record_score', + }, + }, + }, + }, + }, + }, + }, + }, + index: getMlResultIndex(logEntryCategoriesJobId), + size: 0, +}); + +const createDatasetsFilters = (datasets: string[]) => + datasets.length > 0 + ? [ + { + terms: { + partition_field_value: datasets, + }, + }, + ] + : []; + +const metricAggregationRT = rt.type({ + value: rt.union([rt.number, rt.null]), +}); + +export const logEntryCategoryBucketRT = rt.type({ + key: rt.string, + doc_count: rt.number, + filter_record: rt.type({ + maximum_record_score: metricAggregationRT, + }), + filter_model_plot: rt.type({ + sum_actual: metricAggregationRT, + terms_dataset: rt.type({ + buckets: rt.array( + rt.type({ + key: rt.string, + doc_count: rt.number, + }) + ), + }), + }), +}); + +export type LogEntryCategoryBucket = rt.TypeOf; + +export const topLogEntryCategoriesResponseRT = rt.intersection([ + commonSearchSuccessResponseFieldsRT, + rt.type({ + aggregations: rt.type({ + terms_category_id: rt.type({ + buckets: rt.array(logEntryCategoryBucketRT), + }), + }), + }), +]); + +export type TopLogEntryCategoriesResponse = rt.TypeOf; diff --git a/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts b/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts index 147729a1d0b3e..d3c6f7a5f70a1 100644 --- a/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts +++ b/x-pack/legacy/plugins/infra/server/new_platform_plugin.ts @@ -17,7 +17,7 @@ import { InfraElasticsearchSourceStatusAdapter } from './lib/adapters/source_sta import { InfraFieldsDomain } from './lib/domains/fields_domain'; import { InfraLogEntriesDomain } from './lib/domains/log_entries_domain'; import { InfraMetricsDomain } from './lib/domains/metrics_domain'; -import { InfraLogAnalysis } from './lib/log_analysis'; +import { LogEntryCategoriesAnalysis, LogEntryRateAnalysis } from './lib/log_analysis'; import { InfraSnapshot } from './lib/snapshot'; import { InfraSourceStatus } from './lib/source_status'; import { InfraSources } from './lib/sources'; @@ -87,7 +87,8 @@ export class InfraServerPlugin { } ); const snapshot = new InfraSnapshot({ sources, framework }); - const logAnalysis = new InfraLogAnalysis({ framework }); + const logEntryCategoriesAnalysis = new LogEntryCategoriesAnalysis({ framework }); + const logEntryRateAnalysis = new LogEntryRateAnalysis({ framework }); // TODO: separate these out individually and do away with "domains" as a temporary group const domainLibs: InfraDomainLibs = { @@ -103,7 +104,8 @@ export class InfraServerPlugin { this.libs = { configuration: this.config, framework, - logAnalysis, + logEntryCategoriesAnalysis, + logEntryRateAnalysis, snapshot, sources, sourceStatus, diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts index 1749421277719..d9ca9a96ffe51 100644 --- a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/index.ts @@ -4,4 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ +export * from './log_entry_categories'; +export * from './log_entry_category_datasets'; export * from './log_entry_rate'; diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts new file mode 100644 index 0000000000000..7eb7de57b2f92 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_categories.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; + +import { pipe } from 'fp-ts/lib/pipeable'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { schema } from '@kbn/config-schema'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, + getLogEntryCategoriesRequestPayloadRT, + getLogEntryCategoriesSuccessReponsePayloadRT, +} from '../../../../common/http_api/log_analysis'; +import { throwErrors } from '../../../../common/runtime_types'; +import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; + +const anyObject = schema.object({}, { allowUnknowns: true }); + +export const initGetLogEntryCategoriesRoute = ({ + framework, + logEntryCategoriesAnalysis, +}: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORIES_PATH, + validate: { + // short-circuit forced @kbn/config-schema validation so we can do io-ts validation + body: anyObject, + }, + }, + async (requestContext, request, response) => { + const { + data: { + categoryCount, + histograms, + sourceId, + timeRange: { startTime, endTime }, + datasets, + }, + } = pipe( + getLogEntryCategoriesRequestPayloadRT.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + try { + const { + data: topLogEntryCategories, + timing, + } = await logEntryCategoriesAnalysis.getTopLogEntryCategories( + requestContext, + request, + sourceId, + startTime, + endTime, + categoryCount, + datasets ?? [], + histograms.map(histogram => ({ + bucketCount: histogram.bucketCount, + endTime: histogram.timeRange.endTime, + id: histogram.id, + startTime: histogram.timeRange.startTime, + })) + ); + + return response.ok({ + body: getLogEntryCategoriesSuccessReponsePayloadRT.encode({ + data: { + categories: topLogEntryCategories, + }, + timing, + }), + }); + } catch (e) { + const { statusCode = 500, message = 'Unknown error occurred' } = e; + + if (e instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message } }); + } + + return response.customError({ + statusCode, + body: { message }, + }); + } + } + ); +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts new file mode 100644 index 0000000000000..8132633028277 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_category_datasets.ts @@ -0,0 +1,82 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import Boom from 'boom'; +import { fold } from 'fp-ts/lib/Either'; +import { identity } from 'fp-ts/lib/function'; +import { pipe } from 'fp-ts/lib/pipeable'; + +import { + getLogEntryCategoryDatasetsRequestPayloadRT, + getLogEntryCategoryDatasetsSuccessReponsePayloadRT, + LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, +} from '../../../../common/http_api/log_analysis'; +import { throwErrors } from '../../../../common/runtime_types'; +import { InfraBackendLibs } from '../../../lib/infra_types'; +import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; + +const anyObject = schema.object({}, { allowUnknowns: true }); + +export const initGetLogEntryCategoryDatasetsRoute = ({ + framework, + logEntryCategoriesAnalysis, +}: InfraBackendLibs) => { + framework.registerRoute( + { + method: 'post', + path: LOG_ANALYSIS_GET_LOG_ENTRY_CATEGORY_DATASETS_PATH, + validate: { + // short-circuit forced @kbn/config-schema validation so we can do io-ts validation + body: anyObject, + }, + }, + async (requestContext, request, response) => { + const { + data: { + sourceId, + timeRange: { startTime, endTime }, + }, + } = pipe( + getLogEntryCategoryDatasetsRequestPayloadRT.decode(request.body), + fold(throwErrors(Boom.badRequest), identity) + ); + + try { + const { + data: logEntryCategoryDatasets, + timing, + } = await logEntryCategoriesAnalysis.getLogEntryCategoryDatasets( + requestContext, + request, + sourceId, + startTime, + endTime + ); + + return response.ok({ + body: getLogEntryCategoryDatasetsSuccessReponsePayloadRT.encode({ + data: { + datasets: logEntryCategoryDatasets, + }, + timing, + }), + }); + } catch (e) { + const { statusCode = 500, message = 'Unknown error occurred' } = e; + + if (e instanceof NoLogAnalysisResultsIndexError) { + return response.notFound({ body: { message } }); + } + + return response.customError({ + statusCode, + body: { message }, + }); + } + } + ); +}; diff --git a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts index 9778311bd8e58..6551316fd0c64 100644 --- a/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts +++ b/x-pack/legacy/plugins/infra/server/routes/log_analysis/results/log_entry_rate.ts @@ -18,11 +18,11 @@ import { GetLogEntryRateSuccessResponsePayload, } from '../../../../common/http_api/log_analysis'; import { throwErrors } from '../../../../common/runtime_types'; -import { NoLogRateResultsIndexError } from '../../../lib/log_analysis'; +import { NoLogAnalysisResultsIndexError } from '../../../lib/log_analysis'; const anyObject = schema.object({}, { allowUnknowns: true }); -export const initGetLogEntryRateRoute = ({ framework, logAnalysis }: InfraBackendLibs) => { +export const initGetLogEntryRateRoute = ({ framework, logEntryRateAnalysis }: InfraBackendLibs) => { framework.registerRoute( { method: 'post', @@ -39,13 +39,13 @@ export const initGetLogEntryRateRoute = ({ framework, logAnalysis }: InfraBacken fold(throwErrors(Boom.badRequest), identity) ); - const logEntryRateBuckets = await logAnalysis.getLogEntryRateBuckets( + const logEntryRateBuckets = await logEntryRateAnalysis.getLogEntryRateBuckets( requestContext, + request, payload.data.sourceId, payload.data.timeRange.startTime, payload.data.timeRange.endTime, - payload.data.bucketDuration, - request + payload.data.bucketDuration ); return response.ok({ @@ -59,7 +59,7 @@ export const initGetLogEntryRateRoute = ({ framework, logAnalysis }: InfraBacken }); } catch (e) { const { statusCode = 500, message = 'Unknown error occurred' } = e; - if (e instanceof NoLogRateResultsIndexError) { + if (e instanceof NoLogAnalysisResultsIndexError) { return response.notFound({ body: { message } }); } return response.customError({ diff --git a/x-pack/legacy/plugins/infra/server/utils/elasticsearch_runtime_types.ts b/x-pack/legacy/plugins/infra/server/utils/elasticsearch_runtime_types.ts new file mode 100644 index 0000000000000..a48c65d648b25 --- /dev/null +++ b/x-pack/legacy/plugins/infra/server/utils/elasticsearch_runtime_types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; + +export const commonSearchSuccessResponseFieldsRT = rt.type({ + _shards: rt.type({ + total: rt.number, + successful: rt.number, + skipped: rt.number, + failed: rt.number, + }), + timed_out: rt.boolean, + took: rt.number, +}); diff --git a/x-pack/legacy/plugins/lens/index.ts b/x-pack/legacy/plugins/lens/index.ts index c4a684381b17c..a4eb24d4a4de4 100644 --- a/x-pack/legacy/plugins/lens/index.ts +++ b/x-pack/legacy/plugins/lens/index.ts @@ -11,6 +11,7 @@ import KbnServer, { Server } from 'src/legacy/server/kbn_server'; import mappings from './mappings.json'; import { PLUGIN_ID, getEditPath, NOT_INTERNATIONALIZED_PRODUCT_NAME } from './common'; import { lensServerPlugin } from './server'; +import { getTaskManagerSetup, getTaskManagerStart } from '../task_manager/server'; export const lens: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ @@ -64,6 +65,12 @@ export const lens: LegacyPluginInitializer = kibana => { savedObjects: server.savedObjects, config: server.config(), server, + taskManager: getTaskManagerSetup(server)!, + }); + + plugin.start(kbnServer.newPlatform.start.core, { + server, + taskManager: getTaskManagerStart(server)!, }); server.events.on('stop', () => { diff --git a/x-pack/legacy/plugins/lens/server/plugin.tsx b/x-pack/legacy/plugins/lens/server/plugin.tsx index 0223b90c37046..f80d52248b484 100644 --- a/x-pack/legacy/plugins/lens/server/plugin.tsx +++ b/x-pack/legacy/plugins/lens/server/plugin.tsx @@ -5,28 +5,51 @@ */ import { Server, KibanaConfig } from 'src/legacy/server/kbn_server'; -import { Plugin, CoreSetup, SavedObjectsLegacyService } from 'src/core/server'; +import { Plugin, CoreSetup, CoreStart, SavedObjectsLegacyService } from 'src/core/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { Subject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; import { setupRoutes } from './routes'; -import { registerLensUsageCollector, initializeLensTelemetry } from './usage'; +import { + registerLensUsageCollector, + initializeLensTelemetry, + scheduleLensTelemetry, +} from './usage'; export interface PluginSetupContract { savedObjects: SavedObjectsLegacyService; usageCollection: UsageCollectionSetup; config: KibanaConfig; server: Server; + taskManager: TaskManagerSetupContract; } +export interface PluginStartContract { + server: Server; + taskManager: TaskManagerStartContract; +} + +const taskManagerStartContract$ = new Subject(); + export class LensServer implements Plugin<{}, {}, {}, {}> { setup(core: CoreSetup, plugins: PluginSetupContract) { setupRoutes(core, plugins); - registerLensUsageCollector(plugins.usageCollection, plugins.server); - initializeLensTelemetry(core, plugins.server); - + registerLensUsageCollector( + plugins.usageCollection, + taskManagerStartContract$.pipe(first()).toPromise() + ); + initializeLensTelemetry(plugins.server, plugins.taskManager); return {}; } - start() { + start(core: CoreStart, plugins: PluginStartContract) { + scheduleLensTelemetry(plugins.server, plugins.taskManager); + taskManagerStartContract$.next(plugins.taskManager); + taskManagerStartContract$.complete(); return {}; } diff --git a/x-pack/legacy/plugins/lens/server/usage/collectors.ts b/x-pack/legacy/plugins/lens/server/usage/collectors.ts index 274b72c33e59a..666b3718d5125 100644 --- a/x-pack/legacy/plugins/lens/server/usage/collectors.ts +++ b/x-pack/legacy/plugins/lens/server/usage/collectors.ts @@ -6,32 +6,25 @@ import moment from 'moment'; import { get } from 'lodash'; -import { Server } from 'src/legacy/server/kbn_server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { TaskManagerStartContract } from '../../../../../plugins/task_manager/server'; import { LensUsage, LensTelemetryState } from './types'; -export function registerLensUsageCollector(usageCollection: UsageCollectionSetup, server: Server) { +export function registerLensUsageCollector( + usageCollection: UsageCollectionSetup, + taskManager: Promise +) { let isCollectorReady = false; - async function determineIfTaskManagerIsReady() { - let isReady = false; - try { - isReady = await isTaskManagerReady(server); - } catch (err) {} // eslint-disable-line - - if (isReady) { - isCollectorReady = true; - } else { - setTimeout(determineIfTaskManagerIsReady, 500); - } - } - determineIfTaskManagerIsReady(); - + taskManager.then(() => { + // mark lensUsageCollector as ready to collect when the TaskManager is ready + isCollectorReady = true; + }); const lensUsageCollector = usageCollection.makeUsageCollector({ type: 'lens', fetch: async (): Promise => { try { - const docs = await getLatestTaskState(server); + const docs = await getLatestTaskState(await taskManager); // get the accumulated state from the recurring task const state: LensTelemetryState = get(docs, '[0].state'); @@ -73,17 +66,7 @@ function addEvents(prevEvents: Record, newEvents: Record Promise; -export function initializeLensTelemetry(core: CoreSetup, server: Server) { - registerLensTelemetryTask(core, server); - scheduleTasks(server); -} - -function registerLensTelemetryTask(core: CoreSetup, server: Server) { - const taskManager = server.plugins.task_manager; - +export function initializeLensTelemetry(server: Server, taskManager?: TaskManagerSetupContract) { if (!taskManager) { server.log(['debug', 'telemetry'], `Task manager is not available`); - return; + } else { + registerLensTelemetryTask(server, taskManager); } +} +export function scheduleLensTelemetry(server: Server, taskManager?: TaskManagerStartContract) { + if (taskManager) { + scheduleTasks(server, taskManager); + } +} + +function registerLensTelemetryTask(server: Server, taskManager: TaskManagerSetupContract) { taskManager.registerTaskDefinitions({ [TELEMETRY_TASK_TYPE]: { title: 'Lens telemetry fetch task', @@ -62,17 +68,11 @@ function registerLensTelemetryTask(core: CoreSetup, server: Server) { }); } -function scheduleTasks(server: Server) { - const taskManager = server.plugins.task_manager; +function scheduleTasks(server: Server, taskManager: TaskManagerStartContract) { const { kbnServer } = (server.plugins.xpack_main as XPackMainPlugin & { status: { plugin: { kbnServer: KbnServer } }; }).status.plugin; - if (!taskManager) { - server.log(['debug', 'telemetry'], `Task manager is not available`); - return; - } - kbnServer.afterPluginsInit(() => { // The code block below can't await directly within "afterPluginsInit" // callback due to circular dependency The server isn't "ready" until diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js index 325fc28f92051..16cfd34c95ab3 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/get_vector_style_label.js @@ -42,6 +42,14 @@ export function getVectorStyleLabel(styleName) { return i18n.translate('xpack.maps.styles.vector.labelSizeLabel', { defaultMessage: 'Label size', }); + case VECTOR_STYLES.LABEL_BORDER_COLOR: + return i18n.translate('xpack.maps.styles.vector.labelBorderColorLabel', { + defaultMessage: 'Label border color', + }); + case VECTOR_STYLES.LABEL_BORDER_SIZE: + return i18n.translate('xpack.maps.styles.vector.labelBorderWidthLabel', { + defaultMessage: 'Label border width', + }); default: return styleName; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js new file mode 100644 index 0000000000000..7d06e8b530011 --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/label/vector_style_label_border_size_editor.js @@ -0,0 +1,65 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiFormRow, EuiSelect } from '@elastic/eui'; +import { LABEL_BORDER_SIZES, VECTOR_STYLES } from '../../vector_style_defaults'; +import { getVectorStyleLabel } from '../get_vector_style_label'; +import { i18n } from '@kbn/i18n'; + +const options = [ + { + value: LABEL_BORDER_SIZES.NONE, + text: i18n.translate('xpack.maps.styles.labelBorderSize.noneLabel', { + defaultMessage: 'None', + }), + }, + { + value: LABEL_BORDER_SIZES.SMALL, + text: i18n.translate('xpack.maps.styles.labelBorderSize.smallLabel', { + defaultMessage: 'Small', + }), + }, + { + value: LABEL_BORDER_SIZES.MEDIUM, + text: i18n.translate('xpack.maps.styles.labelBorderSize.mediumLabel', { + defaultMessage: 'Medium', + }), + }, + { + value: LABEL_BORDER_SIZES.LARGE, + text: i18n.translate('xpack.maps.styles.labelBorderSize.largeLabel', { + defaultMessage: 'Large', + }), + }, +]; + +export function VectorStyleLabelBorderSizeEditor({ handlePropertyChange, styleProperty }) { + function onChange(e) { + const styleDescriptor = { + options: { size: e.target.value }, + }; + handlePropertyChange(styleProperty.getStyleName(), styleDescriptor); + } + + return ( + + + + ); +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js index dffe513644db8..bd22b4b9cc5ce 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/components/vector_style_editor.js @@ -12,6 +12,7 @@ import { VectorStyleColorEditor } from './color/vector_style_color_editor'; import { VectorStyleSizeEditor } from './size/vector_style_size_editor'; import { VectorStyleSymbolEditor } from './vector_style_symbol_editor'; import { VectorStyleLabelEditor } from './label/vector_style_label_editor'; +import { VectorStyleLabelBorderSizeEditor } from './label/vector_style_label_border_size_editor'; import { VectorStyle } from '../vector_style'; import { OrientationEditor } from './orientation/orientation_editor'; import { @@ -248,6 +249,27 @@ export class VectorStyleEditor extends Component { } /> + + + + + + ); } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js index 57e4d09f3abec..804a0f8975d3e 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.js @@ -55,6 +55,11 @@ export class DynamicColorProperty extends DynamicStyleProperty { mbMap.setPaintProperty(mbLayerId, 'text-opacity', alpha); } + syncLabelBorderColorWithMb(mbLayerId, mbMap) { + const color = this._getMbColor(); + mbMap.setPaintProperty(mbLayerId, 'text-halo-color', color); + } + isCustomColorRamp() { return this._options.useCustomColorRamp; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js index 0affeefde1313..21c24e837b412 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_color_property.test.js @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -// eslint-disable-next-line no-unused-vars +jest.mock('../components/vector_style_editor', () => ({ + VectorStyleEditor: () => { + return
mockVectorStyleEditor
; + }, +})); + import React from 'react'; import { shallow } from 'enzyme'; diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js index f2e5672226814..5a4da1a80c918 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_size_property.js @@ -5,7 +5,7 @@ */ import { DynamicStyleProperty } from './dynamic_style_property'; -import { getComputedFieldName } from '../style_util'; + import { HALF_LARGE_MAKI_ICON_SIZE, LARGE_MAKI_ICON_SIZE, @@ -63,7 +63,7 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } syncHaloWidthWithMb(mbLayerId, mbMap) { - const haloWidth = this._getMbSize(); + const haloWidth = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'icon-halo-width', haloWidth); } @@ -76,7 +76,7 @@ export class DynamicSizeProperty extends DynamicStyleProperty { mbMap.setLayoutProperty(symbolLayerId, 'icon-image', `${symbolId}-${iconPixels}`); const halfIconPixels = iconPixels / 2; - const targetName = getComputedFieldName(VECTOR_STYLES.ICON_SIZE, this._options.field.name); + const targetName = this.getComputedFieldName(); // Using property state instead of feature-state because layout properties do not support feature-state mbMap.setLayoutProperty(symbolLayerId, 'icon-size', [ 'interpolate', @@ -94,29 +94,29 @@ export class DynamicSizeProperty extends DynamicStyleProperty { } syncCircleStrokeWidthWithMb(mbLayerId, mbMap) { - const lineWidth = this._getMbSize(); + const lineWidth = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'circle-stroke-width', lineWidth); } syncCircleRadiusWithMb(mbLayerId, mbMap) { - const circleRadius = this._getMbSize(); + const circleRadius = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'circle-radius', circleRadius); } syncLineWidthWithMb(mbLayerId, mbMap) { - const lineWidth = this._getMbSize(); + const lineWidth = this.getMbSizeExpression(); mbMap.setPaintProperty(mbLayerId, 'line-width', lineWidth); } syncLabelSizeWithMb(mbLayerId, mbMap) { - const lineWidth = this._getMbSize(); + const lineWidth = this.getMbSizeExpression(); mbMap.setLayoutProperty(mbLayerId, 'text-size', lineWidth); } - _getMbSize() { + getMbSizeExpression() { if (this._isSizeDynamicConfigComplete(this._options)) { return this._getMbDataDrivenSize({ - targetName: getComputedFieldName(this._styleName, this._options.field.name), + targetName: this.getComputedFieldName(), minSize: this._options.minSize, maxSize: this._options.maxSize, }); diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js index cb5858fa47b3e..97ab7cb78015b 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/dynamic_style_property.js @@ -8,7 +8,7 @@ import _ from 'lodash'; import { AbstractStyleProperty } from './style_property'; import { DEFAULT_SIGMA } from '../vector_style_defaults'; import { STYLE_TYPE } from '../../../../../common/constants'; -import { scaleValue } from '../style_util'; +import { scaleValue, getComputedFieldName } from '../style_util'; import React from 'react'; import { OrdinalLegend } from './components/ordinal_legend'; import { CategoricalLegend } from './components/categorical_legend'; @@ -31,6 +31,13 @@ export class DynamicStyleProperty extends AbstractStyleProperty { return this._field; } + getComputedFieldName() { + if (!this.isComplete()) { + return null; + } + return getComputedFieldName(this._styleName, this.getField().getName()); + } + isDynamic() { return true; } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js new file mode 100644 index 0000000000000..e08c2875c310e --- /dev/null +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/label_border_size_property.js @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import _ from 'lodash'; +import { AbstractStyleProperty } from './style_property'; +import { DEFAULT_LABEL_SIZE, LABEL_BORDER_SIZES } from '../vector_style_defaults'; + +const SMALL_SIZE = 1 / 16; +const MEDIUM_SIZE = 1 / 8; +const LARGE_SIZE = 1 / 5; // halo of 1/4 is just a square. Use smaller ratio to preserve contour on letters + +function getWidthRatio(size) { + switch (size) { + case LABEL_BORDER_SIZES.LARGE: + return LARGE_SIZE; + case LABEL_BORDER_SIZES.MEDIUM: + return MEDIUM_SIZE; + default: + return SMALL_SIZE; + } +} + +export class LabelBorderSizeProperty extends AbstractStyleProperty { + constructor(options, styleName, labelSizeProperty) { + super(options, styleName); + this._labelSizeProperty = labelSizeProperty; + } + + syncLabelBorderSizeWithMb(mbLayerId, mbMap) { + const widthRatio = getWidthRatio(this.getOptions().size); + + if (this.getOptions().size === LABEL_BORDER_SIZES.NONE) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', 0); + } else if (this._labelSizeProperty.isDynamic() && this._labelSizeProperty.isComplete()) { + const labelSizeExpression = this._labelSizeProperty.getMbSizeExpression(); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', [ + 'max', + ['*', labelSizeExpression, widthRatio], + 1, + ]); + } else { + const labelSize = _.get(this._labelSizeProperty.getOptions(), 'size', DEFAULT_LABEL_SIZE); + const labelBorderSize = Math.max(labelSize * widthRatio, 1); + mbMap.setPaintProperty(mbLayerId, 'text-halo-width', labelBorderSize); + } + } +} diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js index 658eb6a164556..ebe2a322711fc 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/properties/static_color_property.js @@ -39,4 +39,8 @@ export class StaticColorProperty extends StaticStyleProperty { mbMap.setPaintProperty(mbLayerId, 'text-color', this._options.color); mbMap.setPaintProperty(mbLayerId, 'text-opacity', alpha); } + + syncLabelBorderColorWithMb(mbLayerId, mbMap) { + mbMap.setPaintProperty(mbLayerId, 'text-halo-color', this._options.color); + } } diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js index d1efcbb72d1a7..30d1c5726ba48 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.js @@ -38,6 +38,7 @@ import { StaticOrientationProperty } from './properties/static_orientation_prope import { DynamicOrientationProperty } from './properties/dynamic_orientation_property'; import { StaticTextProperty } from './properties/static_text_property'; import { DynamicTextProperty } from './properties/dynamic_text_property'; +import { LabelBorderSizeProperty } from './properties/label_border_size_property'; import { extractColorFromStyleProperty } from './components/legend/extract_color_from_style_property'; const POINTS = [GEO_JSON_TYPE.POINT, GEO_JSON_TYPE.MULTI_POINT]; @@ -100,6 +101,15 @@ export class VectorStyle extends AbstractStyle { this._descriptor.properties[VECTOR_STYLES.LABEL_COLOR], VECTOR_STYLES.LABEL_COLOR ); + this._labelBorderColorStyleProperty = this._makeColorProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_BORDER_COLOR], + VECTOR_STYLES.LABEL_BORDER_COLOR + ); + this._labelBorderSizeStyleProperty = new LabelBorderSizeProperty( + this._descriptor.properties[VECTOR_STYLES.LABEL_BORDER_SIZE].options, + VECTOR_STYLES.LABEL_BORDER_SIZE, + this._labelSizeStyleProperty + ); } _getAllStyleProperties() { @@ -112,6 +122,8 @@ export class VectorStyle extends AbstractStyle { this._labelStyleProperty, this._labelSizeStyleProperty, this._labelColorStyleProperty, + this._labelBorderColorStyleProperty, + this._labelBorderSizeStyleProperty, ]; } @@ -537,6 +549,8 @@ export class VectorStyle extends AbstractStyle { this._labelStyleProperty.syncTextFieldWithMb(textLayerId, mbMap); this._labelColorStyleProperty.syncLabelColorWithMb(textLayerId, mbMap, alpha); this._labelSizeStyleProperty.syncLabelSizeWithMb(textLayerId, mbMap); + this._labelBorderSizeStyleProperty.syncLabelBorderSizeWithMb(textLayerId, mbMap); + this._labelBorderColorStyleProperty.syncLabelBorderColorWithMb(textLayerId, mbMap); } setMBSymbolPropertiesForPoints({ mbMap, symbolLayerId, alpha }) { diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js index 3d2911720c312..c250d83720580 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style.test.js @@ -102,6 +102,17 @@ describe('getDescriptorWithMissingStylePropsRemoved', () => { }, type: 'STATIC', }, + labelBorderColor: { + options: { + color: '#FFFFFF', + }, + type: 'STATIC', + }, + labelBorderSize: { + options: { + size: 'SMALL', + }, + }, labelColor: { options: { color: '#000000', diff --git a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js index 4bae90c3165f2..3631613e7907c 100644 --- a/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js +++ b/x-pack/legacy/plugins/maps/public/layers/styles/vector/vector_style_defaults.js @@ -16,6 +16,14 @@ export const MAX_SIZE = 64; export const DEFAULT_MIN_SIZE = 4; export const DEFAULT_MAX_SIZE = 32; export const DEFAULT_SIGMA = 3; +export const DEFAULT_LABEL_SIZE = 14; + +export const LABEL_BORDER_SIZES = { + NONE: 'NONE', + SMALL: 'SMALL', + MEDIUM: 'MEDIUM', + LARGE: 'LARGE', +}; export const VECTOR_STYLES = { SYMBOL: 'symbol', @@ -27,6 +35,8 @@ export const VECTOR_STYLES = { LABEL_TEXT: 'labelText', LABEL_COLOR: 'labelColor', LABEL_SIZE: 'labelSize', + LABEL_BORDER_COLOR: 'labelBorderColor', + LABEL_BORDER_SIZE: 'labelBorderSize', }; export const LINE_STYLES = [VECTOR_STYLES.LINE_COLOR, VECTOR_STYLES.LINE_WIDTH]; @@ -45,6 +55,11 @@ export function getDefaultProperties(mapColors = []) { symbolId: DEFAULT_ICON, }, }, + [VECTOR_STYLES.LABEL_BORDER_SIZE]: { + options: { + size: LABEL_BORDER_SIZES.SMALL, + }, + }, }; } @@ -103,7 +118,13 @@ export function getDefaultStaticProperties(mapColors = []) { [VECTOR_STYLES.LABEL_SIZE]: { type: VectorStyle.STYLE_TYPE.STATIC, options: { - size: 14, + size: DEFAULT_LABEL_SIZE, + }, + }, + [VECTOR_STYLES.LABEL_BORDER_COLOR]: { + type: VectorStyle.STYLE_TYPE.STATIC, + options: { + color: isDarkMode ? '#000000' : '#FFFFFF', }, }, }; @@ -158,7 +179,7 @@ export function getDefaultDynamicProperties() { }, }, [VECTOR_STYLES.ICON_ORIENTATION]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { field: undefined, fieldMetaOptions: { @@ -168,13 +189,13 @@ export function getDefaultDynamicProperties() { }, }, [VECTOR_STYLES.LABEL_TEXT]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { field: undefined, }, }, [VECTOR_STYLES.LABEL_COLOR]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { color: COLOR_GRADIENTS[0].value, field: undefined, @@ -185,7 +206,7 @@ export function getDefaultDynamicProperties() { }, }, [VECTOR_STYLES.LABEL_SIZE]: { - type: VectorStyle.STYLE_TYPE.STATIC, + type: VectorStyle.STYLE_TYPE.DYNAMIC, options: { minSize: DEFAULT_MIN_SIZE, maxSize: DEFAULT_MAX_SIZE, @@ -196,5 +217,16 @@ export function getDefaultDynamicProperties() { }, }, }, + [VECTOR_STYLES.LABEL_BORDER_COLOR]: { + type: VectorStyle.STYLE_TYPE.DYNAMIC, + options: { + color: COLOR_GRADIENTS[0].value, + field: undefined, + fieldMetaOptions: { + isEnabled: true, + sigma: DEFAULT_SIGMA, + }, + }, + }, }; } diff --git a/x-pack/legacy/plugins/maps/server/test_utils/index.js b/x-pack/legacy/plugins/maps/server/test_utils/index.js index 944d65a21aae2..f208917e20924 100644 --- a/x-pack/legacy/plugins/maps/server/test_utils/index.js +++ b/x-pack/legacy/plugins/maps/server/test_utils/index.js @@ -25,24 +25,3 @@ export const getMockCallWithInternal = (hits = defaultMockSavedObjects) => { export const getMockTaskFetch = (docs = defaultMockTaskDocs) => { return () => Promise.resolve({ docs }); }; - -export const getMockKbnServer = ( - mockCallWithInternal = getMockCallWithInternal(), - mockTaskFetch = getMockTaskFetch() -) => ({ - plugins: { - elasticsearch: { - getCluster: () => ({ - callWithInternalUser: mockCallWithInternal, - }), - }, - xpack_main: {}, - task_manager: { - registerTaskDefinitions: () => undefined, - schedule: () => Promise.resolve(), - fetch: mockTaskFetch, - }, - }, - config: () => ({ get: () => '' }), - log: () => undefined, -}); diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts index 947c1bcf6c1a4..8a4411bf9025f 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/common/job_creator/multi_metric_job_creator.ts @@ -92,12 +92,11 @@ export class MultiMetricJobCreator extends JobCreator { // not split field, use the default this.modelMemoryLimit = DEFAULT_MODEL_MEMORY_LIMIT; } else { - const fieldNames = this._detectors.map(d => d.field_name).filter(fn => fn !== undefined); const { modelMemoryLimit } = await ml.calculateModelMemoryLimit({ indexPattern: this._indexPatternTitle, splitFieldName: this._splitField.name, query: this._datafeed_config.query, - fieldNames, + fieldNames: this.fields.map(f => f.id), influencerNames: this._influencers, timeFieldName: this._job_config.data_description.time_field, earliestMs: this._start, diff --git a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx index c55bdeef4dde8..5c5ba38b1c5a1 100644 --- a/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx +++ b/x-pack/legacy/plugins/ml/public/application/jobs/new_job/pages/components/pick_fields_step/components/influencers/influencers.tsx @@ -25,6 +25,9 @@ export const Influencers: FC = () => { useEffect(() => { jobCreator.removeAllInfluencers(); influencers.forEach(i => jobCreator.addInfluencer(i)); + if (jobCreator instanceof MultiMetricJobCreator) { + jobCreator.calculateModelMemoryLimit(); + } jobCreatorUpdate(); }, [influencers.join()]); diff --git a/x-pack/legacy/plugins/oss_telemetry/index.ts b/x-pack/legacy/plugins/oss_telemetry/index.ts index 8b16c7cf13cad..fce861c7d3f46 100644 --- a/x-pack/legacy/plugins/oss_telemetry/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/index.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Logger, PluginInitializerContext } from 'kibana/server'; +import { Logger, PluginInitializerContext, CoreStart } from 'kibana/server'; +import { Legacy } from 'kibana'; import { PLUGIN_ID } from './constants'; import { OssTelemetryPlugin } from './server/plugin'; import { LegacyPluginInitializer } from '../../../../src/legacy/plugin_discovery/types'; +import { getTaskManagerSetup, getTaskManagerStart } from '../task_manager/server'; export const ossTelemetry: LegacyPluginInitializer = kibana => { return new kibana.Plugin({ @@ -15,7 +17,7 @@ export const ossTelemetry: LegacyPluginInitializer = kibana => { require: ['elasticsearch', 'xpack_main'], configPrefix: 'xpack.oss_telemetry', - init(server) { + init(server: Legacy.Server) { const plugin = new OssTelemetryPlugin({ logger: { get: () => @@ -27,14 +29,24 @@ export const ossTelemetry: LegacyPluginInitializer = kibana => { } as Logger), }, } as PluginInitializerContext); - plugin.setup(server.newPlatform.setup.core, { + + const deps = { usageCollection: server.newPlatform.setup.plugins.usageCollection, - taskManager: server.plugins.task_manager, __LEGACY: { config: server.config(), xpackMainStatus: ((server.plugins.xpack_main as unknown) as { status: any }).status .plugin, }, + }; + + plugin.setup(server.newPlatform.setup.core, { + ...deps, + taskManager: getTaskManagerSetup(server), + }); + + plugin.start((server.newPlatform.setup.core as unknown) as CoreStart, { + ...deps, + taskManager: getTaskManagerStart(server), }); }, }); diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts index 3b47099fdc462..9d547c1b22099 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/index.ts @@ -5,8 +5,8 @@ */ import { registerVisualizationsCollector } from './visualizations/register_usage_collector'; -import { OssTelemetrySetupDependencies } from '../../plugin'; +import { OssTelemetryStartDependencies } from '../../plugin'; -export function registerCollectors(deps: OssTelemetrySetupDependencies) { +export function registerCollectors(deps: OssTelemetryStartDependencies) { registerVisualizationsCollector(deps.usageCollection, deps.taskManager); } diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts index ec35266646650..ce106d1a64fd6 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.test.ts @@ -4,29 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getMockTaskFetch, getMockTaskManager } from '../../../../test_utils'; +import { + getMockTaskFetch, + getMockThrowingTaskFetch, + getMockTaskInstance, +} from '../../../../test_utils'; +import { taskManagerMock } from '../../../../../../../plugins/task_manager/server/task_manager.mock'; import { getUsageCollector } from './get_usage_collector'; describe('getVisualizationsCollector#fetch', () => { test('can return empty stats', async () => { - const { type, fetch } = getUsageCollector(getMockTaskManager()); + const { type, fetch } = getUsageCollector(taskManagerMock.start(getMockTaskFetch())); expect(type).toBe('visualization_types'); const fetchResult = await fetch(); expect(fetchResult).toEqual({}); }); test('provides known stats', async () => { - const mockTaskFetch = getMockTaskFetch([ - { - state: { - runs: 1, - stats: { comic_books: { total: 16, max: 12, min: 2, avg: 6 } }, - }, - taskType: 'test', - params: {}, - }, - ]); - const { type, fetch } = getUsageCollector(getMockTaskManager(mockTaskFetch)); + const { type, fetch } = getUsageCollector( + taskManagerMock.start( + getMockTaskFetch([ + getMockTaskInstance({ + state: { + runs: 1, + stats: { comic_books: { total: 16, max: 12, min: 2, avg: 6 } }, + }, + taskType: 'test', + params: {}, + }), + ]) + ) + ); expect(type).toBe('visualization_types'); const fetchResult = await fetch(); expect(fetchResult).toEqual({ comic_books: { avg: 6, max: 12, min: 2, total: 16 } }); @@ -34,20 +42,21 @@ describe('getVisualizationsCollector#fetch', () => { describe('Error handling', () => { test('Silently handles Task Manager NotInitialized', async () => { - const mockTaskFetch = jest.fn(() => { - throw new Error('NotInitialized taskManager is still waiting for plugins to load'); - }); - const { fetch } = getUsageCollector(getMockTaskManager(mockTaskFetch)); + const { fetch } = getUsageCollector( + taskManagerMock.start( + getMockThrowingTaskFetch( + new Error('NotInitialized taskManager is still waiting for plugins to load') + ) + ) + ); const result = await fetch(); expect(result).toBe(undefined); }); // In real life, the CollectorSet calls fetch and handles errors test('defers the errors', async () => { - const mockTaskFetch = jest.fn(() => { - throw new Error('BOOM'); - }); - - const { fetch } = getUsageCollector(getMockTaskManager(mockTaskFetch)); + const { fetch } = getUsageCollector( + taskManagerMock.start(getMockThrowingTaskFetch(new Error('BOOM'))) + ); await expect(fetch()).rejects.toThrowErrorMatchingInlineSnapshot(`"BOOM"`); }); }); diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts index 11dbddc00f830..bc0d10860a667 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/get_usage_collector.ts @@ -5,15 +5,15 @@ */ import { get } from 'lodash'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../../../../task_manager/server/plugin'; import { PLUGIN_ID, VIS_TELEMETRY_TASK, VIS_USAGE_TYPE } from '../../../../constants'; +import { TaskManagerStartContract } from '../../../../../../../plugins/task_manager/server'; -async function isTaskManagerReady(taskManager: TaskManagerPluginSetupContract | undefined) { +async function isTaskManagerReady(taskManager?: TaskManagerStartContract) { const result = await fetch(taskManager); return result !== null; } -async function fetch(taskManager: TaskManagerPluginSetupContract | undefined) { +async function fetch(taskManager?: TaskManagerStartContract) { if (!taskManager) { return null; } @@ -38,7 +38,7 @@ async function fetch(taskManager: TaskManagerPluginSetupContract | undefined) { return docs; } -export function getUsageCollector(taskManager: TaskManagerPluginSetupContract | undefined) { +export function getUsageCollector(taskManager?: TaskManagerStartContract) { let isCollectorReady = false; async function determineIfTaskManagerIsReady() { let isReady = false; diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts index 46b86091c9db1..657f1c725f4e0 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/collectors/visualizations/register_usage_collector.ts @@ -5,12 +5,12 @@ */ import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../../../../task_manager/server/plugin'; +import { TaskManagerStartContract } from '../../../../../../../plugins/task_manager/server'; import { getUsageCollector } from './get_usage_collector'; export function registerVisualizationsCollector( collectorSet: UsageCollectionSetup, - taskManager: TaskManagerPluginSetupContract | undefined + taskManager?: TaskManagerStartContract ): void { const collector = collectorSet.makeUsageCollector(getUsageCollector(taskManager)); collectorSet.registerCollector(collector); diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts index c9714306d73c5..cf7295f67a231 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/index.ts @@ -5,12 +5,15 @@ */ import { CoreSetup, Logger } from 'kibana/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../../../task_manager/server/plugin'; import { PLUGIN_ID, VIS_TELEMETRY_TASK } from '../../../constants'; import { visualizationsTaskRunner } from './visualizations/task_runner'; import KbnServer from '../../../../../../../src/legacy/server/kbn_server'; import { LegacyConfig } from '../../plugin'; -import { TaskInstance } from '../../../../task_manager/server'; +import { + TaskInstance, + TaskManagerStartContract, + TaskManagerSetupContract, +} from '../../../../../../plugins/task_manager/server'; export function registerTasks({ taskManager, @@ -18,7 +21,7 @@ export function registerTasks({ elasticsearch, config, }: { - taskManager?: TaskManagerPluginSetupContract; + taskManager?: TaskManagerSetupContract; logger: Logger; elasticsearch: CoreSetup['elasticsearch']; config: LegacyConfig; @@ -46,7 +49,7 @@ export function scheduleTasks({ xpackMainStatus, logger, }: { - taskManager?: TaskManagerPluginSetupContract; + taskManager?: TaskManagerStartContract; xpackMainStatus: { kbnServer: KbnServer }; logger: Logger; }) { diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts index af3eed2496f5d..ef03e857de8ef 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.test.ts @@ -12,7 +12,7 @@ import { getMockTaskInstance, } from '../../../../test_utils'; import { visualizationsTaskRunner } from './task_runner'; -import { TaskInstance } from '../../../../../task_manager/server'; +import { TaskInstance } from '../../../../../../../plugins/task_manager/server'; describe('visualizationsTaskRunner', () => { let mockTaskInstance: TaskInstance; diff --git a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts index 8fb2da5627ee8..0b7b301df12bf 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/lib/tasks/visualizations/task_runner.ts @@ -8,7 +8,7 @@ import _, { countBy, groupBy, mapValues } from 'lodash'; import { APICaller, CoreSetup } from 'kibana/server'; import { getNextMidnight } from '../../get_next_midnight'; import { VisState } from '../../../../../../../../src/legacy/core_plugins/visualizations/public'; -import { TaskInstance } from '../../../../../task_manager/server'; +import { TaskInstance } from '../../../../../../../plugins/task_manager/server'; import { ESSearchHit } from '../../../../../apm/typings/elasticsearch'; import { LegacyConfig } from '../../../plugin'; diff --git a/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts b/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts index 209c73eb0eb62..0aac319cf5818 100644 --- a/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts +++ b/x-pack/legacy/plugins/oss_telemetry/server/plugin.ts @@ -4,8 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../task_manager/server/plugin'; +import { CoreSetup, CoreStart, Logger, Plugin, PluginInitializerContext } from 'kibana/server'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; import { registerCollectors } from './lib/collectors'; import { registerTasks, scheduleTasks } from './lib/tasks'; import KbnServer from '../../../../../src/legacy/server/kbn_server'; @@ -15,13 +18,18 @@ export interface LegacyConfig { get: (key: string) => string | number | boolean; } -export interface OssTelemetrySetupDependencies { +interface OssTelemetryDependencies { usageCollection: UsageCollectionSetup; __LEGACY: { config: LegacyConfig; xpackMainStatus: { kbnServer: KbnServer }; }; - taskManager?: TaskManagerPluginSetupContract; +} +export interface OssTelemetrySetupDependencies extends OssTelemetryDependencies { + taskManager?: TaskManagerSetupContract; +} +export interface OssTelemetryStartDependencies extends OssTelemetryDependencies { + taskManager?: TaskManagerStartContract; } export class OssTelemetryPlugin implements Plugin { @@ -32,19 +40,20 @@ export class OssTelemetryPlugin implements Plugin { } public setup(core: CoreSetup, deps: OssTelemetrySetupDependencies) { - registerCollectors(deps); registerTasks({ taskManager: deps.taskManager, logger: this.logger, elasticsearch: core.elasticsearch, config: deps.__LEGACY.config, }); + } + + public start(core: CoreStart, deps: OssTelemetryStartDependencies) { + registerCollectors(deps); scheduleTasks({ taskManager: deps.taskManager, xpackMainStatus: deps.__LEGACY.xpackMainStatus, logger: this.logger, }); } - - public start() {} } diff --git a/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts b/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts index c6046eb648bf4..0695fda3c2c94 100644 --- a/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts +++ b/x-pack/legacy/plugins/oss_telemetry/test_utils/index.ts @@ -6,13 +6,28 @@ import { APICaller, CoreSetup } from 'kibana/server'; -import { TaskInstance } from '../../task_manager/server'; -import { PluginSetupContract as TaskManagerPluginSetupContract } from '../../task_manager/server/plugin'; +import { + ConcreteTaskInstance, + TaskStatus, + TaskManagerStartContract, + // eslint-disable-next-line @kbn/eslint/no-restricted-paths +} from '../../../../plugins/task_manager/server'; -export const getMockTaskInstance = (): TaskInstance => ({ +export const getMockTaskInstance = ( + overrides: Partial = {} +): ConcreteTaskInstance => ({ state: { runs: 0, stats: {} }, taskType: 'test', params: {}, + id: '', + scheduledAt: new Date(), + attempts: 1, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: null, + retryAt: null, + ownerId: null, + ...overrides, }); const defaultMockSavedObjects = [ @@ -38,8 +53,24 @@ export const getMockCallWithInternal = (hits: unknown[] = defaultMockSavedObject }) as unknown) as APICaller; }; -export const getMockTaskFetch = (docs: TaskInstance[] = defaultMockTaskDocs) => { - return () => Promise.resolve({ docs }); +export const getMockTaskFetch = ( + docs: ConcreteTaskInstance[] = defaultMockTaskDocs +): Partial> => { + return { + fetch: jest.fn(fetchOpts => { + return Promise.resolve({ docs, searchAfter: [] }); + }), + } as Partial>; +}; + +export const getMockThrowingTaskFetch = ( + throws: Error +): Partial> => { + return { + fetch: jest.fn(fetchOpts => { + throw throws; + }), + } as Partial>; }; export const getMockConfig = () => { @@ -48,13 +79,6 @@ export const getMockConfig = () => { }; }; -export const getMockTaskManager = (fetch: any = getMockTaskFetch()) => - (({ - registerTaskDefinitions: () => undefined, - ensureScheduled: () => Promise.resolve(), - fetch, - } as unknown) as TaskManagerPluginSetupContract); - export const getCluster = () => ({ callWithInternalUser: getMockCallWithInternal(), }); diff --git a/x-pack/legacy/plugins/reporting/index.ts b/x-pack/legacy/plugins/reporting/index.ts index faa27bfb2d6ea..ef0ab37738362 100644 --- a/x-pack/legacy/plugins/reporting/index.ts +++ b/x-pack/legacy/plugins/reporting/index.ts @@ -59,7 +59,7 @@ export const reporting = (kibana: any) => { defaultMessage: `Custom image to use in the PDF's footer`, }), type: 'image', - options: { + validation: { maxSize: { length: kbToBase64Length(200), description: '200 kB', diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx index b39d43cc01b42..771e220a2a0b3 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map.tsx @@ -15,10 +15,10 @@ import { DEFAULT_INDEX_KEY } from '../../../common/constants'; import { getIndexPatternTitleIdMapping } from '../../hooks/api/helpers'; import { useIndexPatterns } from '../../hooks/use_index_patterns'; import { Loader } from '../loader'; -import { useStateToaster } from '../toasters'; +import { displayErrorToast, useStateToaster } from '../toasters'; import { Embeddable } from './embeddable'; import { EmbeddableHeader } from './embeddable_header'; -import { createEmbeddable, displayErrorToast } from './embedded_map_helpers'; +import { createEmbeddable } from './embedded_map_helpers'; import { IndexPatternsMissingPrompt } from './index_patterns_missing_prompt'; import { MapToolTip } from './map_tool_tip/map_tool_tip'; import * as i18n from './translations'; @@ -134,7 +134,7 @@ export const EmbeddedMapComponent = ({ } } catch (e) { if (isSubscribed) { - displayErrorToast(i18n.ERROR_CREATING_EMBEDDABLE, e.message, dispatchToaster); + displayErrorToast(i18n.ERROR_CREATING_EMBEDDABLE, [e.message], dispatchToaster); setIsError(true); } } diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx index 4e5fcee439827..a83e8377deeb6 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.test.tsx @@ -4,19 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { createEmbeddable, displayErrorToast } from './embedded_map_helpers'; +import { createEmbeddable } from './embedded_map_helpers'; import { createUiNewPlatformMock } from 'ui/new_platform/__mocks__/helpers'; import { createPortalNode } from 'react-reverse-portal'; jest.mock('ui/new_platform'); -jest.mock('uuid', () => { - return { - v1: jest.fn(() => '27261ae0-0bbb-11ea-b0ea-db767b07ea47'), - v4: jest.fn(() => '9e1f72a9-7c73-4b7f-a562-09940f7daf4a'), - }; -}); - const { npStart } = createUiNewPlatformMock(); npStart.plugins.embeddable.getEmbeddableFactory = jest.fn().mockImplementation(() => ({ createFromState: () => ({ @@ -25,24 +18,6 @@ npStart.plugins.embeddable.getEmbeddableFactory = jest.fn().mockImplementation(( })); describe('embedded_map_helpers', () => { - describe('displayErrorToast', () => { - test('dispatches toast with correct title and message', () => { - const mockToast = { - toast: { - color: 'danger', - errors: ['message'], - iconType: 'alert', - id: '9e1f72a9-7c73-4b7f-a562-09940f7daf4a', - title: 'Title', - }, - type: 'addToaster', - }; - const dispatchToasterMock = jest.fn(); - displayErrorToast('Title', 'message', dispatchToasterMock); - expect(dispatchToasterMock.mock.calls[0][0]).toEqual(mockToast); - }); - }); - describe('createEmbeddable', () => { test('attaches refresh action', async () => { const setQueryMock = jest.fn(); diff --git a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx index b9a9df9824eee..838e74cc5624c 100644 --- a/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/components/embeddables/embedded_map_helpers.tsx @@ -7,7 +7,6 @@ import uuid from 'uuid'; import React from 'react'; import { OutPortal, PortalNode } from 'react-reverse-portal'; -import { ActionToaster, AppToast } from '../toasters'; import { ViewMode } from '../../../../../../../src/legacy/core_plugins/embeddable_api/public/np_ready/public'; import { IndexPatternMapping, @@ -22,31 +21,6 @@ import { MAP_SAVED_OBJECT_TYPE } from '../../../../maps/common/constants'; import * as i18n from './translations'; import { Query, esFilters } from '../../../../../../../src/plugins/data/public'; -/** - * Displays an error toast for the provided title and message - * - * @param errorTitle Title of error to display in toaster and modal - * @param errorMessage Message to display in error modal when clicked - * @param dispatchToaster provided by useStateToaster() - */ -export const displayErrorToast = ( - errorTitle: string, - errorMessage: string, - dispatchToaster: React.Dispatch -) => { - const toast: AppToast = { - id: uuid.v4(), - title: errorTitle, - color: 'danger', - iconType: 'alert', - errors: [errorMessage], - }; - dispatchToaster({ - type: 'addToaster', - toast, - }); -}; - /** * Creates MapEmbeddable with provided initial configuration * diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx index aaf88e68684ca..2c669c259d07e 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/events_viewer.tsx @@ -59,7 +59,6 @@ interface Props { kqlMode: KqlMode; onChangeItemsPerPage: OnChangeItemsPerPage; query: Query; - showInspect: boolean; start: number; sort: Sort; timelineTypeContext: TimelineTypeContextProps; @@ -67,171 +66,171 @@ interface Props { utilityBar?: (totalCount: number) => React.ReactNode; } -export const EventsViewer = React.memo( - ({ - browserFields, - columns, +const EventsViewerComponent: React.FC = ({ + browserFields, + columns, + dataProviders, + deletedEventIds, + end, + filters, + headerFilterGroup, + height = DEFAULT_EVENTS_VIEWER_HEIGHT, + id, + indexPattern, + isLive, + itemsPerPage, + itemsPerPageOptions, + kqlMode, + onChangeItemsPerPage, + query, + start, + sort, + timelineTypeContext, + toggleColumn, + utilityBar, +}) => { + const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; + const kibana = useKibana(); + const combinedQueries = combineQueries({ + config: esQuery.getEsQueryConfig(kibana.services.uiSettings), dataProviders, - deletedEventIds, - end, - filters, - headerFilterGroup, - height = DEFAULT_EVENTS_VIEWER_HEIGHT, - id, indexPattern, - isLive, - itemsPerPage, - itemsPerPageOptions, + browserFields, + filters, + kqlQuery: query, kqlMode, - onChangeItemsPerPage, - query, - showInspect, start, - sort, - timelineTypeContext, - toggleColumn, - utilityBar, - }) => { - const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; - const kibana = useKibana(); - const combinedQueries = combineQueries({ - config: esQuery.getEsQueryConfig(kibana.services.uiSettings), - dataProviders, - indexPattern, - browserFields, - filters, - kqlQuery: query, - kqlMode, - start, - end, - isEventViewer: true, - }); - const queryFields = useMemo( - () => - union( - columnsHeader.map(c => c.id), - timelineTypeContext.queryFields ?? [] - ), - [columnsHeader, timelineTypeContext.queryFields] - ); + end, + isEventViewer: true, + }); + const queryFields = useMemo( + () => + union( + columnsHeader.map(c => c.id), + timelineTypeContext.queryFields ?? [] + ), + [columnsHeader, timelineTypeContext.queryFields] + ); - return ( - - - {({ measureRef, content: { width = 0 } }) => ( - <> - -
- + return ( + + + {({ measureRef, content: { width = 0 } }) => ( + <> + +
+ - {combinedQueries != null ? ( - - {({ - events, - getUpdatedAt, - inspect, - loading, - loadMore, - pageInfo, - refetch, - totalCount = 0, - }) => { - const totalCountMinusDeleted = - totalCount > 0 ? totalCount - deletedEventIds.length : 0; + {combinedQueries != null ? ( + + {({ + events, + getUpdatedAt, + inspect, + loading, + loadMore, + pageInfo, + refetch, + totalCount = 0, + }) => { + const totalCountMinusDeleted = + totalCount > 0 ? totalCount - deletedEventIds.length : 0; - // TODO: Reset eventDeletedIds/eventLoadingIds on refresh/loadmore (getUpdatedAt) - return ( - <> - - {headerFilterGroup} - + // TODO: Reset eventDeletedIds/eventLoadingIds on refresh/loadmore (getUpdatedAt) + return ( + <> + + {headerFilterGroup} + - {utilityBar?.(totalCountMinusDeleted)} + {utilityBar?.(totalCountMinusDeleted)} -
+ - - + refetch={refetch} + /> + + !deletedEventIds.includes(e._id))} + id={id} + isEventViewer={true} + height={height} + sort={sort} + toggleColumn={toggleColumn} + /> - !deletedEventIds.includes(e._id))} - id={id} - isEventViewer={true} - height={height} - sort={sort} - toggleColumn={toggleColumn} - /> +
+ +
+ + ); + }} +
+ ) : null} + + )} + + + ); +}; -
- -
- - ); - }} - - ) : null} - - )} -
-
- ); - }, +export const EventsViewer = React.memo( + EventsViewerComponent, (prevProps, nextProps) => prevProps.browserFields === nextProps.browserFields && prevProps.columns === nextProps.columns && @@ -247,10 +246,8 @@ export const EventsViewer = React.memo( prevProps.itemsPerPageOptions === nextProps.itemsPerPageOptions && prevProps.kqlMode === nextProps.kqlMode && isEqual(prevProps.query, nextProps.query) && - prevProps.showInspect === nextProps.showInspect && prevProps.start === nextProps.start && prevProps.sort === nextProps.sort && isEqual(prevProps.timelineTypeContext, nextProps.timelineTypeContext) && prevProps.utilityBar === nextProps.utilityBar ); -EventsViewer.displayName = 'EventsViewer'; diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx index 1e225dabb2541..ec8d329f1dfe3 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.test.tsx @@ -57,7 +57,8 @@ describe('StatefulEventsViewer', () => { ).toBe(true); }); - test('it renders a transparent inspect button when it does NOT have mouse focus', async () => { + // InspectButtonContainer controls displaying InspectButton components + test('it renders InspectButtonContainer', async () => { const wrapper = mount( @@ -74,39 +75,6 @@ describe('StatefulEventsViewer', () => { await wait(); wrapper.update(); - expect( - wrapper - .find(`[data-test-subj="transparent-inspect-container"]`) - .first() - .exists() - ).toBe(true); - }); - - test('it renders an opaque inspect button when it has mouse focus', async () => { - const wrapper = mount( - - - - - - ); - - await wait(); - wrapper.update(); - - wrapper.simulate('mouseenter'); - wrapper.update(); - - expect( - wrapper - .find(`[data-test-subj="opaque-inspect-container"]`) - .first() - .exists() - ).toBe(true); + expect(wrapper.find(`InspectButtonContainer`).exists()).toBe(true); }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx index 9b8ec243d5f38..99d174d74f3f8 100644 --- a/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/events_viewer/index.tsx @@ -5,7 +5,7 @@ */ import { isEqual } from 'lodash/fp'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; import { inputsModel, inputsSelectors, State, timelineSelectors } from '../../store'; @@ -23,6 +23,7 @@ import { InputsModelId } from '../../store/inputs/constants'; import { useFetchIndexPatterns } from '../../containers/detection_engine/rules/fetch_index_patterns'; import { TimelineTypeContextProps } from '../timeline/timeline_context'; import { DEFAULT_INDEX_KEY } from '../../../common/constants'; +import { InspectButtonContainer } from '../inspect'; import * as i18n from './translations'; export interface OwnProps { @@ -83,133 +84,102 @@ interface DispatchProps { type Props = OwnProps & StateReduxProps & DispatchProps; -const StatefulEventsViewerComponent = React.memo( - ({ - createTimeline, - columns, - dataProviders, - defaultModel, - deletedEventIds, - defaultIndices, - deleteEventQuery, - end, - filters, - headerFilterGroup, - id, - isLive, - itemsPerPage, - itemsPerPageOptions, - kqlMode, - pageFilters = [], - query, - removeColumn, - start, - showCheckboxes, - showRowRenderers, - sort, - timelineTypeContext = { - loadingText: i18n.LOADING_EVENTS, - }, - updateItemsPerPage, - upsertColumn, - utilityBar, - }) => { - const [showInspect, setShowInspect] = useState(false); - const [{ browserFields, indexPatterns }] = useFetchIndexPatterns( - defaultIndices ?? useUiSetting(DEFAULT_INDEX_KEY) - ); - - useEffect(() => { - if (createTimeline != null) { - createTimeline({ id, columns, sort, itemsPerPage, showCheckboxes, showRowRenderers }); - } - return () => { - deleteEventQuery({ id, inputId: 'global' }); - }; - }, []); - - const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback( - itemsChangedPerPage => updateItemsPerPage({ id, itemsPerPage: itemsChangedPerPage }), - [id, updateItemsPerPage] - ); - - const toggleColumn = useCallback( - (column: ColumnHeader) => { - const exists = columns.findIndex(c => c.id === column.id) !== -1; - - if (!exists && upsertColumn != null) { - upsertColumn({ - column, - id, - index: 1, - }); - } - - if (exists && removeColumn != null) { - removeColumn({ - columnId: column.id, - id, - }); - } - }, - [columns, id, upsertColumn, removeColumn] - ); - - const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); - const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - - return ( -
- -
- ); +const StatefulEventsViewerComponent: React.FC = ({ + createTimeline, + columns, + dataProviders, + deletedEventIds, + defaultIndices, + deleteEventQuery, + end, + filters, + headerFilterGroup, + id, + isLive, + itemsPerPage, + itemsPerPageOptions, + kqlMode, + pageFilters = [], + query, + removeColumn, + start, + showCheckboxes, + showRowRenderers, + sort, + timelineTypeContext = { + loadingText: i18n.LOADING_EVENTS, }, - (prevProps, nextProps) => - prevProps.id === nextProps.id && - isEqual(prevProps.columns, nextProps.columns) && - isEqual(prevProps.dataProviders, nextProps.dataProviders) && - prevProps.deletedEventIds === nextProps.deletedEventIds && - prevProps.end === nextProps.end && - isEqual(prevProps.filters, nextProps.filters) && - prevProps.isLive === nextProps.isLive && - prevProps.itemsPerPage === nextProps.itemsPerPage && - isEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && - prevProps.kqlMode === nextProps.kqlMode && - isEqual(prevProps.query, nextProps.query) && - prevProps.pageCount === nextProps.pageCount && - isEqual(prevProps.sort, nextProps.sort) && - prevProps.start === nextProps.start && - isEqual(prevProps.pageFilters, nextProps.pageFilters) && - prevProps.showCheckboxes === nextProps.showCheckboxes && - prevProps.showRowRenderers === nextProps.showRowRenderers && - prevProps.start === nextProps.start && - isEqual(prevProps.timelineTypeContext, nextProps.timelineTypeContext) && - prevProps.utilityBar === nextProps.utilityBar -); + updateItemsPerPage, + upsertColumn, + utilityBar, +}) => { + const [{ browserFields, indexPatterns }] = useFetchIndexPatterns( + defaultIndices ?? useUiSetting(DEFAULT_INDEX_KEY) + ); + + useEffect(() => { + if (createTimeline != null) { + createTimeline({ id, columns, sort, itemsPerPage, showCheckboxes, showRowRenderers }); + } + return () => { + deleteEventQuery({ id, inputId: 'global' }); + }; + }, []); + + const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback( + itemsChangedPerPage => updateItemsPerPage({ id, itemsPerPage: itemsChangedPerPage }), + [id, updateItemsPerPage] + ); + + const toggleColumn = useCallback( + (column: ColumnHeader) => { + const exists = columns.findIndex(c => c.id === column.id) !== -1; + + if (!exists && upsertColumn != null) { + upsertColumn({ + column, + id, + index: 1, + }); + } -StatefulEventsViewerComponent.displayName = 'StatefulEventsViewerComponent'; + if (exists && removeColumn != null) { + removeColumn({ + columnId: column.id, + id, + }); + } + }, + [columns, id, upsertColumn, removeColumn] + ); + + return ( + + + + ); +}; const makeMapStateToProps = () => { const getInputsTimeline = inputsSelectors.getTimelineSelector(); @@ -256,4 +226,29 @@ export const StatefulEventsViewer = connect(makeMapStateToProps, { updateItemsPerPage: timelineActions.updateItemsPerPage, removeColumn: timelineActions.removeColumn, upsertColumn: timelineActions.upsertColumn, -})(StatefulEventsViewerComponent); +})( + React.memo( + StatefulEventsViewerComponent, + (prevProps, nextProps) => + prevProps.id === nextProps.id && + isEqual(prevProps.columns, nextProps.columns) && + isEqual(prevProps.dataProviders, nextProps.dataProviders) && + prevProps.deletedEventIds === nextProps.deletedEventIds && + prevProps.end === nextProps.end && + isEqual(prevProps.filters, nextProps.filters) && + prevProps.isLive === nextProps.isLive && + prevProps.itemsPerPage === nextProps.itemsPerPage && + isEqual(prevProps.itemsPerPageOptions, nextProps.itemsPerPageOptions) && + prevProps.kqlMode === nextProps.kqlMode && + isEqual(prevProps.query, nextProps.query) && + prevProps.pageCount === nextProps.pageCount && + isEqual(prevProps.sort, nextProps.sort) && + prevProps.start === nextProps.start && + isEqual(prevProps.pageFilters, nextProps.pageFilters) && + prevProps.showCheckboxes === nextProps.showCheckboxes && + prevProps.showRowRenderers === nextProps.showRowRenderers && + prevProps.start === nextProps.start && + isEqual(prevProps.timelineTypeContext, nextProps.timelineTypeContext) && + prevProps.utilityBar === nextProps.utilityBar + ) +); diff --git a/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx index 2bc80be20e42d..bc4692b6fe0c5 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/header_section/index.test.tsx @@ -63,36 +63,6 @@ describe('HeaderSection', () => { ).toBe(false); }); - test('it renders a transparent inspect button when showInspect is false', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper - .find('[data-test-subj="transparent-inspect-container"]') - .first() - .exists() - ).toBe(true); - }); - - test('it renders an opaque inspect button when showInspect is true', () => { - const wrapper = mount( - - - - ); - - expect( - wrapper - .find('[data-test-subj="opaque-inspect-container"]') - .first() - .exists() - ).toBe(true); - }); - test('it renders supplements when children provided', () => { const wrapper = mount( diff --git a/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx b/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx index 14af10eb6cd9b..3153e785a8a32 100644 --- a/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/header_section/index.tsx @@ -35,48 +35,54 @@ export interface HeaderSectionProps extends HeaderProps { id?: string; split?: boolean; subtitle?: string | React.ReactNode; - showInspect?: boolean; title: string | React.ReactNode; tooltip?: string; } -export const HeaderSection = React.memo( - ({ border, children, id, showInspect = false, split, subtitle, title, tooltip }) => ( -
- - - - - -

- {title} - {tooltip && ( - <> - {' '} - - - )} -

-
+const HeaderSectionComponent: React.FC = ({ + border, + children, + id, + split, + subtitle, + title, + tooltip, +}) => ( +
+ + + + + +

+ {title} + {tooltip && ( + <> + {' '} + + + )} +

+
- {subtitle && } + {subtitle && } +
+ + {id && ( + + + )} +
+
- {id && ( - - - - )} -
+ {children && ( + + {children} - - {children && ( - - {children} - - )} - -
- ) + )} +
+
); -HeaderSection.displayName = 'HeaderSection'; + +export const HeaderSection = React.memo(HeaderSectionComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/inspect/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/inspect/index.test.tsx index 26c5f499717e9..9492002717e2b 100644 --- a/x-pack/legacy/plugins/siem/public/components/inspect/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/inspect/index.test.tsx @@ -17,7 +17,7 @@ import { import { createStore, State } from '../../store'; import { UpdateQueryParams, upsertQuery } from '../../store/inputs/helpers'; -import { InspectButton } from '.'; +import { InspectButton, InspectButtonContainer, BUTTON_CLASS } from '.'; import { cloneDeep } from 'lodash/fp'; describe('Inspect Button', () => { @@ -44,7 +44,7 @@ describe('Inspect Button', () => { test('Eui Empty Button', () => { const wrapper = mount( - + ); expect( @@ -58,13 +58,7 @@ describe('Inspect Button', () => { test('it does NOT render the Eui Empty Button when timeline is timeline and compact is true', () => { const wrapper = mount( - + ); expect( @@ -78,7 +72,7 @@ describe('Inspect Button', () => { test('Eui Icon Button', () => { const wrapper = mount( - + ); expect( @@ -92,13 +86,7 @@ describe('Inspect Button', () => { test('renders the Icon Button when inputId does NOT equal global, but compact is true', () => { const wrapper = mount( - + ); expect( @@ -112,7 +100,7 @@ describe('Inspect Button', () => { test('Eui Empty Button disabled', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiButtonIcon').get(0).props.disabled).toBe(true); @@ -121,11 +109,41 @@ describe('Inspect Button', () => { test('Eui Icon Button disabled', () => { const wrapper = mount( - + ); expect(wrapper.find('.euiButtonIcon').get(0).props.disabled).toBe(true); }); + + describe('InspectButtonContainer', () => { + test('it renders a transparent inspect button by default', async () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(`InspectButtonContainer`)).toHaveStyleRule('opacity', '0', { + modifier: `.${BUTTON_CLASS}`, + }); + }); + + test('it renders an opaque inspect button when it has mouse focus', async () => { + const wrapper = mount( + + + + + + ); + + expect(wrapper.find(`InspectButtonContainer`)).toHaveStyleRule('opacity', '1', { + modifier: `:hover .${BUTTON_CLASS}`, + }); + }); + }); }); describe('Modal Inspect - happy path', () => { @@ -143,7 +161,7 @@ describe('Inspect Button', () => { const wrapper = mount( - + ); @@ -167,7 +185,7 @@ describe('Inspect Button', () => { const wrapper = mount( - + ); @@ -197,7 +215,7 @@ describe('Inspect Button', () => { test('Do not Open Inspect Modal if it is loading', () => { const wrapper = mount( - + ); store.getState().inputs.global.queries[0].loading = true; diff --git a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx index a2a0ffdde34a5..32e71b3db575f 100644 --- a/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/inspect/index.tsx @@ -9,7 +9,7 @@ import { getOr } from 'lodash/fp'; import React, { useCallback } from 'react'; import { connect } from 'react-redux'; import { ActionCreator } from 'typescript-fsa'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { inputsModel, inputsSelectors, State } from '../../store'; import { InputsModelId } from '../../store/inputs/constants'; @@ -18,14 +18,31 @@ import { inputsActions } from '../../store/inputs'; import { ModalInspectQuery } from './modal'; import * as i18n from './translations'; -const InspectContainer = styled.div<{ showInspect: boolean }>` - .euiButtonIcon { - ${props => (props.showInspect ? 'opacity: 1;' : 'opacity: 0;')} +export const BUTTON_CLASS = 'inspectButtonComponent'; + +export const InspectButtonContainer = styled.div<{ show?: boolean }>` + display: flex; + flex-grow: 1; + + .${BUTTON_CLASS} { + opacity: 0; transition: opacity ${props => getOr(250, 'theme.eui.euiAnimSpeedNormal', props)} ease; } + + ${({ show }) => + show && + css` + &:hover .${BUTTON_CLASS} { + opacity: 1; + } + `} `; -InspectContainer.displayName = 'InspectContainer'; +InspectButtonContainer.displayName = 'InspectButtonContainer'; + +InspectButtonContainer.defaultProps = { + show: true, +}; interface OwnProps { compact?: boolean; @@ -34,7 +51,6 @@ interface OwnProps { inspectIndex?: number; isDisabled?: boolean; onCloseInspect?: () => void; - show: boolean; title: string | React.ReactElement | React.ReactNode; } @@ -57,89 +73,84 @@ interface InspectButtonDispatch { type InspectButtonProps = OwnProps & InspectButtonReducer & InspectButtonDispatch; -const InspectButtonComponent = React.memo( - ({ - compact = false, - inputId = 'global', - inspect, - isDisabled, - isInspected, - loading, - inspectIndex = 0, - onCloseInspect, - queryId = '', - selectedInspectIndex, - setIsInspected, - show, - title = '', - }: InspectButtonProps) => { - const handleClick = useCallback(() => { - setIsInspected({ - id: queryId, - inputId, - isInspected: true, - selectedInspectIndex: inspectIndex, - }); - }, [setIsInspected, queryId, inputId, inspectIndex]); - - const handleCloseModal = useCallback(() => { - if (onCloseInspect != null) { - onCloseInspect(); - } - setIsInspected({ - id: queryId, - inputId, - isInspected: false, - selectedInspectIndex: inspectIndex, - }); - }, [onCloseInspect, setIsInspected, queryId, inputId, inspectIndex]); - - return ( - - {inputId === 'timeline' && !compact && ( - - {i18n.INSPECT} - - )} - {(inputId === 'global' || compact) && ( - - )} - 0 ? inspect.dsl[inspectIndex] : null} - response={ - inspect != null && inspect.response.length > 0 ? inspect.response[inspectIndex] : null - } - title={title} - data-test-subj="inspect-modal" - /> - - ); - } -); +const InspectButtonComponent: React.FC = ({ + compact = false, + inputId = 'global', + inspect, + isDisabled, + isInspected, + loading, + inspectIndex = 0, + onCloseInspect, + queryId = '', + selectedInspectIndex, + setIsInspected, + title = '', +}) => { + const isShowingModal = !loading && selectedInspectIndex === inspectIndex && isInspected; + const handleClick = useCallback(() => { + setIsInspected({ + id: queryId, + inputId, + isInspected: true, + selectedInspectIndex: inspectIndex, + }); + }, [setIsInspected, queryId, inputId, inspectIndex]); -InspectButtonComponent.displayName = 'InspectButtonComponent'; + const handleCloseModal = useCallback(() => { + if (onCloseInspect != null) { + onCloseInspect(); + } + setIsInspected({ + id: queryId, + inputId, + isInspected: false, + selectedInspectIndex: inspectIndex, + }); + }, [onCloseInspect, setIsInspected, queryId, inputId, inspectIndex]); + + return ( + <> + {inputId === 'timeline' && !compact && ( + + {i18n.INSPECT} + + )} + {(inputId === 'global' || compact) && ( + + )} + 0 ? inspect.dsl[inspectIndex] : null} + response={ + inspect != null && inspect.response.length > 0 ? inspect.response[inspectIndex] : null + } + title={title} + data-test-subj="inspect-modal" + /> + + ); +}; const makeMapStateToProps = () => { const getGlobalQuery = inputsSelectors.globalQueryByIdSelector(); @@ -150,6 +161,11 @@ const makeMapStateToProps = () => { return mapStateToProps; }; -export const InspectButton = connect(makeMapStateToProps, { +const mapDispatchToProps = { setIsInspected: inputsActions.setInspectionParameter, -})(InspectButtonComponent); +}; + +export const InspectButton = connect( + makeMapStateToProps, + mapDispatchToProps +)(React.memo(InspectButtonComponent)); diff --git a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx index c29b5282e13af..94c05d00d5462 100644 --- a/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/matrix_histogram/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import { ScaleType } from '@elastic/charts'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; @@ -17,10 +17,11 @@ import { DEFAULT_DARK_MODE } from '../../../common/constants'; import { useUiSetting$ } from '../../lib/kibana'; import { Loader } from '../loader'; import { Panel } from '../panel'; +import { InspectButtonContainer } from '../inspect'; import { getBarchartConfigs, getCustomChartData } from './utils'; import { MatrixHistogramProps, MatrixHistogramDataTypes } from './types'; -export const MatrixHistogram = ({ +export const MatrixHistogramComponent: React.FC> = ({ data, dataKey, endDate, @@ -35,7 +36,7 @@ export const MatrixHistogram = ({ updateDateRange, yTickFormatter, showLegend, -}: MatrixHistogramProps) => { +}) => { const barchartConfigs = getBarchartConfigs({ from: startDate, to: endDate, @@ -44,7 +45,6 @@ export const MatrixHistogram = ({ yTickFormatter, showLegend, }); - const [showInspect, setShowInspect] = useState(false); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); const [loadingInitial, setLoadingInitial] = useState(false); @@ -56,40 +56,31 @@ export const MatrixHistogram = ({ } }, [loading, loadingInitial, totalCount]); - const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); - const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - return ( - - + + + - {loadingInitial ? ( - - ) : ( - <> - + {loadingInitial ? ( + + ) : ( + <> + - {loading && ( - - )} - - )} - + {loading && ( + + )} + + )} + + ); }; + +export const MatrixHistogram = React.memo(MatrixHistogramComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx index 9e3f8f91d5cf7..bf32a33af1eac 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/hosts/host_overview/index.tsx @@ -8,14 +8,14 @@ import { EuiFlexItem } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; import { getOr } from 'lodash/fp'; -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext } from 'react'; import { DEFAULT_DARK_MODE } from '../../../../../common/constants'; import { DescriptionList } from '../../../../../common/utility_types'; import { useUiSetting$ } from '../../../../lib/kibana'; import { getEmptyTagValue } from '../../../empty_value'; import { DefaultFieldRenderer, hostIdRenderer } from '../../../field_renderers/field_renderers'; -import { InspectButton } from '../../../inspect'; +import { InspectButton, InspectButtonContainer } from '../../../inspect'; import { HostItem } from '../../../../graphql/types'; import { Loader } from '../../../loader'; import { IPDetailsLink } from '../../../links'; @@ -56,7 +56,6 @@ export const HostOverview = React.memo( anomaliesData, narrowDateRange, }) => { - const [showInspect, setShowInspect] = useState(false); const capabilities = useContext(MlCapabilitiesContext); const userPermissions = hasMlUserPermissions(capabilities); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); @@ -165,32 +164,26 @@ export const HostOverview = React.memo( ], ]; - const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); - const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - return ( - - + + + - {descriptionLists.map((descriptionList, index) => - getDescriptionList(descriptionList, index) - )} + {descriptionLists.map((descriptionList, index) => + getDescriptionList(descriptionList, index) + )} - {loading && ( - - )} - + {loading && ( + + )} + + ); } ); diff --git a/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx index 0c4e594399517..901b82210a661 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/network/ip_overview/index.tsx @@ -7,7 +7,7 @@ import { EuiFlexItem } from '@elastic/eui'; import darkTheme from '@elastic/eui/dist/eui_theme_dark.json'; import lightTheme from '@elastic/eui/dist/eui_theme_light.json'; -import React, { useContext, useState, useCallback } from 'react'; +import React, { useContext } from 'react'; import { DEFAULT_DARK_MODE } from '../../../../../common/constants'; import { DescriptionList } from '../../../../../common/utility_types'; @@ -32,7 +32,7 @@ import { Anomalies, NarrowDateRange } from '../../../ml/types'; import { AnomalyScores } from '../../../ml/score/anomaly_scores'; import { MlCapabilitiesContext } from '../../../ml/permissions/ml_capabilities_provider'; import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions'; -import { InspectButton } from '../../../inspect'; +import { InspectButton, InspectButtonContainer } from '../../../inspect'; interface OwnProps { data: IpOverviewData; @@ -71,7 +71,6 @@ export const IpOverview = React.memo( anomaliesData, narrowDateRange, }) => { - const [showInspect, setShowInspect] = useState(false); const capabilities = useContext(MlCapabilitiesContext); const userPermissions = hasMlUserPermissions(capabilities); const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); @@ -140,32 +139,26 @@ export const IpOverview = React.memo( ], ]; - const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); - const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - return ( - - + + + - {descriptionLists.map((descriptionList, index) => - getDescriptionList(descriptionList, index) - )} + {descriptionLists.map((descriptionList, index) => + getDescriptionList(descriptionList, index) + )} - {loading && ( - - )} - + {loading && ( + + )} + + ); } ); diff --git a/x-pack/legacy/plugins/siem/public/components/page/overview/overview_host/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/overview/overview_host/index.tsx index 302917c3de93e..a70d9d0080271 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/overview/overview_host/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/overview/overview_host/index.tsx @@ -6,7 +6,7 @@ import { EuiButton, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { HeaderSection } from '../../../header_section'; import { manageQuery } from '../../../page/manage_query'; @@ -17,6 +17,7 @@ import { import { inputsModel } from '../../../../store/inputs'; import { OverviewHostStats } from '../overview_host_stats'; import { getHostsUrl } from '../../../link_to'; +import { InspectButtonContainer } from '../../../inspect'; export interface OwnProps { startDate: number; @@ -36,18 +37,14 @@ export interface OwnProps { const OverviewHostStatsManage = manageQuery(OverviewHostStats); type OverviewHostProps = OwnProps; -export const OverviewHost = React.memo(({ endDate, startDate, setQuery }) => { - const [isHover, setIsHover] = useState(false); - const handleMouseEnter = useCallback(() => setIsHover(true), [setIsHover]); - const handleMouseLeave = useCallback(() => setIsHover(false), [setIsHover]); - return ( - - +const OverviewHostComponent: React.FC = ({ endDate, startDate, setQuery }) => ( + + + (({ endDate, startDate, )} - - ); -}); + + +); -OverviewHost.displayName = 'OverviewHost'; +export const OverviewHost = React.memo(OverviewHostComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/page/overview/overview_network/index.tsx b/x-pack/legacy/plugins/siem/public/components/page/overview/overview_network/index.tsx index f60957cf7b485..af8c87ff38596 100644 --- a/x-pack/legacy/plugins/siem/public/components/page/overview/overview_network/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/page/overview/overview_network/index.tsx @@ -6,7 +6,7 @@ import { EuiButton, EuiFlexItem, EuiPanel } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React, { useState, useCallback } from 'react'; +import React from 'react'; import { HeaderSection } from '../../../header_section'; import { manageQuery } from '../../../page/manage_query'; @@ -17,6 +17,7 @@ import { import { inputsModel } from '../../../../store/inputs'; import { OverviewNetworkStats } from '../overview_network_stats'; import { getNetworkUrl } from '../../../link_to'; +import { InspectButtonContainer } from '../../../inspect'; export interface OwnProps { startDate: number; @@ -36,18 +37,13 @@ export interface OwnProps { const OverviewNetworkStatsManage = manageQuery(OverviewNetworkStats); -export const OverviewNetwork = React.memo(({ endDate, startDate, setQuery }) => { - const [isHover, setIsHover] = useState(false); - const handleMouseEnter = useCallback(() => setIsHover(true), [setIsHover]); - const handleMouseLeave = useCallback(() => setIsHover(false), [setIsHover]); - - return ( - - +const OverviewNetworkComponent: React.FC = ({ endDate, startDate, setQuery }) => ( + + + (({ endDate, startDate, setQu )} - - ); -}); + + +); -OverviewNetwork.displayName = 'OverviewNetwork'; +export const OverviewNetwork = React.memo(OverviewNetworkComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap index 9c3bf7b11e3ed..98c9fc202dd6b 100644 --- a/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/paginated_table/__snapshots__/index.test.tsx.snap @@ -520,7 +520,7 @@ exports[`Paginated Table Component rendering it renders the default load more ta } } > - { width?: string; } -export const PaginatedTable = memo( - ({ - activePage, - columns, - dataTestSubj = DEFAULT_DATA_TEST_SUBJ, - headerCount, - headerSupplement, - headerTitle, - headerTooltip, - headerUnit, - id, - isInspect, - itemsPerRow, - limit, - loading, - loadPage, - onChange = noop, - pageOfItems, - showMorePagesIndicator, - sorting = null, - totalCount, - updateActivePage, - updateLimitPagination, - }) => { - const [myLoading, setMyLoading] = useState(loading); - const [myActivePage, setActivePage] = useState(activePage); - const [showInspect, setShowInspect] = useState(false); - const [loadingInitial, setLoadingInitial] = useState(headerCount === -1); - const [isPopoverOpen, setPopoverOpen] = useState(false); - - const pageCount = Math.ceil(totalCount / limit); - const dispatchToaster = useStateToaster()[1]; - - useEffect(() => { - setActivePage(activePage); - }, [activePage]); - - useEffect(() => { - if (headerCount >= 0 && loadingInitial) { - setLoadingInitial(false); - } - }, [loadingInitial, headerCount]); - - useEffect(() => { - setMyLoading(loading); - }, [loading]); - - const onButtonClick = () => { - setPopoverOpen(!isPopoverOpen); - }; - - const closePopover = () => { - setPopoverOpen(false); - }; - - const goToPage = (newActivePage: number) => { - if ((newActivePage + 1) * limit >= DEFAULT_MAX_TABLE_QUERY_SIZE) { - const toast: Toast = { - id: 'PaginationWarningMsg', - title: headerTitle + i18n.TOAST_TITLE, - color: 'warning', - iconType: 'alert', - toastLifeTimeMs: 10000, - text: i18n.TOAST_TEXT, - }; - return dispatchToaster({ - type: 'addToaster', - toast, - }); - } - setActivePage(newActivePage); - loadPage(newActivePage); - updateActivePage(newActivePage); - }; - - const button = ( - - {`${i18n.ROWS}: ${limit}`} - - ); - - const rowItems = - itemsPerRow && - itemsPerRow.map((item: ItemsPerRow) => ( - { - closePopover(); - updateLimitPagination(item.numberOfRow); - updateActivePage(0); // reset results to first page - }} - > - {item.text} - - )); - const PaginationWrapper = showMorePagesIndicator ? PaginationEuiFlexItem : EuiFlexItem; - const handleOnMouseEnter = useCallback(() => setShowInspect(true), []); - const handleOnMouseLeave = useCallback(() => setShowInspect(false), []); - - return ( - = ({ + activePage, + columns, + dataTestSubj = DEFAULT_DATA_TEST_SUBJ, + headerCount, + headerSupplement, + headerTitle, + headerTooltip, + headerUnit, + id, + isInspect, + itemsPerRow, + limit, + loading, + loadPage, + onChange = noop, + pageOfItems, + showMorePagesIndicator, + sorting = null, + totalCount, + updateActivePage, + updateLimitPagination, +}) => { + const [myLoading, setMyLoading] = useState(loading); + const [myActivePage, setActivePage] = useState(activePage); + const [loadingInitial, setLoadingInitial] = useState(headerCount === -1); + const [isPopoverOpen, setPopoverOpen] = useState(false); + + const pageCount = Math.ceil(totalCount / limit); + const dispatchToaster = useStateToaster()[1]; + + useEffect(() => { + setActivePage(activePage); + }, [activePage]); + + useEffect(() => { + if (headerCount >= 0 && loadingInitial) { + setLoadingInitial(false); + } + }, [loadingInitial, headerCount]); + + useEffect(() => { + setMyLoading(loading); + }, [loading]); + + const onButtonClick = () => { + setPopoverOpen(!isPopoverOpen); + }; + + const closePopover = () => { + setPopoverOpen(false); + }; + + const goToPage = (newActivePage: number) => { + if ((newActivePage + 1) * limit >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + const toast: Toast = { + id: 'PaginationWarningMsg', + title: headerTitle + i18n.TOAST_TITLE, + color: 'warning', + iconType: 'alert', + toastLifeTimeMs: 10000, + text: i18n.TOAST_TEXT, + }; + return dispatchToaster({ + type: 'addToaster', + toast, + }); + } + setActivePage(newActivePage); + loadPage(newActivePage); + updateActivePage(newActivePage); + }; + + const button = ( + + {`${i18n.ROWS}: ${limit}`} + + ); + + const rowItems = + itemsPerRow && + itemsPerRow.map((item: ItemsPerRow) => ( + { + closePopover(); + updateLimitPagination(item.numberOfRow); + updateActivePage(0); // reset results to first page + }} > + {item.text} + + )); + const PaginationWrapper = showMorePagesIndicator ? PaginationEuiFlexItem : EuiFlexItem; + + return ( + + = 0 ? headerCount.toLocaleString() : 0} ${headerUnit}` @@ -306,11 +298,11 @@ export const PaginatedTable = memo( )} - ); - } -); + + ); +}; -PaginatedTable.displayName = 'PaginatedTable'; +export const PaginatedTable = memo(PaginatedTableComponent); type BasicTableType = ComponentType>; // eslint-disable-line @typescript-eslint/no-explicit-any const BasicTable: typeof EuiBasicTable & { displayName: string } = styled( diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap index 5ed750b519cbf..ca06c484dc8a2 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/__snapshots__/index.test.tsx.snap @@ -45,71 +45,63 @@ exports[`Stat Items Component disable charts it renders the default widget 1`] = className="euiFlexItem sc-AykKG krmHWP" data-test-subj="stat-item" > -
- +
- -
- -
- HOSTS -
-
-
-
-
- +
+ +
+ HOSTS +
+
+
+ + - - -
-
-
-
+ + +
+
- -
- - -
- - + +
-
-
- - +
-
- - + +
-

- - + +

- — - - - -

- - -
-
- + + + — + + + +

+
+
+
+ + +
+
-
-
- - + + +
+
+ +
+
-
- -
- +
- +
@@ -287,71 +279,63 @@ exports[`Stat Items Component disable charts it renders the default widget 2`] = className="euiFlexItem sc-AykKG krmHWP" data-test-subj="stat-item" > -
- +
- -
- -
- HOSTS -
-
-
-
-
- +
+ +
+ HOSTS +
+
+
+ + - - -
-
-
-
+ + +
+
- -
-
- -
- - + +
-
-
- 0 - - +
-
- - + +
-

- - + +

- — - - - -

- - -
-
- + + + — + + + +

+
+
+
+ + +
+
-
-
- - + + +
+
+ +
+
-
- -
- +
- +
@@ -599,71 +583,63 @@ exports[`Stat Items Component rendering kpis with charts it renders the default className="euiFlexItem sc-AykKG krmHWP" data-test-subj="stat-item" > -
- +
- -
- -
- UNIQUE_PRIVATE_IPS -
-
-
-
-
- +
+ +
+ UNIQUE_PRIVATE_IPS +
+
+
+ + - - -
-
-
-
+ + +
+
- -
-
- -
- - + +
-
-
- - -
- - - + - - -
-
-
- - -
- - + + + +
+
+
+ + +
-

- 1,714 - - Source -

- - -
-
-
+ + +

+ 1,714 + + Source +

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

- 2,359 - - Dest. -

- - -
-
-
+ + +

+ 2,359 + + Dest. +

+
+
+
+ +
+
+
- -
- - -
- - -
-
- -
- - + +
+
+ +
+
+ +
-
- + +
+ +
+ +
+ + + +
- -
-
-
- - -
- + -
- -
- - + } + } + > +
+ +
+ + +
+
- +
- +
diff --git a/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx b/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx index 2d081468599a2..a5c883ebd0e05 100644 --- a/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/stat_items/index.tsx @@ -21,7 +21,7 @@ import { IconType, } from '@elastic/eui'; import { get, getOr } from 'lodash/fp'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; import { KpiHostsData, KpiNetworkData } from '../../graphql/types'; @@ -30,7 +30,7 @@ import { BarChart } from '../charts/barchart'; import { ChartSeriesData, ChartData, ChartSeriesConfigs, UpdateDateRange } from '../charts/common'; import { getEmptyTagValue } from '../empty_value'; -import { InspectButton } from '../inspect'; +import { InspectButton, InspectButtonContainer } from '../inspect'; const FlexItem = styled(EuiFlexItem)` min-width: 0; @@ -209,9 +209,6 @@ export const StatItemsComponent = React.memo( statKey = 'item', to, }) => { - const [isHover, setIsHover] = useState(false); - const handleMouseEnter = useCallback(() => setIsHover(true), [setIsHover]); - const handleMouseLeave = useCallback(() => setIsHover(false), [setIsHover]); const isBarChartDataAvailable = barChart && barChart.length && @@ -223,72 +220,69 @@ export const StatItemsComponent = React.memo( return ( - - - - -
{description}
-
-
- - - -
+ + + + + +
{description}
+
+
+ + + +
- - {fields.map(field => ( - - - {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( - - - - )} + + {fields.map(field => ( + + + {(isAreaChartDataAvailable || isBarChartDataAvailable) && field.icon && ( + + + + )} - - -

- {field.value != null ? field.value.toLocaleString() : getEmptyTagValue()}{' '} - {field.description} -

-
-
-
-
- ))} -
+ + +

+ {field.value != null ? field.value.toLocaleString() : getEmptyTagValue()}{' '} + {field.description} +

+
+
+
+
+ ))} +
- {(enableAreaChart || enableBarChart) && } - - {enableBarChart && ( - - - - )} + {(enableAreaChart || enableBarChart) && } + + {enableBarChart && ( + + + + )} - {enableAreaChart && from != null && to != null && ( - - - - )} - -
+ {enableAreaChart && from != null && to != null && ( + + + + )} +
+ + ); } diff --git a/x-pack/legacy/plugins/siem/public/components/timeline/properties/properties_right.tsx b/x-pack/legacy/plugins/siem/public/components/timeline/properties/properties_right.tsx index 4027682282dcd..b21ab5063441e 100644 --- a/x-pack/legacy/plugins/siem/public/components/timeline/properties/properties_right.tsx +++ b/x-pack/legacy/plugins/siem/public/components/timeline/properties/properties_right.tsx @@ -17,7 +17,7 @@ import { import { NewTimeline, Description, NotesButton } from './helpers'; import { OpenTimelineModalButton } from '../../open_timeline/open_timeline_modal/open_timeline_modal_button'; import { OpenTimelineModal } from '../../open_timeline/open_timeline_modal'; -import { InspectButton } from '../../inspect'; +import { InspectButton, InspectButtonContainer } from '../../inspect'; import * as i18n from './translations'; import { AssociateNote } from '../../notes/helpers'; @@ -82,32 +82,32 @@ interface Props { updateNote: UpdateNote; } -export const PropertiesRight = React.memo( - ({ - onButtonClick, - showActions, - onClosePopover, - createTimeline, - timelineId, - isDataInTimeline, - showNotesFromWidth, - showNotes, - showDescription, - showUsersView, - usersViewing, - description, - updateDescription, - associateNote, - getNotesByIds, - noteIds, - onToggleShowNotes, - updateNote, - showTimelineModal, - onCloseTimelineModal, - onOpenTimelineModal, - }) => ( - - +const PropertiesRightComponent: React.FC = ({ + onButtonClick, + showActions, + onClosePopover, + createTimeline, + timelineId, + isDataInTimeline, + showNotesFromWidth, + showNotes, + showDescription, + showUsersView, + usersViewing, + description, + updateDescription, + associateNote, + getNotesByIds, + noteIds, + onToggleShowNotes, + updateNote, + showTimelineModal, + onCloseTimelineModal, + onOpenTimelineModal, +}) => ( + + + ( inspectIndex={0} isDisabled={!isDataInTimeline} onCloseInspect={onClosePopover} - show={true} title={i18n.INSPECT_TIMELINE_TITLE} /> @@ -177,26 +176,26 @@ export const PropertiesRight = React.memo( ) : null} - - - {showUsersView - ? usersViewing.map(user => ( - // Hide the hard-coded elastic user avatar as the 7.2 release does not implement - // support for multi-user-collaboration as proposed in elastic/ingest-dev#395 - - - - - - )) - : null} - - {showTimelineModal ? : null} - - ) + + + + {showUsersView + ? usersViewing.map(user => ( + // Hide the hard-coded elastic user avatar as the 7.2 release does not implement + // support for multi-user-collaboration as proposed in elastic/ingest-dev#395 + + + + + + )) + : null} + + {showTimelineModal ? : null} + ); -PropertiesRight.displayName = 'PropertiesRight'; +export const PropertiesRight = React.memo(PropertiesRightComponent); diff --git a/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx b/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx index 5ef5a5ab31d4b..9338eb9f0fabd 100644 --- a/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx +++ b/x-pack/legacy/plugins/siem/public/components/toasters/index.test.tsx @@ -8,7 +8,20 @@ import { cloneDeep, set } from 'lodash/fp'; import { mount } from 'enzyme'; import React, { useEffect } from 'react'; -import { AppToast, useStateToaster, ManageGlobalToaster, GlobalToaster } from '.'; +import { + AppToast, + useStateToaster, + ManageGlobalToaster, + GlobalToaster, + displayErrorToast, +} from '.'; + +jest.mock('uuid', () => { + return { + v1: jest.fn(() => '27261ae0-0bbb-11ea-b0ea-db767b07ea47'), + v4: jest.fn(() => '9e1f72a9-7c73-4b7f-a562-09940f7daf4a'), + }; +}); const mockToast: AppToast = { color: 'danger', @@ -270,4 +283,22 @@ describe('Toaster', () => { expect(wrapper.find('.euiToastHeader__title').text()).toBe('Test & Test'); }); }); + + describe('displayErrorToast', () => { + test('dispatches toast with correct title and message', () => { + const mockErrorToast = { + toast: { + color: 'danger', + errors: ['message'], + iconType: 'alert', + id: '9e1f72a9-7c73-4b7f-a562-09940f7daf4a', + title: 'Title', + }, + type: 'addToaster', + }; + const dispatchToasterMock = jest.fn(); + displayErrorToast('Title', ['message'], dispatchToasterMock); + expect(dispatchToasterMock.mock.calls[0][0]).toEqual(mockErrorToast); + }); + }); }); diff --git a/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx b/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx index 27d59d429913c..7098e618aeb55 100644 --- a/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx +++ b/x-pack/legacy/plugins/siem/public/components/toasters/index.tsx @@ -8,6 +8,7 @@ import { EuiGlobalToastList, EuiGlobalToastListToast as Toast, EuiButton } from import { noop } from 'lodash/fp'; import React, { createContext, Dispatch, useReducer, useContext, useState } from 'react'; import styled from 'styled-components'; +import uuid from 'uuid'; import { ModalAllErrors } from './modal_all_errors'; import * as i18n from './translations'; @@ -122,3 +123,28 @@ const ErrorToastContainer = styled.div` `; ErrorToastContainer.displayName = 'ErrorToastContainer'; + +/** + * Displays an error toast for the provided title and message + * + * @param errorTitle Title of error to display in toaster and modal + * @param errorMessages Message to display in error modal when clicked + * @param dispatchToaster provided by useStateToaster() + */ +export const displayErrorToast = ( + errorTitle: string, + errorMessages: string[], + dispatchToaster: React.Dispatch +) => { + const toast: AppToast = { + id: uuid.v4(), + title: errorTitle, + color: 'danger', + iconType: 'alert', + errors: errorMessages, + }; + dispatchToaster({ + type: 'addToaster', + toast, + }); +}; diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts index b69a8de29e047..8f8b66ae35a3b 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/api.ts @@ -16,6 +16,7 @@ import { Rule, FetchRuleProps, BasicFetchProps, + RuleError, } from './types'; import { throwIfNotOk } from '../../../hooks/api/api'; import { @@ -122,50 +123,50 @@ export const fetchRuleById = async ({ id, signal }: FetchRuleProps): Promise => { - const requests = ids.map(id => - fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}`, { + const response = await fetch( + `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + { method: 'PUT', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'kbn-xsrf': 'true', }, - body: JSON.stringify({ id, enabled }), - }) + body: JSON.stringify(ids.map(id => ({ id, enabled }))), + } ); - const responses = await Promise.all(requests); - await responses.map(response => throwIfNotOk(response)); - return Promise.all( - responses.map>(response => response.json()) - ); + await throwIfNotOk(response); + return response.json(); }; /** * Deletes provided Rule ID's * * @param ids array of Rule ID's (not rule_id) to delete + * + * @throws An error if response is not OK */ -export const deleteRules = async ({ ids }: DeleteRulesProps): Promise => { - // TODO: Don't delete if immutable! - const requests = ids.map(id => - fetch(`${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}?id=${id}`, { +export const deleteRules = async ({ ids }: DeleteRulesProps): Promise> => { + const response = await fetch( + `${chrome.getBasePath()}${DETECTION_ENGINE_RULES_URL}/_bulk_delete`, + { method: 'DELETE', credentials: 'same-origin', headers: { 'content-type': 'application/json', 'kbn-xsrf': 'true', }, - }) + body: JSON.stringify(ids.map(id => ({ id }))), + } ); - const responses = await Promise.all(requests); - await responses.map(response => throwIfNotOk(response)); - return Promise.all( - responses.map>(response => response.json()) - ); + await throwIfNotOk(response); + return response.json(); }; /** diff --git a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts index a329d96d444aa..147b04567f6c7 100644 --- a/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/containers/detection_engine/rules/types.ts @@ -78,9 +78,11 @@ export const RuleSchema = t.intersection([ updated_by: t.string, }), t.partial({ + output_index: t.string, saved_id: t.string, timeline_id: t.string, timeline_title: t.string, + version: t.number, }), ]); @@ -89,6 +91,16 @@ export const RulesSchema = t.array(RuleSchema); export type Rule = t.TypeOf; export type Rules = t.TypeOf; +export interface RuleError { + rule_id: string; + error: { status_code: number; message: string }; +} + +export interface RuleResponseBuckets { + rules: Rule[]; + errors: RuleError[]; +} + export interface PaginationOptions { page: number; perPage: number; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts new file mode 100644 index 0000000000000..3762cb0a4ba07 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -0,0 +1,154 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Rule, RuleError } from '../../../../../containers/detection_engine/rules'; +import { TableData } from '../../types'; + +export const mockRule = (id: string): Rule => ({ + created_at: '2020-01-10T21:11:45.839Z', + updated_at: '2020-01-10T21:11:45.839Z', + created_by: 'elastic', + description: '24/7', + enabled: true, + false_positives: [], + filters: [], + from: 'now-300s', + id, + immutable: false, + index: ['auditbeat-*'], + interval: '5m', + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + language: 'kuery', + output_index: '.siem-signals-default', + max_signals: 100, + risk_score: 21, + name: 'Home Grown!', + query: '', + references: [], + saved_id: "Garrett's IP", + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Untitled timeline', + meta: { from: '0m' }, + severity: 'low', + updated_by: 'elastic', + tags: [], + to: 'now', + type: 'saved_query', + threats: [], + version: 1, +}); + +export const mockRuleError = (id: string): RuleError => ({ + rule_id: id, + error: { status_code: 404, message: `id: "${id}" not found` }, +}); + +export const mockRules: Rule[] = [ + mockRule('abe6c564-050d-45a5-aaf0-386c37dd1f61'), + mockRule('63f06f34-c181-4b2d-af35-f2ace572a1ee'), +]; +export const mockTableData: TableData[] = [ + { + activate: true, + id: 'abe6c564-050d-45a5-aaf0-386c37dd1f61', + immutable: false, + isLoading: false, + lastCompletedRun: undefined, + lastResponse: { type: '—' }, + method: 'saved_query', + rule: { + href: '#/detection-engine/rules/id/abe6c564-050d-45a5-aaf0-386c37dd1f61', + name: 'Home Grown!', + status: 'Status Placeholder', + }, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + severity: 'low', + sourceRule: { + created_at: '2020-01-10T21:11:45.839Z', + created_by: 'elastic', + description: '24/7', + enabled: true, + false_positives: [], + filters: [], + from: 'now-300s', + id: 'abe6c564-050d-45a5-aaf0-386c37dd1f61', + immutable: false, + index: ['auditbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { from: '0m' }, + name: 'Home Grown!', + output_index: '.siem-signals-default', + query: '', + references: [], + risk_score: 21, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + saved_id: "Garrett's IP", + severity: 'low', + tags: [], + threats: [], + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Untitled timeline', + to: 'now', + type: 'saved_query', + updated_at: '2020-01-10T21:11:45.839Z', + updated_by: 'elastic', + version: 1, + }, + tags: [], + }, + { + activate: true, + id: '63f06f34-c181-4b2d-af35-f2ace572a1ee', + immutable: false, + isLoading: false, + lastCompletedRun: undefined, + lastResponse: { type: '—' }, + method: 'saved_query', + rule: { + href: '#/detection-engine/rules/id/63f06f34-c181-4b2d-af35-f2ace572a1ee', + name: 'Home Grown!', + status: 'Status Placeholder', + }, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + severity: 'low', + sourceRule: { + created_at: '2020-01-10T21:11:45.839Z', + created_by: 'elastic', + description: '24/7', + enabled: true, + false_positives: [], + filters: [], + from: 'now-300s', + id: '63f06f34-c181-4b2d-af35-f2ace572a1ee', + immutable: false, + index: ['auditbeat-*'], + interval: '5m', + language: 'kuery', + max_signals: 100, + meta: { from: '0m' }, + name: 'Home Grown!', + output_index: '.siem-signals-default', + query: '', + references: [], + risk_score: 21, + rule_id: 'b5ba41ab-aaf3-4f43-971b-bdf9434ce0ea', + saved_id: "Garrett's IP", + severity: 'low', + tags: [], + threats: [], + timeline_id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', + timeline_title: 'Untitled timeline', + to: 'now', + type: 'saved_query', + updated_at: '2020-01-10T21:11:45.839Z', + updated_by: 'elastic', + version: 1, + }, + tags: [], + }, +]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx index 469745262d944..24e3cfde1e448 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/actions.tsx @@ -5,7 +5,7 @@ */ import * as H from 'history'; -import React from 'react'; +import React, { Dispatch } from 'react'; import { DETECTION_ENGINE_PAGE_NAME } from '../../../../components/link_to/redirect_to_detection_engine'; import { @@ -16,40 +16,92 @@ import { } from '../../../../containers/detection_engine/rules'; import { Action } from './reducer'; +import { ActionToaster, displayErrorToast } from '../../../../components/toasters'; + +import * as i18n from '../translations'; +import { bucketRulesResponse } from './helpers'; + export const editRuleAction = (rule: Rule, history: H.History) => { history.push(`/${DETECTION_ENGINE_PAGE_NAME}/rules/id/${rule.id}/edit`); }; export const runRuleAction = () => {}; -export const duplicateRuleAction = async (rule: Rule, dispatch: React.Dispatch) => { - dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: true }); - const duplicatedRule = await duplicateRules({ rules: [rule] }); - dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: false }); - dispatch({ type: 'updateRules', rules: duplicatedRule, appendRuleId: rule.id }); +export const duplicateRuleAction = async ( + rule: Rule, + dispatch: React.Dispatch, + dispatchToaster: Dispatch +) => { + try { + dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: true }); + const duplicatedRule = await duplicateRules({ rules: [rule] }); + dispatch({ type: 'updateLoading', ids: [rule.id], isLoading: false }); + dispatch({ type: 'updateRules', rules: duplicatedRule, appendRuleId: rule.id }); + } catch (e) { + displayErrorToast(i18n.DUPLICATE_RULE_ERROR, [e.message], dispatchToaster); + } }; export const exportRulesAction = async (rules: Rule[], dispatch: React.Dispatch) => { dispatch({ type: 'setExportPayload', exportPayload: rules }); }; -export const deleteRulesAction = async (ids: string[], dispatch: React.Dispatch) => { - dispatch({ type: 'updateLoading', ids, isLoading: true }); - const deletedRules = await deleteRules({ ids }); - dispatch({ type: 'deleteRules', rules: deletedRules }); +export const deleteRulesAction = async ( + ids: string[], + dispatch: React.Dispatch, + dispatchToaster: Dispatch +) => { + try { + dispatch({ type: 'updateLoading', ids, isLoading: true }); + + const response = await deleteRules({ ids }); + const { rules, errors } = bucketRulesResponse(response); + + dispatch({ type: 'deleteRules', rules }); + + if (errors.length > 0) { + displayErrorToast( + i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ids.length), + errors.map(e => e.error.message), + dispatchToaster + ); + } + } catch (e) { + displayErrorToast( + i18n.BATCH_ACTION_DELETE_SELECTED_ERROR(ids.length), + [e.message], + dispatchToaster + ); + } }; export const enableRulesAction = async ( ids: string[], enabled: boolean, - dispatch: React.Dispatch + dispatch: React.Dispatch, + dispatchToaster: Dispatch ) => { + const errorTitle = enabled + ? i18n.BATCH_ACTION_ACTIVATE_SELECTED_ERROR(ids.length) + : i18n.BATCH_ACTION_DEACTIVATE_SELECTED_ERROR(ids.length); + try { dispatch({ type: 'updateLoading', ids, isLoading: true }); - const updatedRules = await enableRules({ ids, enabled }); - dispatch({ type: 'updateRules', rules: updatedRules }); - } catch { - // TODO Add error toast support to actions (and @throw jsdoc to api calls) + + const response = await enableRules({ ids, enabled }); + const { rules, errors } = bucketRulesResponse(response); + + dispatch({ type: 'updateRules', rules }); + + if (errors.length > 0) { + displayErrorToast( + errorTitle, + errors.map(e => e.error.message), + dispatchToaster + ); + } + } catch (e) { + displayErrorToast(errorTitle, [e.message], dispatchToaster); dispatch({ type: 'updateLoading', ids, isLoading: false }); } }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx index 72d38454ad9bc..3356ef101677d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/batch_actions.tsx @@ -5,20 +5,23 @@ */ import { EuiContextMenuItem } from '@elastic/eui'; -import React from 'react'; +import React, { Dispatch } from 'react'; import * as i18n from '../translations'; import { TableData } from '../types'; import { Action } from './reducer'; import { deleteRulesAction, enableRulesAction, exportRulesAction } from './actions'; +import { ActionToaster } from '../../../../components/toasters'; export const getBatchItems = ( selectedState: TableData[], - dispatch: React.Dispatch, + dispatch: Dispatch, + dispatchToaster: Dispatch, closePopover: () => void ) => { const containsEnabled = selectedState.some(v => v.activate); const containsDisabled = selectedState.some(v => !v.activate); const containsLoading = selectedState.some(v => v.isLoading); + const containsImmutable = selectedState.some(v => v.immutable); return [ { closePopover(); const deactivatedIds = selectedState.filter(s => !s.activate).map(s => s.id); - await enableRulesAction(deactivatedIds, true, dispatch); + await enableRulesAction(deactivatedIds, true, dispatch, dispatchToaster); }} > {i18n.BATCH_ACTION_ACTIVATE_SELECTED} @@ -40,7 +43,7 @@ export const getBatchItems = ( onClick={async () => { closePopover(); const activatedIds = selectedState.filter(s => s.activate).map(s => s.id); - await enableRulesAction(activatedIds, false, dispatch); + await enableRulesAction(activatedIds, false, dispatch, dispatchToaster); }} > {i18n.BATCH_ACTION_DEACTIVATE_SELECTED} @@ -72,12 +75,14 @@ export const getBatchItems = ( { closePopover(); await deleteRulesAction( selectedState.map(({ sourceRule: { id } }) => id), - dispatch + dispatch, + dispatchToaster ); }} > diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 95b9c9324894f..0c1804f26ecdd 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -8,7 +8,6 @@ import { EuiBadge, - EuiHealth, EuiIconTip, EuiLink, EuiTextColor, @@ -16,8 +15,7 @@ import { EuiTableActionsColumnType, } from '@elastic/eui'; import * as H from 'history'; -import React from 'react'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import React, { Dispatch } from 'react'; import { getEmptyTagValue } from '../../../../components/empty_value'; import { deleteRulesAction, @@ -32,8 +30,14 @@ import { TableData } from '../types'; import * as i18n from '../translations'; import { PreferenceFormattedDate } from '../../../../components/formatted_date'; import { RuleSwitch } from '../components/rule_switch'; +import { SeverityBadge } from '../components/severity_badge'; +import { ActionToaster } from '../../../../components/toasters'; -const getActions = (dispatch: React.Dispatch, history: H.History) => [ +const getActions = ( + dispatch: React.Dispatch, + dispatchToaster: Dispatch, + history: H.History +) => [ { description: i18n.EDIT_RULE_SETTINGS, icon: 'visControls', @@ -52,7 +56,8 @@ const getActions = (dispatch: React.Dispatch, history: H.History) => [ description: i18n.DUPLICATE_RULE, icon: 'copy', name: i18n.DUPLICATE_RULE, - onClick: (rowItem: TableData) => duplicateRuleAction(rowItem.sourceRule, dispatch), + onClick: (rowItem: TableData) => + duplicateRuleAction(rowItem.sourceRule, dispatch, dispatchToaster), }, { description: i18n.EXPORT_RULE, @@ -64,7 +69,8 @@ const getActions = (dispatch: React.Dispatch, history: H.History) => [ description: i18n.DELETE_RULE, icon: 'trash', name: i18n.DELETE_RULE, - onClick: (rowItem: TableData) => deleteRulesAction([rowItem.id], dispatch), + onClick: (rowItem: TableData) => deleteRulesAction([rowItem.id], dispatch, dispatchToaster), + enabled: (rowItem: TableData) => !rowItem.immutable, }, ]; @@ -73,6 +79,7 @@ type RulesColumns = EuiBasicTableColumn | EuiTableActionsColumnType, + dispatchToaster: Dispatch, history: H.History, hasNoPermissions: boolean ): RulesColumns[] => { @@ -92,21 +99,7 @@ export const getColumns = ( { field: 'severity', name: i18n.COLUMN_SEVERITY, - render: (value: TableData['severity']) => ( - - {value} - - ), + render: (value: TableData['severity']) => , truncateText: true, }, { @@ -179,7 +172,7 @@ export const getColumns = ( ]; const actions: RulesColumns[] = [ { - actions: getActions(dispatch, history), + actions: getActions(dispatch, dispatchToaster, history), width: '40px', } as EuiTableActionsColumnType, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx new file mode 100644 index 0000000000000..e925161444e42 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.test.tsx @@ -0,0 +1,61 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { bucketRulesResponse, formatRules } from './helpers'; +import { mockRule, mockRuleError, mockRules, mockTableData } from './__mocks__/mock'; +import uuid from 'uuid'; +import { Rule, RuleError } from '../../../../containers/detection_engine/rules'; + +describe('AllRulesTable Helpers', () => { + const mockRule1: Readonly = mockRule(uuid.v4()); + const mockRule2: Readonly = mockRule(uuid.v4()); + const mockRuleError1: Readonly = mockRuleError(uuid.v4()); + const mockRuleError2: Readonly = mockRuleError(uuid.v4()); + + describe('formatRules', () => { + test('formats rules with no selection', () => { + const formattedRules = formatRules(mockRules); + expect(formattedRules).toEqual(mockTableData); + }); + + test('formats rules with selection', () => { + const mockTableDataWithSelected = [...mockTableData]; + mockTableDataWithSelected[0].isLoading = true; + const formattedRules = formatRules(mockRules, [mockRules[0].id]); + expect(formattedRules).toEqual(mockTableDataWithSelected); + }); + }); + + describe('bucketRulesResponse', () => { + test('buckets empty response', () => { + const bucketedResponse = bucketRulesResponse([]); + expect(bucketedResponse).toEqual({ rules: [], errors: [] }); + }); + + test('buckets all error response', () => { + const bucketedResponse = bucketRulesResponse([mockRuleError1, mockRuleError2]); + expect(bucketedResponse).toEqual({ rules: [], errors: [mockRuleError1, mockRuleError2] }); + }); + + test('buckets all success response', () => { + const bucketedResponse = bucketRulesResponse([mockRule1, mockRule2]); + expect(bucketedResponse).toEqual({ rules: [mockRule1, mockRule2], errors: [] }); + }); + + test('buckets mixed success/error response', () => { + const bucketedResponse = bucketRulesResponse([ + mockRule1, + mockRuleError1, + mockRule2, + mockRuleError2, + ]); + expect(bucketedResponse).toEqual({ + rules: [mockRule1, mockRule2], + errors: [mockRuleError1, mockRuleError2], + }); + }); + }); +}); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts index f5d3955314242..b18938920082d 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/helpers.ts @@ -4,13 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Rule } from '../../../../containers/detection_engine/rules'; +import { + Rule, + RuleError, + RuleResponseBuckets, +} from '../../../../containers/detection_engine/rules'; import { TableData } from '../types'; import { getEmptyValue } from '../../../../components/empty_value'; +/** + * Formats rules into the correct format for the AllRulesTable + * + * @param rules as returned from the Rules API + * @param selectedIds ids of the currently selected rules + */ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] => rules.map(rule => ({ id: rule.id, + immutable: rule.immutable, rule_id: rule.rule_id, rule: { href: `#/detection-engine/rules/id/${encodeURIComponent(rule.id)}`, @@ -28,3 +39,18 @@ export const formatRules = (rules: Rule[], selectedIds?: string[]): TableData[] sourceRule: rule, isLoading: selectedIds?.includes(rule.id) ?? false, })); + +/** + * Separates rules/errors from bulk rules API response (create/update/delete) + * + * @param response Array from bulk rules API + */ +export const bucketRulesResponse = (response: Array) => + response.reduce( + (acc, cv): RuleResponseBuckets => { + return 'error' in cv + ? { rules: [...acc.rules], errors: [...acc.errors, cv] } + : { rules: [...acc.rules, cv], errors: [...acc.errors] }; + }, + { rules: [], errors: [] } + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index e900058b6c53c..202be75f09e69 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -84,11 +84,35 @@ export const AllRules = React.memo<{ const getBatchItemsPopoverContent = useCallback( (closePopover: () => void) => ( - + ), - [selectedItems, dispatch] + [selectedItems, dispatch, dispatchToaster] + ); + + const tableOnChangeCallback = useCallback( + ({ page, sort }: EuiBasicTableOnChange) => { + dispatch({ + type: 'updatePagination', + pagination: { ...pagination, page: page.index + 1, perPage: page.size }, + }); + dispatch({ + type: 'updateFilterOptions', + filterOptions: { + ...filterOptions, + sortField: 'enabled', // Only enabled is supported for sorting currently + sortOrder: sort?.direction ?? 'desc', + }, + }); + }, + [dispatch, filterOptions, pagination] ); + const columns = useMemo(() => { + return getColumns(dispatch, dispatchToaster, history, hasNoPermissions); + }, [dispatch, dispatchToaster, history]); + useEffect(() => { dispatch({ type: 'loading', isLoading: isLoadingRules }); @@ -195,29 +219,16 @@ export const AllRules = React.memo<{ { - dispatch({ - type: 'updatePagination', - pagination: { ...pagination, page: page.index + 1, perPage: page.size }, - }); - dispatch({ - type: 'updateFilterOptions', - filterOptions: { - ...filterOptions, - sortField: 'enabled', // Only enabled is supported for sorting currently - sortOrder: sort!.direction, - }, - }); - }} + onChange={tableOnChangeCallback} pagination={{ pageIndex: pagination.page - 1, pageSize: pagination.perPage, totalItemCount: pagination.total, - pageSizeOptions: [5, 10, 20], + pageSizeOptions: [5, 10, 20, 50, 100, 200, 300], }} sorting={{ sort: { field: 'activate', direction: filterOptions.sortOrder } }} selection={hasNoPermissions ? undefined : euiBasicTableSelectionProps} diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx index b3cc81b5cdfcf..0c75da7d8a632 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/add_item_form/index.tsx @@ -37,6 +37,25 @@ const MyEuiFormRow = styled(EuiFormRow)` } `; +export const MyAddItemButton = styled(EuiButtonEmpty)` + margin-top: 4px; + + &.euiButtonEmpty--xSmall { + font-size: 12px; + } + + .euiIcon { + width: 12px; + height: 12px; + } +`; + +MyAddItemButton.defaultProps = { + flush: 'left', + iconType: 'plusInCircle', + size: 'xs', +}; + export const AddItem = ({ addText, dataTestSubj, @@ -160,9 +179,9 @@ export const AddItem = ({ ); })} - + {addText} - + ); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/assets/list_tree_icon.svg b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/assets/list_tree_icon.svg new file mode 100644 index 0000000000000..527d8d445bc03 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/assets/list_tree_icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx index 09d0c1131ea10..e8b6919165c8b 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/helpers.tsx @@ -9,12 +9,10 @@ import { EuiLoadingSpinner, EuiFlexGroup, EuiFlexItem, - EuiHealth, EuiLink, - EuiText, - EuiListGroup, + EuiButtonEmpty, + EuiSpacer, } from '@elastic/eui'; -import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import { isEmpty } from 'lodash/fp'; import React from 'react'; @@ -27,6 +25,11 @@ import { tacticsOptions, techniquesOptions } from '../../../mitre/mitre_tactics_ import { FilterLabel } from './filter_label'; import * as i18n from './translations'; import { BuildQueryBarDescription, BuildThreatsDescription, ListItems } from './types'; +import { SeverityBadge } from '../severity_badge'; +import ListTreeIcon from './assets/list_tree_icon.svg'; + +const isNotEmptyArray = (values: string[]) => + !isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0; const EuiBadgeWrap = styled(EuiBadge)` .euiBadge__text { @@ -97,10 +100,17 @@ const ThreatsEuiFlexGroup = styled(EuiFlexGroup)` } `; -const MyEuiListGroup = styled(EuiListGroup)` - padding: 0px; - .euiListGroupItem__button { - padding: 0px; +const TechniqueLinkItem = styled(EuiButtonEmpty)` + .euiIcon { + width: 8px; + height: 8px; + } +`; + +const ReferenceLinkItem = styled(EuiButtonEmpty)` + .euiIcon { + width: 12px; + height: 12px; } `; @@ -118,28 +128,31 @@ export const buildThreatsDescription = ({ const tactic = tacticsOptions.find(t => t.name === threat.tactic.name); return ( - -
- - {tactic != null ? tactic.text : ''} - -
- { - const myTechnique = techniquesOptions.find(t => t.name === technique.name); - return { - label: myTechnique != null ? myTechnique.label : '', - href: technique.reference, - target: '_blank', - }; - })} - /> -
+ + {tactic != null ? tactic.text : ''} + + + {threat.techniques.map(technique => { + const myTechnique = techniquesOptions.find(t => t.name === technique.name); + return ( + + + {myTechnique != null ? myTechnique.label : ''} + + + ); + })} +
); })} + ), }, @@ -148,12 +161,34 @@ export const buildThreatsDescription = ({ return []; }; +export const buildUnorderedListArrayDescription = ( + label: string, + field: string, + values: string[] +): ListItems[] => { + if (isNotEmptyArray(values)) { + return [ + { + title: label, + description: ( +
    + {values.map((val: string) => + isEmpty(val) ? null :
  • {val}
  • + )} +
+ ), + }, + ]; + } + return []; +}; + export const buildStringArrayDescription = ( label: string, field: string, values: string[] ): ListItems[] => { - if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + if (isNotEmptyArray(values)) { return [ { title: label, @@ -174,46 +209,34 @@ export const buildStringArrayDescription = ( return []; }; -export const buildSeverityDescription = (label: string, value: string): ListItems[] => { - return [ - { - title: label, - description: ( - - {value} - - ), - }, - ]; -}; +export const buildSeverityDescription = (label: string, value: string): ListItems[] => [ + { + title: label, + description: , + }, +]; export const buildUrlsDescription = (label: string, values: string[]): ListItems[] => { - if (!isEmpty(values) && values.filter(val => !isEmpty(val)).length > 0) { + if (isNotEmptyArray(values)) { return [ { title: label, description: ( - ({ - label: val, - href: val, - iconType: 'link', - size: 'xs', - target: '_blank', - }))} - /> + + {values.map((val: string) => ( + + + {val} + + + ))} + ), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx index af4f93c0fdbcd..8cf1601e2c4b6 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/description_step/index.tsx @@ -4,10 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem, EuiTextArea } from '@elastic/eui'; +import { EuiDescriptionList, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEmpty, chunk, get, pick } from 'lodash/fp'; import React, { memo, useState } from 'react'; -import styled from 'styled-components'; import { IIndexPattern, @@ -26,6 +25,7 @@ import { buildSeverityDescription, buildStringArrayDescription, buildThreatsDescription, + buildUnorderedListArrayDescription, buildUrlsDescription, } from './helpers'; @@ -36,15 +36,6 @@ interface StepRuleDescriptionProps { schema: FormSchema; } -const EuiFlexItemWidth = styled(EuiFlexItem)<{ direction: string }>` - ${props => (props.direction === 'row' ? 'width : 50%;' : 'width: 100%;')}; -`; - -const MyEuiTextArea = styled(EuiTextArea)` - max-width: 100%; - height: 80px; -`; - const StepRuleDescriptionComponent: React.FC = ({ data, direction = 'row', @@ -62,13 +53,24 @@ const StepRuleDescriptionComponent: React.FC = ({ ], [] ); + + if (direction === 'row') { + return ( + + {chunk(Math.ceil(listItems.length / 2), listItems).map((chunkListItems, index) => ( + + + + ))} + + ); + } + return ( - - {chunk(Math.ceil(listItems.length / 2), listItems).map((chunkListItems, index) => ( - - - - ))} + + + + ); }; @@ -123,18 +125,28 @@ const getDescriptionItem = ( return [ { title: label, - description: , + description: get(field, value), }, ]; } else if (field === 'references') { const urls: string[] = get(field, value); return buildUrlsDescription(label, urls); + } else if (field === 'falsePositives') { + const values: string[] = get(field, value); + return buildUnorderedListArrayDescription(label, field, values); } else if (Array.isArray(get(field, value))) { const values: string[] = get(field, value); return buildStringArrayDescription(label, field, values); } else if (field === 'severity') { const val: string = get(field, value); return buildSeverityDescription(label, val); + } else if (field === 'riskScore') { + return [ + { + title: label, + description: get(field, value), + }, + ]; } else if (field === 'timeline') { const timeline = get(field, value) as FieldValueTimeline; return [ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx index 2c19e99e90114..f9a22c37cfdf0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/mitre/index.tsx @@ -5,7 +5,6 @@ */ import { - EuiButtonEmpty, EuiButtonIcon, EuiFormRow, EuiSuperSelect, @@ -24,6 +23,7 @@ import * as Rulei18n from '../../translations'; import { FieldHook, getFieldValidityAndErrorMessage } from '../shared_imports'; import { threatsDefault } from '../step_about_rule/default_value'; import { IMitreEnterpriseAttack } from '../../types'; +import { MyAddItemButton } from '../add_item_form'; import { isMitreAttackInvalid } from './helpers'; import * as i18n from './translations'; @@ -134,13 +134,19 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI const getSelectTechniques = (item: IMitreEnterpriseAttack, index: number, disabled: boolean) => { const invalid = isMitreAttackInvalid(item.tactic.name, item.techniques); + const options = techniquesOptions.filter(t => t.tactics.includes(kebabCase(item.tactic.name))); + const selectedOptions = item.techniques.map(technic => ({ + ...technic, + label: `${technic.name} (${technic.id})`, // API doesn't allow for label field + })); + return ( t.tactics.includes(kebabCase(item.tactic.name)))} - selectedOptions={item.techniques} + options={options} + selectedOptions={selectedOptions} onChange={updateTechniques.bind(null, index)} isDisabled={disabled || item.tactic.name === 'none'} fullWidth={true} @@ -202,9 +208,9 @@ export const AddMitreThreat = ({ dataTestSubj, field, idAria, isDisabled }: AddI {values.length - 1 !== index && } ))} - + {i18n.ADD_MITRE_ATTACK} - + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.tsx new file mode 100644 index 0000000000000..0dab87b0a3b74 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/optional_field_label/index.tsx @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiText } from '@elastic/eui'; +import React from 'react'; + +import * as RuleI18n from '../../translations'; + +export const OptionalFieldLabel = ( + + {RuleI18n.OPTIONAL_FIELD} + +); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx index 3e39beb6e61b7..46a7a13ec03f1 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/query_bar/index.tsx @@ -51,7 +51,7 @@ interface QueryBarDefineRuleProps { const StyledEuiFormRow = styled(EuiFormRow)` .kbnTypeahead__items { - max-height: 14vh !important; + max-height: 45vh !important; } .globalQueryBar { padding: 4px 0px 0px 0px; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx index 09be3df7d6929..9cb0323ed8987 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/rule_switch/index.tsx @@ -18,6 +18,7 @@ import React, { useCallback, useState, useEffect } from 'react'; import { enableRules } from '../../../../../containers/detection_engine/rules'; import { enableRulesAction } from '../../all/actions'; import { Action } from '../../all/reducer'; +import { useStateToaster } from '../../../../../components/toasters'; const StaticSwitch = styled(EuiSwitch)` .euiSwitch__thumb, @@ -50,12 +51,13 @@ export const RuleSwitchComponent = ({ }: RuleSwitchProps) => { const [myIsLoading, setMyIsLoading] = useState(false); const [myEnabled, setMyEnabled] = useState(enabled ?? false); + const [, dispatchToaster] = useStateToaster(); const onRuleStateChange = useCallback( async (event: EuiSwitchEvent) => { setMyIsLoading(true); if (dispatch != null) { - await enableRulesAction([id], event.target.checked!, dispatch); + await enableRulesAction([id], event.target.checked!, dispatch, dispatchToaster); } else { try { const updatedRules = await enableRules({ diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx index 8097c27cddfe8..fa4bea319f859 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/schedule_item_form/index.tsx @@ -4,7 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiFieldNumber, EuiFormRow, EuiSelect } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFieldNumber, + EuiFormRow, + EuiSelect, + EuiFormControlLayout, +} from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import styled from 'styled-components'; @@ -26,10 +33,28 @@ const timeTypeOptions = [ { value: 'h', text: I18n.HOURS }, ]; +// move optional label to the end of input +const StyledLabelAppend = styled(EuiFlexItem)` + &.euiFlexItem.euiFlexItem--flexGrowZero { + margin-left: 31px; + } +`; + const StyledEuiFormRow = styled(EuiFormRow)` + max-width: none; + .euiFormControlLayout { max-width: 200px !important; } + + .euiFormControlLayout__childrenWrapper > *:first-child { + box-shadow: none; + height: 38px; + } + + .euiFormControlLayout:not(:first-child) { + border-left: 1px solid ${({ theme }) => theme.eui.euiColorLightShade}; + } `; const MyEuiSelect = styled(EuiSelect)` @@ -89,9 +114,9 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu {field.label} - + {field.labelAppend} - + ), [field.label, field.labelAppend] @@ -107,7 +132,7 @@ export const ScheduleItem = ({ dataTestSubj, field, idAria, isDisabled }: Schedu data-test-subj={dataTestSubj} describedByIds={idAria ? [idAria] : undefined} > - } - fullWidth - min={0} - onChange={onChangeTimeVal} - value={timeVal} - {...rest} - /> + > + + ); }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.tsx new file mode 100644 index 0000000000000..09c02dfca56f9 --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/severity_badge/index.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { upperFirst } from 'lodash/fp'; +import React from 'react'; +import { EuiHealth } from '@elastic/eui'; +import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; + +interface Props { + value: string; +} + +const SeverityBadgeComponent: React.FC = ({ value }) => ( + + {upperFirst(value)} + +); + +export const SeverityBadge = React.memo(SeverityBadgeComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx index 9fb64189ebd1a..269d2d4509508 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/data.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import styled from 'styled-components'; import { EuiHealth } from '@elastic/eui'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; @@ -16,22 +17,30 @@ interface SeverityOptionItem { inputDisplay: React.ReactElement; } +const StyledEuiHealth = styled(EuiHealth)` + line-height: inherit; +`; + export const severityOptions: SeverityOptionItem[] = [ { value: 'low', - inputDisplay: {I18n.LOW}, + inputDisplay: {I18n.LOW}, }, { value: 'medium', - inputDisplay: {I18n.MEDIUM} , + inputDisplay: ( + {I18n.MEDIUM} + ), }, { value: 'high', - inputDisplay: {I18n.HIGH} , + inputDisplay: {I18n.HIGH}, }, { value: 'critical', - inputDisplay: {I18n.CRITICAL} , + inputDisplay: ( + {I18n.CRITICAL} + ), }, ]; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx index 8956776dcd3b2..0e03a11776fb7 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/index.tsx @@ -6,7 +6,7 @@ import { EuiButton, EuiHorizontalRule, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { isEqual, get } from 'lodash/fp'; -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import styled from 'styled-components'; import { RuleStepProps, RuleStep, AboutStepRule } from '../../types'; @@ -22,6 +22,7 @@ import { isUrlInvalid } from './helpers'; import { schema } from './schema'; import * as I18n from './translations'; import { PickTimeline } from '../pick_timeline'; +import { StepContentWrapper } from '../step_content_wrapper'; const CommonUseField = getUseField({ component: Field }); @@ -33,64 +34,67 @@ const TagContainer = styled.div` margin-top: 16px; `; -export const StepAboutRule = memo( - ({ - defaultValues, - descriptionDirection = 'row', - isReadOnlyView, - isUpdateView = false, - isLoading, - setForm, - setStepData, - }) => { - const [myStepData, setMyStepData] = useState(stepAboutDefaultValue); +const StepAboutRuleComponent: FC = ({ + addPadding = false, + defaultValues, + descriptionDirection = 'row', + isReadOnlyView, + isUpdateView = false, + isLoading, + setForm, + setStepData, +}) => { + const [myStepData, setMyStepData] = useState(stepAboutDefaultValue); - const { form } = useForm({ - defaultValue: myStepData, - options: { stripEmptyFields: false }, - schema, - }); + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.aboutRule, null, false); - const { isValid, data } = await form.submit(); - if (isValid) { - setStepData(RuleStep.aboutRule, data, isValid); - setMyStepData({ ...data, isNew: false } as AboutStepRule); - } + const onSubmit = useCallback(async () => { + if (setStepData) { + setStepData(RuleStep.aboutRule, null, false); + const { isValid, data } = await form.submit(); + if (isValid) { + setStepData(RuleStep.aboutRule, data, isValid); + setMyStepData({ ...data, isNew: false } as AboutStepRule); } - }, [form]); + } + }, [form]); - useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - if (!isReadOnlyView) { - Object.keys(schema).forEach(key => { - const val = get(key, myDefaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); - } + useEffect(() => { + const { isNew, ...initDefaultValue } = myStepData; + if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { + const myDefaultValues = { + ...defaultValues, + isNew: false, + }; + setMyStepData(myDefaultValues); + if (!isReadOnlyView) { + Object.keys(schema).forEach(key => { + const val = get(key, myDefaultValues); + if (val != null) { + form.setFieldValue(key, val); + } + }); } - }, [defaultValues]); + } + }, [defaultValues]); - useEffect(() => { - if (setForm != null) { - setForm(RuleStep.aboutRule, form); - } - }, [form]); + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.aboutRule, form); + } + }, [form]); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView && myStepData != null ? ( + - ) : ( - <> + + ) : ( + <> +
( }} - {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - - )} - - ); - } -); +
+ {!isUpdateView && ( + <> + + + + + {RuleI18n.CONTINUE} + + + + + )} + + ); +}; + +export const StepAboutRule = memo(StepAboutRuleComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx index 008a1b48610d6..3de0e7605f3d9 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_about_rule/schema.tsx @@ -4,11 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiText } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React from 'react'; -import * as RuleI18n from '../../translations'; import { IMitreEnterpriseAttack } from '../../types'; import { FIELD_TYPES, @@ -18,6 +15,7 @@ import { ERROR_CODE, } from '../shared_imports'; import { isMitreAttackInvalid } from '../mitre/helpers'; +import { OptionalFieldLabel } from '../optional_field_label'; import { isUrlInvalid } from './helpers'; import * as I18n from './translations'; @@ -108,7 +106,7 @@ export const schema: FormSchema = { defaultMessage: 'Reference URLs', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, validations: [ { validator: ( @@ -136,10 +134,10 @@ export const schema: FormSchema = { label: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepAboutRule.fieldFalsePositiveLabel', { - defaultMessage: 'False positives examples', + defaultMessage: 'False positive examples', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, }, threats: { label: i18n.translate( @@ -148,7 +146,7 @@ export const schema: FormSchema = { defaultMessage: 'MITRE ATT&CK\\u2122', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, validations: [ { validator: ( @@ -184,6 +182,6 @@ export const schema: FormSchema = { 'Type one or more custom identifying tags for this rule. Press enter after each tag to begin a new one.', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, }, }; diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx new file mode 100644 index 0000000000000..b04a321dab05b --- /dev/null +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_content_wrapper/index.tsx @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import styled from 'styled-components'; + +const StyledDiv = styled.div<{ addPadding: boolean }>` + padding-left: ${({ addPadding }) => addPadding && '53px'}; /* to align with the step title */ +`; + +StyledDiv.defaultProps = { + addPadding: false, +}; + +export const StepContentWrapper = React.memo(StyledDiv); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index ecd2ce442238f..6bdef4a69af1e 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -12,7 +12,8 @@ import { EuiButton, } from '@elastic/eui'; import { isEmpty, isEqual, get } from 'lodash/fp'; -import React, { memo, useCallback, useState, useEffect } from 'react'; +import React, { FC, memo, useCallback, useState, useEffect } from 'react'; +import styled from 'styled-components'; import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; @@ -22,6 +23,7 @@ import * as RuleI18n from '../../translations'; import { DefineStepRule, RuleStep, RuleStepProps } from '../../types'; import { StepRuleDescription } from '../description_step'; import { QueryBarDefineRule } from '../query_bar'; +import { StepContentWrapper } from '../step_content_wrapper'; import { Field, Form, FormDataProvider, getUseField, UseField, useForm } from '../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; @@ -42,6 +44,20 @@ const stepDefineDefaultValue = { }, }; +const MyLabelButton = styled(EuiButtonEmpty)` + height: 18px; + font-size: 12px; + + .euiIcon { + width: 14px; + height: 14px; + } +`; + +MyLabelButton.defaultProps = { + flush: 'right', +}; + const getStepDefaultValue = ( indicesConfig: string[], defaultValues: DefineStepRule | null @@ -59,106 +75,104 @@ const getStepDefaultValue = ( } }; -export const StepDefineRule = memo( - ({ - defaultValues, - descriptionDirection = 'row', - isReadOnlyView, - isLoading, - isUpdateView = false, - resizeParentContainer, - setForm, - setStepData, - }) => { - const [openTimelineSearch, setOpenTimelineSearch] = useState(false); - const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); - const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); - const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( - defaultValues != null ? defaultValues.index : indicesConfig ?? [] - ); - const [ - { - browserFields, - indexPatterns: indexPatternQueryBar, - isLoading: indexPatternLoadingQueryBar, - }, - ] = useFetchIndexPatterns(mylocalIndicesConfig); - const [myStepData, setMyStepData] = useState( - getStepDefaultValue(indicesConfig, null) - ); - - const { form } = useForm({ - defaultValue: myStepData, - options: { stripEmptyFields: false }, - schema, - }); - - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.defineRule, null, false); - const { isValid, data } = await form.submit(); - if (isValid && setStepData) { - setStepData(RuleStep.defineRule, data, isValid); - setMyStepData({ ...data, isNew: false } as DefineStepRule); - } +const StepDefineRuleComponent: FC = ({ + addPadding = false, + defaultValues, + descriptionDirection = 'row', + isReadOnlyView, + isLoading, + isUpdateView = false, + setForm, + setStepData, +}) => { + const [openTimelineSearch, setOpenTimelineSearch] = useState(false); + const [localUseIndicesConfig, setLocalUseIndicesConfig] = useState(false); + const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); + const [mylocalIndicesConfig, setMyLocalIndicesConfig] = useState( + defaultValues != null ? defaultValues.index : indicesConfig ?? [] + ); + const [ + { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, + ] = useFetchIndexPatterns(mylocalIndicesConfig); + const [myStepData, setMyStepData] = useState( + getStepDefaultValue(indicesConfig, null) + ); + + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); + + const onSubmit = useCallback(async () => { + if (setStepData) { + setStepData(RuleStep.defineRule, null, false); + const { isValid, data } = await form.submit(); + if (isValid && setStepData) { + setStepData(RuleStep.defineRule, data, isValid); + setMyStepData({ ...data, isNew: false } as DefineStepRule); } - }, [form]); - - useEffect(() => { - if (indicesConfig != null && defaultValues != null) { - const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); - if (!isEqual(myDefaultValues, myStepData)) { - setMyStepData(myDefaultValues); - setLocalUseIndicesConfig(isEqual(myDefaultValues.index, indicesConfig)); - if (!isReadOnlyView) { - Object.keys(schema).forEach(key => { - const val = get(key, myDefaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); - } + } + }, [form]); + + useEffect(() => { + if (indicesConfig != null && defaultValues != null) { + const myDefaultValues = getStepDefaultValue(indicesConfig, defaultValues); + if (!isEqual(myDefaultValues, myStepData)) { + setMyStepData(myDefaultValues); + setLocalUseIndicesConfig(isEqual(myDefaultValues.index, indicesConfig)); + if (!isReadOnlyView) { + Object.keys(schema).forEach(key => { + const val = get(key, myDefaultValues); + if (val != null) { + form.setFieldValue(key, val); + } + }); } } - }, [defaultValues, indicesConfig]); + } + }, [defaultValues, indicesConfig]); - useEffect(() => { - if (setForm != null) { - setForm(RuleStep.defineRule, form); - } - }, [form]); + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.defineRule, form); + } + }, [form]); - const handleResetIndices = useCallback(() => { - const indexField = form.getFields().index; - indexField.setValue(indicesConfig); - }, [form, indicesConfig]); + const handleResetIndices = useCallback(() => { + const indexField = form.getFields().index; + indexField.setValue(indicesConfig); + }, [form, indicesConfig]); - const handleOpenTimelineSearch = useCallback(() => { - setOpenTimelineSearch(true); - }, []); + const handleOpenTimelineSearch = useCallback(() => { + setOpenTimelineSearch(true); + }, []); - const handleCloseTimelineSearch = useCallback(() => { - setOpenTimelineSearch(false); - }, []); + const handleCloseTimelineSearch = useCallback(() => { + setOpenTimelineSearch(false); + }, []); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView && myStepData != null ? ( + - ) : ( - <> + + ) : ( + <> +
- {i18n.RESET_DEFAULT_INDEX} - + + {i18n.RESET_DEFAULT_INDEX} + ) : null, }} componentProps={{ @@ -176,9 +190,9 @@ export const StepDefineRule = memo( config={{ ...schema.queryBar, labelAppend: ( - - {i18n.IMPORT_TIMELINE_QUERY} - + + {i18n.IMPORT_TIMELINE_QUERY} + ), }} component={QueryBarDefineRule} @@ -192,7 +206,6 @@ export const StepDefineRule = memo( dataTestSubj: 'detectionEngineStepDefineRuleQueryBar', openTimelineSearch, onCloseTimelineSearch: handleCloseTimelineSearch, - resizeParentContainer, }} /> @@ -212,24 +225,26 @@ export const StepDefineRule = memo( }} - {!isUpdateView && ( - <> - - - - - {RuleI18n.CONTINUE} - - - - - )} - - ); - } -); +
+ {!isUpdateView && ( + <> + + + + + {RuleI18n.CONTINUE} + + + + + )} + + ); +}; + +export const StepDefineRule = memo(StepDefineRuleComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx index 35b8ca6650bf6..b99201abe8777 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/index.tsx @@ -6,12 +6,13 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; import { isEqual, get } from 'lodash/fp'; -import React, { memo, useCallback, useEffect, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useState } from 'react'; import { RuleStep, RuleStepProps, ScheduleStepRule } from '../../types'; import { StepRuleDescription } from '../description_step'; import { ScheduleItem } from '../schedule_item_form'; import { Form, UseField, useForm } from '../shared_imports'; +import { StepContentWrapper } from '../step_content_wrapper'; import { schema } from './schema'; import * as I18n from './translations'; @@ -26,67 +27,70 @@ const stepScheduleDefaultValue = { from: '0m', }; -export const StepScheduleRule = memo( - ({ - defaultValues, - descriptionDirection = 'row', - isReadOnlyView, - isLoading, - isUpdateView = false, - setStepData, - setForm, - }) => { - const [myStepData, setMyStepData] = useState(stepScheduleDefaultValue); +const StepScheduleRuleComponent: FC = ({ + addPadding = false, + defaultValues, + descriptionDirection = 'row', + isReadOnlyView, + isLoading, + isUpdateView = false, + setStepData, + setForm, +}) => { + const [myStepData, setMyStepData] = useState(stepScheduleDefaultValue); - const { form } = useForm({ - defaultValue: myStepData, - options: { stripEmptyFields: false }, - schema, - }); + const { form } = useForm({ + defaultValue: myStepData, + options: { stripEmptyFields: false }, + schema, + }); - const onSubmit = useCallback( - async (enabled: boolean) => { - if (setStepData) { - setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await form.submit(); - if (newIsValid) { - setStepData(RuleStep.scheduleRule, { ...data, enabled }, newIsValid); - setMyStepData({ ...data, isNew: false } as ScheduleStepRule); - } - } - }, - [form] - ); - - useEffect(() => { - const { isNew, ...initDefaultValue } = myStepData; - if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { - const myDefaultValues = { - ...defaultValues, - isNew: false, - }; - setMyStepData(myDefaultValues); - if (!isReadOnlyView) { - Object.keys(schema).forEach(key => { - const val = get(key, myDefaultValues); - if (val != null) { - form.setFieldValue(key, val); - } - }); + const onSubmit = useCallback( + async (enabled: boolean) => { + if (setStepData) { + setStepData(RuleStep.scheduleRule, null, false); + const { isValid: newIsValid, data } = await form.submit(); + if (newIsValid) { + setStepData(RuleStep.scheduleRule, { ...data, enabled }, newIsValid); + setMyStepData({ ...data, isNew: false } as ScheduleStepRule); } } - }, [defaultValues]); + }, + [form] + ); - useEffect(() => { - if (setForm != null) { - setForm(RuleStep.scheduleRule, form); + useEffect(() => { + const { isNew, ...initDefaultValue } = myStepData; + if (defaultValues != null && !isEqual(initDefaultValue, defaultValues)) { + const myDefaultValues = { + ...defaultValues, + isNew: false, + }; + setMyStepData(myDefaultValues); + if (!isReadOnlyView) { + Object.keys(schema).forEach(key => { + const val = get(key, myDefaultValues); + if (val != null) { + form.setFieldValue(key, val); + } + }); } - }, [form]); + } + }, [defaultValues]); - return isReadOnlyView && myStepData != null ? ( + useEffect(() => { + if (setForm != null) { + setForm(RuleStep.scheduleRule, form); + } + }, [form]); + + return isReadOnlyView && myStepData != null ? ( + - ) : ( - <> + + ) : ( + <> +
( }} /> +
+ + {!isUpdateView && ( + <> + + + + + {I18n.COMPLETE_WITHOUT_ACTIVATING} + + + + + {I18n.COMPLETE_WITH_ACTIVATING} + + + + + )} + + ); +}; - {!isUpdateView && ( - <> - - - - - {I18n.COMPLETE_WITHOUT_ACTIVATING} - - - - - {I18n.COMPLETE_WITH_ACTIVATING} - - - - - )} - - ); - } -); +export const StepScheduleRule = memo(StepScheduleRuleComponent); diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx index 31e56265dec42..4da17b88b9ad0 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/components/step_schedule_rule/schema.tsx @@ -4,11 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiText } from '@elastic/eui'; -import React from 'react'; import { i18n } from '@kbn/i18n'; -import * as RuleI18n from '../../translations'; +import { OptionalFieldLabel } from '../optional_field_label'; import { FormSchema } from '../shared_imports'; export const schema: FormSchema = { @@ -33,7 +31,7 @@ export const schema: FormSchema = { defaultMessage: 'Additional look-back', } ), - labelAppend: {RuleI18n.OPTIONAL_FIELD}, + labelAppend: OptionalFieldLabel, helpText: i18n.translate( 'xpack.siem.detectionEngine.createRule.stepScheduleRule.fieldAdditionalLookBackHelpText', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx index 9a0f41bbd8c51..e5656f5b081fb 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/create/index.tsx @@ -27,26 +27,17 @@ import * as i18n from './translations'; const stepsRuleOrder = [RuleStep.defineRule, RuleStep.aboutRule, RuleStep.scheduleRule]; -const ResizeEuiPanel = styled(EuiPanel)<{ - height?: number; +const MyEuiPanel = styled(EuiPanel)<{ + zIndex?: number; }>` + position: relative; + z-index: ${props => props.zIndex}; /* ugly fix to allow searchBar to overflow the EuiPanel */ + .euiAccordion__iconWrapper { display: none; } .euiAccordion__childWrapper { - height: ${props => (props.height !== -1 ? `${props.height}px !important` : 'auto')}; - } - .euiAccordion__button { - cursor: default !important; - &:hover { - text-decoration: none !important; - } - } -`; - -const MyEuiPanel = styled(EuiPanel)` - .euiAccordion__iconWrapper { - display: none; + overflow: visible; } .euiAccordion__button { cursor: default !important; @@ -64,7 +55,6 @@ export const CreateRuleComponent = React.memo(() => { canUserCRUD, hasManageApiKey, } = useUserInfo(); - const [heightAccordion, setHeightAccordion] = useState(-1); const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); @@ -239,7 +229,7 @@ export const CreateRuleComponent = React.memo(() => { isLoading={isLoading || loading} title={i18n.PAGE_TITLE} /> - + { ) } > - + setHeightAccordion(height)} + descriptionDirection="row" /> - - - + + + { ) } > - + { /> - - + + { ) } > - + ( {aboutRuleData != null && ( + i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.activateSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error activating {totalRules, plural, =1 {rule} other {rules}}…', + } + ); + export const BATCH_ACTION_DEACTIVATE_SELECTED = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.batchActions.deactivateSelectedTitle', { @@ -57,6 +66,15 @@ export const BATCH_ACTION_DEACTIVATE_SELECTED = i18n.translate( } ); +export const BATCH_ACTION_DEACTIVATE_SELECTED_ERROR = (totalRules: number) => + i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.deactivateSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error deactivating {totalRules, plural, =1 {rule} other {rules}}…', + } + ); + export const BATCH_ACTION_EXPORT_SELECTED = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.batchActions.exportSelectedTitle', { @@ -78,6 +96,22 @@ export const BATCH_ACTION_DELETE_SELECTED = i18n.translate( } ); +export const BATCH_ACTION_DELETE_SELECTED_IMMUTABLE = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.deleteSelectedImmutableTitle', + { + defaultMessage: 'Selection contains immutable rules which cannot be deleted', + } +); + +export const BATCH_ACTION_DELETE_SELECTED_ERROR = (totalRules: number) => + i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.batchActions.deleteSelectedErrorTitle', + { + values: { totalRules }, + defaultMessage: 'Error deleting {totalRules, plural, =1 {rule} other {rules}}…', + } + ); + export const EXPORT_FILENAME = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.exportFilenameTitle', { @@ -143,6 +177,13 @@ export const DUPLICATE_RULE = i18n.translate( } ); +export const DUPLICATE_RULE_ERROR = i18n.translate( + 'xpack.siem.detectionEngine.rules.allRules.actions.duplicateRuleErrorDescription', + { + defaultMessage: 'Error duplicating rule…', + } +); + export const EXPORT_RULE = i18n.translate( 'xpack.siem.detectionEngine.rules.allRules.actions.exportRuleDescription', { diff --git a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts index 541b058951be7..3da294fc9b845 100644 --- a/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts +++ b/x-pack/legacy/plugins/siem/public/pages/detection_engine/rules/types.ts @@ -25,6 +25,7 @@ export interface EuiBasicTableOnChange { export interface TableData { id: string; + immutable: boolean; rule_id: string; rule: { href: string; @@ -57,6 +58,7 @@ export interface RuleStepData { } export interface RuleStepProps { + addPadding?: boolean; descriptionDirection?: 'row' | 'column'; setStepData?: (step: RuleStep, data: unknown, isValid: boolean) => void; isReadOnlyView: boolean; diff --git a/x-pack/legacy/plugins/siem/server/plugin.ts b/x-pack/legacy/plugins/siem/server/plugin.ts index 90ae79ef19d5b..9d1983cf1d4da 100644 --- a/x-pack/legacy/plugins/siem/server/plugin.ts +++ b/x-pack/legacy/plugins/siem/server/plugin.ts @@ -60,7 +60,16 @@ export class Plugin { ], read: ['config'], }, - ui: ['show', 'crud'], + ui: [ + 'show', + 'crud', + 'alerting:show', + 'actions:show', + 'alerting:save', + 'actions:save', + 'alerting:delete', + 'actions:delete', + ], }, read: { api: ['siem', 'actions-read', 'actions-all', 'alerting-read', 'alerting-all'], @@ -73,7 +82,15 @@ export class Plugin { timelineSavedObjectType, ], }, - ui: ['show'], + ui: [ + 'show', + 'alerting:show', + 'actions:show', + 'alerting:save', + 'actions:save', + 'alerting:delete', + 'actions:delete', + ], }, }, }); diff --git a/x-pack/legacy/plugins/task_manager/server/index.ts b/x-pack/legacy/plugins/task_manager/server/index.ts index 67b85af324f3d..56135bb27326b 100644 --- a/x-pack/legacy/plugins/task_manager/server/index.ts +++ b/x-pack/legacy/plugins/task_manager/server/index.ts @@ -6,19 +6,26 @@ import { Root } from 'joi'; import { Legacy } from 'kibana'; -import { Plugin, PluginSetupContract } from './plugin'; -import { SavedObjectsSerializer, SavedObjectsSchema } from '../../../../../src/core/server'; import mappings from './mappings.json'; import { migrations } from './migrations'; -export { PluginSetupContract as TaskManager }; -export { - TaskInstance, - ConcreteTaskInstance, - TaskRunCreatorFunction, - TaskStatus, - RunContext, -} from './task'; +import { createLegacyApi, getTaskManagerSetup } from './legacy'; +export { LegacyTaskManagerApi, getTaskManagerSetup, getTaskManagerStart } from './legacy'; + +// Once all plugins are migrated to NP, this can be removed +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { TaskManager } from '../../../../plugins/task_manager/server/task_manager'; + +const savedObjectSchemas = { + task: { + hidden: true, + isNamespaceAgnostic: true, + convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, + indexPattern(config: any) { + return config.get('xpack.task_manager.index'); + }, + }, +}; export function taskManager(kibana: any) { return new kibana.Plugin({ @@ -28,73 +35,41 @@ export function taskManager(kibana: any) { config(Joi: Root) { return Joi.object({ enabled: Joi.boolean().default(true), - max_attempts: Joi.number() - .description( - 'The maximum number of times a task will be attempted before being abandoned as failed' - ) - .min(1) - .default(3), - poll_interval: Joi.number() - .description('How often, in milliseconds, the task manager will look for more work.') - .min(100) - .default(3000), - request_capacity: Joi.number() - .description('How many requests can Task Manager buffer before it rejects new requests.') - .min(1) - // a nice round contrived number, feel free to change as we learn how it behaves - .default(1000), index: Joi.string() .description('The name of the index used to store task information.') .default('.kibana_task_manager') .invalid(['.tasks']), - max_workers: Joi.number() - .description( - 'The maximum number of tasks that this Kibana instance will run simultaneously.' - ) - .min(1) // disable the task manager rather than trying to specify it with 0 workers - .default(10), }).default(); }, init(server: Legacy.Server) { - const plugin = new Plugin({ - logger: { - get: () => ({ - info: (message: string) => server.log(['info', 'task_manager'], message), - debug: (message: string) => server.log(['debug', 'task_manager'], message), - warn: (message: string) => server.log(['warn', 'task_manager'], message), - error: (message: string) => server.log(['error', 'task_manager'], message), - }), - }, - }); - const schema = new SavedObjectsSchema(this.kbnServer.uiExports.savedObjectSchemas); - const serializer = new SavedObjectsSerializer(schema); - const setupContract = plugin.setup( - {}, - { - serializer, - config: server.config(), - elasticsearch: server.plugins.elasticsearch, - savedObjects: server.savedObjects, - } + /* + * We must expose the New Platform Task Manager Plugin via the legacy Api + * as removing it now would be a breaking change - we'll remove this in v8.0.0 + */ + server.expose( + createLegacyApi( + getTaskManagerSetup(server)! + .registerLegacyAPI({ + savedObjectSchemas, + }) + .then((taskManagerPlugin: TaskManager) => { + // we can't tell the Kibana Platform Task Manager plugin to + // to wait to `start` as that happens before legacy plugins + // instead we will start the internal Task Manager plugin when + // all legacy plugins have finished initializing + // Once all plugins are migrated to NP, this can be removed + this.kbnServer.afterPluginsInit(() => { + taskManagerPlugin.start(); + }); + return taskManagerPlugin; + }) + ) ); - this.kbnServer.afterPluginsInit(() => { - plugin.start(); - }); - server.expose(setupContract); }, uiExports: { mappings, migrations, - savedObjectSchemas: { - task: { - hidden: true, - isNamespaceAgnostic: true, - convertToAliasScript: `ctx._id = ctx._source.type + ':' + ctx._id`, - indexPattern(config: any) { - return config.get('xpack.task_manager.index'); - }, - }, - }, + savedObjectSchemas, }, }); } diff --git a/x-pack/legacy/plugins/task_manager/server/legacy.ts b/x-pack/legacy/plugins/task_manager/server/legacy.ts new file mode 100644 index 0000000000000..772309d67c334 --- /dev/null +++ b/x-pack/legacy/plugins/task_manager/server/legacy.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Server } from 'src/legacy/server/kbn_server'; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; + +import { Middleware } from '../../../../plugins/task_manager/server/lib/middleware.js'; +import { + TaskDictionary, + TaskInstanceWithDeprecatedFields, + TaskInstanceWithId, + TaskDefinition, +} from '../../../../plugins/task_manager/server/task.js'; +import { FetchOpts } from '../../../../plugins/task_manager/server/task_store.js'; + +// Once all plugins are migrated to NP and we can remove Legacy TaskManager in version 8.0.0, +// this can be removed +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { TaskManager } from '../../../../plugins/task_manager/server/task_manager'; + +export type LegacyTaskManagerApi = Pick< + TaskManagerSetupContract, + 'addMiddleware' | 'registerTaskDefinitions' +> & + TaskManagerStartContract; + +export function getTaskManagerSetup(server: Server): TaskManagerSetupContract | undefined { + return server?.newPlatform?.setup?.plugins?.taskManager as TaskManagerSetupContract; +} + +export function getTaskManagerStart(server: Server): TaskManagerStartContract | undefined { + return server?.newPlatform?.start?.plugins?.taskManager as TaskManagerStartContract; +} + +export function createLegacyApi(legacyTaskManager: Promise): LegacyTaskManagerApi { + return { + addMiddleware: (middleware: Middleware) => { + legacyTaskManager.then((tm: TaskManager) => tm.addMiddleware(middleware)); + }, + registerTaskDefinitions: (taskDefinitions: TaskDictionary) => { + legacyTaskManager.then((tm: TaskManager) => tm.registerTaskDefinitions(taskDefinitions)); + }, + fetch: (opts: FetchOpts) => legacyTaskManager.then((tm: TaskManager) => tm.fetch(opts)), + remove: (id: string) => legacyTaskManager.then((tm: TaskManager) => tm.remove(id)), + schedule: (taskInstance: TaskInstanceWithDeprecatedFields, options?: any) => + legacyTaskManager.then((tm: TaskManager) => tm.schedule(taskInstance, options)), + runNow: (taskId: string) => legacyTaskManager.then((tm: TaskManager) => tm.runNow(taskId)), + ensureScheduled: (taskInstance: TaskInstanceWithId, options?: any) => + legacyTaskManager.then((tm: TaskManager) => tm.ensureScheduled(taskInstance, options)), + }; +} diff --git a/x-pack/legacy/plugins/task_manager/server/plugin.test.ts b/x-pack/legacy/plugins/task_manager/server/plugin.test.ts deleted file mode 100644 index f7c5b35da50c2..0000000000000 --- a/x-pack/legacy/plugins/task_manager/server/plugin.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Plugin, LegacyDeps } from './plugin'; -import { mockLogger } from './test_utils'; -import { TaskManager } from './task_manager'; - -jest.mock('./task_manager'); - -describe('Task Manager Plugin', () => { - let plugin: Plugin; - const mockCoreSetup = {}; - const mockLegacyDeps: LegacyDeps = { - config: { - get: jest.fn(), - }, - serializer: {}, - elasticsearch: { - getCluster: jest.fn(), - }, - savedObjects: { - getSavedObjectsRepository: jest.fn(), - }, - }; - - beforeEach(() => { - jest.resetAllMocks(); - mockLegacyDeps.elasticsearch.getCluster.mockReturnValue({ callWithInternalUser: jest.fn() }); - plugin = new Plugin({ - logger: { - get: mockLogger, - }, - }); - }); - - describe('setup()', () => { - test('exposes proper contract', async () => { - const setupResult = plugin.setup(mockCoreSetup, mockLegacyDeps); - expect(setupResult).toMatchInlineSnapshot(` - Object { - "addMiddleware": [Function], - "ensureScheduled": [Function], - "fetch": [Function], - "registerTaskDefinitions": [Function], - "remove": [Function], - "runNow": [Function], - "schedule": [Function], - } - `); - }); - }); - - describe('start()', () => { - test('properly starts up the task manager', async () => { - plugin.setup(mockCoreSetup, mockLegacyDeps); - plugin.start(); - const taskManager = (TaskManager as any).mock.instances[0]; - expect(taskManager.start).toHaveBeenCalled(); - }); - }); - - describe('stop()', () => { - test('properly stops up the task manager', async () => { - plugin.setup(mockCoreSetup, mockLegacyDeps); - plugin.stop(); - const taskManager = (TaskManager as any).mock.instances[0]; - expect(taskManager.stop).toHaveBeenCalled(); - }); - }); -}); diff --git a/x-pack/legacy/plugins/task_manager/server/plugin.ts b/x-pack/legacy/plugins/task_manager/server/plugin.ts deleted file mode 100644 index 08382d1d825b6..0000000000000 --- a/x-pack/legacy/plugins/task_manager/server/plugin.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { Logger } from './types'; -import { TaskManager } from './task_manager'; - -export interface PluginSetupContract { - fetch: TaskManager['fetch']; - remove: TaskManager['remove']; - schedule: TaskManager['schedule']; - runNow: TaskManager['runNow']; - ensureScheduled: TaskManager['ensureScheduled']; - addMiddleware: TaskManager['addMiddleware']; - registerTaskDefinitions: TaskManager['registerTaskDefinitions']; -} - -export interface LegacyDeps { - config: any; - serializer: any; - elasticsearch: any; - savedObjects: any; -} - -interface PluginInitializerContext { - logger: { - get: () => Logger; - }; -} - -export class Plugin { - private logger: Logger; - private taskManager?: TaskManager; - - constructor(initializerContext: PluginInitializerContext) { - this.logger = initializerContext.logger.get(); - } - - // TODO: Make asynchronous like new platform - public setup( - core: {}, - { config, serializer, elasticsearch, savedObjects }: LegacyDeps - ): PluginSetupContract { - const { callWithInternalUser } = elasticsearch.getCluster('admin'); - const savedObjectsRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser, [ - 'task', - ]); - - const taskManager = new TaskManager({ - config, - savedObjectsRepository, - serializer, - callWithInternalUser, - logger: this.logger, - }); - this.taskManager = taskManager; - - return { - fetch: (...args) => taskManager.fetch(...args), - remove: (...args) => taskManager.remove(...args), - schedule: (...args) => taskManager.schedule(...args), - runNow: (...args) => taskManager.runNow(...args), - ensureScheduled: (...args) => taskManager.ensureScheduled(...args), - addMiddleware: (...args) => taskManager.addMiddleware(...args), - registerTaskDefinitions: (...args) => taskManager.registerTaskDefinitions(...args), - }; - } - - public start() { - if (this.taskManager) { - this.taskManager.start(); - } - } - - public stop() { - if (this.taskManager) { - this.taskManager.stop(); - } - } -} diff --git a/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts b/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts index 4837e75fd3160..a4b80d902d098 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts +++ b/x-pack/legacy/plugins/task_manager/server/task_manager.mock.ts @@ -4,23 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TaskManager } from './types'; - -const createTaskManagerMock = () => { - const mocked: jest.Mocked = { - registerTaskDefinitions: jest.fn(), - addMiddleware: jest.fn(), - ensureScheduled: jest.fn(), - schedule: jest.fn(), - fetch: jest.fn(), - runNow: jest.fn(), - remove: jest.fn(), - start: jest.fn(), - stop: jest.fn(), - }; - return mocked; -}; +import { + TaskManagerSetupContract, + TaskManagerStartContract, +} from '../../../../plugins/task_manager/server'; +import { Subject } from 'rxjs'; export const taskManagerMock = { - create: createTaskManagerMock, + setup(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + registerTaskDefinitions: jest.fn(), + addMiddleware: jest.fn(), + config$: new Subject(), + registerLegacyAPI: jest.fn(), + ...overrides, + }; + return mocked; + }, + start(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + ensureScheduled: jest.fn(), + schedule: jest.fn(), + fetch: jest.fn(), + runNow: jest.fn(), + remove: jest.fn(), + ...overrides, + }; + return mocked; + }, }; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/index.ts new file mode 100644 index 0000000000000..c6ac3649a1477 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/index.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Legacy } from 'kibana'; +import { Root } from 'joi'; +import { resolve } from 'path'; + +export function triggersActionsUI(kibana: any) { + return new kibana.Plugin({ + id: 'triggers_actions_ui', + configPrefix: 'xpack.triggers_actions_ui', + isEnabled(config: Legacy.KibanaConfig) { + return ( + config.get('xpack.triggers_actions_ui.enabled') && + (config.get('xpack.actions.enabled') || config.get('xpack.alerting.enabled')) + ); + }, + publicDir: resolve(__dirname, 'public'), + require: ['kibana'], + config(Joi: Root) { + return Joi.object() + .keys({ + enabled: Joi.boolean().default(false), + createAlertUiEnabled: Joi.boolean().default(false), + }) + .default(); + }, + uiExports: { + home: ['plugins/triggers_actions_ui/hacks/register'], + managementSections: ['plugins/triggers_actions_ui/legacy'], + styleSheetPaths: resolve(__dirname, 'public/index.scss'), + injectDefaultVars(server: Legacy.Server) { + const serverConfig = server.config(); + return { + createAlertUiEnabled: serverConfig.get('xpack.triggers_actions_ui.createAlertUiEnabled'), + }; + }, + }, + }); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/kibana.json b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/kibana.json new file mode 100644 index 0000000000000..3fd7389aef494 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/kibana.json @@ -0,0 +1,6 @@ +{ + "id": "triggers_actions_ui", + "version": "kibana", + "server": false, + "ui": true + } diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/action_type_registry.mock.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/action_type_registry.mock.ts new file mode 100644 index 0000000000000..8ebfd7f933cd3 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/action_type_registry.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionTypeRegistryContract } from '../types'; + +const createActionTypeRegistryMock = () => { + const mocked: jest.Mocked = { + has: jest.fn(x => true), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }; + return mocked; +}; + +export const actionTypeRegistryMock = { + create: createActionTypeRegistryMock, +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/alert_type_registry.mock.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/alert_type_registry.mock.ts new file mode 100644 index 0000000000000..89eca7563a4e1 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/alert_type_registry.mock.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertTypeRegistryContract } from '../types'; + +const createAlertTypeRegistryMock = () => { + const mocked: jest.Mocked = { + has: jest.fn(), + register: jest.fn(), + get: jest.fn(), + list: jest.fn(), + }; + return mocked; +}; + +export const alertTypeRegistryMock = { + create: createAlertTypeRegistryMock, +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx new file mode 100644 index 0000000000000..3ad6b5b7c697d --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app.tsx @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { Switch, Route, Redirect, HashRouter } from 'react-router-dom'; +import { + ChromeStart, + DocLinksStart, + ToastsSetup, + HttpSetup, + IUiSettingsClient, +} from 'kibana/public'; +import { BASE_PATH, Section } from './constants'; +import { TriggersActionsUIHome } from './home'; +import { AppContextProvider, useAppDependencies } from './app_context'; +import { hasShowAlertsCapability } from './lib/capabilities'; +import { LegacyDependencies, ActionTypeModel, AlertTypeModel } from '../types'; +import { TypeRegistry } from './type_registry'; + +export interface AppDeps { + chrome: ChromeStart; + docLinks: DocLinksStart; + toastNotifications: ToastsSetup; + injectedMetadata: any; + http: HttpSetup; + uiSettings: IUiSettingsClient; + legacy: LegacyDependencies; + actionTypeRegistry: TypeRegistry; + alertTypeRegistry: TypeRegistry; +} + +export const App = (appDeps: AppDeps) => { + const sections: Section[] = ['alerts', 'connectors']; + + const sectionsRegex = sections.join('|'); + + return ( + + + + + + ); +}; + +export const AppWithoutRouter = ({ sectionsRegex }: any) => { + const { + legacy: { capabilities }, + } = useAppDependencies(); + const canShowAlerts = hasShowAlertsCapability(capabilities.get()); + const DEFAULT_SECTION: Section = canShowAlerts ? 'alerts' : 'connectors'; + return ( + + + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app_context.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app_context.tsx new file mode 100644 index 0000000000000..bf2e0c7274e7b --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/app_context.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, useContext } from 'react'; +import { AppDeps } from './app'; + +const AppContext = createContext(null); + +export const AppContextProvider = ({ + children, + appDeps, +}: { + appDeps: AppDeps | null; + children: React.ReactNode; +}) => { + return appDeps ? {children} : null; +}; + +export const useAppDependencies = (): AppDeps => { + const ctx = useContext(AppContext); + if (!ctx) { + throw new Error( + 'The app dependencies Context has not been set. Use the "setAppDependencies()" method when bootstrapping the app.' + ); + } + return ctx; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/boot.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/boot.tsx new file mode 100644 index 0000000000000..a37bedbfbdda8 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/boot.tsx @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { SavedObjectsClientContract } from 'src/core/public'; + +import { App, AppDeps } from './app'; +import { setSavedObjectsClient } from '../application/components/builtin_alert_types/threshold/lib/api'; +import { LegacyDependencies } from '../types'; + +interface BootDeps extends AppDeps { + element: HTMLElement; + savedObjects: SavedObjectsClientContract; + I18nContext: any; + legacy: LegacyDependencies; +} + +export const boot = (bootDeps: BootDeps) => { + const { I18nContext, element, legacy, savedObjects, ...appDeps } = bootDeps; + + setSavedObjectsClient(savedObjects); + + render( + + + , + element + ); + return () => unmountComponentAtNode(element); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx new file mode 100644 index 0000000000000..5c924982c3536 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.test.tsx @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.email'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('email'); + }); +}); + +describe('connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + port: '2323', + host: 'localhost', + test: 'test', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + service: [], + port: [], + host: [], + user: [], + password: [], + }, + }); + + delete actionConnector.config.test; + actionConnector.config.host = 'elastic.co'; + actionConnector.config.port = 8080; + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + service: [], + port: [], + host: [], + user: [], + password: [], + }, + }); + delete actionConnector.config.host; + delete actionConnector.config.port; + actionConnector.config.service = 'testService'; + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + service: [], + port: [], + host: [], + user: [], + password: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + from: [], + service: ['Service is required.'], + port: ['Port is required.'], + host: ['Host is required.'], + user: [], + password: [], + }, + }); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + to: [], + cc: ['test1@test.com'], + message: 'message {test}', + subject: 'test', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + to: [], + cc: [], + bcc: [], + message: [], + subject: [], + }, + }); + }); + + test('action params validation fails when action params is not valid', () => { + const actionParams = { + to: ['test@test.com'], + subject: 'test', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + to: [], + cc: [], + bcc: [], + message: ['Message is required.'], + subject: [], + }, + }); + }); +}); + +describe('EmailActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + expect(actionTypeModel.actionConnectorFields).not.toBeNull(); + if (!actionTypeModel.actionConnectorFields) { + return; + } + const ConnectorFields = actionTypeModel.actionConnectorFields; + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: { + from: 'test@test.com', + }, + } as ActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="emailFromInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="emailFromInput"]') + .first() + .prop('value') + ).toBe('test@test.com'); + expect(wrapper.find('[data-test-subj="emailHostInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailPortInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailUserInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailPasswordInput"]').length > 0).toBeTruthy(); + }); +}); + +describe('EmailParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + to: ['test@test.com'], + subject: 'test', + message: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="toEmailAddressInput"]') + .first() + .prop('selectedOptions') + ).toStrictEqual([{ label: 'test@test.com' }]); + expect(wrapper.find('[data-test-subj="ccEmailAddressInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="bccEmailAddressInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailSubjectInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="emailMessageInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx new file mode 100644 index 0000000000000..a6750ccf96deb --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/email.tsx @@ -0,0 +1,545 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { + EuiFieldText, + EuiFlexItem, + EuiFlexGroup, + EuiFieldNumber, + EuiFieldPassword, + EuiComboBox, + EuiTextArea, + EuiSwitch, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + ActionTypeModel, + ActionConnectorFieldsProps, + ActionConnector, + ValidationResult, + ActionParamsProps, +} from '../../../types'; + +export function getActionType(): ActionTypeModel { + const mailformat = /^[^@\s]+@[^@\s]+$/; + return { + id: '.email', + iconClass: 'email', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.emailAction.selectMessageText', + { + defaultMessage: 'Send email from your server.', + } + ), + validateConnector: (action: ActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + from: new Array(), + service: new Array(), + port: new Array(), + host: new Array(), + user: new Array(), + password: new Array(), + }; + validationResult.errors = errors; + if (!action.config.from) { + errors.from.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredFromText', + { + defaultMessage: 'Sender is required.', + } + ) + ); + } + if (action.config.from && !action.config.from.trim().match(mailformat)) { + errors.from.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.formatFromText', + { + defaultMessage: 'Sender is not a valid email address.', + } + ) + ); + } + if (!action.config.port && !action.config.service) { + errors.port.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPortText', + { + defaultMessage: 'Port is required.', + } + ) + ); + } + if (!action.config.service && (!action.config.port || !action.config.host)) { + errors.service.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServiceText', + { + defaultMessage: 'Service is required.', + } + ) + ); + } + if (!action.config.host && !action.config.service) { + errors.host.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredHostText', + { + defaultMessage: 'Host is required.', + } + ) + ); + } + if (!action.secrets.user) { + errors.user.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredUserText', + { + defaultMessage: 'Username is required.', + } + ) + ); + } + if (!action.secrets.password) { + errors.password.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredPasswordText', + { + defaultMessage: 'Password is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + to: new Array(), + cc: new Array(), + bcc: new Array(), + message: new Array(), + subject: new Array(), + }; + validationResult.errors = errors; + if ( + (!(actionParams.to instanceof Array) || actionParams.to.length === 0) && + (!(actionParams.cc instanceof Array) || actionParams.cc.length === 0) && + (!(actionParams.bcc instanceof Array) || actionParams.bcc.length === 0) + ) { + const errorText = i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredEntryText', + { + defaultMessage: 'No [to], [cc], or [bcc] entries. At least one entry is required.', + } + ); + errors.to.push(errorText); + errors.cc.push(errorText); + errors.bcc.push(errorText); + } + if (!actionParams.message) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + if (!actionParams.subject) { + errors.subject.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSubjectText', + { + defaultMessage: 'Subject is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: EmailActionConnectorFields, + actionParamsFields: EmailParamsFields, + }; +} + +const EmailActionConnectorFields: React.FunctionComponent = ({ + action, + editActionConfig, + editActionSecrets, + errors, +}) => { + const { from, host, port, secure } = action.config; + const { user, password } = action.secrets; + + return ( + + + + 0 && from !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.fromTextFieldLabel', + { + defaultMessage: 'Sender', + } + )} + > + 0 && from !== undefined} + name="from" + value={from || ''} + data-test-subj="emailFromInput" + onChange={e => { + editActionConfig('from', e.target.value); + }} + onBlur={() => { + if (!from) { + editActionConfig('from', ''); + } + }} + /> + + + + + + 0 && host !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.hostTextFieldLabel', + { + defaultMessage: 'Host', + } + )} + > + 0 && host !== undefined} + name="host" + value={host || ''} + data-test-subj="emailHostInput" + onChange={e => { + editActionConfig('host', e.target.value); + }} + onBlur={() => { + if (!host) { + editActionConfig('host', ''); + } + }} + /> + + + + + + 0 && port !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.portTextFieldLabel', + { + defaultMessage: 'Port', + } + )} + > + 0 && port !== undefined} + fullWidth + name="port" + value={port || ''} + data-test-subj="emailPortInput" + onChange={e => { + editActionConfig('port', parseInt(e.target.value, 10)); + }} + onBlur={() => { + if (!port) { + editActionConfig('port', ''); + } + }} + /> + + + + + + { + editActionConfig('secure', e.target.checked); + }} + /> + + + + + + + + + 0 && user !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.userTextFieldLabel', + { + defaultMessage: 'Username', + } + )} + > + 0 && user !== undefined} + name="user" + value={user || ''} + data-test-subj="emailUserInput" + onChange={e => { + editActionSecrets('user', e.target.value); + }} + onBlur={() => { + if (!user) { + editActionSecrets('user', ''); + } + }} + /> + + + + 0 && password !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.sections.builtinActionTypes.emailAction.passwordFieldLabel', + { + defaultMessage: 'Password', + } + )} + > + 0 && password !== undefined} + name="password" + value={password || ''} + data-test-subj="emailPasswordInput" + onChange={e => { + editActionSecrets('password', e.target.value); + }} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + /> + + + + + ); +}; + +const EmailParamsFields: React.FunctionComponent = ({ + action, + editAction, + index, + errors, + hasErrors, +}) => { + const { to, cc, bcc, subject, message } = action; + const toOptions = to ? to.map((label: string) => ({ label })) : []; + const ccOptions = cc ? cc.map((label: string) => ({ label })) : []; + const bccOptions = bcc ? bcc.map((label: string) => ({ label })) : []; + + return ( + + + { + const newOptions = [...toOptions, { label: searchValue }]; + editAction( + 'to', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'to', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!to) { + editAction('to', [], index); + } + }} + /> + + + { + const newOptions = [...ccOptions, { label: searchValue }]; + editAction( + 'cc', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'cc', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!cc) { + editAction('cc', [], index); + } + }} + /> + + + { + const newOptions = [...bccOptions, { label: searchValue }]; + editAction( + 'bcc', + newOptions.map(newOption => newOption.label), + index + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + editAction( + 'bcc', + selectedOptions.map(selectedOption => selectedOption.label), + index + ); + }} + onBlur={() => { + if (!bcc) { + editAction('bcc', [], index); + } + }} + /> + + + { + editAction('subject', e.target.value, index); + }} + /> + + + { + editAction('message', e.target.value, index); + }} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx new file mode 100644 index 0000000000000..b6a7c4d82aca4 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.test.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.index'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type .index is registered', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('indexOpen'); + }); +}); + +describe('index connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test_es_index', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: {}, + }); + + delete actionConnector.config.index; + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: {}, + }); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + index: 'test', + refresh: false, + executionTimeField: '1', + documents: ['test'], + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: {}, + }); + + const emptyActionParams = {}; + + expect(actionTypeModel.validateParams(emptyActionParams)).toEqual({ + errors: {}, + }); + }); +}); + +describe('IndexActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + expect(actionTypeModel.actionConnectorFields).not.toBeNull(); + if (!actionTypeModel.actionConnectorFields) { + return; + } + const ConnectorFields = actionTypeModel.actionConnectorFields; + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.index', + name: 'es_index', + config: { + index: 'test', + }, + } as ActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="indexInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="indexInput"]') + .first() + .prop('value') + ).toBe('test'); + }); +}); + +describe('IndexParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + index: 'test_index', + refresh: false, + documents: ['test'], + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="indexInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="indexInput"]') + .first() + .prop('value') + ).toBe('test_index'); + expect(wrapper.find('[data-test-subj="indexRefreshCheckbox"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx new file mode 100644 index 0000000000000..aa15195cdc286 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/es_index.tsx @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { EuiFieldText, EuiFormRow, EuiSwitch } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { i18n } from '@kbn/i18n'; +import { + ActionTypeModel, + ActionConnectorFieldsProps, + ValidationResult, + ActionParamsProps, +} from '../../../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.index', + iconClass: 'indexOpen', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.selectMessageText', + { + defaultMessage: 'Index data into Elasticsearch.', + } + ), + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + actionConnectorFields: IndexActionConnectorFields, + actionParamsFields: IndexParamsFields, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + }; +} + +const IndexActionConnectorFields: React.FunctionComponent = ({ + action, + editActionConfig, +}) => { + const { index } = action.config; + return ( + + ) => { + editActionConfig('index', e.target.value); + }} + onBlur={() => { + if (!index) { + editActionConfig('index', ''); + } + }} + /> + + ); +}; + +const IndexParamsFields: React.FunctionComponent = ({ + action, + index, + editAction, + errors, + hasErrors, +}) => { + const { refresh } = action; + return ( + + + ) => { + editAction('index', e.target.value, index); + }} + onBlur={() => { + if (!action.index) { + editAction('index', '', index); + } + }} + /> + + { + editAction('refresh', e.target.checked, index); + }} + label={ + + } + /> + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/index.ts new file mode 100644 index 0000000000000..6ffd9b2c9ffde --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getActionType as getServerLogActionType } from './server_log'; +import { getActionType as getSlackActionType } from './slack'; +import { getActionType as getEmailActionType } from './email'; +import { getActionType as getIndexActionType } from './es_index'; +import { getActionType as getPagerDutyActionType } from './pagerduty'; +import { getActionType as getWebhookActionType } from './webhook'; +import { TypeRegistry } from '../../type_registry'; +import { ActionTypeModel } from '../../../types'; + +export function registerBuiltInActionTypes({ + actionTypeRegistry, +}: { + actionTypeRegistry: TypeRegistry; +}) { + actionTypeRegistry.register(getServerLogActionType()); + actionTypeRegistry.register(getSlackActionType()); + actionTypeRegistry.register(getEmailActionType()); + actionTypeRegistry.register(getIndexActionType()); + actionTypeRegistry.register(getPagerDutyActionType()); + actionTypeRegistry.register(getWebhookActionType()); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx new file mode 100644 index 0000000000000..582315c95812a --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.test.tsx @@ -0,0 +1,179 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.pagerduty'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('apps'); + }); +}); + +describe('pagerduty connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + routingKey: 'test', + }, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: [], + }, + }); + + delete actionConnector.config.apiUrl; + actionConnector.secrets.routingKey = 'test1'; + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + routingKey: ['A routing key is required.'], + }, + }); + }); +}); + +describe('pagerduty action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + eventAction: 'trigger', + dedupKey: 'test', + summary: '2323', + source: 'source', + severity: 'critical', + timestamp: '234654564654', + component: 'test', + group: 'group', + class: 'test class', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: {}, + }); + }); +}); + +describe('PagerDutyActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + expect(actionTypeModel.actionConnectorFields).not.toBeNull(); + if (!actionTypeModel.actionConnectorFields) { + return; + } + const ConnectorFields = actionTypeModel.actionConnectorFields; + const actionConnector = { + secrets: { + routingKey: 'test', + }, + id: 'test', + actionTypeId: '.pagerduty', + name: 'pagerduty', + config: { + apiUrl: 'http:\\test', + }, + } as ActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="pagerdutyApiUrlInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="pagerdutyApiUrlInput"]') + .first() + .prop('value') + ).toBe('http:\\test'); + expect(wrapper.find('[data-test-subj="pagerdutyRoutingKeyInput"]').length > 0).toBeTruthy(); + }); +}); + +describe('PagerDutyParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + eventAction: 'trigger', + dedupKey: 'test', + summary: '2323', + source: 'source', + severity: 'critical', + timestamp: '234654564654', + component: 'test', + group: 'group', + class: 'test class', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="severitySelect"]') + .first() + .prop('value') + ).toStrictEqual('critical'); + expect(wrapper.find('[data-test-subj="eventActionSelect"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="dedupKeyInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="timestampInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="componentInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="groupInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="sourceInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="pagerdutyDescriptionInput"]').length > 0).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx new file mode 100644 index 0000000000000..69c7ec166df60 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/pagerduty.tsx @@ -0,0 +1,361 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSelect, + EuiLink, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + ActionTypeModel, + ActionConnectorFieldsProps, + ActionConnector, + ValidationResult, + ActionParamsProps, +} from '../../../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.pagerduty', + iconClass: 'apps', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.selectMessageText', + { + defaultMessage: 'Send an event in PagerDuty.', + } + ), + validateConnector: (action: ActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + routingKey: new Array(), + }; + validationResult.errors = errors; + if (!action.secrets.routingKey) { + errors.routingKey.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.error.requiredRoutingKeyText', + { + defaultMessage: 'A routing key is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: PagerDutyActionConnectorFields, + actionParamsFields: PagerDutyParamsFields, + }; +} + +const PagerDutyActionConnectorFields: React.FunctionComponent = ({ + errors, + action, + editActionConfig, + editActionSecrets, +}) => { + const { apiUrl } = action.config; + const { routingKey } = action.secrets; + return ( + + + ) => { + editActionConfig('apiUrl', e.target.value); + }} + onBlur={() => { + if (!apiUrl) { + editActionConfig('apiUrl', ''); + } + }} + /> + + + + + } + error={errors.routingKey} + isInvalid={errors.routingKey.length > 0 && routingKey !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.pagerDutyAction.routingKeyTextFieldLabel', + { + defaultMessage: 'Routing key', + } + )} + > + 0 && routingKey !== undefined} + name="routingKey" + value={routingKey || ''} + data-test-subj="pagerdutyRoutingKeyInput" + onChange={(e: React.ChangeEvent) => { + editActionSecrets('routingKey', e.target.value); + }} + onBlur={() => { + if (!routingKey) { + editActionSecrets('routingKey', ''); + } + }} + /> + + + ); +}; + +const PagerDutyParamsFields: React.FunctionComponent = ({ + action, + editAction, + index, + errors, + hasErrors, +}) => { + const { eventAction, dedupKey, summary, source, severity, timestamp, component, group } = action; + const severityOptions = [ + { value: 'critical', text: 'Critical' }, + { value: 'info', text: 'Info' }, + { value: 'warning', text: 'Warning' }, + { value: 'error', text: 'Error' }, + ]; + const eventActionOptions = [ + { value: 'trigger', text: 'Trigger' }, + { value: 'resolve', text: 'Resolve' }, + { value: 'acknowledge', text: 'Acknowledge' }, + ]; + return ( + + + + + { + editAction('severity', e.target.value, index); + }} + /> + + + + + { + editAction('eventAction', e.target.value, index); + }} + /> + + + + + + + ) => { + editAction('dedupKey', e.target.value, index); + }} + onBlur={() => { + if (!index) { + editAction('dedupKey', '', index); + } + }} + /> + + + + + ) => { + editAction('timestamp', e.target.value, index); + }} + onBlur={() => { + if (!index) { + editAction('timestamp', '', index); + } + }} + /> + + + + + ) => { + editAction('component', e.target.value, index); + }} + onBlur={() => { + if (!index) { + editAction('component', '', index); + } + }} + /> + + + ) => { + editAction('group', e.target.value, index); + }} + onBlur={() => { + if (!index) { + editAction('group', '', index); + } + }} + /> + + + ) => { + editAction('source', e.target.value, index); + }} + onBlur={() => { + if (!index) { + editAction('source', '', index); + } + }} + /> + + + ) => { + editAction('summary', e.target.value, index); + }} + onBlur={() => { + if (!summary) { + editAction('summary', '', index); + } + }} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx new file mode 100644 index 0000000000000..b79be4eef523b --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.test.tsx @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.server-log'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logsApp'); + }); +}); + +describe('server-log connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.server-log', + name: 'server-log', + config: {}, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: {}, + }); + }); +}); + +describe('action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + message: 'test message', + level: 'trace', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { message: [] }, + }); + }); +}); + +describe('ServerLogParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + message: 'test message', + level: 'trace', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="loggingLevelSelect"]') + .first() + .prop('value') + ).toStrictEqual('trace'); + expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); + }); + + test('level param field is rendered with default value if not selected', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + message: 'test message', + level: 'info', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="loggingLevelSelect"]') + .first() + .prop('value') + ).toStrictEqual('info'); + expect(wrapper.find('[data-test-subj="loggingMessageInput"]').length > 0).toBeTruthy(); + }); + + test('params validation fails when message is not valid', () => { + const actionParams = { + message: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + message: ['Message is required.'], + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx new file mode 100644 index 0000000000000..885061aa81924 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/server_log.tsx @@ -0,0 +1,116 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiSelect, EuiTextArea, EuiFormRow } from '@elastic/eui'; +import { ActionTypeModel, ValidationResult, ActionParamsProps } from '../../../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.server-log', + iconClass: 'logsApp', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.serverLogAction.selectMessageText', + { + defaultMessage: 'Add a message to a Kibana log.', + } + ), + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.message || actionParams.message.length === 0) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredServerLogMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: ServerLogParamsFields, + }; +} + +export const ServerLogParamsFields: React.FunctionComponent = ({ + action, + editAction, + index, + errors, + hasErrors, +}) => { + const { message, level } = action; + const levelOptions = [ + { value: 'trace', text: 'Trace' }, + { value: 'debug', text: 'Debug' }, + { value: 'info', text: 'Info' }, + { value: 'warn', text: 'Warning' }, + { value: 'error', text: 'Error' }, + { value: 'fatal', text: 'Fatal' }, + ]; + + // Set default value 'info' for level param + editAction('level', 'info', index); + + return ( + + + { + editAction('level', e.target.value, index); + }} + /> + + + { + editAction('message', e.target.value, index); + }} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx new file mode 100644 index 0000000000000..36beea4d2f2be --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.test.tsx @@ -0,0 +1,152 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.slack'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logoSlack'); + }); +}); + +describe('slack connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + webhookUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: {}, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + webhookUrl: ['Webhook URL is required.'], + }, + }); + }); +}); + +describe('slack action params validation', () => { + test('if action params validation succeeds when action params is valid', () => { + const actionParams = { + message: 'message {test}', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { message: [] }, + }); + }); +}); + +describe('SlackActionFields renders', () => { + test('all connector fields is rendered', () => { + expect(actionTypeModel.actionConnectorFields).not.toBeNull(); + if (!actionTypeModel.actionConnectorFields) { + return; + } + const ConnectorFields = actionTypeModel.actionConnectorFields; + const actionConnector = { + secrets: { + webhookUrl: 'http:\\test', + }, + id: 'test', + actionTypeId: '.email', + name: 'email', + config: {}, + } as ActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + /> + ); + expect(wrapper.find('[data-test-subj="slackWebhookUrlInput"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="slackWebhookUrlInput"]') + .first() + .prop('value') + ).toBe('http:\\test'); + }); +}); + +describe('SlackParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + message: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="slackMessageTextarea"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="slackMessageTextarea"]') + .first() + .prop('value') + ).toStrictEqual('test message'); + }); + + test('params validation fails when message is not valid', () => { + const actionParams = { + message: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + message: ['Message is required.'], + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx new file mode 100644 index 0000000000000..0ae51725169bf --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/slack.tsx @@ -0,0 +1,175 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { + EuiFieldText, + EuiTextArea, + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiFormRow, + EuiLink, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + ActionTypeModel, + ActionConnectorFieldsProps, + ActionConnector, + ValidationResult, + ActionParamsProps, +} from '../../../types'; + +export function getActionType(): ActionTypeModel { + return { + id: '.slack', + iconClass: 'logoSlack', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.selectMessageText', + { + defaultMessage: 'Send a message to a Slack channel or user.', + } + ), + validateConnector: (action: ActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + webhookUrl: new Array(), + }; + validationResult.errors = errors; + if (!action.secrets.webhookUrl) { + errors.webhookUrl.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.error.requiredWebhookUrlText', + { + defaultMessage: 'Webhook URL is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + message: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.message || actionParams.message.length === 0) { + errors.message.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredSlackMessageText', + { + defaultMessage: 'Message is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: SlackActionFields, + actionParamsFields: SlackParamsFields, + }; +} + +const SlackActionFields: React.FunctionComponent = ({ + action, + editActionSecrets, + errors, +}) => { + const { webhookUrl } = action.secrets; + + return ( + + + + + } + error={errors.webhookUrl} + isInvalid={errors.webhookUrl.length > 0 && webhookUrl !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.slackAction.webhookUrlTextFieldLabel', + { + defaultMessage: 'Webhook URL', + } + )} + > + 0 && webhookUrl !== undefined} + name="webhookUrl" + placeholder="URL like https://hooks.slack.com/services" + value={webhookUrl || ''} + data-test-subj="slackWebhookUrlInput" + onChange={e => { + editActionSecrets('webhookUrl', e.target.value); + }} + onBlur={() => { + if (!webhookUrl) { + editActionSecrets('webhookUrl', ''); + } + }} + /> + + + ); +}; + +const SlackParamsFields: React.FunctionComponent = ({ + action, + editAction, + index, + errors, + hasErrors, +}) => { + const { message } = action; + + return ( + + + + window.alert('Button clicked')} + iconType="indexOpen" + aria-label="Add variable" + /> + + + + { + editAction('message', e.target.value, index); + }} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx new file mode 100644 index 0000000000000..cd342f2e19969 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.test.tsx @@ -0,0 +1,174 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { TypeRegistry } from '../../type_registry'; +import { registerBuiltInActionTypes } from './index'; +import { ActionTypeModel, ActionConnector } from '../../../types'; + +const ACTION_TYPE_ID = '.webhook'; +let actionTypeModel: ActionTypeModel; + +beforeAll(() => { + const actionTypeRegistry = new TypeRegistry(); + registerBuiltInActionTypes({ actionTypeRegistry }); + const getResult = actionTypeRegistry.get(ACTION_TYPE_ID); + if (getResult !== null) { + actionTypeModel = getResult; + } +}); + +describe('actionTypeRegistry.get() works', () => { + test('action type static data is as expected', () => { + expect(actionTypeModel.id).toEqual(ACTION_TYPE_ID); + expect(actionTypeModel.iconClass).toEqual('logoWebhook'); + }); +}); + +describe('webhook connector validation', () => { + test('connector validation succeeds when connector config is valid', () => { + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.webhook', + name: 'webhook', + config: { + method: 'PUT', + url: 'http:\\test', + headers: ['content-type: text'], + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + url: [], + method: [], + user: [], + password: [], + }, + }); + }); + + test('connector validation fails when connector config is not valid', () => { + const actionConnector = { + secrets: { + user: 'user', + }, + id: 'test', + actionTypeId: '.webhook', + name: 'webhook', + config: { + method: 'PUT', + }, + } as ActionConnector; + + expect(actionTypeModel.validateConnector(actionConnector)).toEqual({ + errors: { + url: ['URL is required.'], + method: [], + user: [], + password: ['Password is required.'], + }, + }); + }); +}); + +describe('webhook action params validation', () => { + test('action params validation succeeds when action params is valid', () => { + const actionParams = { + body: 'message {test}', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { body: [] }, + }); + }); +}); + +describe('WebhookActionConnectorFields renders', () => { + test('all connector fields is rendered', () => { + expect(actionTypeModel.actionConnectorFields).not.toBeNull(); + if (!actionTypeModel.actionConnectorFields) { + return; + } + const ConnectorFields = actionTypeModel.actionConnectorFields; + const actionConnector = { + secrets: { + user: 'user', + password: 'pass', + }, + id: 'test', + actionTypeId: '.webhook', + name: 'webhook', + config: { + method: 'PUT', + url: 'http:\\test', + headers: ['content-type: text'], + }, + } as ActionConnector; + const wrapper = mountWithIntl( + {}} + editActionSecrets={() => {}} + /> + ); + expect(wrapper.find('[data-test-subj="webhookViewHeadersSwitch"]').length > 0).toBeTruthy(); + wrapper + .find('[data-test-subj="webhookViewHeadersSwitch"]') + .first() + .simulate('click'); + expect(wrapper.find('[data-test-subj="webhookMethodSelect"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookUrlText"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookUserInput"]').length > 0).toBeTruthy(); + expect(wrapper.find('[data-test-subj="webhookPasswordInput"]').length > 0).toBeTruthy(); + }); +}); + +describe('WebhookParamsFields renders', () => { + test('all params fields is rendered', () => { + expect(actionTypeModel.actionParamsFields).not.toBeNull(); + if (!actionTypeModel.actionParamsFields) { + return; + } + const ParamsFields = actionTypeModel.actionParamsFields; + const actionParams = { + body: 'test message', + }; + const wrapper = mountWithIntl( + {}} + index={0} + hasErrors={false} + /> + ); + expect(wrapper.find('[data-test-subj="webhookBodyEditor"]').length > 0).toBeTruthy(); + expect( + wrapper + .find('[data-test-subj="webhookBodyEditor"]') + .first() + .prop('value') + ).toStrictEqual('test message'); + }); + + test('params validation fails when body is not valid', () => { + const actionParams = { + body: '', + }; + + expect(actionTypeModel.validateParams(actionParams)).toEqual({ + errors: { + body: ['Body is required.'], + }, + }); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx new file mode 100644 index 0000000000000..70a9a6f3d75b3 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_action_types/webhook.tsx @@ -0,0 +1,501 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { + EuiFieldPassword, + EuiFieldText, + EuiFormRow, + EuiSelect, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiButtonIcon, + EuiDescriptionList, + EuiDescriptionListDescription, + EuiDescriptionListTitle, + EuiTitle, + EuiCodeEditor, + EuiSwitch, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { + ActionTypeModel, + ActionConnectorFieldsProps, + ActionConnector, + ValidationResult, + ActionParamsProps, +} from '../../../types'; + +const HTTP_VERBS = ['post', 'put']; + +export function getActionType(): ActionTypeModel { + return { + id: '.webhook', + iconClass: 'logoWebhook', + selectMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.selectMessageText', + { + defaultMessage: 'Send a request to a web service.', + } + ), + validateConnector: (action: ActionConnector): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + url: new Array(), + method: new Array(), + user: new Array(), + password: new Array(), + }; + validationResult.errors = errors; + if (!action.config.url) { + errors.url.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.error.requiredUrlText', + { + defaultMessage: 'URL is required.', + } + ) + ); + } + if (!action.config.method) { + errors.method.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredMethodText', + { + defaultMessage: 'Method is required.', + } + ) + ); + } + if (!action.secrets.user) { + errors.user.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHostText', + { + defaultMessage: 'Username is required.', + } + ) + ); + } + if (!action.secrets.password) { + errors.password.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredPasswordText', + { + defaultMessage: 'Password is required.', + } + ) + ); + } + return validationResult; + }, + validateParams: (actionParams: any): ValidationResult => { + const validationResult = { errors: {} }; + const errors = { + body: new Array(), + }; + validationResult.errors = errors; + if (!actionParams.body || actionParams.body.length === 0) { + errors.body.push( + i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.error.requiredWebhookBodyText', + { + defaultMessage: 'Body is required.', + } + ) + ); + } + return validationResult; + }, + actionConnectorFields: WebhookActionConnectorFields, + actionParamsFields: WebhookParamsFields, + }; +} + +const WebhookActionConnectorFields: React.FunctionComponent = ({ + action, + editActionConfig, + editActionSecrets, + errors, +}) => { + const [httpHeaderKey, setHttpHeaderKey] = useState(''); + const [httpHeaderValue, setHttpHeaderValue] = useState(''); + const [hasHeaders, setHasHeaders] = useState(false); + + const { user, password } = action.secrets; + const { method, url, headers } = action.config; + + editActionConfig('method', 'post'); // set method to POST by default + + const headerErrors = { + keyHeader: new Array(), + valueHeader: new Array(), + }; + if (!httpHeaderKey && httpHeaderValue) { + headerErrors.keyHeader.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHeaderKeyText', + { + defaultMessage: 'Header key is required.', + } + ) + ); + } + if (httpHeaderKey && !httpHeaderValue) { + headerErrors.valueHeader.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.addAction.webhookAction.error.requiredHeaderValueText', + { + defaultMessage: 'Header value is required.', + } + ) + ); + } + const hasHeaderErrors = headerErrors.keyHeader.length > 0 || headerErrors.valueHeader.length > 0; + + function addHeader() { + if (headers && !!Object.keys(headers).find(key => key === httpHeaderKey)) { + return; + } + const updatedHeaders = headers + ? { ...headers, [httpHeaderKey]: httpHeaderValue } + : { [httpHeaderKey]: httpHeaderValue }; + editActionConfig('headers', updatedHeaders); + setHttpHeaderKey(''); + setHttpHeaderValue(''); + } + + function viewHeaders() { + setHasHeaders(!hasHeaders); + if (!hasHeaders) { + editActionConfig('headers', {}); + } + } + + function removeHeader(keyToRemove: string) { + const updatedHeaders = Object.keys(headers) + .filter(key => key !== keyToRemove) + .reduce((headerToRemove: Record, key: string) => { + headerToRemove[key] = headers[key]; + return headerToRemove; + }, {}); + editActionConfig('headers', updatedHeaders); + } + + let headerControl; + if (hasHeaders) { + headerControl = ( + + +
+ +
+
+ + + + + { + setHttpHeaderKey(e.target.value); + }} + /> + + + + + { + setHttpHeaderValue(e.target.value); + }} + /> + + + + + addHeader()} + > + + + + + +
+ ); + } + + const headersList = Object.keys(headers || {}).map((key: string) => { + return ( + + + removeHeader(key)} + /> + + + + {key} + {headers[key]} + + + + ); + }); + + return ( + + + + + ({ text: verb.toUpperCase(), value: verb }))} + onChange={e => { + editActionConfig('method', e.target.value); + }} + /> + + + + 0 && url !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.urlTextFieldLabel', + { + defaultMessage: 'URL', + } + )} + > + 0 && url !== undefined} + fullWidth + value={url || ''} + data-test-subj="webhookUrlText" + onChange={e => { + editActionConfig('url', e.target.value); + }} + onBlur={() => { + if (!url) { + editActionConfig('url', ''); + } + }} + /> + + + + + + 0 && user !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.userTextFieldLabel', + { + defaultMessage: 'Username', + } + )} + > + 0 && user !== undefined} + name="user" + value={user || ''} + data-test-subj="webhookUserInput" + onChange={e => { + editActionSecrets('user', e.target.value); + }} + onBlur={() => { + if (!user) { + editActionSecrets('user', ''); + } + }} + /> + + + + 0 && password !== undefined} + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.webhookAction.passwordTextFieldLabel', + { + defaultMessage: 'Password', + } + )} + > + 0 && password !== undefined} + value={password || ''} + data-test-subj="webhookPasswordInput" + onChange={e => { + editActionSecrets('password', e.target.value); + }} + onBlur={() => { + if (!password) { + editActionSecrets('password', ''); + } + }} + /> + + + + + + viewHeaders()} + /> + + +
+ {hasHeaders && Object.keys(headers || {}).length > 0 ? ( + + + +
+ +
+
+ + {headersList} +
+ ) : null} + + {headerControl} + +
+
+ ); +}; + +const WebhookParamsFields: React.FunctionComponent = ({ + action, + editAction, + index, + errors, + hasErrors, +}) => { + const { body } = action; + + return ( + + + { + editAction('body', json, index); + }} + /> + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/index.ts new file mode 100644 index 0000000000000..6c5d440e47888 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getActionType as getThresholdAlertType } from './threshold/expression'; +import { TypeRegistry } from '../../type_registry'; +import { AlertTypeModel } from '../../../types'; + +export function registerBuiltInAlertTypes({ + alertTypeRegistry, +}: { + alertTypeRegistry: TypeRegistry; +}) { + alertTypeRegistry.register(getThresholdAlertType()); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/aggregation_types.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/aggregation_types.ts new file mode 100644 index 0000000000000..68c2818502b2c --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/aggregation_types.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const AGGREGATION_TYPES: { [key: string]: string } = { + COUNT: 'count', + + AVERAGE: 'avg', + + SUM: 'sum', + + MIN: 'min', + + MAX: 'max', +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/comparators.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/comparators.ts new file mode 100644 index 0000000000000..21b350c0f8ce4 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/comparators.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const COMPARATORS: { [key: string]: string } = { + GREATER_THAN: '>', + GREATER_THAN_OR_EQUALS: '>=', + BETWEEN: 'between', + LESS_THAN: '<', + LESS_THAN_OR_EQUALS: '<=', +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/index.ts new file mode 100644 index 0000000000000..f88ee5ee23f90 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/constants/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { COMPARATORS } from './comparators'; +export { AGGREGATION_TYPES } from './aggregation_types'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx new file mode 100644 index 0000000000000..907a61677b263 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/expression.tsx @@ -0,0 +1,1000 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState, Fragment, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexItem, + EuiFlexGroup, + EuiExpression, + EuiPopover, + EuiPopoverTitle, + EuiSelect, + EuiSpacer, + EuiComboBox, + EuiFieldNumber, + EuiComboBoxOptionProps, + EuiText, + EuiFormRow, + EuiCallOut, +} from '@elastic/eui'; +import { AlertTypeModel, Alert, ValidationResult } from '../../../../types'; +import { Comparator, AggregationType, GroupByType } from './types'; +import { AGGREGATION_TYPES, COMPARATORS } from './constants'; +import { + getMatchingIndicesForThresholdAlertType, + getThresholdAlertTypeFields, + loadIndexPatterns, +} from './lib/api'; +import { useAppDependencies } from '../../../app_context'; +import { getTimeOptions, getTimeFieldOptions } from '../../../lib/get_time_options'; +import { getTimeUnitLabel } from '../../../lib/get_time_unit_label'; +import { ThresholdVisualization } from './visualization'; + +const DEFAULT_VALUES = { + AGGREGATION_TYPE: 'count', + TERM_SIZE: 5, + THRESHOLD_COMPARATOR: COMPARATORS.GREATER_THAN, + TIME_WINDOW_SIZE: 5, + TIME_WINDOW_UNIT: 'm', + TRIGGER_INTERVAL_SIZE: 1, + TRIGGER_INTERVAL_UNIT: 'm', + THRESHOLD: [1000, 5000], + GROUP_BY: 'all', +}; + +const expressionFieldsWithValidation = [ + 'index', + 'timeField', + 'aggField', + 'termSize', + 'termField', + 'threshold0', + 'threshold1', + 'timeWindowSize', +]; + +const validateAlertType = (alert: Alert): ValidationResult => { + const { + index, + timeField, + aggType, + aggField, + groupBy, + termSize, + termField, + threshold, + timeWindowSize, + } = alert.params; + const validationResult = { errors: {} }; + const errors = { + aggField: new Array(), + termSize: new Array(), + termField: new Array(), + timeWindowSize: new Array(), + threshold0: new Array(), + threshold1: new Array(), + index: new Array(), + timeField: new Array(), + }; + validationResult.errors = errors; + if (!index) { + errors.index.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredIndexText', { + defaultMessage: 'Index is required.', + }) + ); + } + if (!timeField) { + errors.timeField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTimeFieldText', { + defaultMessage: 'Time field is required.', + }) + ); + } + if (aggType && aggregationTypes[aggType].fieldRequired && !aggField) { + errors.aggField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredAggFieldText', { + defaultMessage: 'Aggregation field is required.', + }) + ); + } + if (!termSize) { + errors.termSize.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTermSizedText', { + defaultMessage: 'Term size is required.', + }) + ); + } + if (groupBy && groupByTypes[groupBy].sizeRequired && !termField) { + errors.termField.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredtTermFieldText', { + defaultMessage: 'Term field is required.', + }) + ); + } + if (!timeWindowSize) { + errors.timeWindowSize.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredTimeWindowSizeText', { + defaultMessage: 'Time window size is required.', + }) + ); + } + if (threshold && threshold.length > 0 && !threshold[0]) { + errors.threshold0.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold0Text', { + defaultMessage: 'Threshold0, is required.', + }) + ); + } + if (threshold && threshold.length > 1 && !threshold[1]) { + errors.threshold1.push( + i18n.translate('xpack.triggersActionsUI.sections.addAlert.error.requiredThreshold1Text', { + defaultMessage: 'Threshold1 is required.', + }) + ); + } + return validationResult; +}; + +export function getActionType(): AlertTypeModel { + return { + id: 'threshold', + name: 'Index Threshold', + iconClass: 'alert', + alertParamsExpression: IndexThresholdAlertTypeExpression, + validate: validateAlertType, + }; +} + +export const aggregationTypes: { [key: string]: AggregationType } = { + count: { + text: 'count()', + fieldRequired: false, + value: AGGREGATION_TYPES.COUNT, + validNormalizedTypes: [], + }, + avg: { + text: 'average()', + fieldRequired: true, + validNormalizedTypes: ['number'], + value: AGGREGATION_TYPES.AVERAGE, + }, + sum: { + text: 'sum()', + fieldRequired: true, + validNormalizedTypes: ['number'], + value: AGGREGATION_TYPES.SUM, + }, + min: { + text: 'min()', + fieldRequired: true, + validNormalizedTypes: ['number', 'date'], + value: AGGREGATION_TYPES.MIN, + }, + max: { + text: 'max()', + fieldRequired: true, + validNormalizedTypes: ['number', 'date'], + value: AGGREGATION_TYPES.MAX, + }, +}; + +export const comparators: { [key: string]: Comparator } = { + [COMPARATORS.GREATER_THAN]: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.comparators.isAboveLabel', + { + defaultMessage: 'Is above', + } + ), + value: COMPARATORS.GREATER_THAN, + requiredValues: 1, + }, + [COMPARATORS.GREATER_THAN_OR_EQUALS]: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.comparators.isAboveOrEqualsLabel', + { + defaultMessage: 'Is above or equals', + } + ), + value: COMPARATORS.GREATER_THAN_OR_EQUALS, + requiredValues: 1, + }, + [COMPARATORS.LESS_THAN]: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.comparators.isBelowLabel', + { + defaultMessage: 'Is below', + } + ), + value: COMPARATORS.LESS_THAN, + requiredValues: 1, + }, + [COMPARATORS.LESS_THAN_OR_EQUALS]: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.comparators.isBelowOrEqualsLabel', + { + defaultMessage: 'Is below or equals', + } + ), + value: COMPARATORS.LESS_THAN_OR_EQUALS, + requiredValues: 1, + }, + [COMPARATORS.BETWEEN]: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.comparators.isBetweenLabel', + { + defaultMessage: 'Is between', + } + ), + value: COMPARATORS.BETWEEN, + requiredValues: 2, + }, +}; + +export const groupByTypes: { [key: string]: GroupByType } = { + all: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.groupByLabel.allDocumentsLabel', + { + defaultMessage: 'all documents', + } + ), + sizeRequired: false, + value: 'all', + validNormalizedTypes: [], + }, + top: { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.groupByLabel.topLabel', + { + defaultMessage: 'top', + } + ), + sizeRequired: true, + value: 'top', + validNormalizedTypes: ['number', 'date', 'keyword'], + }, +}; + +interface Props { + alert: Alert; + setAlertParams: (property: string, value: any) => void; + setAlertProperty: (key: string, value: any) => void; + errors: { [key: string]: string[] }; + hasErrors?: boolean; +} + +export const IndexThresholdAlertTypeExpression: React.FunctionComponent = ({ + alert, + setAlertParams, + setAlertProperty, + errors, + hasErrors, +}) => { + const { http } = useAppDependencies(); + + const { + index, + timeField, + aggType, + aggField, + groupBy, + termSize, + termField, + thresholdComparator, + threshold, + timeWindowSize, + timeWindowUnit, + } = alert.params; + + const firstFieldOption = { + text: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.timeFieldOptionLabel', + { + defaultMessage: 'Select a field', + } + ), + value: '', + }; + + const [aggTypePopoverOpen, setAggTypePopoverOpen] = useState(false); + const [indexPopoverOpen, setIndexPopoverOpen] = useState(false); + const [indexPatterns, setIndexPatterns] = useState([]); + const [esFields, setEsFields] = useState>([]); + const [indexOptions, setIndexOptions] = useState([]); + const [timeFieldOptions, setTimeFieldOptions] = useState([firstFieldOption]); + const [isIndiciesLoading, setIsIndiciesLoading] = useState(false); + const [alertThresholdPopoverOpen, setAlertThresholdPopoverOpen] = useState(false); + const [alertDurationPopoverOpen, setAlertDurationPopoverOpen] = useState(false); + const [aggFieldPopoverOpen, setAggFieldPopoverOpen] = useState(false); + const [groupByPopoverOpen, setGroupByPopoverOpen] = useState(false); + + const andThresholdText = i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.andLabel', + { + defaultMessage: 'AND', + } + ); + + const hasExpressionErrors = !!Object.keys(errors).find( + errorKey => expressionFieldsWithValidation.includes(errorKey) && errors[errorKey].length >= 1 + ); + + const getIndexPatterns = async () => { + const indexPatternObjects = await loadIndexPatterns(); + const titles = indexPatternObjects.map((indexPattern: any) => indexPattern.attributes.title); + setIndexPatterns(titles); + }; + + const expressionErrorMessage = i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.fixErrorInExpressionBelowValidationMessage', + { + defaultMessage: 'Expression contains errors.', + } + ); + + const setDefaultExpressionValues = () => { + setAlertProperty('params', { + aggType: DEFAULT_VALUES.AGGREGATION_TYPE, + termSize: DEFAULT_VALUES.TERM_SIZE, + thresholdComparator: DEFAULT_VALUES.THRESHOLD_COMPARATOR, + timeWindowSize: DEFAULT_VALUES.TIME_WINDOW_SIZE, + timeWindowUnit: DEFAULT_VALUES.TIME_WINDOW_UNIT, + triggerIntervalUnit: DEFAULT_VALUES.TRIGGER_INTERVAL_UNIT, + groupBy: DEFAULT_VALUES.GROUP_BY, + threshold: DEFAULT_VALUES.THRESHOLD, + }); + }; + + const getFields = async (indexes: string[]) => { + return await getThresholdAlertTypeFields({ indexes, http }); + }; + + useEffect(() => { + getIndexPatterns(); + }, []); + + useEffect(() => { + setDefaultExpressionValues(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + interface IOption { + label: string; + options: Array<{ value: string; label: string }>; + } + + const getIndexOptions = async (pattern: string, indexPatternsParam: string[]) => { + const options: IOption[] = []; + + if (!pattern) { + return options; + } + + const matchingIndices = (await getMatchingIndicesForThresholdAlertType({ + pattern, + http, + })) as string[]; + const matchingIndexPatterns = indexPatternsParam.filter(anIndexPattern => { + return anIndexPattern.includes(pattern); + }) as string[]; + + if (matchingIndices.length || matchingIndexPatterns.length) { + const matchingOptions = _.uniq([...matchingIndices, ...matchingIndexPatterns]); + + options.push({ + label: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.indicesAndIndexPatternsLabel', + { + defaultMessage: 'Based on your indices and index patterns', + } + ), + options: matchingOptions.map(match => { + return { + label: match, + value: match, + }; + }), + }); + } + + options.push({ + label: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.threshold.chooseLabel', { + defaultMessage: 'Choose…', + }), + options: [ + { + value: pattern, + label: pattern, + }, + ], + }); + + return options; + }; + + const indexPopover = ( + + + + + + } + isInvalid={hasErrors && index !== undefined} + error={errors.index} + helpText={ + + } + > + { + return { + label: anIndex, + value: anIndex, + }; + })} + onChange={async (selected: EuiComboBoxOptionProps[]) => { + setAlertParams( + 'index', + selected.map(aSelected => aSelected.value) + ); + const indices = selected.map(s => s.value as string); + + // reset time field and expression fields if indices are deleted + if (indices.length === 0) { + setTimeFieldOptions([firstFieldOption]); + setAlertParams('timeFields', []); + + setDefaultExpressionValues(); + return; + } + const currentEsFields = await getFields(indices); + const timeFields = getTimeFieldOptions(currentEsFields as any); + + setEsFields(currentEsFields); + setAlertParams('timeFields', timeFields); + setTimeFieldOptions([firstFieldOption, ...timeFields]); + }} + onSearchChange={async search => { + setIsIndiciesLoading(true); + setIndexOptions(await getIndexOptions(search, indexPatterns)); + setIsIndiciesLoading(false); + }} + onBlur={() => { + if (!index) { + setAlertParams('index', []); + } + }} + /> + + + + + } + isInvalid={hasErrors && timeField !== undefined} + error={errors.timeField} + > + { + setAlertParams('timeField', e.target.value); + }} + onBlur={() => { + if (timeField === undefined) { + setAlertParams('timeField', ''); + } + }} + /> + + + + + + ); + + return ( + + {hasExpressionErrors ? ( + + + + + + ) : null} + + + { + setIndexPopoverOpen(true); + }} + color={index ? 'secondary' : 'danger'} + /> + } + isOpen={indexPopoverOpen} + closePopover={() => { + setIndexPopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + zIndex={8000} + > +
+ + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.indexButtonLabel', + { + defaultMessage: 'index', + } + )} + + {indexPopover} +
+
+
+ + { + setAggTypePopoverOpen(true); + }} + /> + } + isOpen={aggTypePopoverOpen} + closePopover={() => { + setAggTypePopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + > +
+ + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.whenButtonLabel', + { + defaultMessage: 'when', + } + )} + + { + setAlertParams('aggType', e.target.value); + setAggTypePopoverOpen(false); + }} + options={Object.values(aggregationTypes).map(({ text, value }) => { + return { + text, + value, + }; + })} + /> +
+
+
+ {aggType && aggregationTypes[aggType].fieldRequired ? ( + + { + setAggFieldPopoverOpen(true); + }} + color={aggField ? 'secondary' : 'danger'} + /> + } + isOpen={aggFieldPopoverOpen} + closePopover={() => { + setAggFieldPopoverOpen(false); + }} + anchorPosition="downLeft" + > +
+ + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.ofButtonLabel', + { + defaultMessage: 'of', + } + )} + + + + + { + if ( + aggregationTypes[aggType].validNormalizedTypes.includes( + field.normalizedType + ) + ) { + esFieldOptions.push({ + label: field.name, + }); + } + return esFieldOptions; + }, [])} + selectedOptions={aggField ? [{ label: aggField }] : []} + onChange={selectedOptions => { + setAlertParams( + 'aggField', + selectedOptions.length === 1 ? selectedOptions[0].label : undefined + ); + setAggFieldPopoverOpen(false); + }} + /> + + + +
+
+
+ ) : null} + + { + setGroupByPopoverOpen(true); + }} + color={groupBy === 'all' || (termSize && termField) ? 'secondary' : 'danger'} + /> + } + isOpen={groupByPopoverOpen} + closePopover={() => { + setGroupByPopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + > +
+ + {i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.threshold.overButtonLabel', + { + defaultMessage: 'over', + } + )} + + + + { + setAlertParams('termSize', null); + setAlertParams('termField', null); + setAlertParams('groupBy', e.target.value); + }} + options={Object.values(groupByTypes).map(({ text, value }) => { + return { + text, + value, + }; + })} + /> + + + {groupByTypes[groupBy || DEFAULT_VALUES.GROUP_BY].sizeRequired ? ( + + + + { + const { value } = e.target; + const termSizeVal = value !== '' ? parseFloat(value) : value; + setAlertParams('termSize', termSizeVal); + }} + min={1} + /> + + + + + { + setAlertParams('termField', e.target.value); + }} + options={esFields.reduce( + (options: any, field: any) => { + if ( + groupByTypes[ + groupBy || DEFAULT_VALUES.GROUP_BY + ].validNormalizedTypes.includes(field.normalizedType) + ) { + options.push({ + text: field.name, + value: field.name, + }); + } + return options; + }, + [firstFieldOption] + )} + /> + + + + ) : null} + +
+
+
+ + { + setAlertThresholdPopoverOpen(true); + }} + color={ + (errors.threshold0 && errors.threshold0.length) || + (errors.threshold1 && errors.threshold1.length) + ? 'danger' + : 'secondary' + } + /> + } + isOpen={alertThresholdPopoverOpen} + closePopover={() => { + setAlertThresholdPopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + > +
+ + {comparators[thresholdComparator || DEFAULT_VALUES.THRESHOLD_COMPARATOR].text} + + + + { + setAlertParams('thresholdComparator', e.target.value); + }} + options={Object.values(comparators).map(({ text, value }) => { + return { text, value }; + })} + /> + + {Array.from( + Array( + comparators[thresholdComparator || DEFAULT_VALUES.THRESHOLD_COMPARATOR] + .requiredValues + ) + ).map((_notUsed, i) => { + return ( + + {i > 0 ? ( + + {andThresholdText} + {hasErrors && } + + ) : null} + + + { + const { value } = e.target; + const thresholdVal = value !== '' ? parseFloat(value) : value; + const newThreshold = [...threshold]; + newThreshold[i] = thresholdVal; + setAlertParams('threshold', newThreshold); + }} + /> + + + + ); + })} + +
+
+
+ + { + setAlertDurationPopoverOpen(true); + }} + color={timeWindowSize ? 'secondary' : 'danger'} + /> + } + isOpen={alertDurationPopoverOpen} + closePopover={() => { + setAlertDurationPopoverOpen(false); + }} + ownFocus + withTitle + anchorPosition="downLeft" + > +
+ + + + + + + { + const { value } = e.target; + const timeWindowSizeVal = value !== '' ? parseInt(value, 10) : value; + setAlertParams('timeWindowSize', timeWindowSizeVal); + }} + /> + + + + { + setAlertParams('timeWindowUnit', e.target.value); + }} + options={getTimeOptions(timeWindowSize)} + /> + + +
+
+
+
+ {hasExpressionErrors ? null : ( + + + + )} +
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/lib/api.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/lib/api.ts new file mode 100644 index 0000000000000..956007049a821 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/lib/api.ts @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { HttpSetup } from 'kibana/public'; + +const WATCHER_API_ROOT = '/api/watcher'; + +// TODO: replace watcher api with the proper from alerts + +export async function getMatchingIndicesForThresholdAlertType({ + pattern, + http, +}: { + pattern: string; + http: HttpSetup; +}): Promise> { + if (!pattern.startsWith('*')) { + pattern = `*${pattern}`; + } + if (!pattern.endsWith('*')) { + pattern = `${pattern}*`; + } + const { indices } = await http.post(`${WATCHER_API_ROOT}/indices`, { + body: JSON.stringify({ pattern }), + }); + return indices; +} + +export async function getThresholdAlertTypeFields({ + indexes, + http, +}: { + indexes: string[]; + http: HttpSetup; +}): Promise> { + const { fields } = await http.post(`${WATCHER_API_ROOT}/fields`, { + body: JSON.stringify({ indexes }), + }); + return fields; +} + +let savedObjectsClient: any; + +export const setSavedObjectsClient = (aSavedObjectsClient: any) => { + savedObjectsClient = aSavedObjectsClient; +}; + +export const getSavedObjectsClient = () => { + return savedObjectsClient; +}; + +export const loadIndexPatterns = async () => { + const { savedObjects } = await getSavedObjectsClient().find({ + type: 'index-pattern', + fields: ['title'], + perPage: 10000, + }); + return savedObjects; +}; + +export async function getThresholdAlertVisualizationData({ + model, + visualizeOptions, + http, +}: { + model: any; + visualizeOptions: any; + http: HttpSetup; +}): Promise> { + const { visualizeData } = await http.post(`${WATCHER_API_ROOT}/watch/visualize`, { + body: JSON.stringify({ + watch: model, + options: visualizeOptions, + }), + }); + return visualizeData; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/types.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/types.ts new file mode 100644 index 0000000000000..fd2a401fe59f3 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/types.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Comparator { + text: string; + value: string; + requiredValues: number; +} + +export interface AggregationType { + text: string; + fieldRequired: boolean; + value: string; + validNormalizedTypes: string[]; +} + +export interface GroupByType { + text: string; + sizeRequired: boolean; + value: string; + validNormalizedTypes: string[]; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx new file mode 100644 index 0000000000000..8433c585ef3e5 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/builtin_alert_types/threshold/visualization.tsx @@ -0,0 +1,303 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useEffect, useState } from 'react'; +import { IUiSettingsClient } from 'kibana/public'; +import { i18n } from '@kbn/i18n'; +import { + AnnotationDomainTypes, + Axis, + getAxisId, + getSpecId, + Chart, + LineAnnotation, + LineSeries, + Position, + ScaleType, + Settings, +} from '@elastic/charts'; +import { TimeBuckets } from 'ui/time_buckets'; +import dateMath from '@elastic/datemath'; +import moment from 'moment-timezone'; +import { EuiCallOut, EuiLoadingChart, EuiSpacer, EuiEmptyPrompt, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { npStart } from 'ui/new_platform'; +import { getThresholdAlertVisualizationData } from './lib/api'; +import { comparators, aggregationTypes } from './expression'; +import { useAppDependencies } from '../../../app_context'; +import { Alert } from '../../../../types'; + +const customTheme = () => { + return { + lineSeriesStyle: { + line: { + strokeWidth: 3, + }, + point: { + visible: false, + }, + }, + }; +}; + +const getTimezone = (uiSettings: IUiSettingsClient) => { + const config = uiSettings; + const DATE_FORMAT_CONFIG_KEY = 'dateFormat:tz'; + const isCustomTimezone = !config.isDefault(DATE_FORMAT_CONFIG_KEY); + if (isCustomTimezone) { + return config.get(DATE_FORMAT_CONFIG_KEY); + } + + const detectedTimezone = moment.tz.guess(); + if (detectedTimezone) { + return detectedTimezone; + } + // default to UTC if we can't figure out the timezone + const tzOffset = moment().format('Z'); + return tzOffset; +}; + +const getDomain = (alertParams: any) => { + const VISUALIZE_TIME_WINDOW_MULTIPLIER = 5; + const fromExpression = `now-${alertParams.timeWindowSize * VISUALIZE_TIME_WINDOW_MULTIPLIER}${ + alertParams.timeWindowUnit + }`; + const toExpression = 'now'; + const fromMoment = dateMath.parse(fromExpression); + const toMoment = dateMath.parse(toExpression); + const visualizeTimeWindowFrom = fromMoment ? fromMoment.valueOf() : 0; + const visualizeTimeWindowTo = toMoment ? toMoment.valueOf() : 0; + return { + min: visualizeTimeWindowFrom, + max: visualizeTimeWindowTo, + }; +}; + +const getThreshold = (alertParams: any) => { + return alertParams.threshold.slice( + 0, + comparators[alertParams.thresholdComparator].requiredValues + ); +}; + +const getTimeBuckets = (alertParams: any) => { + const domain = getDomain(alertParams); + const timeBuckets = new TimeBuckets(); + timeBuckets.setBounds(domain); + return timeBuckets; +}; + +interface Props { + alert: Alert; +} + +export const ThresholdVisualization: React.FunctionComponent = ({ alert }) => { + const { http, uiSettings, toastNotifications } = useAppDependencies(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(undefined); + const [visualizationData, setVisualizationData] = useState>([]); + + const chartsTheme = npStart.plugins.eui_utils.useChartsTheme(); + const { + index, + timeField, + triggerIntervalSize, + triggerIntervalUnit, + aggType, + aggField, + termSize, + termField, + thresholdComparator, + timeWindowSize, + timeWindowUnit, + groupBy, + threshold, + } = alert.params; + + const domain = getDomain(alert.params); + const timeBuckets = new TimeBuckets(); + timeBuckets.setBounds(domain); + const interval = timeBuckets.getInterval().expression; + const visualizeOptions = { + rangeFrom: domain.min, + rangeTo: domain.max, + interval, + timezone: getTimezone(uiSettings), + }; + + // Fetching visualization data is independent of alert actions + const alertWithoutActions = { ...alert.params, actions: [], type: 'threshold' }; + + useEffect(() => { + (async () => { + try { + setIsLoading(true); + setVisualizationData( + await getThresholdAlertVisualizationData({ + model: alertWithoutActions, + visualizeOptions, + http, + }) + ); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.unableToLoadVisualizationMessage', + { defaultMessage: 'Unable to load visualization' } + ), + }); + setError(e); + } finally { + setIsLoading(false); + } + })(); + /* eslint-disable react-hooks/exhaustive-deps */ + }, [ + index, + timeField, + triggerIntervalSize, + triggerIntervalUnit, + aggType, + aggField, + termSize, + termField, + thresholdComparator, + timeWindowSize, + timeWindowUnit, + groupBy, + threshold, + ]); + /* eslint-enable react-hooks/exhaustive-deps */ + + if (isLoading) { + return ( + } + body={ + + + + } + /> + ); + } + + if (error) { + return ( + + + + } + color="danger" + iconType="alert" + > + {error} + + + + ); + } + + if (visualizationData) { + const alertVisualizationDataKeys = Object.keys(visualizationData); + const timezone = getTimezone(uiSettings); + const actualThreshold = getThreshold(alert.params); + let maxY = actualThreshold[actualThreshold.length - 1]; + + (Object.values(visualizationData) as number[][][]).forEach(data => { + data.forEach(([, y]) => { + if (y > maxY) { + maxY = y; + } + }); + }); + const dateFormatter = (d: number) => { + return moment(d) + .tz(timezone) + .format(getTimeBuckets(alert.params).getScaledDateFormat()); + }; + const aggLabel = aggregationTypes[aggType].text; + return ( +
+ + {alertVisualizationDataKeys.length ? ( + + + + + {alertVisualizationDataKeys.map((key: string) => { + return ( + + ); + })} + {actualThreshold.map((_value: any, i: number) => { + const specId = i === 0 ? 'threshold' : `threshold${i}`; + return ( + + ); + })} + + ) : ( + + } + color="warning" + > + + + )} + +
+ ); + } + + return null; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/delete_connectors_modal.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/delete_connectors_modal.tsx new file mode 100644 index 0000000000000..b7d1a4ffe2966 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/delete_connectors_modal.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { EuiConfirmModal, EuiOverlayMask } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useAppDependencies } from '../app_context'; +import { deleteActions } from '../lib/action_connector_api'; + +export const DeleteConnectorsModal = ({ + connectorsToDelete, + callback, +}: { + connectorsToDelete: string[]; + callback: (deleted?: string[]) => void; +}) => { + const { http, toastNotifications } = useAppDependencies(); + const numConnectorsToDelete = connectorsToDelete.length; + if (!numConnectorsToDelete) { + return null; + } + const confirmModalText = i18n.translate( + 'xpack.triggersActionsUI.deleteSelectedConnectorsConfirmModal.descriptionText', + { + defaultMessage: + "You can't recover {numConnectorsToDelete, plural, one {a deleted connector} other {deleted connectors}}.", + values: { numConnectorsToDelete }, + } + ); + const confirmButtonText = i18n.translate( + 'xpack.triggersActionsUI.deleteSelectedConnectorsConfirmModal.deleteButtonLabel', + { + defaultMessage: + 'Delete {numConnectorsToDelete, plural, one {connector} other {# connectors}} ', + values: { numConnectorsToDelete }, + } + ); + const cancelButtonText = i18n.translate( + 'xpack.triggersActionsUI.deleteSelectedConnectorsConfirmModal.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + ); + return ( + + callback()} + onConfirm={async () => { + const { successes, errors } = await deleteActions({ ids: connectorsToDelete, http }); + const numSuccesses = successes.length; + const numErrors = errors.length; + callback(successes); + if (numSuccesses > 0) { + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.connectorsList.deleteSelectedConnectorsSuccessNotification.descriptionText', + { + defaultMessage: + 'Deleted {numSuccesses, number} {numSuccesses, plural, one {connector} other {connectors}}', + values: { numSuccesses }, + } + ) + ); + } + + if (numErrors > 0) { + toastNotifications.addDanger( + i18n.translate( + 'xpack.triggersActionsUI.sections.connectorsList.deleteSelectedConnectorsErrorNotification.descriptionText', + { + defaultMessage: + 'Failed to delete {numErrors, number} {numErrors, plural, one {connector} other {connectors}}', + values: { numErrors }, + } + ) + ); + } + }} + cancelButtonText={cancelButtonText} + confirmButtonText={confirmButtonText} + > + {confirmModalText} + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/section_loading.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/section_loading.tsx new file mode 100644 index 0000000000000..4c6273682a0e4 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/components/section_loading.tsx @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; + +import { EuiEmptyPrompt, EuiLoadingSpinner, EuiText } from '@elastic/eui'; + +interface Props { + children: React.ReactNode; +} + +export const SectionLoading: React.FunctionComponent = ({ children }) => { + return ( + } + body={{children}} + data-test-subj="sectionLoading" + /> + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts new file mode 100644 index 0000000000000..83a03010d55ad --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/action_groups.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum ACTION_GROUPS { + ALERT = 'alert', + WARNING = 'warning', + UNACKNOWLEDGED = 'unacknowledged', +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts new file mode 100644 index 0000000000000..a8364ffe21019 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const BASE_PATH = '/management/kibana/triggersActions'; +export const BASE_ACTION_API_PATH = '/api/action'; +export const BASE_ALERT_API_PATH = '/api/alert'; + +export type Section = 'connectors' | 'alerts'; + +export const routeToHome = `${BASE_PATH}`; +export const routeToConnectors = `${BASE_PATH}/connectors`; +export const routeToAlerts = `${BASE_PATH}/alerts`; + +export { TIME_UNITS } from './time_units'; +export enum SORT_ORDERS { + ASCENDING = 'asc', + DESCENDING = 'desc', +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/plugin.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/plugin.ts new file mode 100644 index 0000000000000..63ba7df2556de --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/plugin.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const PLUGIN = { + ID: 'triggers_actions_ui', + getI18nName: (i18n: any): string => { + return i18n.translate('xpack.triggersActionsUI.appName', { + defaultMessage: 'Alerts and Actions', + }); + }, +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/time_units.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/time_units.ts new file mode 100644 index 0000000000000..2a4f8fbd421ed --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/constants/time_units.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export enum TIME_UNITS { + SECOND = 's', + MINUTE = 'm', + HOUR = 'h', + DAY = 'd', +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/actions_connectors_context.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/actions_connectors_context.tsx new file mode 100644 index 0000000000000..11786950d0f26 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/actions_connectors_context.tsx @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { createContext, useContext } from 'react'; +import { ActionType } from '../../types'; + +export interface ActionsConnectorsContextValue { + addFlyoutVisible: boolean; + editFlyoutVisible: boolean; + setEditFlyoutVisibility: React.Dispatch>; + setAddFlyoutVisibility: React.Dispatch>; + actionTypesIndex: Record | undefined; + reloadConnectors: () => Promise; +} + +const ActionsConnectorsContext = createContext(null as any); + +export const ActionsConnectorsContextProvider = ({ + children, + value, +}: { + value: ActionsConnectorsContextValue; + children: React.ReactNode; +}) => { + return ( + {children} + ); +}; + +export const useActionsConnectorsContext = () => { + const ctx = useContext(ActionsConnectorsContext); + if (!ctx) { + throw new Error('ActionsConnectorsContext has not been set.'); + } + return ctx; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx new file mode 100644 index 0000000000000..06be1bb7c5851 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/context/alerts_context.tsx @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useContext, createContext } from 'react'; + +export interface AlertsContextValue { + alertFlyoutVisible: boolean; + setAlertFlyoutVisibility: React.Dispatch>; +} + +const AlertsContext = createContext(null as any); + +export const AlertsContextProvider = ({ + children, + value, +}: { + value: AlertsContextValue; + children: React.ReactNode; +}) => { + return {children}; +}; + +export const useAlertsContext = () => { + const ctx = useContext(AlertsContext); + if (!ctx) { + throw new Error('ActionsConnectorsContext has not been set.'); + } + return ctx; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/home.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/home.tsx new file mode 100644 index 0000000000000..3312f1a103b29 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/home.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect } from 'react'; +import { Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiPageBody, + EuiPageContent, + EuiPageContentHeader, + EuiPageContentHeaderSection, + EuiSpacer, + EuiTab, + EuiTabs, + EuiTitle, +} from '@elastic/eui'; + +import { BASE_PATH, Section, routeToConnectors, routeToAlerts } from './constants'; +import { getCurrentBreadcrumb } from './lib/breadcrumb'; +import { getCurrentDocTitle } from './lib/doc_title'; +import { useAppDependencies } from './app_context'; +import { hasShowActionsCapability, hasShowAlertsCapability } from './lib/capabilities'; + +import { ActionsConnectorsList } from './sections/actions_connectors_list/components/actions_connectors_list'; +import { AlertsList } from './sections/alerts_list/components/alerts_list'; + +interface MatchParams { + section: Section; +} + +export const TriggersActionsUIHome: React.FunctionComponent> = ({ + match: { + params: { section }, + }, + history, +}) => { + const { + chrome, + legacy: { MANAGEMENT_BREADCRUMB, capabilities }, + } = useAppDependencies(); + + const canShowActions = hasShowActionsCapability(capabilities.get()); + const canShowAlerts = hasShowAlertsCapability(capabilities.get()); + const tabs: Array<{ + id: Section; + name: React.ReactNode; + }> = []; + + if (canShowAlerts) { + tabs.push({ + id: 'alerts', + name: ( + + ), + }); + } + + if (canShowActions) { + tabs.push({ + id: 'connectors', + name: ( + + ), + }); + } + + const onSectionChange = (newSection: Section) => { + history.push(`${BASE_PATH}/${newSection}`); + }; + + // Set breadcrumb and page title + useEffect(() => { + chrome.setBreadcrumbs([MANAGEMENT_BREADCRUMB, getCurrentBreadcrumb(section || 'home')]); + chrome.docTitle.change(getCurrentDocTitle(section || 'home')); + }, [section, chrome, MANAGEMENT_BREADCRUMB]); + + return ( + + + + + +

+ +

+
+
+
+ + + {tabs.map(tab => ( + onSectionChange(tab.id)} + isSelected={tab.id === section} + key={tab.id} + data-test-subj={`${tab.id}Tab`} + > + {tab.name} + + ))} + + + + + + {canShowActions && ( + + )} + {canShowAlerts && } + +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts new file mode 100644 index 0000000000000..bc2949917edea --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.test.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ActionConnector, ActionConnectorWithoutId, ActionType } from '../../types'; +import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { + createActionConnector, + deleteActions, + loadActionTypes, + loadAllActions, + updateActionConnector, +} from './action_connector_api'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('loadActionTypes', () => { + test('should call get types API', async () => { + const resolvedValue: ActionType[] = [ + { + id: 'test', + name: 'Test', + }, + ]; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadActionTypes({ http }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/action/types", + ] + `); + }); +}); + +describe('loadAllActions', () => { + test('should call find actions API', async () => { + const resolvedValue = { + page: 1, + perPage: 10000, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAllActions({ http }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/action/_find", + Object { + "query": Object { + "per_page": 10000, + }, + }, + ] + `); + }); +}); + +describe('createActionConnector', () => { + test('should call create action API', async () => { + const connector: ActionConnectorWithoutId = { + actionTypeId: 'test', + name: 'My test', + config: {}, + secrets: {}, + }; + const resolvedValue: ActionConnector = { ...connector, id: '123' }; + http.post.mockResolvedValueOnce(resolvedValue); + + const result = await createActionConnector({ http, connector }); + expect(result).toEqual(resolvedValue); + expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/action", + Object { + "body": "{\\"actionTypeId\\":\\"test\\",\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{}}", + }, + ] + `); + }); +}); + +describe('updateActionConnector', () => { + test('should call the update API', async () => { + const id = '123'; + const connector: ActionConnectorWithoutId = { + actionTypeId: 'test', + name: 'My test', + config: {}, + secrets: {}, + }; + const resolvedValue = { ...connector, id }; + http.put.mockResolvedValueOnce(resolvedValue); + + const result = await updateActionConnector({ http, connector, id }); + expect(result).toEqual(resolvedValue); + expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/action/123", + Object { + "body": "{\\"name\\":\\"My test\\",\\"config\\":{},\\"secrets\\":{}}", + }, + ] + `); + }); +}); + +describe('deleteActions', () => { + test('should call delete API per action', async () => { + const ids = ['1', '2', '3']; + + const result = await deleteActions({ ids, http }); + expect(result).toEqual({ errors: [], successes: [undefined, undefined, undefined] }); + expect(http.delete.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/action/1", + ], + Array [ + "/api/action/2", + ], + Array [ + "/api/action/3", + ], + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.ts new file mode 100644 index 0000000000000..5b2b59603d281 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/action_connector_api.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'kibana/public'; +import { BASE_ACTION_API_PATH } from '../constants'; +import { ActionConnector, ActionConnectorWithoutId, ActionType } from '../../types'; + +// We are assuming there won't be many actions. This is why we will load +// all the actions in advance and assume the total count to not go over 100 or so. +// We'll set this max setting assuming it's never reached. +const MAX_ACTIONS_RETURNED = 10000; + +export async function loadActionTypes({ http }: { http: HttpSetup }): Promise { + return await http.get(`${BASE_ACTION_API_PATH}/types`); +} + +export async function loadAllActions({ + http, +}: { + http: HttpSetup; +}): Promise<{ + page: number; + perPage: number; + total: number; + data: ActionConnector[]; +}> { + return await http.get(`${BASE_ACTION_API_PATH}/_find`, { + query: { + per_page: MAX_ACTIONS_RETURNED, + }, + }); +} + +export async function createActionConnector({ + http, + connector, +}: { + http: HttpSetup; + connector: Omit; +}): Promise { + return await http.post(`${BASE_ACTION_API_PATH}`, { + body: JSON.stringify(connector), + }); +} + +export async function updateActionConnector({ + http, + connector, + id, +}: { + http: HttpSetup; + connector: Pick; + id: string; +}): Promise { + return await http.put(`${BASE_ACTION_API_PATH}/${id}`, { + body: JSON.stringify({ + name: connector.name, + config: connector.config, + secrets: connector.secrets, + }), + }); +} + +export async function deleteActions({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise<{ successes: string[]; errors: string[] }> { + const successes: string[] = []; + const errors: string[] = []; + await Promise.all(ids.map(id => http.delete(`${BASE_ACTION_API_PATH}/${id}`))).then( + function(fulfilled) { + successes.push(...fulfilled); + }, + function(rejected) { + errors.push(...rejected); + } + ); + return { successes, errors }; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts new file mode 100644 index 0000000000000..858c90258247e --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.test.ts @@ -0,0 +1,406 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Alert, AlertType } from '../../types'; +import { httpServiceMock } from '../../../../../../../../src/core/public/mocks'; +import { + createAlert, + deleteAlerts, + disableAlerts, + enableAlerts, + loadAlerts, + loadAlertTypes, + muteAlerts, + unmuteAlerts, + updateAlert, +} from './alert_api'; + +const http = httpServiceMock.createStartContract(); + +beforeEach(() => jest.resetAllMocks()); + +describe('loadAlertTypes', () => { + test('should call get alert types API', async () => { + const resolvedValue: AlertType[] = [ + { + id: 'test', + name: 'Test', + }, + ]; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlertTypes({ http }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/types", + ] + `); + }); +}); + +describe('loadAlerts', () => { + test('should call find API with base parameters', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ http, page: { index: 0, size: 10 } }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + }, + }, + ] + `); + }); + + test('should call find API with searchText', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ http, searchText: 'apples', page: { index: 0, size: 10 } }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": "apples", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); + + test('should call find API with actionTypesFilter', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + searchText: 'foo', + page: { index: 0, size: 10 }, + }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": undefined, + "page": 1, + "per_page": 10, + "search": "foo", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); + + test('should call find API with typesFilter', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + typesFilter: ['foo', 'bar'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": undefined, + "search_fields": undefined, + }, + }, + ] + `); + }); + + test('should call find API with actionTypesFilter and typesFilter', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + searchText: 'baz', + typesFilter: ['foo', 'bar'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": "baz", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); + + test('should call find API with searchText and tagsFilter and typesFilter', async () => { + const resolvedValue = { + page: 1, + perPage: 10, + total: 0, + data: [], + }; + http.get.mockResolvedValueOnce(resolvedValue); + + const result = await loadAlerts({ + http, + searchText: 'apples, foo, baz', + typesFilter: ['foo', 'bar'], + page: { index: 0, size: 10 }, + }); + expect(result).toEqual(resolvedValue); + expect(http.get.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/_find", + Object { + "query": Object { + "default_search_operator": "AND", + "filter": "alert.attributes.alertTypeId:(foo or bar)", + "page": 1, + "per_page": 10, + "search": "apples, foo, baz", + "search_fields": "[\\"name\\",\\"tags\\"]", + }, + }, + ] + `); + }); +}); + +describe('deleteAlerts', () => { + test('should call delete API for each alert', async () => { + const ids = ['1', '2', '3']; + const result = await deleteAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.delete.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1", + ], + Array [ + "/api/alert/2", + ], + Array [ + "/api/alert/3", + ], + ] + `); + }); +}); + +describe('createAlert', () => { + test('should call create alert API', async () => { + const alertToCreate = { + name: 'test', + tags: ['foo'], + enabled: true, + alertTypeId: 'test', + interval: '1m', + actions: [], + params: {}, + throttle: null, + }; + const resolvedValue: Alert = { + ...alertToCreate, + id: '123', + createdBy: null, + updatedBy: null, + muteAll: false, + mutedInstanceIds: [], + }; + http.post.mockResolvedValueOnce(resolvedValue); + + const result = await createAlert({ http, alert: alertToCreate }); + expect(result).toEqual(resolvedValue); + expect(http.post.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert", + Object { + "body": "{\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"enabled\\":true,\\"alertTypeId\\":\\"test\\",\\"interval\\":\\"1m\\",\\"actions\\":[],\\"params\\":{},\\"throttle\\":null}", + }, + ] + `); + }); +}); + +describe('updateAlert', () => { + test('should call alert update API', async () => { + const alertToUpdate = { + throttle: '1m', + name: 'test', + tags: ['foo'], + interval: '1m', + params: {}, + actions: [], + }; + const resolvedValue: Alert = { + ...alertToUpdate, + id: '123', + enabled: true, + alertTypeId: 'test', + createdBy: null, + updatedBy: null, + muteAll: false, + mutedInstanceIds: [], + }; + http.put.mockResolvedValueOnce(resolvedValue); + + const result = await updateAlert({ http, id: '123', alert: alertToUpdate }); + expect(result).toEqual(resolvedValue); + expect(http.put.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "/api/alert/123", + Object { + "body": "{\\"throttle\\":\\"1m\\",\\"name\\":\\"test\\",\\"tags\\":[\\"foo\\"],\\"interval\\":\\"1m\\",\\"params\\":{},\\"actions\\":[]}", + }, + ] + `); + }); +}); + +describe('enableAlerts', () => { + test('should call enable alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await enableAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_enable", + ], + Array [ + "/api/alert/2/_enable", + ], + Array [ + "/api/alert/3/_enable", + ], + ] + `); + }); +}); + +describe('disableAlerts', () => { + test('should call disable alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await disableAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_disable", + ], + Array [ + "/api/alert/2/_disable", + ], + Array [ + "/api/alert/3/_disable", + ], + ] + `); + }); +}); + +describe('muteAlerts', () => { + test('should call mute alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await muteAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_mute_all", + ], + Array [ + "/api/alert/2/_mute_all", + ], + Array [ + "/api/alert/3/_mute_all", + ], + ] + `); + }); +}); + +describe('unmuteAlerts', () => { + test('should call unmute alert API per alert', async () => { + const ids = ['1', '2', '3']; + const result = await unmuteAlerts({ http, ids }); + expect(result).toEqual(undefined); + expect(http.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/alert/1/_unmute_all", + ], + Array [ + "/api/alert/2/_unmute_all", + ], + Array [ + "/api/alert/3/_unmute_all", + ], + ] + `); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts new file mode 100644 index 0000000000000..9867acbd7a622 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/alert_api.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { HttpSetup } from 'kibana/public'; +import { BASE_ALERT_API_PATH } from '../constants'; +import { Alert, AlertType, AlertWithoutId } from '../../types'; + +export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { + return await http.get(`${BASE_ALERT_API_PATH}/types`); +} + +export async function loadAlerts({ + http, + page, + searchText, + typesFilter, + actionTypesFilter, +}: { + http: HttpSetup; + page: { index: number; size: number }; + searchText?: string; + typesFilter?: string[]; + actionTypesFilter?: string[]; +}): Promise<{ + page: number; + perPage: number; + total: number; + data: Alert[]; +}> { + const filters = []; + if (typesFilter && typesFilter.length) { + filters.push(`alert.attributes.alertTypeId:(${typesFilter.join(' or ')})`); + } + if (actionTypesFilter && actionTypesFilter.length) { + filters.push( + [ + '(', + actionTypesFilter.map(id => `alert.attributes.actions:{ actionTypeId:${id} }`).join(' OR '), + ')', + ].join('') + ); + } + return await http.get(`${BASE_ALERT_API_PATH}/_find`, { + query: { + page: page.index + 1, + per_page: page.size, + search_fields: searchText ? JSON.stringify(['name', 'tags']) : undefined, + search: searchText, + filter: filters.length ? filters.join(' and ') : undefined, + default_search_operator: 'AND', + }, + }); +} + +export async function deleteAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map(id => http.delete(`${BASE_ALERT_API_PATH}/${id}`))); +} + +export async function createAlert({ + http, + alert, +}: { + http: HttpSetup; + alert: Omit; +}): Promise { + return await http.post(`${BASE_ALERT_API_PATH}`, { + body: JSON.stringify(alert), + }); +} + +export async function updateAlert({ + http, + alert, + id, +}: { + http: HttpSetup; + alert: Pick; + id: string; +}): Promise { + return await http.put(`${BASE_ALERT_API_PATH}/${id}`, { + body: JSON.stringify(alert), + }); +} + +export async function enableAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_enable`))); +} + +export async function disableAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_disable`))); +} + +export async function muteAlerts({ ids, http }: { ids: string[]; http: HttpSetup }): Promise { + await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_mute_all`))); +} + +export async function unmuteAlerts({ + ids, + http, +}: { + ids: string[]; + http: HttpSetup; +}): Promise { + await Promise.all(ids.map(id => http.post(`${BASE_ALERT_API_PATH}/${id}/_unmute_all`))); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.test.ts new file mode 100644 index 0000000000000..b75e014640d72 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getCurrentBreadcrumb } from './breadcrumb'; +import { i18n } from '@kbn/i18n'; +import { routeToConnectors, routeToAlerts, routeToHome } from '../constants'; + +describe('getCurrentBreadcrumb', () => { + test('if change calls return proper breadcrumb title ', async () => { + expect(getCurrentBreadcrumb('connectors')).toMatchObject({ + text: i18n.translate('xpack.triggersActionsUI.connectors.breadcrumbTitle', { + defaultMessage: 'Connectors', + }), + href: `#${routeToConnectors}`, + }); + expect(getCurrentBreadcrumb('alerts')).toMatchObject({ + text: i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', { + defaultMessage: 'Alerts', + }), + href: `#${routeToAlerts}`, + }); + expect(getCurrentBreadcrumb('home')).toMatchObject({ + text: i18n.translate('xpack.triggersActionsUI.home.breadcrumbTitle', { + defaultMessage: 'Alerts and Actions', + }), + href: `#${routeToHome}`, + }); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.ts new file mode 100644 index 0000000000000..f833ae9eb39ac --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/breadcrumb.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { routeToHome, routeToConnectors, routeToAlerts } from '../constants'; + +export const getCurrentBreadcrumb = (type: string): { text: string; href: string } => { + // Home and sections + switch (type) { + case 'connectors': + return { + text: i18n.translate('xpack.triggersActionsUI.connectors.breadcrumbTitle', { + defaultMessage: 'Connectors', + }), + href: `#${routeToConnectors}`, + }; + case 'alerts': + return { + text: i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', { + defaultMessage: 'Alerts', + }), + href: `#${routeToAlerts}`, + }; + default: + return { + text: i18n.translate('xpack.triggersActionsUI.home.breadcrumbTitle', { + defaultMessage: 'Alerts and Actions', + }), + href: `#${routeToHome}`, + }; + } +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/capabilities.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/capabilities.ts new file mode 100644 index 0000000000000..e5693e31c2d66 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/capabilities.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * NOTE: Applications that want to show the alerting UIs will need to add + * check against their features here until we have a better solution. This + * will possibly go away with https://github.com/elastic/kibana/issues/52300. + */ + +export function hasShowAlertsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['alerting:show']) { + return true; + } + return false; +} + +export function hasShowActionsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['actions:show']) { + return true; + } + return false; +} + +export function hasSaveAlertsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['alerting:save']) { + return true; + } + return false; +} + +export function hasSaveActionsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['actions:save']) { + return true; + } + return false; +} + +export function hasDeleteAlertsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['alerting:delete']) { + return true; + } + return false; +} + +export function hasDeleteActionsCapability(capabilities: any): boolean { + if (capabilities.siem && capabilities.siem['actions:delete']) { + return true; + } + return false; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.test.ts new file mode 100644 index 0000000000000..f351adf79eb2c --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.test.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getCurrentDocTitle } from './doc_title'; + +describe('getCurrentDocTitle', () => { + test('if change calls return the proper doc title ', async () => { + expect(getCurrentDocTitle('home') === 'Alerts and Actions').toBeTruthy(); + expect(getCurrentDocTitle('connectors') === 'Connectors').toBeTruthy(); + expect(getCurrentDocTitle('alerts') === 'Alerts').toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.ts new file mode 100644 index 0000000000000..15bd6bc77b132 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/doc_title.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { i18n } from '@kbn/i18n'; + +export const getCurrentDocTitle = (page: string): string => { + let updatedTitle: string; + + switch (page) { + case 'connectors': + updatedTitle = i18n.translate('xpack.triggersActionsUI.connectors.breadcrumbTitle', { + defaultMessage: 'Connectors', + }); + break; + case 'alerts': + updatedTitle = i18n.translate('xpack.triggersActionsUI.alerts.breadcrumbTitle', { + defaultMessage: 'Alerts', + }); + break; + default: + updatedTitle = i18n.translate('xpack.triggersActionsUI.home.breadcrumbTitle', { + defaultMessage: 'Alerts and Actions', + }); + } + return updatedTitle; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.test.ts new file mode 100644 index 0000000000000..3ed7eea026db4 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getTimeOptions, getTimeFieldOptions } from './get_time_options'; + +describe('get_time_options', () => { + test('if getTimeOptions return single unit time options', () => { + const timeUnitValue = getTimeOptions('1'); + expect(timeUnitValue).toMatchObject([ + { text: 'second', value: 's' }, + { text: 'minute', value: 'm' }, + { text: 'hour', value: 'h' }, + { text: 'day', value: 'd' }, + ]); + }); + + test('if getTimeOptions return multiple unit time options', () => { + const timeUnitValue = getTimeOptions('10'); + expect(timeUnitValue).toMatchObject([ + { text: 'seconds', value: 's' }, + { text: 'minutes', value: 'm' }, + { text: 'hours', value: 'h' }, + { text: 'days', value: 'd' }, + ]); + }); + + test('if getTimeFieldOptions return only date type fields', () => { + const timeOnlyTypeFields = getTimeFieldOptions([ + { type: 'date', name: 'order_date' }, + { type: 'number', name: 'sum' }, + ]); + expect(timeOnlyTypeFields).toMatchObject([{ text: 'order_date', value: 'order_date' }]); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.ts new file mode 100644 index 0000000000000..d24f20a4fc289 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_options.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getTimeUnitLabel } from './get_time_unit_label'; +import { TIME_UNITS } from '../constants'; + +export const getTimeOptions = (unitSize: string) => + Object.entries(TIME_UNITS).map(([_key, value]) => { + return { + text: getTimeUnitLabel(value, unitSize), + value, + }; + }); + +interface TimeFieldOptions { + text: string; + value: string; +} + +export const getTimeFieldOptions = ( + fields: Array<{ type: string; name: string }> +): TimeFieldOptions[] => { + const options: TimeFieldOptions[] = []; + + fields.forEach((field: { type: string; name: string }) => { + if (field.type === 'date') { + options.push({ + text: field.name, + value: field.name, + }); + } + }); + return options; +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_unit_label.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_unit_label.ts new file mode 100644 index 0000000000000..a621855415328 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/lib/get_time_unit_label.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { TIME_UNITS } from '../constants'; + +export function getTimeUnitLabel(timeUnit = TIME_UNITS.SECOND, timeValue = '0') { + switch (timeUnit) { + case TIME_UNITS.SECOND: + return i18n.translate('xpack.triggersActionsUI.timeUnits.secondLabel', { + defaultMessage: '{timeValue, plural, one {second} other {seconds}}', + values: { timeValue }, + }); + case TIME_UNITS.MINUTE: + return i18n.translate('xpack.triggersActionsUI.timeUnits.minuteLabel', { + defaultMessage: '{timeValue, plural, one {minute} other {minutes}}', + values: { timeValue }, + }); + case TIME_UNITS.HOUR: + return i18n.translate('xpack.triggersActionsUI.timeUnits.hourLabel', { + defaultMessage: '{timeValue, plural, one {hour} other {hours}}', + values: { timeValue }, + }); + case TIME_UNITS.DAY: + return i18n.translate('xpack.triggersActionsUI.timeUnits.dayLabel', { + defaultMessage: '{timeValue, plural, one {day} other {days}}', + values: { timeValue }, + }); + } +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx new file mode 100644 index 0000000000000..c129ce73c7176 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult, ActionConnector } from '../../../types'; +import { ActionConnectorForm } from './action_connector_form'; +import { AppContextProvider } from '../../app_context'; +const actionTypeRegistry = actionTypeRegistryMock.create(); + +describe('action_connector_form', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + actions: { + delete: true, + save: true, + show: true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + + const actionType = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValue(actionType); + actionTypeRegistry.has.mockReturnValue(true); + + const initialConnector = { + actionTypeId: actionType.id, + config: {}, + secrets: {}, + } as ActionConnector; + + await act(async () => { + wrapper = mountWithIntl( + + {}, + editFlyoutVisible: false, + setEditFlyoutVisibility: () => {}, + actionTypesIndex: { + 'my-action-type': { id: 'my-action-type', name: 'my-action-type-name' }, + }, + reloadConnectors: () => { + return new Promise(() => {}); + }, + }} + > + {}} + /> + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders action_connector_form', () => { + const connectorNameField = wrapper.find('[data-test-subj="nameInput"]'); + expect(connectorNameField.exists()).toBeTruthy(); + expect(connectorNameField.first().prop('value')).toBe(''); + }); +}); + +async function waitForRender(wrapper: ReactWrapper) { + await Promise.resolve(); + await Promise.resolve(); + wrapper.update(); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx new file mode 100644 index 0000000000000..682c1fbb54b67 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_connector_form.tsx @@ -0,0 +1,270 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useState, useReducer } from 'react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiCallOut, + EuiLink, + EuiText, + EuiSpacer, + EuiButtonEmpty, + EuiFlyoutFooter, + EuiFieldText, + EuiFlyoutBody, + EuiFormRow, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { createActionConnector, updateActionConnector } from '../../lib/action_connector_api'; +import { useAppDependencies } from '../../app_context'; +import { connectorReducer } from './connector_reducer'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { ActionConnector, IErrorObject } from '../../../types'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; + +interface ActionConnectorProps { + initialConnector: ActionConnector; + actionTypeName: string; + setFlyoutVisibility: React.Dispatch>; +} + +export const ActionConnectorForm = ({ + initialConnector, + actionTypeName, + setFlyoutVisibility, +}: ActionConnectorProps) => { + const { + http, + toastNotifications, + legacy: { capabilities }, + actionTypeRegistry, + } = useAppDependencies(); + + const { reloadConnectors } = useActionsConnectorsContext(); + const canSave = hasSaveActionsCapability(capabilities.get()); + + // hooks + const [{ connector }, dispatch] = useReducer(connectorReducer, { connector: initialConnector }); + + const setActionProperty = (key: string, value: any) => { + dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); + }; + + const setActionConfigProperty = (key: string, value: any) => { + dispatch({ command: { type: 'setConfigProperty' }, payload: { key, value } }); + }; + + const setActionSecretsProperty = (key: string, value: any) => { + dispatch({ command: { type: 'setSecretsProperty' }, payload: { key, value } }); + }; + + const [isSaving, setIsSaving] = useState(false); + const [serverError, setServerError] = useState<{ + body: { message: string; error: string }; + } | null>(null); + + const actionTypeRegistered = actionTypeRegistry.get(initialConnector.actionTypeId); + if (!actionTypeRegistered) return null; + + function validateBaseProperties(actionObject: ActionConnector) { + const validationResult = { errors: {} }; + const errors = { + name: new Array(), + }; + validationResult.errors = errors; + if (!actionObject.name) { + errors.name.push( + i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.error.requiredNameText', + { + defaultMessage: 'Name is required.', + } + ) + ); + } + return validationResult; + } + + const FieldsComponent = actionTypeRegistered.actionConnectorFields; + const errors = { + ...actionTypeRegistered.validateConnector(connector).errors, + ...validateBaseProperties(connector).errors, + } as IErrorObject; + const hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1); + + async function onActionConnectorSave(): Promise { + let message: string; + let savedConnector: ActionConnector | undefined; + let error; + if (connector.id === undefined) { + await createActionConnector({ http, connector }) + .then(res => { + savedConnector = res; + }) + .catch(errorRes => { + error = errorRes; + }); + + message = 'Created'; + } else { + await updateActionConnector({ http, connector, id: connector.id }) + .then(res => { + savedConnector = res; + }) + .catch(errorRes => { + error = errorRes; + }); + message = 'Updated'; + } + if (error) { + return { + error, + }; + } + toastNotifications.addSuccess( + i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.updateSuccessNotificationText', + { + defaultMessage: "{message} '{connectorName}'", + values: { + message, + connectorName: savedConnector ? savedConnector.name : '', + }, + } + ) + ); + return savedConnector; + } + + return ( + + + + + } + isInvalid={errors.name.length > 0 && connector.name !== undefined} + error={errors.name} + > + 0 && connector.name !== undefined} + name="name" + placeholder="Untitled" + data-test-subj="nameInput" + value={connector.name || ''} + onChange={e => { + setActionProperty('name', e.target.value); + }} + onBlur={() => { + if (!connector.name) { + setActionProperty('name', ''); + } + }} + /> + + + {FieldsComponent !== null ? ( + + {initialConnector.actionTypeId === null ? ( + + + +

+ + + + ), + }} + /> +

+
+
+ +
+ ) : null} +
+ ) : null} +
+
+ + + + setFlyoutVisibility(false)}> + {i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + )} + + + {canSave ? ( + + { + setIsSaving(true); + const savedAction = await onActionConnectorSave(); + setIsSaving(false); + if (savedAction && savedAction.error) { + return setServerError(savedAction.error); + } + setFlyoutVisibility(false); + reloadConnectors(); + }} + > + + + + ) : null} + + +
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx new file mode 100644 index 0000000000000..a9e2afb061720 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.test.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ActionTypeMenu } from './action_type_menu'; +import { ValidationResult } from '../../../types'; +import { AppContextProvider } from '../../app_context'; +const actionTypeRegistry = actionTypeRegistryMock.create(); + +describe('connector_add_flyout', () => { + let deps: any; + + beforeAll(async () => { + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + actions: { + delete: true, + save: true, + show: true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + }); + + it('renders action type menu with proper EuiCards for registered action types', () => { + const onActionTypeChange = jest.fn(); + const actionType = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValueOnce(actionType); + + const wrapper = mountWithIntl( + + {}, + editFlyoutVisible: false, + setEditFlyoutVisibility: state => {}, + actionTypesIndex: { + 'first-action-type': { id: 'first-action-type', name: 'first' }, + 'second-action-type': { id: 'second-action-type', name: 'second' }, + }, + reloadConnectors: () => { + return new Promise(() => {}); + }, + }} + > + + + + ); + + expect(wrapper.find('[data-test-subj="first-action-type-card"]').exists()).toBeTruthy(); + expect(wrapper.find('[data-test-subj="second-action-type-card"]').exists()).toBeTruthy(); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx new file mode 100644 index 0000000000000..19373fda79b9e --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/action_type_menu.tsx @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiCard, + EuiIcon, + EuiFlexGrid, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiButtonEmpty, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { ActionType } from '../../../types'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { useAppDependencies } from '../../app_context'; + +interface Props { + onActionTypeChange: (actionType: ActionType) => void; +} + +export const ActionTypeMenu = ({ onActionTypeChange }: Props) => { + const { actionTypeRegistry } = useAppDependencies(); + const { actionTypesIndex, setAddFlyoutVisibility } = useActionsConnectorsContext(); + if (!actionTypesIndex) { + return null; + } + + const actionTypes = Object.entries(actionTypesIndex) + .filter(([index]) => actionTypeRegistry.has(index)) + .map(([index, actionType]) => { + const actionTypeModel = actionTypeRegistry.get(index); + return { + iconClass: actionTypeModel ? actionTypeModel.iconClass : '', + selectMessage: actionTypeModel ? actionTypeModel.selectMessage : '', + actionType, + name: actionType.name, + typeName: index.replace('.', ''), + }; + }); + + const cardNodes = actionTypes + .sort((a, b) => a.name.localeCompare(b.name)) + .map((item, index): any => { + return ( + + } + title={item.name} + description={item.selectMessage} + onClick={() => onActionTypeChange(item.actionType)} + /> + + ); + }); + + return ( + + + {cardNodes} + + + + + setAddFlyoutVisibility(false)}> + {i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.cancelButtonLabel', + { + defaultMessage: 'Cancel', + } + )} + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx new file mode 100644 index 0000000000000..5095cc140f9c9 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { ConnectorAddFlyout } from './connector_add_flyout'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult } from '../../../types'; +import { AppContextProvider } from '../../app_context'; +const actionTypeRegistry = actionTypeRegistryMock.create(); + +describe('connector_add_flyout', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + actions: { + delete: true, + save: true, + show: true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + {}, + editFlyoutVisible: false, + setEditFlyoutVisibility: state => {}, + actionTypesIndex: { 'my-action-type': { id: 'my-action-type', name: 'test' } }, + reloadConnectors: () => { + return new Promise(() => {}); + }, + }} + > + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders action type menu on flyout open', () => { + const actionType = { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValueOnce(actionType); + actionTypeRegistry.has.mockReturnValue(true); + + expect(wrapper.find('ActionTypeMenu')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="my-action-type-card"]').exists()).toBeTruthy(); + }); +}); + +async function waitForRender(wrapper: ReactWrapper) { + await Promise.resolve(); + await Promise.resolve(); + wrapper.update(); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx new file mode 100644 index 0000000000000..a3ec7ab4b3ab9 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_add_flyout.tsx @@ -0,0 +1,104 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback, useState, Fragment } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiFlyoutHeader, + EuiFlyout, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiText, +} from '@elastic/eui'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { ActionTypeMenu } from './action_type_menu'; +import { ActionConnectorForm } from './action_connector_form'; +import { ActionType, ActionConnector } from '../../../types'; +import { useAppDependencies } from '../../app_context'; + +export const ConnectorAddFlyout = () => { + const { actionTypeRegistry } = useAppDependencies(); + const { addFlyoutVisible, setAddFlyoutVisibility } = useActionsConnectorsContext(); + const [actionType, setActionType] = useState(undefined); + const closeFlyout = useCallback(() => { + setAddFlyoutVisibility(false); + setActionType(undefined); + }, [setAddFlyoutVisibility, setActionType]); + + if (!addFlyoutVisible) { + return null; + } + + function onActionTypeChange(newActionType: ActionType) { + setActionType(newActionType); + } + + let currentForm; + let actionTypeModel; + if (!actionType) { + currentForm = ; + } else { + actionTypeModel = actionTypeRegistry.get(actionType.id); + const initialConnector = { + actionTypeId: actionType.id, + config: {}, + secrets: {}, + } as ActionConnector; + + currentForm = ( + + ); + } + + return ( + + + + {actionTypeModel && actionTypeModel.iconClass ? ( + + + + ) : null} + + {actionTypeModel && actionType ? ( + + +

+ +

+
+ + {actionTypeModel.selectMessage} + +
+ ) : ( + +

+ +

+
+ )} +
+
+
+ {currentForm} +
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx new file mode 100644 index 0000000000000..d01539d7232fa --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../src/core/public/mocks'; +import { ActionsConnectorsContextProvider } from '../../context/actions_connectors_context'; +import { actionTypeRegistryMock } from '../../action_type_registry.mock'; +import { ValidationResult } from '../../../types'; +import { ConnectorEditFlyout } from './connector_edit_flyout'; +import { AppContextProvider } from '../../app_context'; +const actionTypeRegistry = actionTypeRegistryMock.create(); +let deps: any; + +describe('connector_edit_flyout', () => { + beforeAll(async () => { + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + actions: { + delete: true, + save: true, + show: true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + }); + + test('if input connector render correct in the edit form', () => { + const connector = { + secrets: {}, + id: 'test', + actionTypeId: 'test-action-type-id', + actionType: 'test-action-type-name', + name: 'action-connector', + referencedByCount: 0, + config: {}, + }; + + const actionType = { + id: 'test-action-type-id', + iconClass: 'test', + selectMessage: 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; + actionTypeRegistry.get.mockReturnValue(actionType); + actionTypeRegistry.has.mockReturnValue(true); + + const wrapper = mountWithIntl( + + {}, + editFlyoutVisible: true, + setEditFlyoutVisibility: state => {}, + actionTypesIndex: { + 'test-action-type-id': { id: 'test-action-type-id', name: 'test' }, + }, + reloadConnectors: () => { + return new Promise(() => {}); + }, + }} + > + + + + ); + + const connectorNameField = wrapper.find('[data-test-subj="nameInput"]'); + expect(connectorNameField.exists()).toBeTruthy(); + expect(connectorNameField.first().prop('value')).toBe('action-connector'); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx new file mode 100644 index 0000000000000..408989609d2ec --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_edit_flyout.tsx @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { useCallback } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiTitle, + EuiFlyoutHeader, + EuiFlyout, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, +} from '@elastic/eui'; +import { useActionsConnectorsContext } from '../../context/actions_connectors_context'; +import { ActionConnectorForm } from './action_connector_form'; +import { useAppDependencies } from '../../app_context'; +import { ActionConnectorTableItem } from '../../../types'; + +export interface ConnectorEditProps { + connector: ActionConnectorTableItem; +} + +export const ConnectorEditFlyout = ({ connector }: ConnectorEditProps) => { + const { actionTypeRegistry } = useAppDependencies(); + const { editFlyoutVisible, setEditFlyoutVisibility } = useActionsConnectorsContext(); + const closeFlyout = useCallback(() => setEditFlyoutVisibility(false), [setEditFlyoutVisibility]); + + if (!editFlyoutVisible) { + return null; + } + + const actionTypeModel = actionTypeRegistry.get(connector.actionTypeId); + + return ( + + + + {actionTypeModel ? ( + + + + ) : null} + + +

+ +

+
+
+
+
+ +
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.test.ts new file mode 100644 index 0000000000000..df7e5d8fe9a78 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.test.ts @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { connectorReducer } from './connector_reducer'; +import { ActionConnector } from '../../../types'; + +describe('connector reducer', () => { + let initialConnector: ActionConnector; + beforeAll(() => { + initialConnector = { + secrets: {}, + id: 'test', + actionTypeId: 'test-action-type-id', + name: 'action-connector', + referencedByCount: 0, + config: {}, + }; + }); + + test('if property name was changed', () => { + const updatedConnector = connectorReducer( + { connector: initialConnector }, + { + command: { type: 'setProperty' }, + payload: { + key: 'name', + value: 'new name', + }, + } + ); + expect(updatedConnector.connector.name).toBe('new name'); + }); + + test('if config property was added and updated', () => { + const updatedConnector = connectorReducer( + { connector: initialConnector }, + { + command: { type: 'setConfigProperty' }, + payload: { + key: 'testConfig', + value: 'new test config property', + }, + } + ); + expect(updatedConnector.connector.config.testConfig).toBe('new test config property'); + + const updatedConnectorUpdatedProperty = connectorReducer( + { connector: updatedConnector.connector }, + { + command: { type: 'setConfigProperty' }, + payload: { + key: 'testConfig', + value: 'test config property updated', + }, + } + ); + expect(updatedConnectorUpdatedProperty.connector.config.testConfig).toBe( + 'test config property updated' + ); + }); + + test('if secrets property was added', () => { + const updatedConnector = connectorReducer( + { connector: initialConnector }, + { + command: { type: 'setSecretsProperty' }, + payload: { + key: 'testSecret', + value: 'new test secret property', + }, + } + ); + expect(updatedConnector.connector.secrets.testSecret).toBe('new test secret property'); + + const updatedConnectorUpdatedProperty = connectorReducer( + { connector: updatedConnector.connector }, + { + command: { type: 'setSecretsProperty' }, + payload: { + key: 'testSecret', + value: 'test secret property updated', + }, + } + ); + expect(updatedConnectorUpdatedProperty.connector.secrets.testSecret).toBe( + 'test secret property updated' + ); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.ts new file mode 100644 index 0000000000000..4a2610f965735 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/connector_reducer.ts @@ -0,0 +1,78 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isEqual } from 'lodash'; + +interface CommandType { + type: 'setProperty' | 'setConfigProperty' | 'setSecretsProperty'; +} + +export interface ActionState { + connector: any; +} + +export interface ReducerAction { + command: CommandType; + payload: { + key: string; + value: any; + }; +} + +export const connectorReducer = (state: ActionState, action: ReducerAction) => { + const { command, payload } = action; + const { connector } = state; + + switch (command.type) { + case 'setProperty': { + const { key, value } = payload; + if (isEqual(connector[key], value)) { + return state; + } else { + return { + ...state, + connector: { + ...connector, + [key]: value, + }, + }; + } + } + case 'setConfigProperty': { + const { key, value } = payload; + if (isEqual(connector.config[key], value)) { + return state; + } else { + return { + ...state, + connector: { + ...connector, + config: { + ...connector.config, + [key]: value, + }, + }, + }; + } + } + case 'setSecretsProperty': { + const { key, value } = payload; + if (isEqual(connector.secrets[key], value)) { + return state; + } else { + return { + ...state, + connector: { + ...connector, + secrets: { + ...connector.secrets, + [key]: value, + }, + }, + }; + } + } + } +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/index.ts new file mode 100644 index 0000000000000..aac7a514948d1 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/action_connector_form/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { ConnectorAddFlyout } from './connector_add_flyout'; +export { ConnectorEditFlyout } from './connector_edit_flyout'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/_index.scss b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/_index.scss new file mode 100644 index 0000000000000..98c6c2a307a74 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/_index.scss @@ -0,0 +1 @@ +@import 'actions_connectors_list'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss new file mode 100644 index 0000000000000..7a824aaeaa8d8 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.scss @@ -0,0 +1,3 @@ +.actConnectorsList__logo + .actConnectorsList__logo { + margin-left: $euiSize; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx new file mode 100644 index 0000000000000..511deb8cf3b0d --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -0,0 +1,362 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ActionsConnectorsList } from './actions_connectors_list'; +import { coreMock } from '../../../../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; +import { AppContextProvider } from '../../../app_context'; +jest.mock('../../../lib/action_connector_api', () => ({ + loadAllActions: jest.fn(), + loadActionTypes: jest.fn(), +})); + +const actionTypeRegistry = actionTypeRegistryMock.create(); + +describe('actions_connectors_list component empty', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const { loadAllActions, loadActionTypes } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAllActions.mockResolvedValueOnce({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + loadActionTypes.mockResolvedValueOnce([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'actions:show': true, + 'actions:save': true, + 'actions:delete': true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: {} as any, + }; + actionTypeRegistry.has.mockReturnValue(true); + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders empty prompt', () => { + expect(wrapper.find('EuiEmptyPrompt')).toHaveLength(1); + expect( + wrapper.find('[data-test-subj="createFirstActionButton"]').find('EuiButton') + ).toHaveLength(1); + }); + + test('if click create button should render ConnectorAddFlyout', () => { + wrapper + .find('[data-test-subj="createFirstActionButton"]') + .first() + .simulate('click'); + expect(wrapper.find('ConnectorAddFlyout')).toHaveLength(1); + }); +}); + +describe('actions_connectors_list component with items', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const { loadAllActions, loadActionTypes } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAllActions.mockResolvedValueOnce({ + page: 1, + perPage: 10000, + total: 2, + data: [ + { + id: '1', + actionTypeId: 'test', + description: 'My test', + referencedByCount: 1, + config: {}, + }, + { + id: '2', + actionTypeId: 'test2', + description: 'My test 2', + referencedByCount: 1, + config: {}, + }, + ], + }); + loadActionTypes.mockResolvedValueOnce([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'actions:show': true, + 'actions:save': true, + 'actions:delete': true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: { + get() { + return null; + }, + } as any, + alertTypeRegistry: {} as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + + expect(loadAllActions).toHaveBeenCalled(); + }); + + it('renders table of connectors', () => { + expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1); + expect(wrapper.find('EuiTableRow')).toHaveLength(2); + }); + + test('if select item for edit should render ConnectorEditFlyout', () => { + wrapper + .find('[data-test-subj="edit1"]') + .first() + .simulate('click'); + expect(wrapper.find('ConnectorEditFlyout')).toHaveLength(1); + }); +}); + +describe('actions_connectors_list component empty with show only capability', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const { loadAllActions, loadActionTypes } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAllActions.mockResolvedValueOnce({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + loadActionTypes.mockResolvedValueOnce([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'actions:show': true, + 'actions:save': false, + 'actions:delete': false, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: { + get() { + return null; + }, + } as any, + alertTypeRegistry: {} as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders no permissions to create connector', () => { + expect(wrapper.find('[defaultMessage="No permissions to create connector"]')).toHaveLength(1); + expect(wrapper.find('[data-test-subj="createActionButton"]')).toHaveLength(0); + }); +}); + +describe('actions_connectors_list with show only capability', () => { + let wrapper: ReactWrapper; + + beforeAll(async () => { + const { loadAllActions, loadActionTypes } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAllActions.mockResolvedValueOnce({ + page: 1, + perPage: 10000, + total: 2, + data: [ + { + id: '1', + actionTypeId: 'test', + description: 'My test', + referencedByCount: 1, + config: {}, + }, + { + id: '2', + actionTypeId: 'test2', + description: 'My test 2', + referencedByCount: 1, + config: {}, + }, + ], + }); + loadActionTypes.mockResolvedValueOnce([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: mockes.injectedMetadata, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'actions:show': true, + 'actions:save': false, + 'actions:delete': false, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: { + get() { + return null; + }, + } as any, + alertTypeRegistry: {} as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders table of connectors with delete button disabled', () => { + expect(wrapper.find('EuiInMemoryTable')).toHaveLength(1); + expect(wrapper.find('EuiTableRow')).toHaveLength(2); + wrapper.find('EuiTableRow').forEach(elem => { + const deleteButton = elem.find('[data-test-subj="deleteConnector"]').first(); + expect(deleteButton).toBeTruthy(); + expect(deleteButton.prop('isDisabled')).toBeTruthy(); + }); + }); +}); + +async function waitForRender(wrapper: ReactWrapper) { + await Promise.resolve(); + await Promise.resolve(); + wrapper.update(); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx new file mode 100644 index 0000000000000..1990ffefdf84e --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/actions_connectors_list/components/actions_connectors_list.tsx @@ -0,0 +1,399 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, useState, useEffect } from 'react'; +import { + EuiBadge, + EuiInMemoryTable, + EuiSpacer, + EuiButton, + EuiIcon, + EuiEmptyPrompt, + EuiTitle, + EuiLink, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ActionsConnectorsContextProvider } from '../../../context/actions_connectors_context'; +import { useAppDependencies } from '../../../app_context'; +import { loadAllActions, loadActionTypes } from '../../../lib/action_connector_api'; +import { ActionConnector, ActionConnectorTableItem, ActionTypeIndex } from '../../../../types'; +import { ConnectorAddFlyout, ConnectorEditFlyout } from '../../action_connector_form'; +import { hasDeleteActionsCapability, hasSaveActionsCapability } from '../../../lib/capabilities'; +import { DeleteConnectorsModal } from '../../../components/delete_connectors_modal'; + +export const ActionsConnectorsList: React.FunctionComponent = () => { + const { + http, + toastNotifications, + legacy: { capabilities }, + } = useAppDependencies(); + const canDelete = hasDeleteActionsCapability(capabilities.get()); + const canSave = hasSaveActionsCapability(capabilities.get()); + + const [actionTypesIndex, setActionTypesIndex] = useState(undefined); + const [actions, setActions] = useState([]); + const [data, setData] = useState([]); + const [selectedItems, setSelectedItems] = useState([]); + const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); + const [isLoadingActions, setIsLoadingActions] = useState(false); + const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); + const [addFlyoutVisible, setAddFlyoutVisibility] = useState(false); + const [actionTypesList, setActionTypesList] = useState>( + [] + ); + const [editedConnectorItem, setEditedConnectorItem] = useState< + ActionConnectorTableItem | undefined + >(undefined); + const [connectorsToDelete, setConnectorsToDelete] = useState([]); + + useEffect(() => { + loadActions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + (async () => { + try { + setIsLoadingActionTypes(true); + const actionTypes = await loadActionTypes({ http }); + const index: ActionTypeIndex = {}; + for (const actionTypeItem of actionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setActionTypesIndex(index); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } finally { + setIsLoadingActionTypes(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // Avoid flickering before action types load + if (typeof actionTypesIndex === 'undefined') { + return; + } + // Update the data for the table + const updatedData = actions.map(action => { + return { + ...action, + actionType: actionTypesIndex[action.actionTypeId] + ? actionTypesIndex[action.actionTypeId].name + : action.actionTypeId, + }; + }); + setData(updatedData); + // Update the action types list for the filter + const actionTypes = Object.values(actionTypesIndex) + .map(actionType => ({ + value: actionType.id, + name: `${actionType.name} (${getActionsCountByActionType(actions, actionType.id)})`, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + setActionTypesList(actionTypes); + }, [actions, actionTypesIndex]); + + async function loadActions() { + setIsLoadingActions(true); + try { + const actionsResponse = await loadAllActions({ http }); + setActions(actionsResponse.data); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.unableToLoadActionsMessage', + { + defaultMessage: 'Unable to load actions', + } + ), + }); + } finally { + setIsLoadingActions(false); + } + } + + async function editItem(connectorTableItem: ActionConnectorTableItem) { + setEditedConnectorItem(connectorTableItem); + setEditFlyoutVisibility(true); + } + + const actionsTableColumns = [ + { + field: 'name', + 'data-test-subj': 'connectorsTableCell-name', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.nameTitle', + { + defaultMessage: 'Name', + } + ), + sortable: false, + truncateText: true, + render: (value: string, item: ActionConnectorTableItem) => { + return ( + editItem(item)} key={item.id}> + {value} + + ); + }, + }, + { + field: 'actionType', + 'data-test-subj': 'connectorsTableCell-actionType', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actionTypeTitle', + { + defaultMessage: 'Type', + } + ), + sortable: false, + truncateText: true, + }, + { + field: 'referencedByCount', + 'data-test-subj': 'connectorsTableCell-referencedByCount', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.referencedByCountTitle', + { defaultMessage: 'Actions' } + ), + sortable: false, + truncateText: true, + render: (value: number, item: ActionConnectorTableItem) => { + return ( + + {value} + + ); + }, + }, + { + field: '', + name: '', + actions: [ + { + enabled: () => canDelete, + 'data-test-subj': 'deleteConnector', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionName', + { defaultMessage: 'Delete' } + ), + description: canDelete + ? i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDescription', + { defaultMessage: 'Delete this action' } + ) + : i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.connectorsListTable.columns.actions.deleteActionDisabledDescription', + { defaultMessage: 'Unable to delete actions' } + ), + type: 'icon', + icon: 'trash', + color: 'danger', + onClick: (item: ActionConnectorTableItem) => setConnectorsToDelete([item.id]), + }, + ], + }, + ]; + + const table = ( + ({ + 'data-test-subj': 'connectors-row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + data-test-subj="actionsTable" + pagination={true} + selection={ + canDelete + ? { + onSelectionChange(updatedSelectedItemsList: ActionConnectorTableItem[]) { + setSelectedItems(updatedSelectedItemsList); + }, + } + : undefined + } + search={{ + filters: [ + { + type: 'field_value_selection', + field: 'actionTypeId', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.filters.actionTypeIdName', + { defaultMessage: 'Type' } + ), + multiSelect: 'or', + options: actionTypesList, + }, + ], + toolsLeft: + selectedItems.length === 0 || !canDelete + ? [] + : [ + { + setConnectorsToDelete(selectedItems.map((selected: any) => selected.id)); + }} + title={ + canDelete + ? undefined + : i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.buttons.deleteDisabledTitle', + { defaultMessage: 'Unable to delete actions' } + ) + } + > + + , + ], + toolsRight: [ + setAddFlyoutVisibility(true)} + > + + , + ], + }} + /> + ); + + const emptyPrompt = ( + + + + + + +

+ +

+
+ + } + body={ +

+ +

+ } + actions={ + setAddFlyoutVisibility(true)} + > + + + } + /> + ); + + const noPermissionPrompt = ( +

+ +

+ ); + + return ( +
+ { + if (deleted) { + if (selectedItems.length === 0 || selectedItems.length === deleted.length) { + const updatedActions = actions.filter( + action => action.id && !connectorsToDelete.includes(action.id) + ); + setActions(updatedActions); + setSelectedItems([]); + } else { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.actionsConnectorsList.failedToDeleteActionsMessage', + { defaultMessage: 'Failed to delete action(s)' } + ), + }); + // Refresh the actions from the server, some actions may have beend deleted + loadActions(); + } + } + setConnectorsToDelete([]); + }} + connectorsToDelete={connectorsToDelete} + /> + + {/* Render the view based on if there's data or if they can save */} + {data.length !== 0 && table} + {data.length === 0 && canSave && emptyPrompt} + {data.length === 0 && !canSave && noPermissionPrompt} + + + {editedConnectorItem ? : null} + +
+ ); +}; + +function getActionsCountByActionType(actions: ActionConnector[], actionTypeId: string) { + return actions.filter(action => action.actionTypeId === actionTypeId).length; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx new file mode 100644 index 0000000000000..9380392112c8e --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_add.tsx @@ -0,0 +1,803 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React, { Fragment, useState, useCallback, useReducer, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiTitle, + EuiForm, + EuiSpacer, + EuiButtonEmpty, + EuiFlyoutFooter, + EuiFlyoutBody, + EuiFlyoutHeader, + EuiFlyout, + EuiFieldText, + EuiFlexGrid, + EuiFormRow, + EuiComboBox, + EuiKeyPadMenuItem, + EuiTabs, + EuiTab, + EuiLink, + EuiFieldNumber, + EuiSelect, + EuiIconTip, + EuiPortal, + EuiAccordion, + EuiButtonIcon, +} from '@elastic/eui'; +import { useAppDependencies } from '../../app_context'; +import { createAlert } from '../../lib/alert_api'; +import { loadActionTypes, loadAllActions } from '../../lib/action_connector_api'; +import { useAlertsContext } from '../../context/alerts_context'; +import { alertReducer } from './alert_reducer'; +import { + AlertTypeModel, + Alert, + IErrorObject, + ActionTypeModel, + AlertAction, + ActionTypeIndex, + ActionConnector, +} from '../../../types'; +import { ACTION_GROUPS } from '../../constants/action_groups'; +import { getTimeOptions } from '../../lib/get_time_options'; +import { SectionLoading } from '../../components/section_loading'; + +interface Props { + refreshList: () => Promise; +} + +function validateBaseProperties(alertObject: Alert) { + const validationResult = { errors: {} }; + const errors = { + name: new Array(), + interval: new Array(), + alertTypeId: new Array(), + actionConnectors: new Array(), + }; + validationResult.errors = errors; + if (!alertObject.name) { + errors.name.push( + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredNameText', { + defaultMessage: 'Name is required.', + }) + ); + } + if (!alertObject.interval) { + errors.interval.push( + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredIntervalText', { + defaultMessage: 'Check interval is required.', + }) + ); + } + if (!alertObject.alertTypeId) { + errors.alertTypeId.push( + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.error.requiredAlertTypeIdText', { + defaultMessage: 'Alert trigger is required.', + }) + ); + } + return validationResult; +} + +export const AlertAdd = ({ refreshList }: Props) => { + const { http, toastNotifications, alertTypeRegistry, actionTypeRegistry } = useAppDependencies(); + const initialAlert = { + params: {}, + alertTypeId: null, + interval: '1m', + actions: [], + tags: [], + }; + + const { alertFlyoutVisible, setAlertFlyoutVisibility } = useAlertsContext(); + // hooks + const [alertType, setAlertType] = useState(undefined); + const [{ alert }, dispatch] = useReducer(alertReducer, { alert: initialAlert }); + const [isSaving, setIsSaving] = useState(false); + const [isLoadingActionTypes, setIsLoadingActionTypes] = useState(false); + const [selectedTabId, setSelectedTabId] = useState('alert'); + const [actionTypesIndex, setActionTypesIndex] = useState(undefined); + const [alertInterval, setAlertInterval] = useState(null); + const [alertIntervalUnit, setAlertIntervalUnit] = useState('m'); + const [alertThrottle, setAlertThrottle] = useState(null); + const [alertThrottleUnit, setAlertThrottleUnit] = useState(''); + const [serverError, setServerError] = useState<{ + body: { message: string; error: string }; + } | null>(null); + const [isAddActionPanelOpen, setIsAddActionPanelOpen] = useState(true); + const [connectors, setConnectors] = useState([]); + + useEffect(() => { + (async () => { + try { + setIsLoadingActionTypes(true); + const actionTypes = await loadActionTypes({ http }); + const index: ActionTypeIndex = {}; + for (const actionTypeItem of actionTypes) { + index[actionTypeItem.id] = actionTypeItem; + } + setActionTypesIndex(index); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } finally { + setIsLoadingActionTypes(false); + } + })(); + }, [toastNotifications, http]); + + useEffect(() => { + dispatch({ + command: { type: 'setAlert' }, + payload: { + key: 'alert', + value: { + params: {}, + alertTypeId: null, + interval: '1m', + actions: [], + tags: [], + }, + }, + }); + }, [alertFlyoutVisible]); + + useEffect(() => { + loadConnectors(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alertFlyoutVisible]); + + const setAlertProperty = (key: string, value: any) => { + dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); + }; + + const setAlertParams = (key: string, value: any) => { + dispatch({ command: { type: 'setAlertParams' }, payload: { key, value } }); + }; + + const setActionParamsProperty = (key: string, value: any, index: number) => { + dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); + }; + + const setActionProperty = (key: string, value: any, index: number) => { + dispatch({ command: { type: 'setAlertActionProperty' }, payload: { key, value, index } }); + }; + + const closeFlyout = useCallback(() => { + setAlertFlyoutVisibility(false); + setAlertType(undefined); + setIsAddActionPanelOpen(true); + setSelectedTabId('alert'); + setServerError(null); + }, [setAlertFlyoutVisibility]); + + if (!alertFlyoutVisible) { + return null; + } + + const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; + + async function loadConnectors() { + try { + const actionsResponse = await loadAllActions({ http }); + setConnectors(actionsResponse.data); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertAdd.unableToLoadActionsMessage', + { + defaultMessage: 'Unable to load connectors', + } + ), + }); + } + } + + const AlertParamsExpressionComponent = alertType ? alertType.alertParamsExpression : null; + + const errors = { + ...(alertType ? alertType.validate(alert).errors : []), + ...validateBaseProperties(alert).errors, + } as IErrorObject; + const hasErrors = !!Object.keys(errors).find(errorKey => errors[errorKey].length >= 1); + + const actionErrors = alert.actions.reduce((acc: any, alertAction: AlertAction) => { + const actionTypeConnectors = connectors.find(field => field.id === alertAction.id); + if (!actionTypeConnectors) { + return []; + } + const actionType = actionTypeRegistry.get(actionTypeConnectors.actionTypeId); + if (!actionType) { + return []; + } + const actionValidationErrors = actionType.validateParams(alertAction.params); + acc[alertAction.id] = actionValidationErrors; + return acc; + }, {}); + + const hasActionErrors = !!Object.keys(actionErrors).find(actionError => { + return !!Object.keys(actionErrors[actionError]).find((actionErrorKey: string) => { + return actionErrors[actionError][actionErrorKey].length >= 1; + }); + }); + + const tabs = [ + { + id: ACTION_GROUPS.ALERT, + name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.alertTabText', { + defaultMessage: 'Alert', + }), + }, + { + id: ACTION_GROUPS.WARNING, + name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.warningTabText', { + defaultMessage: 'Warning', + }), + }, + { + id: ACTION_GROUPS.UNACKNOWLEDGED, + name: i18n.translate('xpack.triggersActionsUI.sections.alertAdd.unacknowledgedTabText', { + defaultMessage: 'If unacknowledged', + }), + disabled: false, + }, + ]; + + async function onSaveAlert(): Promise { + try { + const newAlert = await createAlert({ http, alert }); + toastNotifications.addSuccess( + i18n.translate('xpack.triggersActionsUI.sections.alertAdd.saveSuccessNotificationText', { + defaultMessage: "Saved '{alertName}'", + values: { + alertName: newAlert.id, + }, + }) + ); + return newAlert; + } catch (error) { + return { + error, + }; + } + } + + function addActionType(actionTypeModel: ActionTypeModel) { + setIsAddActionPanelOpen(false); + const actionTypeConnectors = connectors.filter( + field => field.actionTypeId === actionTypeModel.id + ); + if (actionTypeConnectors.length > 0) { + alert.actions.push({ id: actionTypeConnectors[0].id, group: selectedTabId, params: {} }); + } + } + + const alertTypeNodes = alertTypeRegistry.list().map(function(item, index) { + return ( + { + setAlertProperty('alertTypeId', item.id); + setAlertType(item); + }} + > + + + ); + }); + + const actionTypeNodes = actionTypeRegistry.list().map(function(item, index) { + return ( + addActionType(item)} + > + + + ); + }); + + const alertTabs = tabs.map(function(tab, index): any { + return ( + { + setSelectedTabId(tab.id); + if (!alert.actions.find((action: AlertAction) => action.group === tab.id)) { + setIsAddActionPanelOpen(true); + } else { + setIsAddActionPanelOpen(false); + } + }} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + key={index} + > + {tab.name} + + ); + }); + + const alertTypeDetails = ( + + + + +
+ +
+
+
+ + { + setAlertProperty('alertTypeId', null); + setAlertType(undefined); + }} + > + + + +
+ {AlertParamsExpressionComponent ? ( + + ) : null} +
+ ); + + const getSelectedOptions = (actionItemId: string) => { + const val = connectors.find(connector => connector.id === actionItemId); + if (!val) { + return []; + } + return [ + { + label: val.name, + value: val.name, + id: actionItemId, + }, + ]; + }; + + const actionsListForGroup = ( + + {alert.actions.map((actionItem: AlertAction, index: number) => { + const actionConnector = connectors.find(field => field.id === actionItem.id); + if (!actionConnector) { + return null; + } + const optionsList = connectors + .filter(field => field.actionTypeId === actionConnector.actionTypeId) + .map(({ name, id }) => ({ + label: name, + key: id, + id, + })); + const actionTypeRegisterd = actionTypeRegistry.get(actionConnector.actionTypeId); + if (actionTypeRegisterd === null || actionItem.group !== selectedTabId) return null; + const ParamsFieldsComponent = actionTypeRegisterd.actionParamsFields; + const actionParamsErrors = + Object.keys(actionErrors).length > 0 ? actionErrors[actionItem.id] : []; + const hasActionParamsErrors = !!Object.keys(actionParamsErrors).find( + errorKey => actionParamsErrors[errorKey].length >= 1 + ); + return ( + + + + + + +
+ +
+
+
+
+ } + extraAction={ + { + const updatedActions = alert.actions.filter( + (item: AlertAction) => item.id !== actionItem.id + ); + setAlertProperty('actions', updatedActions); + }} + /> + } + paddingSize="l" + > + + } + // errorKey="name" + // isShowingErrors={hasErrors} + // errors={errors} + > + { + setActionProperty('id', selectedOptions[0].id, index); + }} + isClearable={false} + /> + + + {ParamsFieldsComponent ? ( + + ) : null} + + ); + })} + + {!isAddActionPanelOpen ? ( + setIsAddActionPanelOpen(true)} + > + + + ) : null} + + ); + + let alertTypeArea; + if (alertType) { + alertTypeArea = {alertTypeDetails}; + } else { + alertTypeArea = ( + + +
+ +
+
+ + + {alertTypeNodes} + +
+ ); + } + + const labelForAlertChecked = ( + <> + {' '} + + + ); + + const labelForAlertRenotify = ( + <> + {' '} + + + ); + + return ( + + + + +

+ +

+
+
+ + + + + + } + isInvalid={hasErrors && alert.name !== undefined} + error={errors.name} + > + { + setAlertProperty('name', e.target.value); + }} + onBlur={() => { + if (!alert.name) { + setAlertProperty('name', ''); + } + }} + /> + + + + + { + const newOptions = [...tagsOptions, { label: searchValue }]; + setAlertProperty( + 'tags', + newOptions.map(newOption => newOption.label) + ); + }} + onChange={(selectedOptions: Array<{ label: string }>) => { + setAlertProperty( + 'tags', + selectedOptions.map(selectedOption => selectedOption.label) + ); + }} + onBlur={() => { + if (!alert.tags) { + setAlertProperty('tags', []); + } + }} + /> + + + + + + + + + + { + const interval = + e.target.value !== '' ? parseInt(e.target.value, 10) : null; + setAlertInterval(interval); + setAlertProperty('interval', `${e.target.value}${alertIntervalUnit}`); + }} + /> + + + { + setAlertIntervalUnit(e.target.value); + setAlertProperty('interval', `${alertInterval}${e.target.value}`); + }} + /> + + + + + + + + + { + const throttle = + e.target.value !== '' ? parseInt(e.target.value, 10) : null; + setAlertThrottle(throttle); + setAlertProperty('throttle', `${e.target.value}${alertThrottleUnit}`); + }} + /> + + + { + setAlertThrottleUnit(e.target.value); + setAlertProperty('throttle', `${alertThrottle}${e.target.value}`); + }} + /> + + + + + + + {alertTabs} + + {alertTypeArea} + + {actionsListForGroup} + {isAddActionPanelOpen ? ( + + +
+ +
+
+ + + {isLoadingActionTypes ? ( + + + + ) : ( + actionTypeNodes + )} + +
+ ) : null} +
+
+ + + + + {i18n.translate('xpack.triggersActionsUI.sections.alertAdd.cancelButtonLabel', { + defaultMessage: 'Cancel', + })} + + + + { + setIsSaving(true); + const savedAlert = await onSaveAlert(); + setIsSaving(false); + if (savedAlert && savedAlert.error) { + return setServerError(savedAlert.error); + } + closeFlyout(); + refreshList(); + }} + > + + + + + +
+
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts new file mode 100644 index 0000000000000..9c2260f0178be --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/alert_reducer.ts @@ -0,0 +1,121 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { isEqual } from 'lodash'; + +interface CommandType { + type: + | 'setAlert' + | 'setProperty' + | 'setAlertParams' + | 'setAlertActionParams' + | 'setAlertActionProperty'; +} + +export interface AlertState { + alert: any; +} + +export interface AlertReducerAction { + command: CommandType; + payload: { + key: string; + value: {}; + index?: number; + }; +} + +export const alertReducer = (state: any, action: AlertReducerAction) => { + const { command, payload } = action; + const { alert } = state; + + switch (command.type) { + case 'setAlert': { + const { key, value } = payload; + if (key === 'alert') { + return { + ...state, + alert: value, + }; + } else { + return state; + } + } + case 'setProperty': { + const { key, value } = payload; + if (isEqual(alert[key], value)) { + return state; + } else { + return { + ...state, + alert: { + ...alert, + [key]: value, + }, + }; + } + } + case 'setAlertParams': { + const { key, value } = payload; + if (isEqual(alert.params[key], value)) { + return state; + } else { + return { + ...state, + alert: { + ...alert, + params: { + ...alert.params, + [key]: value, + }, + }, + }; + } + } + case 'setAlertActionParams': { + const { key, value, index } = payload; + if (index === undefined || isEqual(alert.actions[index][key], value)) { + return state; + } else { + const oldAction = alert.actions.splice(index, 1)[0]; + const updatedAction = { + ...oldAction, + params: { + ...oldAction.params, + [key]: value, + }, + }; + alert.actions.splice(index, 0, updatedAction); + return { + ...state, + alert: { + ...alert, + actions: [...alert.actions], + }, + }; + } + } + case 'setAlertActionProperty': { + const { key, value, index } = payload; + if (index === undefined || isEqual(alert.actions[index][key], value)) { + return state; + } else { + const oldAction = alert.actions.splice(index, 1)[0]; + const updatedAction = { + ...oldAction, + [key]: value, + }; + alert.actions.splice(index, 0, updatedAction); + return { + ...state, + alert: { + ...alert, + actions: [...alert.actions], + }, + }; + } + } + } +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/index.ts new file mode 100644 index 0000000000000..f88a8bb1c49d0 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alert_add/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { AlertAdd } from './alert_add'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/action_type_filter.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/action_type_filter.tsx new file mode 100644 index 0000000000000..7a25a241b0162 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/action_type_filter.tsx @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFilterGroup, EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; +import { ActionType } from '../../../../types'; + +interface ActionTypeFilterProps { + actionTypes: ActionType[]; + onChange?: (selectedActionTypeIds: string[]) => void; +} + +export const ActionTypeFilter: React.FunctionComponent = ({ + actionTypes, + onChange, +}: ActionTypeFilterProps) => { + const [selectedValues, setSelectedValues] = useState([]); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + useEffect(() => { + if (onChange) { + onChange(selectedValues); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedValues]); + + return ( + + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + > + + + } + > +
+ {actionTypes.map(item => ( + { + const isPreviouslyChecked = selectedValues.includes(item.id); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter(val => val !== item.id)); + } else { + setSelectedValues(selectedValues.concat(item.id)); + } + }} + checked={selectedValues.includes(item.id) ? 'on' : undefined} + > + {item.name} + + ))} +
+
+
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx new file mode 100644 index 0000000000000..8f8aef5a16bd5 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -0,0 +1,453 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { coreMock } from '../../../../../../../../../../src/core/public/mocks'; +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; +import { actionTypeRegistryMock } from '../../../action_type_registry.mock'; +import { alertTypeRegistryMock } from '../../../alert_type_registry.mock'; +import { AlertsList } from './alerts_list'; +import { ValidationResult } from '../../../../types'; +import { AppContextProvider } from '../../../app_context'; +jest.mock('../../../lib/action_connector_api', () => ({ + loadActionTypes: jest.fn(), + loadAllActions: jest.fn(), +})); +jest.mock('../../../lib/alert_api', () => ({ + loadAlerts: jest.fn(), + loadAlertTypes: jest.fn(), +})); + +const actionTypeRegistry = actionTypeRegistryMock.create(); +const alertTypeRegistry = alertTypeRegistryMock.create(); + +const alertType = { + id: 'test_alert_type', + name: 'some alert type', + iconClass: 'test', + validate: (): ValidationResult => { + return { errors: {} }; + }, + alertParamsExpression: () => null, +}; +alertTypeRegistry.list.mockReturnValue([alertType]); +actionTypeRegistry.list.mockReturnValue([]); + +describe('alerts_list component empty', () => { + let wrapper: ReactWrapper; + + beforeEach(async () => { + const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); + const { loadActionTypes, loadAllActions } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAlerts.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + loadActionTypes.mockResolvedValue([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAllActions.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: { + getInjectedVar(name: string) { + if (name === 'createAlertUiEnabled') { + return true; + } + }, + } as any, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'alerting:show': true, + 'alerting:save': true, + 'alerting:delete': true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders empty list', () => { + expect(wrapper.find('[data-test-subj="createAlertButton"]').find('EuiButton')).toHaveLength(1); + }); + + test('if click create button should render AlertAdd', () => { + wrapper + .find('[data-test-subj="createAlertButton"]') + .first() + .simulate('click'); + expect(wrapper.find('AlertAdd')).toHaveLength(1); + }); +}); + +describe('alerts_list component with items', () => { + let wrapper: ReactWrapper; + + beforeEach(async () => { + const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); + const { loadActionTypes, loadAllActions } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAlerts.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 2, + data: [ + { + id: '1', + name: 'test alert', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + interval: '5d', + actions: [], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + }, + { + id: '2', + name: 'test alert 2', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + interval: '5d', + actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + }, + ], + }); + loadActionTypes.mockResolvedValue([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAllActions.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: { + getInjectedVar(name: string) { + if (name === 'createAlertUiEnabled') { + return true; + } + }, + } as any, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'alerting:show': true, + 'alerting:save': true, + 'alerting:delete': true, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + + expect(loadAlerts).toHaveBeenCalled(); + expect(loadActionTypes).toHaveBeenCalled(); + }); + + it('renders table of connectors', () => { + expect(wrapper.find('EuiBasicTable')).toHaveLength(1); + expect(wrapper.find('EuiTableRow')).toHaveLength(2); + }); +}); + +describe('alerts_list component empty with show only capability', () => { + let wrapper: ReactWrapper; + + beforeEach(async () => { + const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); + const { loadActionTypes, loadAllActions } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAlerts.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + loadActionTypes.mockResolvedValue([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAllActions.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: { + getInjectedVar(name: string) { + if (name === 'createAlertUiEnabled') { + return true; + } + }, + } as any, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'alerting:show': true, + 'alerting:save': false, + 'alerting:delete': false, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: { + get() { + return null; + }, + } as any, + alertTypeRegistry: {} as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('not renders create alert button', () => { + expect(wrapper.find('[data-test-subj="createAlertButton"]')).toHaveLength(0); + }); +}); + +describe('alerts_list with show only capability', () => { + let wrapper: ReactWrapper; + + beforeEach(async () => { + const { loadAlerts, loadAlertTypes } = jest.requireMock('../../../lib/alert_api'); + const { loadActionTypes, loadAllActions } = jest.requireMock( + '../../../lib/action_connector_api' + ); + loadAlerts.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 2, + data: [ + { + id: '1', + name: 'test alert', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + interval: '5d', + actions: [], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + }, + { + id: '2', + name: 'test alert 2', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + interval: '5d', + actions: [{ id: 'test', group: 'alert', params: { message: 'test' } }], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + }, + ], + }); + loadActionTypes.mockResolvedValue([ + { + id: 'test', + name: 'Test', + }, + { + id: 'test2', + name: 'Test2', + }, + ]); + loadAlertTypes.mockResolvedValue([{ id: 'test_alert_type', name: 'some alert type' }]); + loadAllActions.mockResolvedValue({ + page: 1, + perPage: 10000, + total: 0, + data: [], + }); + const mockes = coreMock.createSetup(); + const [{ chrome, docLinks }] = await mockes.getStartServices(); + const deps = { + chrome, + docLinks, + toastNotifications: mockes.notifications.toasts, + injectedMetadata: { + getInjectedVar(name: string) { + if (name === 'createAlertUiEnabled') { + return true; + } + }, + } as any, + http: mockes.http, + uiSettings: mockes.uiSettings, + legacy: { + capabilities: { + get() { + return { + siem: { + 'alerting:show': true, + 'alerting:save': false, + 'alerting:delete': false, + }, + }; + }, + } as any, + MANAGEMENT_BREADCRUMB: { set: () => {} } as any, + }, + actionTypeRegistry: actionTypeRegistry as any, + alertTypeRegistry: alertTypeRegistry as any, + }; + + await act(async () => { + wrapper = mountWithIntl( + + + + ); + }); + + await waitForRender(wrapper); + }); + + it('renders table of alerts with delete button disabled', () => { + expect(wrapper.find('EuiBasicTable')).toHaveLength(1); + expect(wrapper.find('EuiTableRow')).toHaveLength(2); + // TODO: check delete button + }); +}); + +async function waitForRender(wrapper: ReactWrapper) { + await Promise.resolve(); + await Promise.resolve(); + wrapper.update(); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx new file mode 100644 index 0000000000000..64f06521c0f9d --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/alerts_list.tsx @@ -0,0 +1,330 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { Fragment, useEffect, useState } from 'react'; +import { + EuiBasicTable, + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, +} from '@elastic/eui'; + +import { AlertsContextProvider } from '../../../context/alerts_context'; +import { useAppDependencies } from '../../../app_context'; +import { ActionType, Alert, AlertTableItem, AlertTypeIndex, Pagination } from '../../../../types'; +import { AlertAdd } from '../../alert_add'; +import { BulkActionPopover } from './bulk_action_popover'; +import { CollapsedItemActions } from './collapsed_item_actions'; +import { TypeFilter } from './type_filter'; +import { ActionTypeFilter } from './action_type_filter'; +import { loadAlerts, loadAlertTypes } from '../../../lib/alert_api'; +import { loadActionTypes } from '../../../lib/action_connector_api'; +import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities'; + +const ENTER_KEY = 13; + +export const AlertsList: React.FunctionComponent = () => { + const { + http, + injectedMetadata, + toastNotifications, + legacy: { capabilities }, + } = useAppDependencies(); + const canDelete = hasDeleteAlertsCapability(capabilities.get()); + const canSave = hasSaveAlertsCapability(capabilities.get()); + const createAlertUiEnabled = injectedMetadata.getInjectedVar('createAlertUiEnabled'); + + const [actionTypes, setActionTypes] = useState([]); + const [alertTypesIndex, setAlertTypesIndex] = useState(undefined); + const [alerts, setAlerts] = useState([]); + const [data, setData] = useState([]); + const [selectedIds, setSelectedIds] = useState([]); + const [isLoadingAlertTypes, setIsLoadingAlertTypes] = useState(false); + const [isLoadingAlerts, setIsLoadingAlerts] = useState(false); + const [isPerformingAction, setIsPerformingAction] = useState(false); + const [totalItemCount, setTotalItemCount] = useState(0); + const [page, setPage] = useState({ index: 0, size: 10 }); + const [searchText, setSearchText] = useState(); + const [inputText, setInputText] = useState(); + const [typesFilter, setTypesFilter] = useState([]); + const [actionTypesFilter, setActionTypesFilter] = useState([]); + const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); + + useEffect(() => { + loadAlertsData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, searchText, typesFilter, actionTypesFilter]); + + useEffect(() => { + (async () => { + try { + setIsLoadingAlertTypes(true); + const alertTypes = await loadAlertTypes({ http }); + const index: AlertTypeIndex = {}; + for (const alertType of alertTypes) { + index[alertType.id] = alertType; + } + setAlertTypesIndex(index); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertTypesMessage', + { defaultMessage: 'Unable to load alert types' } + ), + }); + } finally { + setIsLoadingAlertTypes(false); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + (async () => { + try { + const result = await loadActionTypes({ http }); + setActionTypes(result); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.unableToLoadActionTypesMessage', + { defaultMessage: 'Unable to load action types' } + ), + }); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + // Avoid flickering before alert types load + if (typeof alertTypesIndex === 'undefined') { + return; + } + const updatedData = alerts.map(alert => ({ + ...alert, + tagsText: alert.tags.join(', '), + alertType: alertTypesIndex[alert.alertTypeId] + ? alertTypesIndex[alert.alertTypeId].name + : alert.alertTypeId, + })); + setData(updatedData); + }, [alerts, alertTypesIndex]); + + async function loadAlertsData() { + setIsLoadingAlerts(true); + try { + const alertsResponse = await loadAlerts({ + http, + page, + searchText, + typesFilter, + actionTypesFilter, + }); + setAlerts(alertsResponse.data); + setTotalItemCount(alertsResponse.total); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsMessage', + { + defaultMessage: 'Unable to load alerts', + } + ), + }); + } finally { + setIsLoadingAlerts(false); + } + } + + const alertsTableColumns = [ + { + field: 'name', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.nameTitle', + { defaultMessage: 'Name' } + ), + sortable: false, + truncateText: true, + 'data-test-subj': 'alertsTableCell-name', + }, + { + field: 'tagsText', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.tagsText', + { defaultMessage: 'Tags' } + ), + sortable: false, + 'data-test-subj': 'alertsTableCell-tagsText', + }, + { + field: 'alertType', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.alertTypeTitle', + { defaultMessage: 'Type' } + ), + sortable: false, + truncateText: true, + 'data-test-subj': 'alertsTableCell-alertType', + }, + { + field: 'schedule.interval', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.intervalTitle', + { defaultMessage: 'Runs every' } + ), + sortable: false, + truncateText: false, + 'data-test-subj': 'alertsTableCell-interval', + }, + { + name: '', + width: '40px', + render(item: AlertTableItem) { + return ( + loadAlertsData()} /> + ); + }, + }, + ]; + + const toolsRight = [ + setTypesFilter(types)} + options={Object.values(alertTypesIndex || {}) + .map(alertType => ({ + value: alertType.id, + name: alertType.name, + })) + .sort((a, b) => a.name.localeCompare(b.name))} + />, + setActionTypesFilter(ids)} + />, + ]; + + if (canSave && createAlertUiEnabled) { + toolsRight.push( + setAlertFlyoutVisibility(true)} + > + + + ); + } + + return ( +
+ + + + + {selectedIds.length > 0 && canDelete && ( + + setIsPerformingAction(true)} + onActionPerformed={() => { + loadAlertsData(); + setIsPerformingAction(false); + }} + /> + + )} + + } + onChange={e => setInputText(e.target.value)} + onKeyUp={e => { + if (e.keyCode === ENTER_KEY) { + setSearchText(inputText); + } + }} + placeholder={i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.searchPlaceholderTitle', + { defaultMessage: 'Search...' } + )} + /> + + + + {toolsRight.map((tool, index: number) => ( + + {tool} + + ))} + + + + + {/* Large to remain consistent with ActionsList table spacing */} + + + ({ + 'data-test-subj': 'alert-row', + })} + cellProps={() => ({ + 'data-test-subj': 'cell', + })} + data-test-subj="alertsList" + pagination={{ + pageIndex: page.index, + pageSize: page.size, + totalItemCount, + }} + selection={ + canDelete + ? { + onSelectionChange(updatedSelectedItemsList: AlertTableItem[]) { + setSelectedIds(updatedSelectedItemsList.map(item => item.id)); + }, + } + : undefined + } + onChange={({ page: changedPage }: { page: Pagination }) => { + setPage(changedPage); + }} + /> + + + +
+ ); +}; + +function pickFromData(data: AlertTableItem[], ids: string[]): AlertTableItem[] { + const result: AlertTableItem[] = []; + for (const id of ids) { + const match = data.find(item => item.id === id); + if (match) { + result.push(match); + } + } + return result; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/bulk_action_popover.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/bulk_action_popover.tsx new file mode 100644 index 0000000000000..59ec52ac83a6c --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/bulk_action_popover.tsx @@ -0,0 +1,253 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiButton, EuiButtonEmpty, EuiFormRow, EuiPopover } from '@elastic/eui'; + +import { AlertTableItem } from '../../../../types'; +import { useAppDependencies } from '../../../app_context'; +import { + deleteAlerts, + disableAlerts, + enableAlerts, + muteAlerts, + unmuteAlerts, +} from '../../../lib/alert_api'; + +export interface ComponentOpts { + selectedItems: AlertTableItem[]; + onPerformingAction: () => void; + onActionPerformed: () => void; +} + +export const BulkActionPopover: React.FunctionComponent = ({ + selectedItems, + onPerformingAction, + onActionPerformed, +}: ComponentOpts) => { + const { http, toastNotifications } = useAppDependencies(); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isMutingAlerts, setIsMutingAlerts] = useState(false); + const [isUnmutingAlerts, setIsUnmutingAlerts] = useState(false); + const [isEnablingAlerts, setIsEnablingAlerts] = useState(false); + const [isDisablingAlerts, setIsDisablingAlerts] = useState(false); + const [isDeletingAlerts, setIsDeletingAlerts] = useState(false); + + const allAlertsMuted = selectedItems.every(isAlertMuted); + const allAlertsDisabled = selectedItems.every(isAlertDisabled); + const isPerformingAction = + isMutingAlerts || isUnmutingAlerts || isEnablingAlerts || isDisablingAlerts || isDeletingAlerts; + + async function onmMuteAllClick() { + onPerformingAction(); + setIsMutingAlerts(true); + const ids = selectedItems.filter(item => !isAlertMuted(item)).map(item => item.id); + try { + await muteAlerts({ http, ids }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToMuteAlertsMessage', + { + defaultMessage: 'Failed to mute alert(s)', + } + ), + }); + } finally { + setIsMutingAlerts(false); + onActionPerformed(); + } + } + + async function onUnmuteAllClick() { + onPerformingAction(); + setIsUnmutingAlerts(true); + const ids = selectedItems.filter(isAlertMuted).map(item => item.id); + try { + await unmuteAlerts({ http, ids }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToUnmuteAlertsMessage', + { + defaultMessage: 'Failed to unmute alert(s)', + } + ), + }); + } finally { + setIsUnmutingAlerts(false); + onActionPerformed(); + } + } + + async function onEnableAllClick() { + onPerformingAction(); + setIsEnablingAlerts(true); + const ids = selectedItems.filter(isAlertDisabled).map(item => item.id); + try { + await enableAlerts({ http, ids }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToEnableAlertsMessage', + { + defaultMessage: 'Failed to enable alert(s)', + } + ), + }); + } finally { + setIsEnablingAlerts(false); + onActionPerformed(); + } + } + + async function onDisableAllClick() { + onPerformingAction(); + setIsDisablingAlerts(true); + const ids = selectedItems.filter(item => !isAlertDisabled(item)).map(item => item.id); + try { + await disableAlerts({ http, ids }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToDisableAlertsMessage', + { + defaultMessage: 'Failed to disable alert(s)', + } + ), + }); + } finally { + setIsDisablingAlerts(false); + onActionPerformed(); + } + } + + async function deleteSelectedItems() { + onPerformingAction(); + setIsDeletingAlerts(true); + const ids = selectedItems.map(item => item.id); + try { + await deleteAlerts({ http, ids }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.bulkActionPopover.failedToDeleteAlertsMessage', + { + defaultMessage: 'Failed to delete alert(s)', + } + ), + }); + } finally { + setIsDeletingAlerts(false); + onActionPerformed(); + } + } + + return ( + setIsPopoverOpen(false)} + data-test-subj="bulkAction" + button={ + setIsPopoverOpen(!isPopoverOpen)} + > + + + } + > + {!allAlertsMuted && ( + + + + + + )} + {allAlertsMuted && ( + + + + + + )} + {allAlertsDisabled && ( + + + + + + )} + {!allAlertsDisabled && ( + + + + + + )} + + + + + + + ); +}; + +function isAlertDisabled(alert: AlertTableItem) { + return alert.enabled === false; +} + +function isAlertMuted(alert: AlertTableItem) { + return alert.muteAll === true; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx new file mode 100644 index 0000000000000..f063ab4f7cde3 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/collapsed_item_actions.tsx @@ -0,0 +1,140 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import React, { useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButtonEmpty, + EuiButtonIcon, + EuiFormRow, + EuiPopover, + EuiPopoverFooter, + EuiSwitch, +} from '@elastic/eui'; + +import { AlertTableItem } from '../../../../types'; +import { useAppDependencies } from '../../../app_context'; +import { hasDeleteAlertsCapability, hasSaveAlertsCapability } from '../../../lib/capabilities'; +import { + deleteAlerts, + disableAlerts, + enableAlerts, + muteAlerts, + unmuteAlerts, +} from '../../../lib/alert_api'; + +export interface ComponentOpts { + item: AlertTableItem; + onAlertChanged: () => void; +} + +export const CollapsedItemActions: React.FunctionComponent = ({ + item, + onAlertChanged, +}: ComponentOpts) => { + const { + http, + legacy: { capabilities }, + } = useAppDependencies(); + + const canDelete = hasDeleteAlertsCapability(capabilities.get()); + const canSave = hasSaveAlertsCapability(capabilities.get()); + + const [isEnabled, setIsEnabled] = useState(item.enabled); + const [isMuted, setIsMuted] = useState(item.muteAll); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const button = ( + setIsPopoverOpen(!isPopoverOpen)} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.collapsedItemActons.popoverButtonTitle', + { defaultMessage: 'Actions' } + )} + /> + ); + + return ( + setIsPopoverOpen(false)} + ownFocus + data-test-subj="collapsedItemActions" + > + + { + if (isEnabled) { + setIsEnabled(false); + await disableAlerts({ http, ids: [item.id] }); + } else { + setIsEnabled(true); + await enableAlerts({ http, ids: [item.id] }); + } + onAlertChanged(); + }} + label={ + + } + /> + + + { + if (isMuted) { + setIsMuted(false); + await unmuteAlerts({ http, ids: [item.id] }); + } else { + setIsMuted(true); + await muteAlerts({ http, ids: [item.id] }); + } + onAlertChanged(); + }} + label={ + + } + /> + + + + { + await deleteAlerts({ http, ids: [item.id] }); + onAlertChanged(); + }} + > + + + + + + ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/type_filter.tsx b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/type_filter.tsx new file mode 100644 index 0000000000000..f9cf7a6efd461 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/sections/alerts_list/components/type_filter.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiFilterGroup, EuiPopover, EuiFilterButton, EuiFilterSelectItem } from '@elastic/eui'; + +interface TypeFilterProps { + options: Array<{ + value: string; + name: string; + }>; + onChange?: (selectedTags: string[]) => void; +} + +export const TypeFilter: React.FunctionComponent = ({ + options, + onChange, +}: TypeFilterProps) => { + const [selectedValues, setSelectedValues] = useState([]); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + useEffect(() => { + if (onChange) { + onChange(selectedValues); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedValues]); + + return ( + + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + > + + + } + > +
+ {options.map((item, index) => ( + { + const isPreviouslyChecked = selectedValues.includes(item.value); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter(val => val !== item.value)); + } else { + setSelectedValues(selectedValues.concat(item.value)); + } + }} + checked={selectedValues.includes(item.value) ? 'on' : undefined} + > + {item.name} + + ))} +
+
+
+ ); +}; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.test.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.test.ts new file mode 100644 index 0000000000000..efe58aedb8353 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TypeRegistry } from './type_registry'; +import { ValidationResult, AlertTypeModel, ActionTypeModel } from '../types'; + +export const ExpressionComponent: React.FunctionComponent = () => { + return null; +}; + +const getTestAlertType = (id?: string, name?: string, iconClass?: string) => { + return { + id: id || 'test-alet-type', + name: name || 'Test alert type', + iconClass: iconClass || 'icon', + validate: (): ValidationResult => { + return { errors: {} }; + }, + alertParamsExpression: ExpressionComponent, + }; +}; + +const getTestActionType = (id?: string, iconClass?: string, selectedMessage?: string) => { + return { + id: id || 'my-action-type', + iconClass: iconClass || 'test', + selectMessage: selectedMessage || 'test', + validateConnector: (): ValidationResult => { + return { errors: {} }; + }, + validateParams: (): ValidationResult => { + const validationResult = { errors: {} }; + return validationResult; + }, + actionConnectorFields: null, + actionParamsFields: null, + }; +}; + +beforeEach(() => jest.resetAllMocks()); + +describe('register()', () => { + test('able to register alert types', () => { + const alertTypeRegistry = new TypeRegistry(); + alertTypeRegistry.register(getTestAlertType()); + expect(alertTypeRegistry.has('test-alet-type')).toEqual(true); + }); + + test('throws error if alert type already registered', () => { + const alertTypeRegistry = new TypeRegistry(); + alertTypeRegistry.register(getTestAlertType('my-test-alert-type-1')); + expect(() => + alertTypeRegistry.register(getTestAlertType('my-test-alert-type-1')) + ).toThrowErrorMatchingInlineSnapshot( + `"Object type \\"my-test-alert-type-1\\" is already registered."` + ); + }); +}); + +describe('get()', () => { + test('returns action type', () => { + const actionTypeRegistry = new TypeRegistry(); + actionTypeRegistry.register(getTestActionType('my-action-type-snapshot')); + const actionType = actionTypeRegistry.get('my-action-type-snapshot'); + expect(actionType).toMatchInlineSnapshot(` + Object { + "actionConnectorFields": null, + "actionParamsFields": null, + "iconClass": "test", + "id": "my-action-type-snapshot", + "selectMessage": "test", + "validateConnector": [Function], + "validateParams": [Function], + } + `); + }); + + test(`return null when action type doesn't exist`, () => { + const actionTypeRegistry = new TypeRegistry(); + expect(actionTypeRegistry.get('not-exist-action-type')).toBeNull(); + }); +}); + +describe('list()', () => { + test('returns list of action types', () => { + const actionTypeRegistry = new TypeRegistry(); + actionTypeRegistry.register(getTestActionType()); + const actionTypes = actionTypeRegistry.list(); + expect(actionTypes).toEqual([ + { + id: 'my-action-type', + iconClass: 'test', + selectMessage: 'test', + actionConnectorFields: null, + actionParamsFields: null, + validateConnector: actionTypes[0].validateConnector, + validateParams: actionTypes[0].validateParams, + }, + ]); + }); +}); + +describe('has()', () => { + test('returns false for unregistered alert types', () => { + const alertTypeRegistry = new TypeRegistry(); + expect(alertTypeRegistry.has('my-alert-type')).toEqual(false); + }); + + test('returns true after registering an alert type', () => { + const alertTypeRegistry = new TypeRegistry(); + alertTypeRegistry.register(getTestAlertType()); + expect(alertTypeRegistry.has('test-alet-type')); + }); +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.ts new file mode 100644 index 0000000000000..3390d8910a45f --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/application/type_registry.ts @@ -0,0 +1,56 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +interface BaseObjectType { + id: string; +} + +export class TypeRegistry { + private readonly objectTypes: Map = new Map(); + + /** + * Returns if the object type registry has the given type registered + */ + public has(id: string) { + return this.objectTypes.has(id); + } + + /** + * Registers an object type to the type registry + */ + public register(objectType: T) { + if (this.has(objectType.id)) { + throw new Error( + i18n.translate( + 'xpack.triggersActionsUI.typeRegistry.register.duplicateObjectTypeErrorMessage', + { + defaultMessage: 'Object type "{id}" is already registered.', + values: { + id: objectType.id, + }, + } + ) + ); + } + this.objectTypes.set(objectType.id, objectType); + } + + /** + * Returns an object type, null if not registered + */ + public get(id: string): T | null { + if (!this.has(id)) { + return null; + } + return this.objectTypes.get(id)!; + } + + public list() { + return Array.from(this.objectTypes).map(([id, objectType]) => objectType); + } +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/index.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/index.ts new file mode 100644 index 0000000000000..7eed516019dd0 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/public'; +import { Plugin } from './plugin'; + +export function plugin(ctx: PluginInitializerContext) { + return new Plugin(ctx); +} + +export { Plugin }; +export * from './plugin'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/plugin.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/plugin.ts new file mode 100644 index 0000000000000..0b0f8a4ee6790 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/plugin.ts @@ -0,0 +1,107 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + CoreSetup, + CoreStart, + Plugin as CorePlugin, + PluginInitializerContext, +} from 'src/core/public'; + +import { i18n } from '@kbn/i18n'; +import { registerBuiltInActionTypes } from './application/components/builtin_action_types'; +import { registerBuiltInAlertTypes } from './application/components/builtin_alert_types'; +import { hasShowActionsCapability, hasShowAlertsCapability } from './application/lib/capabilities'; +import { PLUGIN } from './application/constants/plugin'; +import { LegacyDependencies, ActionTypeModel, AlertTypeModel } from './types'; +import { TypeRegistry } from './application/type_registry'; + +export type Setup = void; +export type Start = void; + +interface LegacyPlugins { + __LEGACY: LegacyDependencies; +} + +export class Plugin implements CorePlugin { + private actionTypeRegistry: TypeRegistry; + private alertTypeRegistry: TypeRegistry; + + constructor(initializerContext: PluginInitializerContext) { + const actionTypeRegistry = new TypeRegistry(); + this.actionTypeRegistry = actionTypeRegistry; + + const alertTypeRegistry = new TypeRegistry(); + this.alertTypeRegistry = alertTypeRegistry; + } + + public setup( + { application, notifications, http, uiSettings, injectedMetadata }: CoreSetup, + { __LEGACY }: LegacyPlugins + ): Setup { + const canShowActions = hasShowActionsCapability(__LEGACY.capabilities.get()); + const canShowAlerts = hasShowAlertsCapability(__LEGACY.capabilities.get()); + + if (!canShowActions && !canShowAlerts) { + return; + } + registerBuiltInActionTypes({ + actionTypeRegistry: this.actionTypeRegistry, + }); + + registerBuiltInAlertTypes({ + alertTypeRegistry: this.alertTypeRegistry, + }); + application.register({ + id: PLUGIN.ID, + title: PLUGIN.getI18nName(i18n), + mount: async ( + { + core: { + docLinks, + chrome, + // Waiting for types to be updated. + // @ts-ignore + savedObjects, + i18n: { Context: I18nContext }, + }, + }, + { element } + ) => { + const { boot } = await import('./application/boot'); + return boot({ + element, + toastNotifications: notifications.toasts, + injectedMetadata, + http, + uiSettings, + docLinks, + chrome, + savedObjects: savedObjects.client, + I18nContext, + legacy: { + ...__LEGACY, + }, + actionTypeRegistry: this.actionTypeRegistry, + alertTypeRegistry: this.alertTypeRegistry, + }); + }, + }); + } + + public start(core: CoreStart, { __LEGACY }: LegacyPlugins) { + const { capabilities } = __LEGACY; + const canShowActions = hasShowActionsCapability(capabilities.get()); + const canShowAlerts = hasShowAlertsCapability(capabilities.get()); + + // Don't register routes when user doesn't have access to the application + if (!canShowActions && !canShowAlerts) { + return; + } + } + + public stop() {} +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts new file mode 100644 index 0000000000000..4cf28d3bbd06f --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/np_ready/public/types.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { capabilities } from 'ui/capabilities'; +import { TypeRegistry } from './application/type_registry'; + +export type ActionTypeIndex = Record; +export type AlertTypeIndex = Record; +export type ActionTypeRegistryContract = PublicMethodsOf>; +export type AlertTypeRegistryContract = PublicMethodsOf>; + +export interface ActionConnectorFieldsProps { + action: ActionConnector; + editActionConfig: (property: string, value: any) => void; + editActionSecrets: (property: string, value: any) => void; + errors: { [key: string]: string[] }; + hasErrors?: boolean; +} + +export interface ActionParamsProps { + action: any; + index: number; + editAction: (property: string, value: any, index: number) => void; + errors: { [key: string]: string[] }; + hasErrors?: boolean; +} + +export interface Pagination { + index: number; + size: number; +} + +export interface ActionTypeModel { + id: string; + iconClass: string; + selectMessage: string; + validateConnector: (action: ActionConnector) => ValidationResult; + validateParams: (actionParams: any) => ValidationResult; + actionConnectorFields: React.FunctionComponent | null; + actionParamsFields: React.FunctionComponent | null; +} + +export interface ValidationResult { + errors: Record; +} + +export interface ActionType { + id: string; + name: string; +} + +export interface ActionConnector { + secrets: Record; + id: string; + actionTypeId: string; + name: string; + referencedByCount?: number; + config: Record; +} + +export type ActionConnectorWithoutId = Omit; + +export interface ActionConnectorTableItem extends ActionConnector { + actionType: ActionType['name']; +} + +export interface AlertType { + id: string; + name: string; +} + +export interface AlertAction { + group: string; + id: string; + params: Record; +} + +export interface Alert { + id: string; + name: string; + tags: string[]; + enabled: boolean; + alertTypeId: string; + interval: string; + actions: AlertAction[]; + params: Record; + scheduledTaskId?: string; + createdBy: string | null; + updatedBy: string | null; + apiKeyOwner?: string; + throttle: string | null; + muteAll: boolean; + mutedInstanceIds: string[]; +} + +export type AlertWithoutId = Omit; + +export interface AlertTableItem extends Alert { + alertType: AlertType['name']; + tagsText: string; +} + +export interface AlertTypeModel { + id: string; + name: string; + iconClass: string; + validate: (alert: Alert) => ValidationResult; + alertParamsExpression: React.FunctionComponent; +} + +export interface IErrorObject { + [key: string]: string[]; +} + +export interface LegacyDependencies { + MANAGEMENT_BREADCRUMB: { text: string; href?: string }; + capabilities: typeof capabilities; +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/public/hacks/register.ts b/x-pack/legacy/plugins/triggers_actions_ui/public/hacks/register.ts new file mode 100644 index 0000000000000..7991604fcc667 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/public/hacks/register.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { + FeatureCatalogueRegistryProvider, + FeatureCatalogueCategory, +} from 'ui/registry/feature_catalogue'; + +FeatureCatalogueRegistryProvider.register(() => { + return { + id: 'triggersActions', + title: 'Alerts and Actions', // This is a product name so we don't translate it. + description: i18n.translate('xpack.triggersActionsUI.triggersActionsDescription', { + defaultMessage: 'Data by creating, managing, and monitoring triggers and actions.', + }), + icon: 'triggersActionsApp', + path: '/app/kibana#/management/kibana/triggersActions', + showOnHomePage: true, + category: FeatureCatalogueCategory.ADMIN, + }; +}); diff --git a/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss b/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss new file mode 100644 index 0000000000000..6faad81630b2b --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/public/index.scss @@ -0,0 +1,5 @@ +// Imported EUI +@import 'src/legacy/ui/public/styles/_styling_constants'; + +// Styling within the app +@import '../np_ready/public/application/sections/actions_connectors_list/components/index'; diff --git a/x-pack/legacy/plugins/triggers_actions_ui/public/legacy.ts b/x-pack/legacy/plugins/triggers_actions_ui/public/legacy.ts new file mode 100644 index 0000000000000..bae9104081267 --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/public/legacy.ts @@ -0,0 +1,98 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup, App, AppUnmount } from 'src/core/public'; +import { capabilities } from 'ui/capabilities'; +import { i18n } from '@kbn/i18n'; + +/* Legacy UI imports */ +import { npSetup, npStart } from 'ui/new_platform'; +import routes from 'ui/routes'; +import { management, MANAGEMENT_BREADCRUMB } from 'ui/management'; +// @ts-ignore +import { xpackInfo } from 'plugins/xpack_main/services/xpack_info'; +/* Legacy UI imports */ + +import { plugin } from '../np_ready/public'; +import { manageAngularLifecycle } from './manage_angular_lifecycle'; +import { BASE_PATH } from '../np_ready/public/application/constants'; +import { + hasShowActionsCapability, + hasShowAlertsCapability, +} from '../np_ready/public/application/lib/capabilities'; + +const REACT_ROOT_ID = 'triggersActionsRoot'; +const canShowActions = hasShowActionsCapability(capabilities.get()); +const canShowAlerts = hasShowAlertsCapability(capabilities.get()); + +const template = ` +
+
`; + +let elem: HTMLElement; +let mountApp: () => AppUnmount | Promise; +let unmountApp: AppUnmount | Promise; +routes.when(`${BASE_PATH}:section?/:subsection?/:view?/:id?`, { + template, + controller: (() => { + return ($route: any, $scope: any) => { + const shimCore: CoreSetup = { + ...npSetup.core, + application: { + ...npSetup.core.application, + register(app: App): void { + mountApp = () => + app.mount(npStart as any, { + element: elem, + appBasePath: BASE_PATH, + onAppLeave: () => undefined, + }); + }, + }, + }; + + // clean up previously rendered React app if one exists + // this happens because of React Router redirects + if (elem) { + ((unmountApp as unknown) as AppUnmount)(); + } + + $scope.$$postDigest(() => { + elem = document.getElementById(REACT_ROOT_ID)!; + const instance = plugin({} as any); + instance.setup(shimCore, { + ...(npSetup.plugins as typeof npSetup.plugins), + __LEGACY: { + MANAGEMENT_BREADCRUMB, + capabilities, + }, + }); + + instance.start(npStart.core, { + ...(npSetup.plugins as typeof npSetup.plugins), + __LEGACY: { + MANAGEMENT_BREADCRUMB, + capabilities, + }, + }); + + (mountApp() as Promise).then(fn => (unmountApp = fn)); + + manageAngularLifecycle($scope, $route, elem); + }); + }; + })(), +}); + +if (canShowActions || canShowAlerts) { + management.getSection('kibana').register('triggersActions', { + display: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', { + defaultMessage: 'Alerts and Actions', + }), + order: 7, + url: `#${BASE_PATH}`, + }); +} diff --git a/x-pack/legacy/plugins/triggers_actions_ui/public/manage_angular_lifecycle.ts b/x-pack/legacy/plugins/triggers_actions_ui/public/manage_angular_lifecycle.ts new file mode 100644 index 0000000000000..efd40eaf83daa --- /dev/null +++ b/x-pack/legacy/plugins/triggers_actions_ui/public/manage_angular_lifecycle.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { unmountComponentAtNode } from 'react-dom'; + +export const manageAngularLifecycle = ($scope: any, $route: any, elem: HTMLElement) => { + const lastRoute = $route.current; + + const deregister = $scope.$on('$locationChangeSuccess', () => { + const currentRoute = $route.current; + if (lastRoute.$$route.template === currentRoute.$$route.template) { + $route.current = lastRoute; + } + }); + + $scope.$on('$destroy', () => { + if (deregister) { + deregister(); + } + + if (elem) { + unmountComponentAtNode(elem); + } + }); +}; diff --git a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts index a40453b3671b7..ea3cfe677ca99 100644 --- a/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts +++ b/x-pack/legacy/plugins/uptime/common/runtime_types/monitor/locations.ts @@ -10,6 +10,7 @@ import { CheckGeoType, SummaryType } from '../common'; export const MonitorLocationType = t.partial({ summary: SummaryType, geo: CheckGeoType, + timestamp: t.string, }); // Typescript type for type checking diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap deleted file mode 100644 index d731a168225b7..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/__snapshots__/monitor_ssl_certificate.test.tsx.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`MonitorStatusBar component renders 1`] = ` -Array [ -
, -
-
- SSL certificate expires in 2 months -
-
, -] -`; - -exports[`MonitorStatusBar component renders null if invalid date 1`] = `null`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx deleted file mode 100644 index 03eb252aa8c09..0000000000000 --- a/x-pack/legacy/plugins/uptime/public/components/functional/__tests__/monitor_ssl_certificate.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import moment from 'moment'; -import { renderWithIntl } from 'test_utils/enzyme_helpers'; -import { PingTls } from '../../../../common/graphql/types'; -import { MonitorSSLCertificate } from '../monitor_status_details/monitor_status_bar'; - -describe('MonitorStatusBar component', () => { - let monitorTls: PingTls; - - beforeEach(() => { - const dateInTwoMonths = moment() - .add(2, 'month') - .toString(); - - monitorTls = { - certificate_not_valid_after: dateInTwoMonths, - }; - }); - - it('renders', () => { - const component = renderWithIntl(); - expect(component).toMatchSnapshot(); - }); - - it('renders null if invalid date', () => { - monitorTls = { - certificate_not_valid_after: 'i am so invalid date', - }; - const component = renderWithIntl(); - expect(component).toMatchSnapshot(); - }); -}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap new file mode 100644 index 0000000000000..6228183e7c2b2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/__snapshots__/location_status_tags.test.tsx.snap @@ -0,0 +1,577 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`StatusByLocation component renders when all locations are down 1`] = ` +.c3 { + display: inline-block; + margin-left: 4px; +} + +.c2 { + font-weight: 600; +} + +.c1 { + margin-bottom: 5px; +} + +.c0 { + padding: 10px; + max-height: 229px; + overflow: hidden; +} + +
+ +
+ + + +
+
+ Islamabad +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Berlin +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ +
+`; + +exports[`StatusByLocation component renders when all locations are up 1`] = ` +.c3 { + display: inline-block; + margin-left: 4px; +} + +.c2 { + font-weight: 600; +} + +.c1 { + margin-bottom: 5px; +} + +.c0 { + padding: 10px; + max-height: 229px; + overflow: hidden; +} + +
+ + +
+ + + +
+
+ Islamabad +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Berlin +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+
+`; + +exports[`StatusByLocation component renders when there are many location 1`] = ` +Array [ + .c3 { + display: inline-block; + margin-left: 4px; +} + +.c2 { + font-weight: 600; +} + +.c1 { + margin-bottom: 5px; +} + +.c0 { + padding: 10px; + max-height: 229px; + overflow: hidden; +} + +
+ +
+ + + +
+
+ Islamabad +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Berlin +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ st-paul +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Tokya +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ New York +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Toronto +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Sydney +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ + + +
+
+ Paris +
+
+
+
+
+ +
+
+ 3d ago +
+
+
+
+
+ +
, + .c0 { + padding-left: 18px; +} + +
+
+
+

+ 1 Others ... +

+
+
+
, +] +`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx new file mode 100644 index 0000000000000..de04347148bb2 --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/__tests__/location_status_tags.test.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import moment from 'moment'; +import { renderWithIntl } from 'test_utils/enzyme_helpers'; +import { MonitorLocation } from '../../../../../common/runtime_types/monitor'; +import { LocationStatusTags } from '../'; + +// These tests use absolute time +// Failing: https://github.com/elastic/kibana/issues/54672 +describe.skip('StatusByLocation component', () => { + let monitorLocations: MonitorLocation[]; + + const start = moment('2020-01-10T12:22:32.567Z'); + beforeAll(() => { + moment.prototype.fromNow = jest.fn((date: string) => start.from(date)); + }); + + it('renders when there are many location', () => { + monitorLocations = [ + { + summary: { up: 0, down: 1 }, + geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:28.825Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:31.586Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Tokya', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:25.771Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'New York', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:27.485Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Toronto', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:28.815Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Sydney', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.132Z', + }, + { + summary: { up: 0, down: 1 }, + geo: { name: 'Paris', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.973Z', + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when all locations are up', () => { + monitorLocations = [ + { + summary: { up: 4, down: 0 }, + geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', + }, + { + summary: { up: 4, down: 0 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-08T12:22:28.825Z', + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders when all locations are down', () => { + monitorLocations = [ + { + summary: { up: 0, down: 2 }, + geo: { name: 'Islamabad', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-06T12:22:32.567Z', + }, + { + summary: { up: 0, down: 2 }, + geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:28.825Z', + }, + ]; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx index 1f4b88b971c4c..140d33bbeef66 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/index.tsx @@ -5,3 +5,4 @@ */ export * from './location_map'; +export * from './location_status_tags'; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx index a10d8e02e6863..6563c03ad7c34 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/location_map/location_status_tags.tsx @@ -7,9 +7,16 @@ import React, { useContext } from 'react'; import styled from 'styled-components'; import { EuiBadge, EuiText } from '@elastic/eui'; +import moment from 'moment'; +import { FormattedMessage } from '@kbn/i18n/react'; import { UptimeSettingsContext } from '../../../contexts'; import { MonitorLocation } from '../../../../common/runtime_types'; +const TimeStampSpan = styled.span` + display: inline-block; + margin-left: 4px; +`; + const TextStyle = styled.div` font-weight: 600; `; @@ -20,54 +27,97 @@ const BadgeItem = styled.div` const TagContainer = styled.div` padding: 10px; - max-height: 200px; + max-height: 229px; overflow: hidden; `; +const OtherLocationsDiv = styled.div` + padding-left: 18px; +`; + interface Props { locations: MonitorLocation[]; } +interface StatusTag { + label: string; + timestamp: number; +} + export const LocationStatusTags = ({ locations }: Props) => { const { colors: { gray, danger }, } = useContext(UptimeSettingsContext); - const upLocs: string[] = []; - const downLocs: string[] = []; + const upLocations: StatusTag[] = []; + const downLocations: StatusTag[] = []; locations.forEach((item: any) => { if (item.summary.down === 0) { - upLocs.push(item.geo.name); + upLocations.push({ label: item.geo.name, timestamp: new Date(item.timestamp).valueOf() }); } else { - downLocs.push(item.geo.name); + downLocations.push({ label: item.geo.name, timestamp: new Date(item.timestamp).valueOf() }); } }); + // Sort by recent timestamp + upLocations.sort((a, b) => { + return a.timestamp < b.timestamp ? 1 : b.timestamp < a.timestamp ? -1 : 0; + }); + + moment.locale('en', { + relativeTime: { + future: 'in %s', + past: '%s ago', + s: '%ds', + ss: '%ss', + m: '%dm', + mm: '%dm', + h: '%dh', + hh: '%dh', + d: '%dd', + dd: '%dd', + M: '%d Mon', + MM: '%d Mon', + y: '%d Yr', + yy: '%d Yr', + }, + }); + + const tagLabel = (item: StatusTag, ind: number, color: string) => ( + + + + {item.label} + + + + {moment(item.timestamp).fromNow()} + + + ); + return ( - - - {downLocs.map((item, ind) => ( - - - - {item} - - - - ))} - - - {upLocs.map((item, ind) => ( - - - - {item} - - - - ))} - - + <> + + {downLocations.map((item, ind) => tagLabel(item, ind, danger))} + {upLocations.map((item, ind) => tagLabel(item, ind, gray))} + + {locations.length > 7 && ( + + +

+ +

+
+
+ )} + ); }; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/monitor_ssl_certificate.test.tsx.snap b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/monitor_ssl_certificate.test.tsx.snap new file mode 100644 index 0000000000000..0cb0a7ec248df --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/__snapshots__/monitor_ssl_certificate.test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MonitorStatusBar component renders 1`] = ` +Array [ +
, +
+ SSL certificate expires + + + + in 2 months + + + +
, +] +`; + +exports[`MonitorStatusBar component renders null if invalid date 1`] = `null`; diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx new file mode 100644 index 0000000000000..2eae14301fd4d --- /dev/null +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/monitor_ssl_certificate.test.tsx @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import moment from 'moment'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { EuiBadge } from '@elastic/eui'; +import { renderWithIntl } from 'test_utils/enzyme_helpers'; +import { PingTls } from '../../../../../common/graphql/types'; +import { MonitorSSLCertificate } from '../monitor_status_bar'; + +describe('MonitorStatusBar component', () => { + let monitorTls: PingTls; + + beforeEach(() => { + const dateInTwoMonths = moment() + .add(2, 'month') + .toString(); + + monitorTls = { + certificate_not_valid_after: dateInTwoMonths, + }; + }); + + it('renders', () => { + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders null if invalid date', () => { + monitorTls = { + certificate_not_valid_after: 'i am so invalid date', + }; + const component = renderWithIntl(); + expect(component).toMatchSnapshot(); + }); + + it('renders expiration date with a warning state if ssl expiry date is less than 30 days', () => { + const dateIn15Days = moment() + .add(15, 'day') + .toString(); + monitorTls = { + certificate_not_valid_after: dateIn15Days, + }; + const component = mountWithIntl(); + + const badgeComponent = component.find(EuiBadge); + expect(badgeComponent.props().color).toBe('warning'); + + const badgeComponentText = component.find('.euiBadge__text'); + expect(badgeComponentText.text()).toBe(moment(dateIn15Days).fromNow()); + + expect(badgeComponent.find('span.euiBadge--warning')).toBeTruthy(); + }); + + it('does not render the expiration date with a warning state if expiry date is greater than a month', () => { + const dateIn40Days = moment() + .add(40, 'day') + .toString(); + monitorTls = { + certificate_not_valid_after: dateIn40Days, + }; + const component = mountWithIntl(); + + const badgeComponent = component.find(EuiBadge); + expect(badgeComponent.props().color).toBe('default'); + + const badgeComponentText = component.find('.euiBadge__text'); + expect(badgeComponentText.text()).toBe(moment(dateIn40Days).fromNow()); + + expect(badgeComponent.find('span.euiBadge--warning')).toHaveLength(0); + }); +}); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx index 4e515a52b8de6..ac6a1baf8a110 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/__test__/status_by_location.test.tsx @@ -9,7 +9,7 @@ import { renderWithIntl } from 'test_utils/enzyme_helpers'; import { MonitorLocation } from '../../../../../common/runtime_types'; import { StatusByLocations } from '../'; -describe('StatusByLocation component', () => { +describe.skip('StatusByLocation component', () => { let monitorLocations: MonitorLocation[]; it('renders when up in all locations', () => { @@ -17,6 +17,7 @@ describe('StatusByLocation component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, { summary: { up: 4, down: 0 }, @@ -32,6 +33,7 @@ describe('StatusByLocation component', () => { { summary: { up: 4, down: 0 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); @@ -43,6 +45,7 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); @@ -54,10 +57,12 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, { summary: { up: 0, down: 4 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); @@ -69,10 +74,12 @@ describe('StatusByLocation component', () => { { summary: { up: 0, down: 4 }, geo: { name: 'Berlin', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, { summary: { up: 4, down: 0 }, geo: { name: 'st-paul', location: { lat: '52.487448', lon: ' 13.394798' } }, + timestamp: '2020-01-09T12:22:32.567Z', }, ]; const component = renderWithIntl(); diff --git a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx index 5e916c40e712d..c57348c4ab4cd 100644 --- a/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx +++ b/x-pack/legacy/plugins/uptime/public/components/functional/monitor_status_details/monitor_status_bar/monitor_ssl_certificate.tsx @@ -5,9 +5,8 @@ */ import React from 'react'; -import { get } from 'lodash'; import moment from 'moment'; -import { EuiSpacer, EuiText } from '@elastic/eui'; +import { EuiSpacer, EuiText, EuiBadge } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -21,30 +20,37 @@ interface Props { } export const MonitorSSLCertificate = ({ tls }: Props) => { - const certificateValidity: string | undefined = get( - tls, - 'certificate_not_valid_after', - undefined - ); + const certValidityDate = new Date(tls?.certificate_not_valid_after ?? ''); - const validExpiryDate = certificateValidity && !isNaN(new Date(certificateValidity).valueOf()); + const isValidDate = !isNaN(certValidityDate.valueOf()); - return validExpiryDate && certificateValidity ? ( + const dateIn30Days = moment().add('30', 'days'); + + const isExpiringInMonth = isValidDate && dateIn30Days > moment(certValidityDate); + + return isValidDate ? ( <> + {moment(certValidityDate).fromNow()} + + ), }} /> diff --git a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts index 37a9e032cd442..b237fd8771f58 100644 --- a/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts +++ b/x-pack/legacy/plugins/uptime/server/lib/adapters/monitors/elasticsearch_monitors_adapter.ts @@ -334,7 +334,7 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { order: 'desc', }, }, - _source: ['monitor', 'summary', 'observer'], + _source: ['monitor', 'summary', 'observer', '@timestamp'], }, }, }, @@ -365,6 +365,7 @@ export const elasticsearchMonitorsAdapter: UMMonitorsAdapter = { const location: MonitorLocation = { summary: mostRecentLocation?.summary, geo: getGeo(mostRecentLocation?.observer?.geo), + timestamp: mostRecentLocation['@timestamp'], }; monLocs.push(location); } diff --git a/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx b/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx index 90393f9f4ff6f..9880a2b811f8b 100644 --- a/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx +++ b/x-pack/plugins/advanced_ui_actions/public/customize_time_range_modal.tsx @@ -137,7 +137,7 @@ export class CustomizeTimeRangeModal extends Component {i18n.translate( diff --git a/x-pack/plugins/apm/server/index.ts b/x-pack/plugins/apm/server/index.ts index b0e10d245e0b9..e301d157d2c7c 100644 --- a/x-pack/plugins/apm/server/index.ts +++ b/x-pack/plugins/apm/server/index.ts @@ -16,6 +16,7 @@ export const config = { }, schema: schema.object({ serviceMapEnabled: schema.boolean({ defaultValue: false }), + serviceMapInitialTimeRange: schema.number({ defaultValue: 60 * 1000 * 60 }), // last 1 hour autocreateApmIndexPattern: schema.boolean({ defaultValue: true }), ui: schema.object({ enabled: schema.boolean({ defaultValue: true }), @@ -37,6 +38,7 @@ export function mergeConfigs(apmOssConfig: APMOSSConfig, apmConfig: APMXPackConf 'apm_oss.onboardingIndices': apmOssConfig.onboardingIndices, 'apm_oss.indexPattern': apmOssConfig.indexPattern, 'xpack.apm.serviceMapEnabled': apmConfig.serviceMapEnabled, + 'xpack.apm.serviceMapInitialTimeRange': apmConfig.serviceMapInitialTimeRange, 'xpack.apm.ui.enabled': apmConfig.ui.enabled, 'xpack.apm.ui.maxTraceItems': apmConfig.ui.maxTraceItems, 'xpack.apm.ui.transactionGroupBucketSize': apmConfig.ui.transactionGroupBucketSize, diff --git a/x-pack/plugins/task_manager/kibana.json b/x-pack/plugins/task_manager/kibana.json new file mode 100644 index 0000000000000..ad2d5d00ae0be --- /dev/null +++ b/x-pack/plugins/task_manager/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "taskManager", + "server": true, + "version": "8.0.0", + "kibanaVersion": "kibana", + "configPath": ["xpack", "task_manager"], + "ui": false +} diff --git a/x-pack/legacy/plugins/task_manager/server/README.md b/x-pack/plugins/task_manager/server/README.md similarity index 68% rename from x-pack/legacy/plugins/task_manager/server/README.md rename to x-pack/plugins/task_manager/server/README.md index 3afcb758260c0..a067358dc8841 100644 --- a/x-pack/legacy/plugins/task_manager/server/README.md +++ b/x-pack/plugins/task_manager/server/README.md @@ -55,51 +55,61 @@ Plugins define tasks by calling the `registerTaskDefinitions` method on the `ser A sample task can be found in the [x-pack/test/plugin_api_integration/plugins/task_manager](../../test/plugin_api_integration/plugins/task_manager/index.js) folder. ```js -const taskManager = server.plugins.task_manager; -taskManager.registerTaskDefinitions({ - // clusterMonitoring is the task type, and must be unique across the entire system - clusterMonitoring: { - // Human friendly name, used to represent this task in logs, UI, etc - title: 'Human friendly name', - - // Optional, human-friendly, more detailed description - description: 'Amazing!!', - - // Optional, how long, in minutes or seconds, the system should wait before - // a running instance of this task is considered to be timed out. - // This defaults to 5 minutes. - timeout: '5m', - - // Optional, how many attempts before marking task as failed. - // This defaults to what is configured at the task manager level. - maxAttempts: 5, - - // The clusterMonitoring task occupies 2 workers, so if the system has 10 worker slots, - // 5 clusterMonitoring tasks could run concurrently per Kibana instance. This value is - // overridden by the `override_num_workers` config value, if specified. - numWorkers: 2, - - // The createTaskRunner function / method returns an object that is responsible for - // performing the work of the task. context: { taskInstance }, is documented below. - createTaskRunner(context) { - return { - // Perform the work of the task. The return value should fit the TaskResult interface, documented - // below. Invalid return values will result in a logged warning. - async run() { - // Do some work - // Conditionally send some alerts - // Return some result or other... +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + taskManager.registerTaskDefinitions({ + // clusterMonitoring is the task type, and must be unique across the entire system + clusterMonitoring: { + // Human friendly name, used to represent this task in logs, UI, etc + title: 'Human friendly name', + + // Optional, human-friendly, more detailed description + description: 'Amazing!!', + + // Optional, how long, in minutes or seconds, the system should wait before + // a running instance of this task is considered to be timed out. + // This defaults to 5 minutes. + timeout: '5m', + + // Optional, how many attempts before marking task as failed. + // This defaults to what is configured at the task manager level. + maxAttempts: 5, + + // The clusterMonitoring task occupies 2 workers, so if the system has 10 worker slots, + // 5 clusterMonitoring tasks could run concurrently per Kibana instance. This value is + // overridden by the `override_num_workers` config value, if specified. + numWorkers: 2, + + // The createTaskRunner function / method returns an object that is responsible for + // performing the work of the task. context: { taskInstance }, is documented below. + createTaskRunner(context) { + return { + // Perform the work of the task. The return value should fit the TaskResult interface, documented + // below. Invalid return values will result in a logged warning. + async run() { + // Do some work + // Conditionally send some alerts + // Return some result or other... + }, + + // Optional, will be called if a running instance of this task times out, allowing the task + // to attempt to clean itself up. + async cancel() { + // Do whatever is required to cancel this task, such as killing any spawned processes + }, + }; }, + }, + }); + } - // Optional, will be called if a running instance of this task times out, allowing the task - // to attempt to clean itself up. - async cancel() { - // Do whatever is required to cancel this task, such as killing any spawned processes - }, - }; - }, - }, -}); + public start(core: CoreStart, plugins: { taskManager }) { + + } +} ``` When Kibana attempts to claim and run a task instance, it looks its definition up, and executes its createTaskRunner's method, passing it a run context which looks like this: @@ -222,67 +232,129 @@ The data stored for a task instance looks something like this: The task manager mixin exposes a taskManager object on the Kibana server which plugins can use to manage scheduled tasks. Each method takes an optional `scope` argument and ensures that only tasks with the specified scope(s) will be affected. -### schedule -Using `schedule` you can instruct TaskManger to schedule an instance of a TaskType at some point in the future. +### Overview +Interaction with the TaskManager Plugin is done via the Kibana Platform Plugin system. +When developing your Plugin, you're asked to define a `setup` method and a `start` method. +These methods are handed Kibana's Plugin APIs for these two stages, which means you'll have access to the following apis in these two stages: + +#### Setup +The _Setup_ Plugin api includes methods which configure Task Manager to support your Plugin's requirements, such as defining custom Middleware and Task Definitions. +```js +{ + addMiddleware: (middleware: Middleware) => { + // ... + }, + registerTaskDefinitions: (taskDefinitions: TaskDictionary) => { + // ... + }, +} +``` + +#### Start +The _Start_ Plugin api allow you to use Task Manager to facilitate your Plugin's behaviour, such as scheduling tasks. ```js -const taskManager = server.plugins.task_manager; -// Schedules a task. All properties are as documented in the previous -// storage section, except that here, params is an object, not a JSON -// string. -const task = await taskManager.schedule({ - taskType, - runAt, - schedule, - params, - scope: ['my-fanci-app'], -}); - -// Removes the specified task -await manager.remove(task.id); - -// Fetches tasks, supports pagination, via the search-after API: -// https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-search-after.html -// If scope is not specified, all tasks are returned, otherwise only tasks -// with the given scope are returned. -const results = await manager.find({ scope: 'my-fanci-app', searchAfter: ['ids'] }); - -// results look something like this: { - searchAfter: ['233322'], - // Tasks is an array of task instances - tasks: [{ - id: '3242342', - taskType: 'reporting', - // etc - }] + fetch: (opts: FetchOpts) => { + // ... + }, + remove: (id: string) => { + // ... + }, + schedule: (taskInstance: TaskInstanceWithDeprecatedFields, options?: any) => { + // ... + }, + runNow: (taskId: string) => { + // ... + }, + ensureScheduled: (taskInstance: TaskInstanceWithId, options?: any) => { + // ... + }, } ``` -### ensureScheduling +### Detailed APIs + +#### schedule +Using `schedule` you can instruct TaskManger to schedule an instance of a TaskType at some point in the future. + + +```js +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + } + + public start(core: CoreStart, plugins: { taskManager }) { + // Schedules a task. All properties are as documented in the previous + // storage section, except that here, params is an object, not a JSON + // string. + const task = await taskManager.schedule({ + taskType, + runAt, + schedule, + params, + scope: ['my-fanci-app'], + }); + + // Removes the specified task + await taskManager.remove(task.id); + + // Fetches tasks, supports pagination, via the search-after API: + // https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-search-after.html + // If scope is not specified, all tasks are returned, otherwise only tasks + // with the given scope are returned. + const results = await taskManager.find({ scope: 'my-fanci-app', searchAfter: ['ids'] }); + } +} +``` +*results* then look something like this: + +```json + { + "searchAfter": ["233322"], + // Tasks is an array of task instances + "tasks": [{ + "id": "3242342", + "taskType": "reporting", + // etc + }] + } +``` + +#### ensureScheduling When using the `schedule` api to schedule a Task you can provide a hard coded `id` on the Task. This tells TaskManager to use this `id` to identify the Task Instance rather than generate an `id` on its own. The danger is that in such a situation, a Task with that same `id` might already have been scheduled at some earlier point, and this would result in an error. In some cases, this is the expected behavior, but often you only care about ensuring the task has been _scheduled_ and don't need it to be scheduled a fresh. To achieve this you should use the `ensureScheduling` api which has the exact same behavior as `schedule`, except it allows the scheduling of a Task with an `id` that's already in assigned to another Task and it will assume that the existing Task is the one you wished to `schedule`, treating this as a successful operation. -### runNow +#### runNow Using `runNow` you can instruct TaskManger to run an existing task on-demand, without waiting for its scheduled time to be reached. ```js -const taskManager = server.plugins.task_manager; - -try { - const taskRunResult = await taskManager.runNow('91760f10-ba42-de9799'); - // If no error is thrown, the task has completed successfully. -} catch(err: Error) { - // If running the task has failed, we throw an error with an appropriate message. - // For example, if the requested task doesnt exist: `Error: failed to run task "91760f10-ba42-de9799" as it does not exist` - // Or if, for example, the task is already running: `Error: failed to run task "91760f10-ba42-de9799" as it is currently running` +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + } + + public start(core: CoreStart, plugins: { taskManager }) { + try { + const taskRunResult = await taskManager.runNow('91760f10-ba42-de9799'); + // If no error is thrown, the task has completed successfully. + } catch(err: Error) { + // If running the task has failed, we throw an error with an appropriate message. + // For example, if the requested task doesnt exist: `Error: failed to run task "91760f10-ba42-de9799" as it does not exist` + // Or if, for example, the task is already running: `Error: failed to run task "91760f10-ba42-de9799" as it is currently running` + } + } } ``` - -### more options +#### more options More custom access to the tasks can be done directly via Elasticsearch, though that won't be officially supported, as we can change the document structure at any time. @@ -291,35 +363,44 @@ More custom access to the tasks can be done directly via Elasticsearch, though t The task manager exposes a middleware layer that allows modifying tasks before they are scheduled / persisted to the task manager index, and modifying tasks / the run context before a task is run. For example: - ```js -// In your plugin's init -server.plugins.task_manager.addMiddleware({ - async beforeSave({ taskInstance, ...opts }) { - console.log(`About to save a task of type ${taskInstance.taskType}`); - - return { - ...opts, - taskInstance: { - ...taskInstance, - params: { - ...taskInstance.params, - example: 'Added to params!', - }, +export class Plugin { + constructor() { + } + + public setup(core: CoreSetup, plugins: { taskManager }) { + taskManager.addMiddleware({ + async beforeSave({ taskInstance, ...opts }) { + console.log(`About to save a task of type ${taskInstance.taskType}`); + + return { + ...opts, + taskInstance: { + ...taskInstance, + params: { + ...taskInstance.params, + example: 'Added to params!', + }, + }, + }; }, - }; - }, - async beforeRun({ taskInstance, ...opts }) { - console.log(`About to run ${taskInstance.taskType} ${taskInstance.id}`); - const { example, ...taskWithoutExampleProp } = taskInstance; + async beforeRun({ taskInstance, ...opts }) { + console.log(`About to run ${taskInstance.taskType} ${taskInstance.id}`); + const { example, ...taskWithoutExampleProp } = taskInstance; - return { - ...opts, - taskInstance: taskWithoutExampleProp, - }; - }, -}); + return { + ...opts, + taskInstance: taskWithoutExampleProp, + }; + }, + }); + } + + public start(core: CoreStart, plugins: { taskManager }) { + + } +} ``` ## Task Poller: polling for work diff --git a/x-pack/plugins/task_manager/server/config.test.ts b/x-pack/plugins/task_manager/server/config.test.ts new file mode 100644 index 0000000000000..f7962f7011f34 --- /dev/null +++ b/x-pack/plugins/task_manager/server/config.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { configSchema } from './config'; + +describe('config validation', () => { + test('task manager defaults', () => { + const config: Record = {}; + expect(configSchema.validate(config)).toMatchInlineSnapshot(` + Object { + "enabled": true, + "index": ".kibana_task_manager", + "max_attempts": 3, + "max_workers": 10, + "poll_interval": 3000, + "request_capacity": 1000, + } + `); + }); + + test('the ElastiSearch Tasks index cannot be used for task manager', () => { + const config: Record = { + index: '.tasks', + }; + expect(() => { + configSchema.validate(config); + }).toThrowErrorMatchingInlineSnapshot( + `"[index]: \\".tasks\\" is an invalid Kibana Task Manager index, as it is already in use by the ElasticSearch Tasks Manager"` + ); + }); +}); diff --git a/x-pack/plugins/task_manager/server/config.ts b/x-pack/plugins/task_manager/server/config.ts new file mode 100644 index 0000000000000..06e6ad3e62282 --- /dev/null +++ b/x-pack/plugins/task_manager/server/config.ts @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), + /* The maximum number of times a task will be attempted before being abandoned as failed */ + max_attempts: schema.number({ + defaultValue: 3, + min: 1, + }), + /* How often, in milliseconds, the task manager will look for more work. */ + poll_interval: schema.number({ + defaultValue: 3000, + min: 100, + }), + /* How many requests can Task Manager buffer before it rejects new requests. */ + request_capacity: schema.number({ + // a nice round contrived number, feel free to change as we learn how it behaves + defaultValue: 1000, + min: 1, + }), + /* The name of the index used to store task information. */ + index: schema.string({ + defaultValue: '.kibana_task_manager', + validate: val => { + if (val.toLowerCase() === '.tasks') { + return `"${val}" is an invalid Kibana Task Manager index, as it is already in use by the ElasticSearch Tasks Manager`; + } + }, + }), + /* The maximum number of tasks that this Kibana instance will run simultaneously. */ + max_workers: schema.number({ + defaultValue: 10, + // disable the task manager rather than trying to specify it with 0 workers + min: 1, + }), +}); + +export type TaskManagerConfig = TypeOf; diff --git a/x-pack/plugins/task_manager/server/create_task_manager.test.ts b/x-pack/plugins/task_manager/server/create_task_manager.test.ts new file mode 100644 index 0000000000000..f4deeb1ea02ed --- /dev/null +++ b/x-pack/plugins/task_manager/server/create_task_manager.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createTaskManager, LegacyDeps } from './create_task_manager'; +import { mockLogger } from './test_utils'; +import { CoreSetup, UuidServiceSetup } from 'kibana/server'; +import { savedObjectsRepositoryMock } from '../../../../src/core/server/mocks'; + +jest.mock('./task_manager'); + +describe('createTaskManager', () => { + const uuid: UuidServiceSetup = { + getInstanceUuid() { + return 'some-uuid'; + }, + }; + const mockCoreSetup = { + uuid, + } as CoreSetup; + + const getMockLegacyDeps = (): LegacyDeps => ({ + config: {}, + savedObjectSchemas: {}, + elasticsearch: { + callAsInternalUser: jest.fn(), + }, + savedObjectsRepository: savedObjectsRepositoryMock.create(), + logger: mockLogger(), + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + test('exposes the underlying TaskManager', async () => { + const mockLegacyDeps = getMockLegacyDeps(); + const setupResult = createTaskManager(mockCoreSetup, mockLegacyDeps); + expect(setupResult).toMatchInlineSnapshot(` + TaskManager { + "addMiddleware": [MockFunction], + "assertUninitialized": [MockFunction], + "attemptToRun": [MockFunction], + "ensureScheduled": [MockFunction], + "fetch": [MockFunction], + "registerTaskDefinitions": [MockFunction], + "remove": [MockFunction], + "runNow": [MockFunction], + "schedule": [MockFunction], + "start": [MockFunction], + "stop": [MockFunction], + "waitUntilStarted": [MockFunction], + } + `); + }); +}); diff --git a/x-pack/plugins/task_manager/server/create_task_manager.ts b/x-pack/plugins/task_manager/server/create_task_manager.ts new file mode 100644 index 0000000000000..5c66b8ba5bd58 --- /dev/null +++ b/x-pack/plugins/task_manager/server/create_task_manager.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + IClusterClient, + SavedObjectsSerializer, + SavedObjectsSchema, + CoreSetup, + ISavedObjectsRepository, +} from '../../../../src/core/server'; +import { TaskManager } from './task_manager'; +import { Logger } from './types'; + +export interface LegacyDeps { + config: any; + savedObjectSchemas: any; + elasticsearch: Pick; + savedObjectsRepository: ISavedObjectsRepository; + logger: Logger; +} + +export function createTaskManager( + core: CoreSetup, + { + logger, + config, + savedObjectSchemas, + elasticsearch: { callAsInternalUser }, + savedObjectsRepository, + }: LegacyDeps +) { + // as we use this Schema solely to interact with Tasks, we + // can initialise it with solely the Tasks schema + const serializer = new SavedObjectsSerializer(new SavedObjectsSchema(savedObjectSchemas)); + return new TaskManager({ + taskManagerId: core.uuid.getInstanceUuid(), + config, + savedObjectsRepository, + serializer, + callAsInternalUser, + logger, + }); +} diff --git a/x-pack/plugins/task_manager/server/index.ts b/x-pack/plugins/task_manager/server/index.ts new file mode 100644 index 0000000000000..7eba218e16fed --- /dev/null +++ b/x-pack/plugins/task_manager/server/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PluginInitializerContext } from 'src/core/server'; +import { TaskManagerPlugin } from './plugin'; +import { configSchema } from './config'; + +export const plugin = (initContext: PluginInitializerContext) => new TaskManagerPlugin(initContext); + +export { + TaskInstance, + ConcreteTaskInstance, + TaskRunCreatorFunction, + TaskStatus, + RunContext, +} from './task'; + +export { + TaskManagerPlugin as TaskManager, + TaskManagerSetupContract, + TaskManagerStartContract, +} from './plugin'; + +export const config = { + schema: configSchema, +}; diff --git a/x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts b/x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts rename to x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.ts b/x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/correct_deprecated_fields.ts rename to x-pack/plugins/task_manager/server/lib/correct_deprecated_fields.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/fill_pool.test.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/fill_pool.test.ts rename to x-pack/plugins/task_manager/server/lib/fill_pool.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/fill_pool.ts b/x-pack/plugins/task_manager/server/lib/fill_pool.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/fill_pool.ts rename to x-pack/plugins/task_manager/server/lib/fill_pool.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/get_template_version.test.ts b/x-pack/plugins/task_manager/server/lib/get_template_version.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/get_template_version.test.ts rename to x-pack/plugins/task_manager/server/lib/get_template_version.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/get_template_version.ts b/x-pack/plugins/task_manager/server/lib/get_template_version.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/get_template_version.ts rename to x-pack/plugins/task_manager/server/lib/get_template_version.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.test.ts b/x-pack/plugins/task_manager/server/lib/identify_es_error.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.test.ts rename to x-pack/plugins/task_manager/server/lib/identify_es_error.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.ts b/x-pack/plugins/task_manager/server/lib/identify_es_error.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/identify_es_error.ts rename to x-pack/plugins/task_manager/server/lib/identify_es_error.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/intervals.test.ts b/x-pack/plugins/task_manager/server/lib/intervals.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/intervals.test.ts rename to x-pack/plugins/task_manager/server/lib/intervals.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/intervals.ts b/x-pack/plugins/task_manager/server/lib/intervals.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/intervals.ts rename to x-pack/plugins/task_manager/server/lib/intervals.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/middleware.test.ts b/x-pack/plugins/task_manager/server/lib/middleware.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/middleware.test.ts rename to x-pack/plugins/task_manager/server/lib/middleware.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/middleware.ts b/x-pack/plugins/task_manager/server/lib/middleware.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/middleware.ts rename to x-pack/plugins/task_manager/server/lib/middleware.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.test.ts b/x-pack/plugins/task_manager/server/lib/pull_from_set.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.test.ts rename to x-pack/plugins/task_manager/server/lib/pull_from_set.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.ts b/x-pack/plugins/task_manager/server/lib/pull_from_set.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/pull_from_set.ts rename to x-pack/plugins/task_manager/server/lib/pull_from_set.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/result_type.ts b/x-pack/plugins/task_manager/server/lib/result_type.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/result_type.ts rename to x-pack/plugins/task_manager/server/lib/result_type.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts b/x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts rename to x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.ts b/x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/lib/sanitize_task_definitions.ts rename to x-pack/plugins/task_manager/server/lib/sanitize_task_definitions.ts diff --git a/x-pack/plugins/task_manager/server/plugin.ts b/x-pack/plugins/task_manager/server/plugin.ts new file mode 100644 index 0000000000000..9bdd1ce6d8748 --- /dev/null +++ b/x-pack/plugins/task_manager/server/plugin.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { PluginInitializerContext, Plugin, CoreSetup } from 'src/core/server'; +import { Observable, Subject } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { once } from 'lodash'; +import { TaskDictionary, TaskDefinition } from './task'; +import { TaskManager } from './task_manager'; +import { createTaskManager, LegacyDeps } from './create_task_manager'; +import { TaskManagerConfig } from './config'; +import { Middleware } from './lib/middleware'; + +export type PluginLegacyDependencies = Pick; +export type TaskManagerSetupContract = { + config$: Observable; + registerLegacyAPI: (legacyDependencies: PluginLegacyDependencies) => Promise; +} & Pick; + +export type TaskManagerStartContract = Pick< + TaskManager, + 'fetch' | 'remove' | 'schedule' | 'runNow' | 'ensureScheduled' +>; + +export class TaskManagerPlugin + implements Plugin { + legacyTaskManager$: Subject = new Subject(); + taskManager: Promise = this.legacyTaskManager$.pipe(first()).toPromise(); + currentConfig: TaskManagerConfig; + + constructor(private readonly initContext: PluginInitializerContext) { + this.initContext = initContext; + this.currentConfig = {} as TaskManagerConfig; + } + + public setup(core: CoreSetup, plugins: any): TaskManagerSetupContract { + const logger = this.initContext.logger.get('taskManager'); + const config$ = this.initContext.config.create(); + const savedObjectsRepository = core.savedObjects.createInternalRepository(['task']); + const elasticsearch = core.elasticsearch.adminClient; + return { + config$, + registerLegacyAPI: once((__LEGACY: PluginLegacyDependencies) => { + config$.subscribe(async config => { + this.legacyTaskManager$.next( + createTaskManager(core, { + logger, + config, + elasticsearch, + savedObjectsRepository, + ...__LEGACY, + }) + ); + this.legacyTaskManager$.complete(); + }); + return this.taskManager; + }), + addMiddleware: (middleware: Middleware) => { + this.taskManager.then(tm => tm.addMiddleware(middleware)); + }, + registerTaskDefinitions: (taskDefinition: TaskDictionary) => { + this.taskManager.then(tm => tm.registerTaskDefinitions(taskDefinition)); + }, + }; + } + + public start(): TaskManagerStartContract { + return { + fetch: (...args) => this.taskManager.then(tm => tm.fetch(...args)), + remove: (...args) => this.taskManager.then(tm => tm.remove(...args)), + schedule: (...args) => this.taskManager.then(tm => tm.schedule(...args)), + runNow: (...args) => this.taskManager.then(tm => tm.runNow(...args)), + ensureScheduled: (...args) => this.taskManager.then(tm => tm.ensureScheduled(...args)), + }; + } + public stop() { + this.taskManager.then(tm => { + tm.stop(); + }); + } +} diff --git a/x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts rename to x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts b/x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts rename to x-pack/plugins/task_manager/server/queries/mark_available_tasks_as_claimed.ts diff --git a/x-pack/legacy/plugins/task_manager/server/queries/query_clauses.ts b/x-pack/plugins/task_manager/server/queries/query_clauses.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/queries/query_clauses.ts rename to x-pack/plugins/task_manager/server/queries/query_clauses.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task.ts b/x-pack/plugins/task_manager/server/task.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task.ts rename to x-pack/plugins/task_manager/server/task.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_events.ts b/x-pack/plugins/task_manager/server/task_events.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_events.ts rename to x-pack/plugins/task_manager/server/task_events.ts diff --git a/x-pack/plugins/task_manager/server/task_manager.mock.ts b/x-pack/plugins/task_manager/server/task_manager.mock.ts new file mode 100644 index 0000000000000..9750dd14100d9 --- /dev/null +++ b/x-pack/plugins/task_manager/server/task_manager.mock.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TaskManagerSetupContract, TaskManagerStartContract } from './plugin'; +import { Subject } from 'rxjs'; + +export const taskManagerMock = { + setup(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + registerTaskDefinitions: jest.fn(), + addMiddleware: jest.fn(), + config$: new Subject(), + registerLegacyAPI: jest.fn(), + ...overrides, + }; + return mocked; + }, + start(overrides: Partial> = {}) { + const mocked: jest.Mocked = { + ensureScheduled: jest.fn(), + schedule: jest.fn(), + fetch: jest.fn(), + runNow: jest.fn(), + remove: jest.fn(), + ...overrides, + }; + return mocked; + }, +}; diff --git a/x-pack/legacy/plugins/task_manager/server/task_manager.test.ts b/x-pack/plugins/task_manager/server/task_manager.test.ts similarity index 94% rename from x-pack/legacy/plugins/task_manager/server/task_manager.test.ts rename to x-pack/plugins/task_manager/server/task_manager.test.ts index 51c3e5b81d764..a65723b2e8de7 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_manager.test.ts +++ b/x-pack/plugins/task_manager/server/task_manager.test.ts @@ -20,39 +20,33 @@ import { awaitTaskRunResult, TaskLifecycleEvent, } from './task_manager'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; -import { SavedObjectsSerializer, SavedObjectsSchema } from '../../../../../src/core/server'; +import { savedObjectsRepositoryMock } from '../../../../src/core/server/mocks'; +import { SavedObjectsSerializer, SavedObjectsSchema } from '../../../../src/core/server'; import { mockLogger } from './test_utils'; import { asErr, asOk } from './lib/result_type'; import { ConcreteTaskInstance, TaskLifecycleResult, TaskStatus } from './task'; -const savedObjectsClient = savedObjectsClientMock.create(); +const savedObjectsClient = savedObjectsRepositoryMock.create(); const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); describe('TaskManager', () => { let clock: sinon.SinonFakeTimers; - const defaultConfig = { - xpack: { - task_manager: { - max_workers: 10, - index: 'foo', - max_attempts: 9, - poll_interval: 6000000, - }, - }, - server: { - uuid: 'some-uuid', - }, - }; + const config = { - get: (path: string) => _.get(defaultConfig, path), + enabled: true, + max_workers: 10, + index: 'foo', + max_attempts: 9, + poll_interval: 6000000, + request_capacity: 1000, }; const taskManagerOpts = { config, savedObjectsRepository: savedObjectsClient, serializer, - callWithInternalUser: jest.fn(), + callAsInternalUser: jest.fn(), logger: mockLogger(), + taskManagerId: 'some-uuid', }; beforeEach(() => { @@ -63,21 +57,9 @@ describe('TaskManager', () => { test('throws if no valid UUID is available', async () => { expect(() => { - const configWithoutServerUUID = { - xpack: { - task_manager: { - max_workers: 10, - index: 'foo', - max_attempts: 9, - poll_interval: 6000000, - }, - }, - }; new TaskManager({ ...taskManagerOpts, - config: { - get: (path: string) => _.get(configWithoutServerUUID, path), - }, + taskManagerId: '', }); }).toThrowErrorMatchingInlineSnapshot( `"TaskManager is unable to start as Kibana has no valid UUID assigned to it."` @@ -234,7 +216,7 @@ describe('TaskManager', () => { test('allows and queues fetching tasks before starting', async () => { const client = new TaskManager(taskManagerOpts); - taskManagerOpts.callWithInternalUser.mockResolvedValue({ + taskManagerOpts.callAsInternalUser.mockResolvedValue({ hits: { total: { value: 0, @@ -245,13 +227,13 @@ describe('TaskManager', () => { const promise = client.fetch({}); client.start(); await promise; - expect(taskManagerOpts.callWithInternalUser).toHaveBeenCalled(); + expect(taskManagerOpts.callAsInternalUser).toHaveBeenCalled(); }); test('allows fetching tasks after starting', async () => { const client = new TaskManager(taskManagerOpts); client.start(); - taskManagerOpts.callWithInternalUser.mockResolvedValue({ + taskManagerOpts.callAsInternalUser.mockResolvedValue({ hits: { total: { value: 0, @@ -260,7 +242,7 @@ describe('TaskManager', () => { }, }); await client.fetch({}); - expect(taskManagerOpts.callWithInternalUser).toHaveBeenCalled(); + expect(taskManagerOpts.callAsInternalUser).toHaveBeenCalled(); }); test('allows middleware registration before starting', () => { @@ -282,7 +264,6 @@ describe('TaskManager', () => { }; client.start(); - expect(() => client.addMiddleware(middleware)).toThrow( /Cannot add middleware after the task manager is initialized/i ); diff --git a/x-pack/legacy/plugins/task_manager/server/task_manager.ts b/x-pack/plugins/task_manager/server/task_manager.ts similarity index 95% rename from x-pack/legacy/plugins/task_manager/server/task_manager.ts rename to x-pack/plugins/task_manager/server/task_manager.ts index 6c9191ffe3d0e..c0baed3708a0a 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_manager.ts +++ b/x-pack/plugins/task_manager/server/task_manager.ts @@ -10,8 +10,13 @@ import { performance } from 'perf_hooks'; import { pipe } from 'fp-ts/lib/pipeable'; import { Option, none, some, map as mapOptional } from 'fp-ts/lib/Option'; -import { SavedObjectsClientContract, SavedObjectsSerializer } from '../../../../../src/core/server'; +import { + SavedObjectsSerializer, + IScopedClusterClient, + ISavedObjectsRepository, +} from '../../../../src/core/server'; import { Result, asErr, either, map, mapErr, promiseResult } from './lib/result_type'; +import { TaskManagerConfig } from './config'; import { Logger } from './types'; import { @@ -56,10 +61,11 @@ const VERSION_CONFLICT_STATUS = 409; export interface TaskManagerOpts { logger: Logger; - config: any; - callWithInternalUser: any; - savedObjectsRepository: SavedObjectsClientContract; + config: TaskManagerConfig; + callAsInternalUser: IScopedClusterClient['callAsInternalUser']; + savedObjectsRepository: ISavedObjectsRepository; serializer: SavedObjectsSerializer; + taskManagerId: string; } interface RunNowResult { @@ -110,7 +116,7 @@ export class TaskManager { constructor(opts: TaskManagerOpts) { this.logger = opts.logger; - const taskManagerId = opts.config.get('server.uuid'); + const { taskManagerId } = opts; if (!taskManagerId) { this.logger.error( `TaskManager is unable to start as there the Kibana UUID is invalid (value of the "server.uuid" configuration is ${taskManagerId})` @@ -123,9 +129,9 @@ export class TaskManager { this.store = new TaskStore({ serializer: opts.serializer, savedObjectsRepository: opts.savedObjectsRepository, - callCluster: opts.callWithInternalUser, - index: opts.config.get('xpack.task_manager.index'), - maxAttempts: opts.config.get('xpack.task_manager.max_attempts'), + callCluster: opts.callAsInternalUser, + index: opts.config.index, + maxAttempts: opts.config.max_attempts, definitions: this.definitions, taskManagerId: `kibana:${taskManagerId}`, }); @@ -134,12 +140,12 @@ export class TaskManager { this.pool = new TaskPool({ logger: this.logger, - maxWorkers: opts.config.get('xpack.task_manager.max_workers'), + maxWorkers: opts.config.max_workers, }); this.poller$ = createTaskPoller({ - pollInterval: opts.config.get('xpack.task_manager.poll_interval'), - bufferCapacity: opts.config.get('xpack.task_manager.request_capacity'), + pollInterval: opts.config.poll_interval, + bufferCapacity: opts.config.request_capacity, getCapacity: () => this.pool.availableWorkers, pollRequests$: this.claimRequests$, work: this.pollForWork, diff --git a/x-pack/legacy/plugins/task_manager/server/task_poller.test.ts b/x-pack/plugins/task_manager/server/task_poller.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_poller.test.ts rename to x-pack/plugins/task_manager/server/task_poller.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_poller.ts b/x-pack/plugins/task_manager/server/task_poller.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_poller.ts rename to x-pack/plugins/task_manager/server/task_poller.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_pool.test.ts b/x-pack/plugins/task_manager/server/task_pool.test.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_pool.test.ts rename to x-pack/plugins/task_manager/server/task_pool.test.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_pool.ts b/x-pack/plugins/task_manager/server/task_pool.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_pool.ts rename to x-pack/plugins/task_manager/server/task_pool.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_runner.test.ts b/x-pack/plugins/task_manager/server/task_runner.test.ts similarity index 99% rename from x-pack/legacy/plugins/task_manager/server/task_runner.test.ts rename to x-pack/plugins/task_manager/server/task_runner.test.ts index 3f7877aa4c20f..3f0132105347e 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_runner.test.ts +++ b/x-pack/plugins/task_manager/server/task_runner.test.ts @@ -12,7 +12,7 @@ import { TaskEvent, asTaskRunEvent, asTaskMarkRunningEvent } from './task_events import { ConcreteTaskInstance, TaskStatus } from './task'; import { TaskManagerRunner } from './task_runner'; import { mockLogger } from './test_utils'; -import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; +import { SavedObjectsErrorHelpers } from '../../../../src/core/server'; let fakeTimer: sinon.SinonFakeTimers; diff --git a/x-pack/legacy/plugins/task_manager/server/task_runner.ts b/x-pack/plugins/task_manager/server/task_runner.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/task_runner.ts rename to x-pack/plugins/task_manager/server/task_runner.ts diff --git a/x-pack/legacy/plugins/task_manager/server/task_store.test.ts b/x-pack/plugins/task_manager/server/task_store.test.ts similarity index 99% rename from x-pack/legacy/plugins/task_manager/server/task_store.test.ts rename to x-pack/plugins/task_manager/server/task_store.test.ts index c7a0a1a020721..f47cc41c2d045 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_store.test.ts +++ b/x-pack/plugins/task_manager/server/task_store.test.ts @@ -17,13 +17,13 @@ import { TaskLifecycleResult, } from './task'; import { FetchOpts, StoreOpts, OwnershipClaimingOpts, TaskStore } from './task_store'; -import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; +import { savedObjectsRepositoryMock } from '../../../../src/core/server/mocks'; import { SavedObjectsSerializer, SavedObjectsSchema, SavedObjectAttributes, -} from '../../../../../src/core/server'; -import { SavedObjectsErrorHelpers } from '../../../../../src/core/server/saved_objects/service/lib/errors'; +} from '../../../../src/core/server'; +import { SavedObjectsErrorHelpers } from '../../../../src/core/server/saved_objects/service/lib/errors'; import { asTaskClaimEvent, TaskEvent } from './task_events'; import { asOk, asErr } from './lib/result_type'; @@ -45,7 +45,7 @@ const taskDefinitions: TaskDictionary = { }, }; -const savedObjectsClient = savedObjectsClientMock.create(); +const savedObjectsClient = savedObjectsRepositoryMock.create(); const serializer = new SavedObjectsSerializer(new SavedObjectsSchema()); beforeEach(() => jest.resetAllMocks()); diff --git a/x-pack/legacy/plugins/task_manager/server/task_store.ts b/x-pack/plugins/task_manager/server/task_store.ts similarity index 98% rename from x-pack/legacy/plugins/task_manager/server/task_store.ts rename to x-pack/plugins/task_manager/server/task_store.ts index e8fc0ccb90936..f4695b152237a 100644 --- a/x-pack/legacy/plugins/task_manager/server/task_store.ts +++ b/x-pack/plugins/task_manager/server/task_store.ts @@ -11,12 +11,12 @@ import { Subject, Observable } from 'rxjs'; import { omit, difference } from 'lodash'; import { - SavedObjectsClientContract, SavedObject, SavedObjectAttributes, SavedObjectsSerializer, SavedObjectsRawDoc, -} from '../../../../../src/core/server'; + ISavedObjectsRepository, +} from '../../../../src/core/server'; import { asOk, asErr } from './lib/result_type'; @@ -60,7 +60,7 @@ export interface StoreOpts { taskManagerId: string; maxAttempts: number; definitions: TaskDictionary; - savedObjectsRepository: SavedObjectsClientContract; + savedObjectsRepository: ISavedObjectsRepository; serializer: SavedObjectsSerializer; } @@ -123,7 +123,7 @@ export class TaskStore { private callCluster: ElasticJs; private definitions: TaskDictionary; - private savedObjectsRepository: SavedObjectsClientContract; + private savedObjectsRepository: ISavedObjectsRepository; private serializer: SavedObjectsSerializer; private events$: Subject; diff --git a/x-pack/legacy/plugins/task_manager/server/test_utils/index.ts b/x-pack/plugins/task_manager/server/test_utils/index.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/test_utils/index.ts rename to x-pack/plugins/task_manager/server/test_utils/index.ts diff --git a/x-pack/legacy/plugins/task_manager/server/types.ts b/x-pack/plugins/task_manager/server/types.ts similarity index 100% rename from x-pack/legacy/plugins/task_manager/server/types.ts rename to x-pack/plugins/task_manager/server/types.ts diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 3b0c188318309..5661020ba6fa6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6075,8 +6075,6 @@ "xpack.infra.logs.highlights.goToPreviousHighlightButtonLabel": "前のハイライトにスキップ", "xpack.infra.logs.index.settingsTabTitle": "設定", "xpack.infra.logs.index.streamTabTitle": "ストリーム", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessContent": "機械学習ロボットがデータの収集を開始するまでしばらくお待ちください。", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessTitle": "成功!", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | ストリーム", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "ログエントリーを検索", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "パーセント", @@ -11825,8 +11823,6 @@ "xpack.uptime.kueryBar.searchPlaceholder": "モニター ID、名前、プロトコルタイプなどを検索…", "xpack.uptime.monitorList.noItemForSelectedFiltersMessage": "選択されたフィルター条件でモニターが見つかりませんでした", "xpack.uptime.monitorList.table.description": "列にステータス、名前、URL、IP、ダウンタイム履歴、統合が入力されたモニターステータス表です。この表は現在 {length} 項目を表示しています。", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.ariaLabel": "SSL 証明書の有効期限:", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.content": "SSL 証明書の有効期限: {certificateValidity}", "xpack.uptime.notFountPage.homeLinkText": "ホームへ戻る", "xpack.uptime.overviewPageLink.disabled.ariaLabel": "無効になったページ付けボタンです。モニターリストがこれ以上ナビゲーションできないことを示しています。", "xpack.uptime.overviewPageLink.next.ariaLabel": "次の結果ページ", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3cc476937d4e7..1bcfab4240aed 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6077,8 +6077,6 @@ "xpack.infra.logs.highlights.goToPreviousHighlightButtonLabel": "跳转到上一高亮条目", "xpack.infra.logs.index.settingsTabTitle": "设置", "xpack.infra.logs.index.streamTabTitle": "流式传输", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessContent": "请注意,我们的 Machine Learning 机器人若干分钟后才会开始收集数据。", - "xpack.infra.logs.logsAnalysisResults.onboardingSuccessTitle": "成功!", "xpack.infra.logs.streamPage.documentTitle": "{previousTitle} | 流式传输", "xpack.infra.logsPage.toolbar.kqlSearchFieldAriaLabel": "搜索日志条目", "xpack.infra.metricDetailPage.awsMetricsLayout.cpuUtilSection.percentSeriesLabel": "百分比", @@ -11914,8 +11912,6 @@ "xpack.uptime.kueryBar.searchPlaceholder": "搜索监测 ID、名称和协议类型......", "xpack.uptime.monitorList.noItemForSelectedFiltersMessage": "未找到匹配选定筛选条件的监测", "xpack.uptime.monitorList.table.description": "具有“状态”、“名称”、“URL”、“IP”、“中断历史记录”和“集成”列的“监测状态”表。该表当前显示 {length} 个项目。", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.ariaLabel": "SSL 证书过期", - "xpack.uptime.monitorStatusBar.sslCertificateExpiry.content": "SSL 证书于 {certificateValidity} 过期", "xpack.uptime.notFountPage.homeLinkText": "返回主页", "xpack.uptime.overviewPageLink.disabled.ariaLabel": "禁用的分页按钮表示在监测列表中无法进行进一步导航。", "xpack.uptime.overviewPageLink.next.ariaLabel": "下页结果", diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index bda5b51623d05..11ee038cf39f0 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -9,6 +9,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/reporting/configs/chromium_api.js'), require.resolve('../test/reporting/configs/chromium_functional.js'), require.resolve('../test/reporting/configs/generate_api.js'), + require.resolve('../test/functional_with_es_ssl/config.ts'), require.resolve('../test/functional/config_security_basic.js'), require.resolve('../test/api_integration/config_security_basic.js'), require.resolve('../test/api_integration/config.js'), diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts index 3bfad59b71166..29708f86b0a9b 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/task_manager/index.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { TaskManagerStartContract } from '../../../../../../plugins/task_manager/server'; + const taskManagerQuery = (...filters: any[]) => ({ bool: { filter: { @@ -38,7 +40,7 @@ export default function(kibana: any) { }, init(server: any) { - const taskManager = server.plugins.task_manager; + const taskManager = server.newPlatform.start.plugins.taskManager as TaskManagerStartContract; server.route({ path: '/api/alerting_tasks/{taskId}', diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index 551498e22d5c8..d20450f8ec47e 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -761,7 +761,8 @@ export default function alertTests({ getService }: FtrProviderContext) { } }); - it(`should unmute all instances when unmuting an alert`, async () => { + // Flaky: https://github.com/elastic/kibana/issues/54125 + it.skip(`should unmute all instances when unmuting an alert`, async () => { const testStart = new Date(); const reference = alertUtils.generateReference(); const response = await alertUtils.createAlwaysFiringAction({ diff --git a/x-pack/test/functional/apps/uptime/overview.ts b/x-pack/test/functional/apps/uptime/overview.ts index bcfb72967b75a..ba701da6e517d 100644 --- a/x-pack/test/functional/apps/uptime/overview.ts +++ b/x-pack/test/functional/apps/uptime/overview.ts @@ -50,7 +50,8 @@ export default ({ getPageObjects }: FtrProviderContext) => { ]); }); - it('pagination is cleared when filter criteria changes', async () => { + // flakey see https://github.com/elastic/kibana/issues/54527 + it.skip('pagination is cleared when filter criteria changes', async () => { await pageObjects.uptime.goToUptimePageAndSetDateRange(DEFAULT_DATE_START, DEFAULT_DATE_END); await pageObjects.uptime.changePage('next'); // there should now be pagination data in the URL @@ -86,7 +87,8 @@ export default ({ getPageObjects }: FtrProviderContext) => { ]); }); - describe('snapshot counts', () => { + // Flakey, see https://github.com/elastic/kibana/issues/54541 + describe.skip('snapshot counts', () => { it('updates the snapshot count when status filter is set to down', async () => { await pageObjects.uptime.goToUptimePageAndSetDateRange( DEFAULT_DATE_START, diff --git a/x-pack/test/functional/services/index.ts b/x-pack/test/functional/services/index.ts index 5ab8933a4804a..84d5a792ae6ca 100644 --- a/x-pack/test/functional/services/index.ts +++ b/x-pack/test/functional/services/index.ts @@ -57,6 +57,7 @@ export const services = { ...kibanaFunctionalServices, ...commonServices, + supertest: kibanaApiIntegrationServices.supertest, esSupertest: kibanaApiIntegrationServices.esSupertest, monitoringNoData: MonitoringNoDataProvider, monitoringClusterList: MonitoringClusterListProvider, diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts new file mode 100644 index 0000000000000..4fdfe0d32ace3 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alerts.ts @@ -0,0 +1,344 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +function generateUniqueKey() { + return uuid.v4().replace(/-/g, ''); +} + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const supertest = getService('supertest'); + const retry = getService('retry'); + + async function createAlert() { + const { body: createdAlert } = await supertest + .post(`/api/alert`) + .set('kbn-xsrf', 'foo') + .send({ + enabled: true, + name: generateUniqueKey(), + tags: ['foo', 'bar'], + alertTypeId: 'test.noop', + consumer: 'test', + schedule: { interval: '1m' }, + throttle: '1m', + actions: [], + params: {}, + }) + .expect(200); + return createdAlert; + } + + describe('alerts', function() { + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + const alertsTab = await testSubjects.find('alertsTab'); + await alertsTab.click(); + }); + + it('should search for alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + name: createdAlert.name, + tagsText: 'foo, bar', + alertType: 'Test: Noop', + interval: '1m', + }, + ]); + }); + + it('should search for tags', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(`${createdAlert.name} foo`); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults).to.eql([ + { + name: createdAlert.name, + tagsText: 'foo, bar', + alertType: 'Test: Noop', + interval: '1m', + }, + ]); + }); + + // Flaky until https://github.com/elastic/eui/issues/2612 fixed + it.skip('should disable single alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const enableSwitch = await testSubjects.find('enableSwitch'); + await enableSwitch.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterDisable = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterDisable.click(); + + const enableSwitchAfterDisable = await testSubjects.find('enableSwitch'); + const isChecked = await enableSwitchAfterDisable.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + }); + + // Flaky until https://github.com/elastic/eui/issues/2612 fixed + it.skip('should re-enable single alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const enableSwitch = await testSubjects.find('enableSwitch'); + await enableSwitch.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterDisable = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterDisable.click(); + + const enableSwitchAfterDisable = await testSubjects.find('enableSwitch'); + await enableSwitchAfterDisable.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterReEnable = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterReEnable.click(); + + const enableSwitchAfterReEnable = await testSubjects.find('enableSwitch'); + const isChecked = await enableSwitchAfterReEnable.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + }); + + // Flaky until https://github.com/elastic/eui/issues/2612 fixed + it.skip('should mute single alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const muteSwitch = await testSubjects.find('muteSwitch'); + await muteSwitch.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterMute = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterMute.click(); + + const muteSwitchAfterMute = await testSubjects.find('muteSwitch'); + const isChecked = await muteSwitchAfterMute.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + }); + + // Flaky until https://github.com/elastic/eui/issues/2612 fixed + it.skip('should unmute single alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const muteSwitch = await testSubjects.find('muteSwitch'); + await muteSwitch.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterMute = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterMute.click(); + + const muteSwitchAfterMute = await testSubjects.find('muteSwitch'); + await muteSwitchAfterMute.click(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActionsAfterUnmute = await testSubjects.find('collapsedItemActions'); + await collapsedItemActionsAfterUnmute.click(); + + const muteSwitchAfterUnmute = await testSubjects.find('muteSwitch'); + const isChecked = await muteSwitchAfterUnmute.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/53956 + it.skip('should delete single alert', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const deleteBtn = await testSubjects.find('deleteAlert'); + await deleteBtn.click(); + + retry.try(async () => { + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults.length).to.eql(0); + }); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830 + it.skip('should mute all selection', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`); + await checkbox.click(); + + const bulkActionBtn = await testSubjects.find('bulkAction'); + await bulkActionBtn.click(); + + const muteAllBtn = await testSubjects.find('muteAll'); + await muteAllBtn.click(); + + // Unmute all button shows after clicking mute all + await testSubjects.existOrFail('unmuteAll'); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const muteSwitch = await testSubjects.find('muteSwitch'); + const isChecked = await muteSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830 + it.skip('should unmute all selection', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`); + await checkbox.click(); + + const bulkActionBtn = await testSubjects.find('bulkAction'); + await bulkActionBtn.click(); + + const muteAllBtn = await testSubjects.find('muteAll'); + await muteAllBtn.click(); + + const unmuteAllBtn = await testSubjects.find('unmuteAll'); + await unmuteAllBtn.click(); + + // Mute all button shows after clicking unmute all + await testSubjects.existOrFail('muteAll'); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const muteSwitch = await testSubjects.find('muteSwitch'); + const isChecked = await muteSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830 + it.skip('should disable all selection', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`); + await checkbox.click(); + + const bulkActionBtn = await testSubjects.find('bulkAction'); + await bulkActionBtn.click(); + + const disableAllBtn = await testSubjects.find('disableAll'); + await disableAllBtn.click(); + + // Enable all button shows after clicking disable all + await testSubjects.existOrFail('enableAll'); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const enableSwitch = await testSubjects.find('enableSwitch'); + const isChecked = await enableSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('false'); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/49830 + it.skip('should enable all selection', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`); + await checkbox.click(); + + const bulkActionBtn = await testSubjects.find('bulkAction'); + await bulkActionBtn.click(); + + const disableAllBtn = await testSubjects.find('disableAll'); + await disableAllBtn.click(); + + const enableAllBtn = await testSubjects.find('enableAll'); + await enableAllBtn.click(); + + // Disable all button shows after clicking enable all + await testSubjects.existOrFail('disableAll'); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const collapsedItemActions = await testSubjects.find('collapsedItemActions'); + await collapsedItemActions.click(); + + const enableSwitch = await testSubjects.find('enableSwitch'); + const isChecked = await enableSwitch.getAttribute('aria-checked'); + expect(isChecked).to.eql('true'); + }); + + // Flaky, will be fixed with https://github.com/elastic/kibana/issues/53956 + it.skip('should delete all selection', async () => { + const createdAlert = await createAlert(); + + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const checkbox = await testSubjects.find(`checkboxSelectRow-${createdAlert.id}`); + await checkbox.click(); + + const bulkActionBtn = await testSubjects.find('bulkAction'); + await bulkActionBtn.click(); + + const deleteAllBtn = await testSubjects.find('deleteAll'); + await deleteAllBtn.click(); + + retry.try(async () => { + await pageObjects.triggersActionsUI.searchAlerts(createdAlert.name); + + const searchResults = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResults.length).to.eql(0); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts new file mode 100644 index 0000000000000..7b60685225ac6 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/connectors.ts @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +function generateUniqueKey() { + return uuid.v4().replace(/-/g, ''); +} + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const find = getService('find'); + + describe('Connectors', function() { + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + const alertsTab = await testSubjects.find('connectorsTab'); + await alertsTab.click(); + }); + + it('should create a connector', async () => { + const connectorName = generateUniqueKey(); + + await pageObjects.triggersActionsUI.clickCreateConnectorButton(); + + const serverLogCard = await testSubjects.find('.server-log-card'); + await serverLogCard.click(); + + const nameInput = await testSubjects.find('nameInput'); + await nameInput.click(); + await nameInput.clearValue(); + await nameInput.type(connectorName); + + const saveButton = await find.byCssSelector( + '[data-test-subj="saveActionButton"]:not(disabled)' + ); + await saveButton.click(); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created '${connectorName}'`); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResults = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResults).to.eql([ + { + name: connectorName, + actionType: 'Server log', + referencedByCount: '0', + }, + ]); + }); + + it('should edit a connector', async () => { + const connectorName = generateUniqueKey(); + const updatedConnectorName = `${connectorName}updated`; + + await pageObjects.triggersActionsUI.clickCreateConnectorButton(); + + const serverLogCard = await testSubjects.find('.server-log-card'); + await serverLogCard.click(); + + const nameInput = await testSubjects.find('nameInput'); + await nameInput.click(); + await nameInput.clearValue(); + await nameInput.type(connectorName); + + const saveButton = await find.byCssSelector( + '[data-test-subj="saveActionButton"]:not(disabled)' + ); + await saveButton.click(); + + await pageObjects.common.closeToast(); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsBeforeEdit = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsBeforeEdit.length).to.eql(1); + + const editConnectorBtn = await find.byCssSelector( + '[data-test-subj="connectorsTableCell-name"] button' + ); + await editConnectorBtn.click(); + + const nameInputToUpdate = await testSubjects.find('nameInput'); + await nameInputToUpdate.click(); + await nameInputToUpdate.clearValue(); + await nameInputToUpdate.type(updatedConnectorName); + + const saveEditButton = await find.byCssSelector( + '[data-test-subj="saveActionButton"]:not(disabled)' + ); + await saveEditButton.click(); + + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Updated '${updatedConnectorName}'`); + + await pageObjects.triggersActionsUI.searchConnectors(updatedConnectorName); + + const searchResultsAfterEdit = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsAfterEdit).to.eql([ + { + name: updatedConnectorName, + actionType: 'Server log', + referencedByCount: '0', + }, + ]); + }); + + it('should delete a connector', async () => { + async function createConnector(connectorName: string) { + await pageObjects.triggersActionsUI.clickCreateConnectorButton(); + + const serverLogCard = await testSubjects.find('.server-log-card'); + await serverLogCard.click(); + + const nameInput = await testSubjects.find('nameInput'); + await nameInput.click(); + await nameInput.clearValue(); + await nameInput.type(connectorName); + + const saveButton = await find.byCssSelector( + '[data-test-subj="saveActionButton"]:not(disabled)' + ); + await saveButton.click(); + await pageObjects.common.closeToast(); + } + const connectorName = generateUniqueKey(); + await createConnector(connectorName); + + await createConnector(generateUniqueKey()); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsBeforeDelete = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsBeforeDelete.length).to.eql(1); + + const deleteConnectorBtn = await testSubjects.find('deleteConnector'); + await deleteConnectorBtn.click(); + await testSubjects.existOrFail('deleteConnectorsConfirmation'); + await testSubjects.click('deleteConnectorsConfirmation > confirmModalConfirmButton'); + await testSubjects.missingOrFail('deleteConnectorsConfirmation'); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsAfterDelete = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsAfterDelete.length).to.eql(0); + }); + + it('should bulk delete connectors', async () => { + async function createConnector(connectorName: string) { + await pageObjects.triggersActionsUI.clickCreateConnectorButton(); + + const serverLogCard = await testSubjects.find('.server-log-card'); + await serverLogCard.click(); + + const nameInput = await testSubjects.find('nameInput'); + await nameInput.click(); + await nameInput.clearValue(); + await nameInput.type(connectorName); + + const saveButton = await find.byCssSelector( + '[data-test-subj="saveActionButton"]:not(disabled)' + ); + await saveButton.click(); + await pageObjects.common.closeToast(); + } + + const connectorName = generateUniqueKey(); + await createConnector(connectorName); + + await createConnector(generateUniqueKey()); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsBeforeDelete = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsBeforeDelete.length).to.eql(1); + + const deleteCheckbox = await find.byCssSelector( + '.euiTableRowCellCheckbox .euiCheckbox__input' + ); + await deleteCheckbox.click(); + + const bulkDeleteBtn = await testSubjects.find('bulkDelete'); + await bulkDeleteBtn.click(); + await testSubjects.existOrFail('deleteConnectorsConfirmation'); + await testSubjects.click('deleteConnectorsConfirmation > confirmModalConfirmButton'); + await testSubjects.missingOrFail('deleteConnectorsConfirmation'); + + await pageObjects.triggersActionsUI.searchConnectors(connectorName); + + const searchResultsAfterDelete = await pageObjects.triggersActionsUI.getConnectorsList(); + expect(searchResultsAfterDelete.length).to.eql(0); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts new file mode 100644 index 0000000000000..13f50a505b0b6 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/home_page.ts @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const testSubjects = getService('testSubjects'); + const pageObjects = getPageObjects(['common', 'triggersActionsUI', 'header']); + const log = getService('log'); + const browser = getService('browser'); + + describe('Home page', function() { + before(async () => { + await pageObjects.common.navigateToApp('triggersActions'); + }); + + it('Loads the app', async () => { + await log.debug('Checking for section heading to say Triggers and Actions.'); + + const headingText = await pageObjects.triggersActionsUI.getSectionHeadingText(); + expect(headingText).to.be('Alerts and Actions'); + }); + + describe('Connectors tab', () => { + it('renders the connectors tab', async () => { + // Navigate to the connectors tab + pageObjects.triggersActionsUI.changeTabs('connectorsTab'); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify url + const url = await browser.getCurrentUrl(); + expect(url).to.contain(`/connectors`); + + // Verify content + await testSubjects.existOrFail('actionsList'); + }); + }); + + describe('Alerts tab', () => { + it('renders the alerts tab', async () => { + // Navigate to the alerts tab + pageObjects.triggersActionsUI.changeTabs('alertsTab'); + + await pageObjects.header.waitUntilLoadingHasFinished(); + + // Verify url + const url = await browser.getCurrentUrl(); + expect(url).to.contain(`/alerts`); + + // Verify content + await testSubjects.existOrFail('alertsList'); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts new file mode 100644 index 0000000000000..c76f477c8cfbe --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ loadTestFile, getService }: FtrProviderContext) => { + describe('Actions and Triggers app', function() { + this.tags('ciGroup3'); + loadTestFile(require.resolve('./home_page')); + loadTestFile(require.resolve('./connectors')); + loadTestFile(require.resolve('./alerts')); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/config.ts b/x-pack/test/functional_with_es_ssl/config.ts new file mode 100644 index 0000000000000..1a9736b0b4773 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/config.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { resolve, join } from 'path'; +import { CA_CERT_PATH } from '@kbn/dev-utils'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +import { services } from './services'; +import { pageObjects } from './page_objects'; + +// eslint-disable-next-line import/no-default-export +export default async function({ readConfigFile }: FtrConfigProviderContext) { + const xpackFunctionalConfig = await readConfigFile(require.resolve('../functional/config.js')); + + const servers = { + ...xpackFunctionalConfig.get('servers'), + elasticsearch: { + ...xpackFunctionalConfig.get('servers.elasticsearch'), + protocol: 'https', + }, + }; + + const returnedObject = { + ...xpackFunctionalConfig.getAll(), + servers, + services, + pageObjects, + // list paths to the files that contain your plugins tests + testFiles: [resolve(__dirname, './apps/triggers_actions_ui')], + apps: { + ...xpackFunctionalConfig.get('apps'), + triggersActions: { + pathname: '/app/kibana', + hash: '/management/kibana/triggersActions', + }, + }, + esTestCluster: { + ...xpackFunctionalConfig.get('esTestCluster'), + ssl: true, + }, + kbnTestServer: { + ...xpackFunctionalConfig.get('kbnTestServer'), + serverArgs: [ + ...xpackFunctionalConfig.get('kbnTestServer.serverArgs'), + `--elasticsearch.hosts=https://${servers.elasticsearch.hostname}:${servers.elasticsearch.port}`, + `--elasticsearch.ssl.certificateAuthorities=${CA_CERT_PATH}`, + `--plugin-path=${join(__dirname, 'fixtures', 'plugins', 'alerts')}`, + '--xpack.actions.enabled=true', + '--xpack.alerting.enabled=true', + '--xpack.triggers_actions_ui.enabled=true', + '--xpack.triggers_actions_ui.createAlertUiEnabled=true', + ], + }, + }; + + return returnedObject; +} diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts new file mode 100644 index 0000000000000..df651c67c2c28 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/index.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AlertType } from '../../../../../legacy/plugins/alerting'; + +// eslint-disable-next-line import/no-default-export +export default function(kibana: any) { + return new kibana.Plugin({ + require: ['alerting'], + name: 'alerts', + init(server: any) { + const noopAlertType: AlertType = { + id: 'test.noop', + name: 'Test: Noop', + actionGroups: ['default'], + async executor() {}, + }; + server.plugins.alerting.setup.registerType(noopAlertType); + }, + }); +} diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json new file mode 100644 index 0000000000000..836fa09855d8f --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/package.json @@ -0,0 +1,7 @@ +{ + "name": "alerts", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/functional_with_es_ssl/ftr_provider_context.d.ts b/x-pack/test/functional_with_es_ssl/ftr_provider_context.d.ts new file mode 100644 index 0000000000000..bb257cdcbfe1b --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/ftr_provider_context.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; + +import { pageObjects } from './page_objects'; +import { services } from './services'; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/functional_with_es_ssl/page_objects/index.ts b/x-pack/test/functional_with_es_ssl/page_objects/index.ts new file mode 100644 index 0000000000000..a068ba7dfe81d --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/page_objects/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pageObjects as xpackFunctionalPageObjects } from '../../functional/page_objects'; +import { TriggersActionsPageProvider } from './triggers_actions_ui_page'; + +export const pageObjects = { + ...xpackFunctionalPageObjects, + triggersActionsUI: TriggersActionsPageProvider, +}; diff --git a/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts new file mode 100644 index 0000000000000..ce68109771487 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/page_objects/triggers_actions_ui_page.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FtrProviderContext } from '../ftr_provider_context'; + +const ENTER_KEY = '\uE007'; + +export function TriggersActionsPageProvider({ getService }: FtrProviderContext) { + const find = getService('find'); + const testSubjects = getService('testSubjects'); + + return { + async getSectionHeadingText() { + return await testSubjects.getVisibleText('appTitle'); + }, + async clickCreateConnectorButton() { + const createBtn = await find.byCssSelector( + '[data-test-subj="createActionButton"],[data-test-subj="createFirstActionButton"]' + ); + await createBtn.click(); + }, + async searchConnectors(searchText: string) { + const searchBox = await find.byCssSelector('[data-test-subj="actionsList"] .euiFieldSearch'); + await searchBox.click(); + await searchBox.clearValue(); + await searchBox.type(searchText); + await searchBox.pressKeys(ENTER_KEY); + await find.byCssSelector( + '.euiBasicTable[data-test-subj="actionsTable"]:not(.euiBasicTable-loading)' + ); + }, + async searchAlerts(searchText: string) { + const searchBox = await testSubjects.find('alertSearchField'); + await searchBox.click(); + await searchBox.clearValue(); + await searchBox.type(searchText); + await searchBox.pressKeys(ENTER_KEY); + await find.byCssSelector( + '.euiBasicTable[data-test-subj="alertsList"]:not(.euiBasicTable-loading)' + ); + }, + async getConnectorsList() { + const table = await find.byCssSelector('[data-test-subj="actionsList"] table'); + const $ = await table.parseDomContent(); + return $.findTestSubjects('connectors-row') + .toArray() + .map(row => { + return { + name: $(row) + .findTestSubject('connectorsTableCell-name') + .find('.euiTableCellContent') + .text(), + actionType: $(row) + .findTestSubject('connectorsTableCell-actionType') + .find('.euiTableCellContent') + .text(), + referencedByCount: $(row) + .findTestSubject('connectorsTableCell-referencedByCount') + .find('.euiTableCellContent') + .text(), + }; + }); + }, + async getAlertsList() { + const table = await find.byCssSelector('[data-test-subj="alertsList"] table'); + const $ = await table.parseDomContent(); + return $.findTestSubjects('alert-row') + .toArray() + .map(row => { + return { + name: $(row) + .findTestSubject('alertsTableCell-name') + .find('.euiTableCellContent') + .text(), + tagsText: $(row) + .findTestSubject('alertsTableCell-tagsText') + .find('.euiTableCellContent') + .text(), + alertType: $(row) + .findTestSubject('alertsTableCell-alertType') + .find('.euiTableCellContent') + .text(), + interval: $(row) + .findTestSubject('alertsTableCell-interval') + .find('.euiTableCellContent') + .text(), + }; + }); + }, + async changeTabs(tab: 'alertsTab' | 'connectorsTab') { + return await testSubjects.click(tab); + }, + }; +} diff --git a/x-pack/test/functional_with_es_ssl/services/index.ts b/x-pack/test/functional_with_es_ssl/services/index.ts new file mode 100644 index 0000000000000..6e96921c25a31 --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/services/index.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { services as xpackFunctionalServices } from '../../functional/services'; + +export const services = { + ...xpackFunctionalServices, +}; diff --git a/x-pack/test/plugin_api_integration/plugins/task_manager/index.js b/x-pack/test/plugin_api_integration/plugins/task_manager/index.js index b0e46543b4e76..50fb9571c2687 100644 --- a/x-pack/test/plugin_api_integration/plugins/task_manager/index.js +++ b/x-pack/test/plugin_api_integration/plugins/task_manager/index.js @@ -28,7 +28,11 @@ export default function TaskTestingAPI(kibana) { }, init(server) { - const taskManager = server.plugins.task_manager; + const taskManager = { + ...server.newPlatform.setup.plugins.taskManager, + ...server.newPlatform.start.plugins.taskManager, + }; + const legacyTaskManager = server.plugins.task_manager; const defaultSampleTaskConfig = { timeout: '1m', @@ -128,7 +132,7 @@ export default function TaskTestingAPI(kibana) { }, }); - initRoutes(server, taskTestingEvents); + initRoutes(server, taskManager, legacyTaskManager, taskTestingEvents); }, }); } diff --git a/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js b/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js index 3330d08dfd0d2..c0dcd99525915 100644 --- a/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js +++ b/x-pack/test/plugin_api_integration/plugins/task_manager/init_routes.js @@ -23,9 +23,7 @@ const taskManagerQuery = { }, }; -export function initRoutes(server, taskTestingEvents) { - const taskManager = server.plugins.task_manager; - +export function initRoutes(server, taskManager, legacyTaskManager, taskTestingEvents) { server.route({ path: '/api/sample_tasks/schedule', method: 'POST', @@ -62,6 +60,45 @@ export function initRoutes(server, taskTestingEvents) { }, }); + /* + Schedule using legacy Api + */ + server.route({ + path: '/api/sample_tasks/schedule_legacy', + method: 'POST', + config: { + validate: { + payload: Joi.object({ + task: Joi.object({ + taskType: Joi.string().required(), + schedule: Joi.object({ + interval: Joi.string(), + }).optional(), + interval: Joi.string().optional(), + params: Joi.object().required(), + state: Joi.object().optional(), + id: Joi.string().optional(), + }), + }), + }, + }, + async handler(request) { + try { + const { task: taskFields } = request.payload; + const task = { + ...taskFields, + scope: [scope], + }; + + const taskResult = await legacyTaskManager.schedule(task, { request }); + + return taskResult; + } catch (err) { + return err; + } + }, + }); + server.route({ path: '/api/sample_tasks/run_now', method: 'POST', diff --git a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js index ff06bee83d51d..0b1c1cbb5af29 100644 --- a/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js +++ b/x-pack/test/plugin_api_integration/test_suites/task_manager/task_manager_integration.js @@ -74,6 +74,15 @@ export default function({ getService }) { .then(response => response.body); } + function scheduleTaskUsingLegacyApi(task) { + return supertest + .post('/api/sample_tasks/schedule_legacy') + .set('kbn-xsrf', 'xxx') + .send({ task }) + .expect(200) + .then(response => response.body); + } + function runTaskNow(task) { return supertest .post('/api/sample_tasks/run_now') @@ -494,5 +503,15 @@ export default function({ getService }) { expect(getTaskById(tasks, longRunningTask.id).state.count).to.eql(1); }); }); + + it('should retain the legacy api until v8.0.0', async () => { + const result = await scheduleTaskUsingLegacyApi({ + id: 'task-with-legacy-api', + taskType: 'sampleTask', + params: {}, + }); + + expect(result.id).to.be('task-with-legacy-api'); + }); }); } diff --git a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js index c3cd582fd59c4..87e3b3b66a201 100644 --- a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js +++ b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/index.js @@ -23,7 +23,10 @@ export default function TaskManagerPerformanceAPI(kibana) { }, init(server) { - const taskManager = server.plugins.task_manager; + const taskManager = { + ...server.newPlatform.setup.plugins.taskManager, + ...server.newPlatform.start.plugins.taskManager, + }; const performanceState = resetPerfState({}); let lastFlush = new Date(); diff --git a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js index ca6d8707f5c58..6cd706a6ebecd 100644 --- a/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js +++ b/x-pack/test/plugin_api_perf/plugins/task_manager_performance/init_routes.js @@ -9,7 +9,10 @@ import { range, chunk } from 'lodash'; const scope = 'perf-testing'; export function initRoutes(server, performanceState) { - const taskManager = server.plugins.task_manager; + const taskManager = { + ...server.newPlatform.setup.plugins.taskManager, + ...server.newPlatform.start.plugins.taskManager, + }; server.route({ path: '/api/perf_tasks', diff --git a/x-pack/test/typings/hapi.d.ts b/x-pack/test/typings/hapi.d.ts index 0400c1b7d8f23..fc5ce09e5e618 100644 --- a/x-pack/test/typings/hapi.d.ts +++ b/x-pack/test/typings/hapi.d.ts @@ -9,7 +9,6 @@ import 'hapi'; import { XPackMainPlugin } from '../../legacy/plugins/xpack_main/server/xpack_main'; import { SecurityPlugin } from '../../legacy/plugins/security'; import { ActionsPlugin, ActionsClient } from '../../legacy/plugins/actions'; -import { TaskManager } from '../../legacy/plugins/task_manager/server'; import { AlertingPlugin, AlertsClient } from '../../legacy/plugins/alerting'; declare module 'hapi' { @@ -22,6 +21,5 @@ declare module 'hapi' { security?: SecurityPlugin; actions?: ActionsPlugin; alerting?: AlertingPlugin; - task_manager?: TaskManager; } } diff --git a/x-pack/typings/hapi.d.ts b/x-pack/typings/hapi.d.ts index cfc1a641550fc..a739d5f884f6e 100644 --- a/x-pack/typings/hapi.d.ts +++ b/x-pack/typings/hapi.d.ts @@ -9,8 +9,8 @@ import 'hapi'; import { XPackMainPlugin } from '../legacy/plugins/xpack_main/server/xpack_main'; import { SecurityPlugin } from '../legacy/plugins/security'; import { ActionsPlugin, ActionsClient } from '../legacy/plugins/actions'; -import { TaskManager } from '../legacy/plugins/task_manager/server'; import { AlertingPlugin, AlertsClient } from '../legacy/plugins/alerting'; +import { LegacyTaskManagerApi } from '../legacy/plugins/task_manager/server'; declare module 'hapi' { interface Request { @@ -22,6 +22,6 @@ declare module 'hapi' { security?: SecurityPlugin; actions?: ActionsPlugin; alerting?: AlertingPlugin; - task_manager?: TaskManager; + task_manager?: LegacyTaskManagerApi; } }