diff --git a/docs/api/saved-objects/bulk_get.asciidoc b/docs/api/saved-objects/bulk_get.asciidoc index eaf91a662849ea..1d2c9cc32d431f 100644 --- a/docs/api/saved-objects/bulk_get.asciidoc +++ b/docs/api/saved-objects/bulk_get.asciidoc @@ -29,7 +29,7 @@ experimental[] Retrieve multiple {kib} saved objects by ID. (Required, string) ID of the retrieved object. The ID includes the {kib} unique identifier or a custom identifier. `fields`:: - (Optional, array) The fields returned in the object response. + (Optional, array) The fields to return in the `attributes` key of the object response. [[saved-objects-api-bulk-get-response-body]] ==== Response body diff --git a/docs/api/saved-objects/find.asciidoc b/docs/api/saved-objects/find.asciidoc index 93e60be5d49239..e82c4e0c00d112 100644 --- a/docs/api/saved-objects/find.asciidoc +++ b/docs/api/saved-objects/find.asciidoc @@ -41,7 +41,7 @@ experimental[] Retrieve a paginated set of {kib} saved objects by various condit (Optional, array|string) The fields to perform the `simple_query_string` parsed query against. `fields`:: - (Optional, array|string) The fields to return in the response. + (Optional, array|string) The fields to return in the `attributes` key of the response. `sort_field`:: (Optional, string) The field that sorts the response. diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 561919738786e3..9a94c25bcdf6eb 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -247,7 +247,7 @@ retrieved. `timelion:es.timefield`:: The default field containing a timestamp when using the `.es()` query. `timelion:graphite.url`:: [experimental] Used with graphite queries, this is the URL of your graphite host in the form https://www.hostedgraphite.com/UID/ACCESS_KEY/graphite. This URL can be -selected from a whitelist configured in the `kibana.yml` under `timelion.graphiteUrls`. +selected from an allow-list configured in the `kibana.yml` under `timelion.graphiteUrls`. `timelion:max_buckets`:: The maximum number of buckets a single data source can return. This value is used for calculating automatic intervals in visualizations. `timelion:min_interval`:: The smallest interval to calculate when using "auto". diff --git a/src/core/MIGRATION.md b/src/core/MIGRATION.md index 5757b6dff8d3f7..f7acff14915a79 100644 --- a/src/core/MIGRATION.md +++ b/src/core/MIGRATION.md @@ -942,7 +942,7 @@ export class MyPlugin implements Plugin { return mountApp(await core.getStartServices(), params); }, }); - plugins.management.sections.getSection('another').registerApp({ + plugins.management.sections.section.kibana.registerApp({ id: 'app', title: 'My app', order: 1, diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 8b3347f8d88f0e..35f6dd65925bad 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -18,7 +18,6 @@ */ import { i18n } from '@kbn/i18n'; import { CoreSetup, Plugin } from 'kibana/public'; -import { ManagementSectionId } from '../../management/public'; import { ComponentRegistry } from './component_registry'; import { AdvancedSettingsSetup, AdvancedSettingsStart, AdvancedSettingsPluginSetup } from './types'; @@ -31,7 +30,7 @@ const title = i18n.translate('advancedSettings.advancedSettingsLabel', { export class AdvancedSettingsPlugin implements Plugin { public setup(core: CoreSetup, { management }: AdvancedSettingsPluginSetup) { - const kibanaSection = management.sections.getSection(ManagementSectionId.Kibana); + const kibanaSection = management.sections.section.kibana; kibanaSection.registerApp({ id: 'settings', diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 6e93d23f8469ca..fe680eff8657e2 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -27,7 +27,7 @@ import { IndexPatternManagementServiceStart, } from './service'; -import { ManagementSetup, ManagementSectionId } from '../../management/public'; +import { ManagementSetup } from '../../management/public'; export interface IndexPatternManagementSetupDependencies { management: ManagementSetup; @@ -64,7 +64,7 @@ export class IndexPatternManagementPlugin core: CoreSetup, { management, kibanaLegacy }: IndexPatternManagementSetupDependencies ) { - const kibanaSection = management.sections.getSection(ManagementSectionId.Kibana); + const kibanaSection = management.sections.section.kibana; if (!kibanaSection) { throw new Error('`kibana` management section not found.'); diff --git a/src/plugins/management/kibana.json b/src/plugins/management/kibana.json index f48158e98ff3f2..308e006b5aba0a 100644 --- a/src/plugins/management/kibana.json +++ b/src/plugins/management/kibana.json @@ -4,5 +4,5 @@ "server": true, "ui": true, "requiredPlugins": ["kibanaLegacy", "home"], - "requiredBundles": ["kibanaReact"] + "requiredBundles": ["kibanaReact", "kibanaUtils"] } diff --git a/src/plugins/management/public/application.tsx b/src/plugins/management/public/application.tsx index 5d014504b8938f..035f5d56e4cc78 100644 --- a/src/plugins/management/public/application.tsx +++ b/src/plugins/management/public/application.tsx @@ -20,21 +20,15 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { AppMountContext, AppMountParameters } from 'kibana/public'; +import { AppMountParameters } from 'kibana/public'; import { ManagementApp, ManagementAppDependencies } from './components/management_app'; export const renderApp = async ( - context: AppMountContext, { history, appBasePath, element }: AppMountParameters, dependencies: ManagementAppDependencies ) => { ReactDOM.render( - , + , element ); diff --git a/src/plugins/management/public/components/index.ts b/src/plugins/management/public/components/index.ts index 8979809c5245e1..3a2a3eafb89e22 100644 --- a/src/plugins/management/public/components/index.ts +++ b/src/plugins/management/public/components/index.ts @@ -18,4 +18,3 @@ */ export { ManagementApp } from './management_app'; -export { managementSections } from './management_sections'; diff --git a/src/plugins/management/public/components/management_app/management_app.tsx b/src/plugins/management/public/components/management_app/management_app.tsx index fc5a8924c95d6f..313884a90908f7 100644 --- a/src/plugins/management/public/components/management_app/management_app.tsx +++ b/src/plugins/management/public/components/management_app/management_app.tsx @@ -17,36 +17,32 @@ * under the License. */ import React, { useState, useEffect, useCallback } from 'react'; -import { - AppMountContext, - AppMountParameters, - ChromeBreadcrumb, - ScopedHistory, -} from 'kibana/public'; +import { AppMountParameters, ChromeBreadcrumb, ScopedHistory } from 'kibana/public'; import { I18nProvider } from '@kbn/i18n/react'; import { EuiPage } from '@elastic/eui'; -import { ManagementStart } from '../../types'; import { ManagementSection, MANAGEMENT_BREADCRUMB } from '../../utils'; import { ManagementRouter } from './management_router'; import { ManagementSidebarNav } from '../management_sidebar_nav'; import { reactRouterNavigate } from '../../../../kibana_react/public'; +import { SectionsServiceStart } from '../../types'; import './management_app.scss'; interface ManagementAppProps { appBasePath: string; - context: AppMountContext; history: AppMountParameters['history']; dependencies: ManagementAppDependencies; } export interface ManagementAppDependencies { - management: ManagementStart; + sections: SectionsServiceStart; kibanaVersion: string; + setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; } -export const ManagementApp = ({ context, dependencies, history }: ManagementAppProps) => { +export const ManagementApp = ({ dependencies, history }: ManagementAppProps) => { + const { setBreadcrumbs } = dependencies; const [selectedId, setSelectedId] = useState(''); const [sections, setSections] = useState(); @@ -55,24 +51,24 @@ export const ManagementApp = ({ context, dependencies, history }: ManagementAppP window.scrollTo(0, 0); }, []); - const setBreadcrumbs = useCallback( + const setBreadcrumbsScoped = useCallback( (crumbs: ChromeBreadcrumb[] = [], appHistory?: ScopedHistory) => { const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ ...item, ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), }); - context.core.chrome.setBreadcrumbs([ + setBreadcrumbs([ wrapBreadcrumb(MANAGEMENT_BREADCRUMB, history), ...crumbs.map((item) => wrapBreadcrumb(item, appHistory || history)), ]); }, - [context.core.chrome, history] + [setBreadcrumbs, history] ); useEffect(() => { - setSections(dependencies.management.sections.getSectionsEnabled()); - }, [dependencies.management.sections]); + setSections(dependencies.sections.getSectionsEnabled()); + }, [dependencies.sections]); if (!sections) { return null; @@ -84,7 +80,7 @@ export const ManagementApp = ({ context, dependencies, history }: ManagementAppP ( - - - {text} +export const KibanaSection = { + id: ManagementSectionId.Kibana, + title: kibanaTitle, + tip: kibanaTip, + order: 4, +}; - - - - - -); +export const StackSection = { + id: ManagementSectionId.Stack, + title: stackTitle, + tip: stackTip, + order: 4, +}; export const managementSections = [ - { - id: ManagementSectionId.Ingest, - title: ( - - ), - }, - { - id: ManagementSectionId.Data, - title: , - }, - { - id: ManagementSectionId.InsightsAndAlerting, - title: ( - - ), - }, - { - id: ManagementSectionId.Security, - title: , - }, - { - id: ManagementSectionId.Kibana, - title: , - }, - { - id: ManagementSectionId.Stack, - title: , - }, + IngestSection, + DataSection, + InsightsAndAlertingSection, + SecuritySection, + KibanaSection, + StackSection, ]; diff --git a/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx index 055dda5ed84a1e..37d1167661d82d 100644 --- a/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx +++ b/src/plugins/management/public/components/management_sidebar_nav/management_sidebar_nav.tsx @@ -21,7 +21,15 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { sortBy } from 'lodash'; -import { EuiIcon, EuiSideNav, EuiScreenReaderOnly, EuiSideNavItemType } from '@elastic/eui'; +import { + EuiIcon, + EuiSideNav, + EuiScreenReaderOnly, + EuiSideNavItemType, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, +} from '@elastic/eui'; import { AppMountParameters } from 'kibana/public'; import { ManagementApp, ManagementSection } from '../../utils'; @@ -79,6 +87,23 @@ export const ManagementSidebarNav = ({ }), })); + interface TooltipWrapperProps { + text: string; + tip?: string; + } + + const TooltipWrapper = ({ text, tip }: TooltipWrapperProps) => ( + + + {text} + + + + + + + ); + const createNavItem = ( item: T, customParams: Partial> = {} @@ -87,7 +112,7 @@ export const ManagementSidebarNav = ({ return { id: item.id, - name: item.title, + name: item.tip ? : item.title, isSelected: item.id === selectedId, icon: iconType ? : undefined, 'data-test-subj': item.id, diff --git a/src/plugins/management/public/index.ts b/src/plugins/management/public/index.ts index 3ba469c7831f6f..f6c23ccf0143fd 100644 --- a/src/plugins/management/public/index.ts +++ b/src/plugins/management/public/index.ts @@ -27,8 +27,8 @@ export function plugin(initializerContext: PluginInitializerContext) { export { RegisterManagementAppArgs, ManagementSection, ManagementApp } from './utils'; export { - ManagementSectionId, ManagementAppMountParams, ManagementSetup, ManagementStart, + DefinedSections, } from './types'; diff --git a/src/plugins/management/public/management_sections_service.test.ts b/src/plugins/management/public/management_sections_service.test.ts index fd56dd8a6ee274..3e0001e4ca5502 100644 --- a/src/plugins/management/public/management_sections_service.test.ts +++ b/src/plugins/management/public/management_sections_service.test.ts @@ -17,8 +17,10 @@ * under the License. */ -import { ManagementSectionId } from './index'; -import { ManagementSectionsService } from './management_sections_service'; +import { + ManagementSectionsService, + getSectionsServiceStartPrivate, +} from './management_sections_service'; describe('ManagementService', () => { let managementService: ManagementSectionsService; @@ -35,15 +37,10 @@ describe('ManagementService', () => { test('Provides default sections', () => { managementService.setup(); - const start = managementService.start({ capabilities }); - - expect(start.getAllSections().length).toEqual(6); - expect(start.getSection(ManagementSectionId.Ingest)).toBeDefined(); - expect(start.getSection(ManagementSectionId.Data)).toBeDefined(); - expect(start.getSection(ManagementSectionId.InsightsAndAlerting)).toBeDefined(); - expect(start.getSection(ManagementSectionId.Security)).toBeDefined(); - expect(start.getSection(ManagementSectionId.Kibana)).toBeDefined(); - expect(start.getSection(ManagementSectionId.Stack)).toBeDefined(); + managementService.start({ capabilities }); + const start = getSectionsServiceStartPrivate(); + + expect(start.getSectionsEnabled().length).toEqual(6); }); test('Register section, enable and disable', () => { @@ -51,10 +48,11 @@ describe('ManagementService', () => { const setup = managementService.setup(); const testSection = setup.register({ id: 'test-section', title: 'Test Section' }); - expect(setup.getSection('test-section')).not.toBeUndefined(); + expect(testSection).not.toBeUndefined(); // Start phase: - const start = managementService.start({ capabilities }); + managementService.start({ capabilities }); + const start = getSectionsServiceStartPrivate(); expect(start.getSectionsEnabled().length).toEqual(7); @@ -71,7 +69,7 @@ describe('ManagementService', () => { testSection.registerApp({ id: 'test-app-2', title: 'Test App 2', mount: jest.fn() }); testSection.registerApp({ id: 'test-app-3', title: 'Test App 3', mount: jest.fn() }); - expect(setup.getSection('test-section')).not.toBeUndefined(); + expect(testSection).not.toBeUndefined(); // Start phase: managementService.start({ diff --git a/src/plugins/management/public/management_sections_service.ts b/src/plugins/management/public/management_sections_service.ts index d8d148a9247fff..b9dc2dd416d9a0 100644 --- a/src/plugins/management/public/management_sections_service.ts +++ b/src/plugins/management/public/management_sections_service.ts @@ -17,22 +17,47 @@ * under the License. */ -import { ReactElement } from 'react'; import { ManagementSection, RegisterManagementSectionArgs } from './utils'; -import { managementSections } from './components/management_sections'; +import { + IngestSection, + DataSection, + InsightsAndAlertingSection, + SecuritySection, + KibanaSection, + StackSection, +} from './components/management_sections'; import { ManagementSectionId, SectionsServiceSetup, - SectionsServiceStart, SectionsServiceStartDeps, + DefinedSections, + ManagementSectionsStartPrivate, } from './types'; +import { createGetterSetter } from '../../kibana_utils/public'; + +const [getSectionsServiceStartPrivate, setSectionsServiceStartPrivate] = createGetterSetter< + ManagementSectionsStartPrivate +>('SectionsServiceStartPrivate'); + +export { getSectionsServiceStartPrivate }; export class ManagementSectionsService { - private sections: Map = new Map(); + definedSections: DefinedSections; - private getSection = (sectionId: ManagementSectionId | string) => - this.sections.get(sectionId) as ManagementSection; + constructor() { + // Note on adding sections - sections can be defined in a plugin and exported as a contract + // It is not necessary to define all sections here, although we've chose to do it for discovery reasons. + this.definedSections = { + ingest: this.registerSection(IngestSection), + data: this.registerSection(DataSection), + insightsAndAlerting: this.registerSection(InsightsAndAlertingSection), + security: this.registerSection(SecuritySection), + kibana: this.registerSection(KibanaSection), + stack: this.registerSection(StackSection), + }; + } + private sections: Map = new Map(); private getAllSections = () => [...this.sections.values()]; @@ -48,19 +73,15 @@ export class ManagementSectionsService { }; setup(): SectionsServiceSetup { - managementSections.forEach( - ({ id, title }: { id: ManagementSectionId; title: ReactElement }, idx: number) => { - this.registerSection({ id, title, order: idx }); - } - ); - return { register: this.registerSection, - getSection: this.getSection, + section: { + ...this.definedSections, + }, }; } - start({ capabilities }: SectionsServiceStartDeps): SectionsServiceStart { + start({ capabilities }: SectionsServiceStartDeps) { this.getAllSections().forEach((section) => { if (capabilities.management.hasOwnProperty(section.id)) { const sectionCapabilities = capabilities.management[section.id]; @@ -72,10 +93,10 @@ export class ManagementSectionsService { } }); - return { - getSection: this.getSection, - getAllSections: this.getAllSections, + setSectionsServiceStartPrivate({ getSectionsEnabled: () => this.getAllSections().filter((section) => section.enabled), - }; + }); + + return {}; } } diff --git a/src/plugins/management/public/mocks/index.ts b/src/plugins/management/public/mocks/index.ts index 123e3f28877aac..fbb37647dad90f 100644 --- a/src/plugins/management/public/mocks/index.ts +++ b/src/plugins/management/public/mocks/index.ts @@ -17,10 +17,10 @@ * under the License. */ -import { ManagementSetup, ManagementStart } from '../types'; +import { ManagementSetup, ManagementStart, DefinedSections } from '../types'; import { ManagementSection } from '../index'; -const createManagementSectionMock = () => +export const createManagementSectionMock = () => (({ disable: jest.fn(), enable: jest.fn(), @@ -29,19 +29,22 @@ const createManagementSectionMock = () => getEnabledItems: jest.fn().mockReturnValue([]), } as unknown) as ManagementSection); -const createSetupContract = (): DeeplyMockedKeys => ({ +const createSetupContract = (): ManagementSetup => ({ sections: { - register: jest.fn(), - getSection: jest.fn().mockReturnValue(createManagementSectionMock()), + register: jest.fn(() => createManagementSectionMock()), + section: ({ + ingest: createManagementSectionMock(), + data: createManagementSectionMock(), + insightsAndAlerting: createManagementSectionMock(), + security: createManagementSectionMock(), + kibana: createManagementSectionMock(), + stack: createManagementSectionMock(), + } as unknown) as DefinedSections, }, }); -const createStartContract = (): DeeplyMockedKeys => ({ - sections: { - getSection: jest.fn(), - getAllSections: jest.fn(), - getSectionsEnabled: jest.fn(), - }, +const createStartContract = (): ManagementStart => ({ + sections: {}, }); export const managementPluginMock = { diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index dada4636e6add9..17d8cb4adc7018 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -26,9 +26,13 @@ import { Plugin, DEFAULT_APP_CATEGORIES, PluginInitializerContext, + AppMountParameters, } from '../../../core/public'; -import { ManagementSectionsService } from './management_sections_service'; +import { + ManagementSectionsService, + getSectionsServiceStartPrivate, +} from './management_sections_service'; interface ManagementSetupDependencies { home: HomePublicPluginSetup; @@ -64,13 +68,14 @@ export class ManagementPlugin implements Plugin ManagementSection[]; } export interface SectionsServiceStartDeps { @@ -36,12 +47,10 @@ export interface SectionsServiceStartDeps { export interface SectionsServiceSetup { register: (args: Omit) => ManagementSection; - getSection: (sectionId: ManagementSectionId | string) => ManagementSection; + section: DefinedSections; } export interface SectionsServiceStart { - getSection: (sectionId: ManagementSectionId | string) => ManagementSection; - getAllSections: () => ManagementSection[]; getSectionsEnabled: () => ManagementSection[]; } @@ -66,7 +75,8 @@ export interface ManagementAppMountParams { export interface CreateManagementItemArgs { id: string; - title: string | ReactElement; + title: string; + tip?: string; order?: number; euiIconType?: string; // takes precedence over `icon` property. icon?: string; // URL to image file; fallback if no `euiIconType` diff --git a/src/plugins/management/public/utils/management_item.ts b/src/plugins/management/public/utils/management_item.ts index ef0c8e46938952..e6e473c77bf61c 100644 --- a/src/plugins/management/public/utils/management_item.ts +++ b/src/plugins/management/public/utils/management_item.ts @@ -16,21 +16,22 @@ * specific language governing permissions and limitations * under the License. */ -import { ReactElement } from 'react'; import { CreateManagementItemArgs } from '../types'; export class ManagementItem { public readonly id: string = ''; - public readonly title: string | ReactElement = ''; + public readonly title: string; + public readonly tip?: string; public readonly order: number; public readonly euiIconType?: string; public readonly icon?: string; public enabled: boolean = true; - constructor({ id, title, order = 100, euiIconType, icon }: CreateManagementItemArgs) { + constructor({ id, title, tip, order = 100, euiIconType, icon }: CreateManagementItemArgs) { this.id = id; this.title = title; + this.tip = tip; this.order = order; this.euiIconType = euiIconType; this.icon = icon; diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index f3d6318db89f24..47d445e63b9428 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; -import { ManagementSetup, ManagementSectionId } from '../../management/public'; +import { ManagementSetup } from '../../management/public'; import { DataPublicPluginStart } from '../../data/public'; import { DashboardStart } from '../../dashboard/public'; import { DiscoverStart } from '../../discover/public'; @@ -87,7 +87,7 @@ export class SavedObjectsManagementPlugin category: FeatureCatalogueCategory.ADMIN, }); - const kibanaSection = management.sections.getSection(ManagementSectionId.Kibana); + const kibanaSection = management.sections.section.kibana; kibanaSection.registerApp({ id: 'objects', title: i18n.translate('savedObjectsManagement.managementSectionLabel', { diff --git a/src/plugins/tile_map/public/plugin.ts b/src/plugins/tile_map/public/plugin.ts index 1f79104b183ee6..4582cd2283dc13 100644 --- a/src/plugins/tile_map/public/plugin.ts +++ b/src/plugins/tile_map/public/plugin.ts @@ -32,7 +32,7 @@ import 'angular-sanitize'; import { createTileMapFn } from './tile_map_fn'; // @ts-ignore import { createTileMapTypeDefinition } from './tile_map_type'; -import { MapsLegacyPluginSetup } from '../../maps_legacy/public'; +import { IServiceSettings, MapsLegacyPluginSetup } from '../../maps_legacy/public'; import { DataPublicPluginStart } from '../../data/public'; import { setFormatService, setQueryService } from './services'; import { setKibanaLegacy } from './services'; @@ -48,6 +48,7 @@ interface TileMapVisualizationDependencies { getZoomPrecision: any; getPrecision: any; BaseMapsVisualization: any; + serviceSettings: IServiceSettings; } /** @internal */ @@ -81,12 +82,13 @@ export class TileMapPlugin implements Plugin = { getZoomPrecision, getPrecision, BaseMapsVisualization: mapsLegacy.getBaseMapsVis(), uiSettings: core.uiSettings, + serviceSettings, }; expressions.registerFunction(() => createTileMapFn(visualizationDependencies)); diff --git a/src/plugins/vis_type_vega/public/vega_visualization.test.js b/src/plugins/vis_type_vega/public/vega_visualization.test.js index a6ad6e4908bb48..108b34b36c66f2 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.test.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.test.js @@ -52,7 +52,8 @@ jest.mock('./lib/vega', () => ({ vegaLite: jest.requireActual('vega-lite'), })); -describe('VegaVisualizations', () => { +// FLAKY: https://github.com/elastic/kibana/issues/71713 +describe.skip('VegaVisualizations', () => { let domNode; let VegaVisualization; let vis; diff --git a/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx b/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx index 494570b26f561f..9cbff335590a3d 100644 --- a/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx +++ b/test/plugin_functional/plugins/management_test_plugin/public/plugin.tsx @@ -21,12 +21,12 @@ import * as React from 'react'; import ReactDOM from 'react-dom'; import { Router, Switch, Route, Link } from 'react-router-dom'; import { CoreSetup, Plugin } from 'kibana/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../../src/plugins/management/public'; export class ManagementTestPlugin implements Plugin { public setup(core: CoreSetup, { management }: { management: ManagementSetup }) { - const testSection = management.sections.getSection(ManagementSectionId.Data); + const testSection = management.sections.section.data; testSection.registerApp({ id: 'test-management', diff --git a/x-pack/plugins/alerts/server/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client.ts index ba832c65319f94..e49745b186bb30 100644 --- a/x-pack/plugins/alerts/server/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client.ts @@ -5,7 +5,7 @@ */ import Boom from 'boom'; -import { omit, isEqual, map } from 'lodash'; +import { omit, isEqual, map, truncate } from 'lodash'; import { i18n } from '@kbn/i18n'; import { Logger, @@ -13,7 +13,6 @@ import { SavedObjectReference, SavedObject, } from 'src/core/server'; -import _ from 'lodash'; import { ActionsClient } from '../../actions/server'; import { Alert, @@ -713,6 +712,6 @@ export class AlertsClient { } private generateAPIKeyName(alertTypeId: string, alertName: string) { - return _.truncate(`Alerting: ${alertTypeId}/${alertName}`, { length: 256 }); + return truncate(`Alerting: ${alertTypeId}/${alertName}`, { length: 256 }); } } diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap index a86f7fdf41f4fb..0589fce727115d 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/List/__test__/__snapshots__/List.test.tsx.snap @@ -786,11 +786,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > a0ce2 @@ -831,11 +831,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -878,13 +878,13 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > List should render with data 1`] = ` > f3ac9 @@ -1065,11 +1065,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1112,13 +1112,13 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > List should render with data 1`] = ` > e9086 @@ -1299,11 +1299,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1346,13 +1346,13 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > List should render with data 1`] = ` > 8673d @@ -1533,11 +1533,11 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > @@ -1580,13 +1580,13 @@ exports[`ErrorGroupOverview -> List should render with data 1`] = ` > ); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); - expect(setBreadcrumbs.mock.calls[0][0]).toMatchSnapshot(); } describe('UpdateBreadcrumbs', () => { @@ -58,36 +57,88 @@ describe('UpdateBreadcrumbs', () => { }); it('Homepage', () => { - expectBreadcrumbToMatchSnapshot('/'); + mountBreadcrumb('/'); expect(window.document.title).toMatchInlineSnapshot(`"APM"`); }); it('/services/:serviceName/errors/:groupId', () => { - expectBreadcrumbToMatchSnapshot('/services/opbeans-node/errors/myGroupId'); + mountBreadcrumb( + '/services/opbeans-node/errors/myGroupId', + 'rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0' + ); + const breadcrumbs = setBreadcrumbs.mock.calls[0][0]; + expect(breadcrumbs).toEqual([ + { + text: 'APM', + href: + '#/?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', + }, + { + text: 'Services', + href: + '#/services?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', + }, + { + text: 'opbeans-node', + href: + '#/services/opbeans-node?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', + }, + { + text: 'Errors', + href: + '#/services/opbeans-node/errors?kuery=myKuery&rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', + }, + { text: 'myGroupId', href: undefined }, + ]); expect(window.document.title).toMatchInlineSnapshot( `"myGroupId | Errors | opbeans-node | Services | APM"` ); }); it('/services/:serviceName/errors', () => { - expectBreadcrumbToMatchSnapshot('/services/opbeans-node/errors'); + mountBreadcrumb('/services/opbeans-node/errors'); + const breadcrumbs = setBreadcrumbs.mock.calls[0][0]; + expect(breadcrumbs).toEqual([ + { text: 'APM', href: '#/?kuery=myKuery' }, + { text: 'Services', href: '#/services?kuery=myKuery' }, + { text: 'opbeans-node', href: '#/services/opbeans-node?kuery=myKuery' }, + { text: 'Errors', href: undefined }, + ]); expect(window.document.title).toMatchInlineSnapshot( `"Errors | opbeans-node | Services | APM"` ); }); it('/services/:serviceName/transactions', () => { - expectBreadcrumbToMatchSnapshot('/services/opbeans-node/transactions'); + mountBreadcrumb('/services/opbeans-node/transactions'); + const breadcrumbs = setBreadcrumbs.mock.calls[0][0]; + expect(breadcrumbs).toEqual([ + { text: 'APM', href: '#/?kuery=myKuery' }, + { text: 'Services', href: '#/services?kuery=myKuery' }, + { text: 'opbeans-node', href: '#/services/opbeans-node?kuery=myKuery' }, + { text: 'Transactions', href: undefined }, + ]); expect(window.document.title).toMatchInlineSnapshot( `"Transactions | opbeans-node | Services | APM"` ); }); it('/services/:serviceName/transactions/view?transactionName=my-transaction-name', () => { - expectBreadcrumbToMatchSnapshot( + mountBreadcrumb( '/services/opbeans-node/transactions/view', 'transactionName=my-transaction-name' ); + const breadcrumbs = setBreadcrumbs.mock.calls[0][0]; + expect(breadcrumbs).toEqual([ + { text: 'APM', href: '#/?kuery=myKuery' }, + { text: 'Services', href: '#/services?kuery=myKuery' }, + { text: 'opbeans-node', href: '#/services/opbeans-node?kuery=myKuery' }, + { + text: 'Transactions', + href: '#/services/opbeans-node/transactions?kuery=myKuery', + }, + { text: 'my-transaction-name', href: undefined }, + ]); expect(window.document.title).toMatchInlineSnapshot( `"my-transaction-name | Transactions | opbeans-node | Services | APM"` ); diff --git a/x-pack/plugins/apm/public/components/app/Main/__snapshots__/UpdateBreadcrumbs.test.tsx.snap b/x-pack/plugins/apm/public/components/app/Main/__snapshots__/UpdateBreadcrumbs.test.tsx.snap deleted file mode 100644 index e7f6cba59318a5..00000000000000 --- a/x-pack/plugins/apm/public/components/app/Main/__snapshots__/UpdateBreadcrumbs.test.tsx.snap +++ /dev/null @@ -1,102 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`UpdateBreadcrumbs /services/:serviceName/errors 1`] = ` -Array [ - Object { - "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "APM", - }, - Object { - "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Services", - }, - Object { - "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "opbeans-node", - }, - Object { - "href": undefined, - "text": "Errors", - }, -] -`; - -exports[`UpdateBreadcrumbs /services/:serviceName/errors/:groupId 1`] = ` -Array [ - Object { - "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "APM", - }, - Object { - "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Services", - }, - Object { - "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "opbeans-node", - }, - Object { - "href": "#/services/opbeans-node/errors?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Errors", - }, - Object { - "href": undefined, - "text": "myGroupId", - }, -] -`; - -exports[`UpdateBreadcrumbs /services/:serviceName/transactions 1`] = ` -Array [ - Object { - "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "APM", - }, - Object { - "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Services", - }, - Object { - "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "opbeans-node", - }, - Object { - "href": undefined, - "text": "Transactions", - }, -] -`; - -exports[`UpdateBreadcrumbs /services/:serviceName/transactions/view?transactionName=my-transaction-name 1`] = ` -Array [ - Object { - "href": "#/?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "APM", - }, - Object { - "href": "#/services?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Services", - }, - Object { - "href": "#/services/opbeans-node?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "opbeans-node", - }, - Object { - "href": "#/services/opbeans-node/transactions?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=myKuery", - "text": "Transactions", - }, - Object { - "href": undefined, - "text": "my-transaction-name", - }, -] -`; - -exports[`UpdateBreadcrumbs Homepage 1`] = ` -Array [ - Object { - "href": undefined, - "text": "APM", - }, -] -`; diff --git a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap index 241ba8c2444961..e46da26f7dcb08 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap +++ b/x-pack/plugins/apm/public/components/app/ServiceOverview/__test__/__snapshots__/ServiceOverview.test.tsx.snap @@ -157,7 +157,7 @@ NodeList [ > My Go Service @@ -263,7 +263,7 @@ NodeList [ > My Python Service diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx index 639277a79ac9a9..215e97aebf6464 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/__test__/DatePicker.test.tsx @@ -17,6 +17,7 @@ import { mount } from 'enzyme'; import { EuiSuperDatePicker } from '@elastic/eui'; import { MemoryRouter } from 'react-router-dom'; import { wait } from '@testing-library/react'; +import { MockApmPluginContextWrapper } from '../../../../context/ApmPluginContext/MockApmPluginContext'; const mockHistoryPush = jest.spyOn(history, 'push'); const mockRefreshTimeRange = jest.fn(); @@ -35,13 +36,15 @@ const MockUrlParamsProvider: React.FC<{ function mountDatePicker(params?: IUrlParams) { return mount( - - - - - - - + + + + + + + + + ); } @@ -58,6 +61,41 @@ describe('DatePicker', () => { jest.clearAllMocks(); }); + it('should set default query params in the URL', () => { + mountDatePicker(); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith( + expect.objectContaining({ + search: + 'rangeFrom=now-15m&rangeTo=now&refreshPaused=false&refreshInterval=10000', + }) + ); + }); + + it('should add missing default value', () => { + mountDatePicker({ + rangeTo: 'now', + refreshInterval: 5000, + }); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledWith( + expect.objectContaining({ + search: + 'rangeFrom=now-15m&rangeTo=now&refreshInterval=5000&refreshPaused=false', + }) + ); + }); + + it('should not set default query params in the URL when values already defined', () => { + mountDatePicker({ + rangeFrom: 'now-1d', + rangeTo: 'now', + refreshPaused: false, + refreshInterval: 5000, + }); + expect(mockHistoryPush).toHaveBeenCalledTimes(0); + }); + it('should update the URL when the date range changes', () => { const datePicker = mountDatePicker(); datePicker.find(EuiSuperDatePicker).props().onTimeChange({ @@ -66,9 +104,11 @@ describe('DatePicker', () => { isInvalid: false, isQuickSelection: true, }); - expect(mockHistoryPush).toHaveBeenCalledWith( + expect(mockHistoryPush).toHaveBeenCalledTimes(2); + expect(mockHistoryPush).toHaveBeenLastCalledWith( expect.objectContaining({ - search: 'rangeFrom=updated-start&rangeTo=updated-end', + search: + 'rangeFrom=updated-start&rangeTo=updated-end&refreshInterval=5000&refreshPaused=false', }) ); }); diff --git a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx index 4391e4a5b89528..5201d80de5a122 100644 --- a/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/DatePicker/index.tsx @@ -5,75 +5,61 @@ */ import { EuiSuperDatePicker } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React from 'react'; +import { isEmpty, isEqual, pickBy } from 'lodash'; import { fromQuery, toQuery } from '../Links/url_helpers'; import { history } from '../../../utils/history'; import { useLocation } from '../../../hooks/useLocation'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { clearCache } from '../../../services/rest/callApi'; +import { useApmPluginContext } from '../../../hooks/useApmPluginContext'; +import { UI_SETTINGS } from '../../../../../../../src/plugins/data/common'; +import { + TimePickerQuickRange, + TimePickerTimeDefaults, + TimePickerRefreshInterval, +} from './typings'; + +function removeUndefinedAndEmptyProps(obj: T): Partial { + return pickBy(obj, (value) => value !== undefined && !isEmpty(String(value))); +} export function DatePicker() { const location = useLocation(); + const { core } = useApmPluginContext(); + + const timePickerQuickRanges = core.uiSettings.get( + UI_SETTINGS.TIMEPICKER_QUICK_RANGES + ); + + const timePickerTimeDefaults = core.uiSettings.get( + UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS + ); + + const timePickerRefreshIntervalDefaults = core.uiSettings.get< + TimePickerRefreshInterval + >(UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS); + + const DEFAULT_VALUES = { + rangeFrom: timePickerTimeDefaults.from, + rangeTo: timePickerTimeDefaults.to, + refreshPaused: timePickerRefreshIntervalDefaults.pause, + /* + * Must be replaced by timePickerRefreshIntervalDefaults.value when this issue is fixed. + * https://github.com/elastic/kibana/issues/70562 + */ + refreshInterval: 10000, + }; + + const commonlyUsedRanges = timePickerQuickRanges.map( + ({ from, to, display }) => ({ + start: from, + end: to, + label: display, + }) + ); + const { urlParams, refreshTimeRange } = useUrlParams(); - const commonlyUsedRanges = [ - { - start: 'now-15m', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last15MinutesLabel', { - defaultMessage: 'Last 15 minutes', - }), - }, - { - start: 'now-30m', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last30MinutesLabel', { - defaultMessage: 'Last 30 minutes', - }), - }, - { - start: 'now-1h', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last1HourLabel', { - defaultMessage: 'Last 1 hour', - }), - }, - { - start: 'now-24h', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last24HoursLabel', { - defaultMessage: 'Last 24 hours', - }), - }, - { - start: 'now-7d', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last7DaysLabel', { - defaultMessage: 'Last 7 days', - }), - }, - { - start: 'now-30d', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last30DaysLabel', { - defaultMessage: 'Last 30 days', - }), - }, - { - start: 'now-90d', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last90DaysLabel', { - defaultMessage: 'Last 90 days', - }), - }, - { - start: 'now-1y', - end: 'now', - label: i18n.translate('xpack.apm.datePicker.last1YearLabel', { - defaultMessage: 'Last 1 year', - }), - }, - ]; function updateUrl(nextQuery: { rangeFrom?: string; @@ -105,6 +91,20 @@ export function DatePicker() { } const { rangeFrom, rangeTo, refreshPaused, refreshInterval } = urlParams; + const timePickerURLParams = removeUndefinedAndEmptyProps({ + rangeFrom, + rangeTo, + refreshPaused, + refreshInterval, + }); + + const nextParams = { + ...DEFAULT_VALUES, + ...timePickerURLParams, + }; + if (!isEqual(nextParams, timePickerURLParams)) { + updateUrl(nextParams); + } return ( { const href = await getRenderedHref( () => , { - search: '?rangeFrom=now/w&rangeTo=now', + search: + '?rangeFrom=now/w&rangeTo=now&refreshPaused=true&refreshInterval=0', } as Location ); @@ -45,7 +46,8 @@ describe('DiscoverLinks', () => { } as Span; const href = await getRenderedHref(() => , { - search: '?rangeFrom=now/w&rangeTo=now', + search: + '?rangeFrom=now/w&rangeTo=now&refreshPaused=true&refreshInterval=0', } as Location); expect(href).toEqual( @@ -65,7 +67,8 @@ describe('DiscoverLinks', () => { const href = await getRenderedHref( () => , { - search: '?rangeFrom=now/w&rangeTo=now', + search: + '?rangeFrom=now/w&rangeTo=now&refreshPaused=true&refreshInterval=0', } as Location ); @@ -87,7 +90,8 @@ describe('DiscoverLinks', () => { const href = await getRenderedHref( () => , { - search: '?rangeFrom=now/w&rangeTo=now', + search: + '?rangeFrom=now/w&rangeTo=now&refreshPaused=true&refreshInterval=0', } as Location ); diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx index c832d3ded61754..39082c2639a2cf 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLJobLink.test.tsx @@ -15,7 +15,10 @@ describe('MLJobLink', () => { () => ( ), - { search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location + { + search: + '?rangeFrom=now/w&rangeTo=now-4h&refreshPaused=true&refreshInterval=0', + } as Location ); expect(href).toMatchInlineSnapshot( @@ -31,7 +34,10 @@ describe('MLJobLink', () => { transactionType="request" /> ), - { search: '?rangeFrom=now/w&rangeTo=now-4h' } as Location + { + search: + '?rangeFrom=now/w&rangeTo=now-4h&refreshPaused=true&refreshInterval=0', + } as Location ); expect(href).toMatchInlineSnapshot( diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx index 840846adae0190..b4187b2f797aba 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx @@ -15,7 +15,8 @@ test('MLLink produces the correct URL', async () => { ), { - search: '?rangeFrom=now-5h&rangeTo=now-2h', + search: + '?rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0', } as Location ); diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx index d6518e76aa5e93..1e849e8865d0db 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.test.tsx @@ -13,7 +13,8 @@ test('APMLink should produce the correct URL', async () => { const href = await getRenderedHref( () => , { - search: '?rangeFrom=now-5h&rangeTo=now-2h', + search: + '?rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0', } as Location ); @@ -26,12 +27,13 @@ test('APMLink should retain current kuery value if it exists', async () => { const href = await getRenderedHref( () => , { - search: '?kuery=host.hostname~20~3A~20~22fakehostname~22', + search: + '?kuery=host.hostname~20~3A~20~22fakehostname~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0', } as Location ); expect(href).toMatchInlineSnapshot( - `"#/some/path?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=host.hostname~20~3A~20~22fakehostname~22&transactionId=blah"` + `"#/some/path?kuery=host.hostname~20~3A~20~22fakehostname~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0&transactionId=blah"` ); }); @@ -44,11 +46,12 @@ test('APMLink should overwrite current kuery value if new kuery value is provide /> ), { - search: '?kuery=host.hostname~20~3A~20~22fakehostname~22', + search: + '?kuery=host.hostname~20~3A~20~22fakehostname~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0', } as Location ); expect(href).toMatchInlineSnapshot( - `"#/some/path?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0&kuery=host.os~20~3A~20~22linux~22"` + `"#/some/path?kuery=host.os~20~3A~20~22linux~22&rangeFrom=now-5h&rangeTo=now-2h&refreshPaused=true&refreshInterval=0"` ); }); diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx index 3aff241c6dee27..353f476e3f9935 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx @@ -10,7 +10,6 @@ import url from 'url'; import { pick } from 'lodash'; import { useLocation } from '../../../../hooks/useLocation'; import { APMQueryParams, toQuery, fromQuery } from '../url_helpers'; -import { TIMEPICKER_DEFAULTS } from '../../../../context/UrlParamsContext/constants'; interface Props extends EuiLinkAnchorProps { path?: string; @@ -36,7 +35,6 @@ export function getAPMHref( ) { const currentQuery = toQuery(currentSearch); const nextQuery = { - ...TIMEPICKER_DEFAULTS, ...pick(currentQuery, PERSISTENT_APM_PARAMS), ...query, }; diff --git a/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts b/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts index 434bd285029ab0..8b4d891dba83b9 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/rison_helpers.ts @@ -5,7 +5,6 @@ */ import { Location } from 'history'; -import { TIMEPICKER_DEFAULTS } from '../../../context/UrlParamsContext/constants'; import { toQuery } from './url_helpers'; export interface TimepickerRisonData { @@ -21,18 +20,20 @@ export interface TimepickerRisonData { export function getTimepickerRisonData(currentSearch: Location['search']) { const currentQuery = toQuery(currentSearch); - const nextQuery = { - ...TIMEPICKER_DEFAULTS, - ...currentQuery, - }; return { time: { - from: encodeURIComponent(nextQuery.rangeFrom), - to: encodeURIComponent(nextQuery.rangeTo), + from: currentQuery.rangeFrom + ? encodeURIComponent(currentQuery.rangeFrom) + : '', + to: currentQuery.rangeTo ? encodeURIComponent(currentQuery.rangeTo) : '', }, refreshInterval: { - pause: String(nextQuery.refreshPaused), - value: String(nextQuery.refreshInterval), + pause: currentQuery.refreshPaused + ? String(currentQuery.refreshPaused) + : '', + value: currentQuery.refreshInterval + ? String(currentQuery.refreshInterval) + : '', }, }; } diff --git a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts index 50325e0b9d6044..186fc082ce5fe1 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts +++ b/x-pack/plugins/apm/public/components/shared/TransactionActionMenu/__test__/sections.test.ts @@ -17,6 +17,18 @@ describe('Transaction action menu', () => { const date = '2020-02-06T11:00:00.000Z'; const timestamp = { us: new Date(date).getTime() }; + const urlParams = { + rangeFrom: 'now-24h', + rangeTo: 'now', + refreshPaused: true, + refreshInterval: 0, + }; + + const location = ({ + search: + '?rangeFrom=now-24h&rangeTo=now&refreshPaused=true&refreshInterval=0', + } as unknown) as Location; + it('shows required sections only', () => { const transaction = ({ timestamp, @@ -28,8 +40,8 @@ describe('Transaction action menu', () => { getSections({ transaction, basePath, - location: ({} as unknown) as Location, - urlParams: {}, + location, + urlParams, }) ).toEqual([ [ @@ -77,8 +89,8 @@ describe('Transaction action menu', () => { getSections({ transaction, basePath, - location: ({} as unknown) as Location, - urlParams: {}, + location, + urlParams, }) ).toEqual([ [ @@ -148,8 +160,8 @@ describe('Transaction action menu', () => { getSections({ transaction, basePath, - location: ({} as unknown) as Location, - urlParams: {}, + location, + urlParams, }) ).toEqual([ [ diff --git a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx index 922796afd39bf1..7a5d0dd5ce877f 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/Timeline/Marker/ErrorMarker.test.tsx @@ -44,7 +44,9 @@ describe('ErrorMarker', () => { return component; } function getKueryDecoded(url: string) { - return decodeURIComponent(url.substring(url.indexOf('kuery='), url.length)); + return decodeURIComponent( + url.substring(url.indexOf('kuery='), url.indexOf('&')) + ); } it('renders link with trace and transaction', () => { const component = openPopover(mark); diff --git a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx index 329368e0c80f11..8c38cdcda958d5 100644 --- a/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx +++ b/x-pack/plugins/apm/public/context/ApmPluginContext/MockApmPluginContext.tsx @@ -7,6 +7,30 @@ import React from 'react'; import { ApmPluginContext, ApmPluginContextValue } from '.'; import { createCallApmApi } from '../../services/rest/createCallApmApi'; import { ConfigSchema } from '../..'; +import { UI_SETTINGS } from '../../../../../../src/plugins/data/common'; + +const uiSettings: Record = { + [UI_SETTINGS.TIMEPICKER_QUICK_RANGES]: [ + { + from: 'now/d', + to: 'now/d', + display: 'Today', + }, + { + from: 'now/w', + to: 'now/w', + display: 'This week', + }, + ], + [UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS]: { + from: 'now-15m', + to: 'now', + }, + [UI_SETTINGS.TIMEPICKER_REFRESH_INTERVAL_DEFAULTS]: { + pause: false, + value: 100000, + }, +}; const mockCore = { chrome: { @@ -27,6 +51,9 @@ const mockCore = { addDanger: () => {}, }, }, + uiSettings: { + get: (key: string) => uiSettings[key], + }, }; const mockConfig: ConfigSchema = { diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx b/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx index b88e0b8e23ea50..fbb79eae6a136a 100644 --- a/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/__tests__/UrlParamsContext.test.tsx @@ -53,15 +53,9 @@ describe('UrlParamsContext', () => { const params = getDataFromOutput(wrapper); expect(params).toEqual({ - start: '2000-06-14T12:00:00.000Z', serviceName: 'opbeans-node', - end: '2000-06-15T12:00:00.000Z', page: 0, processorEvent: 'transaction', - rangeFrom: 'now-24h', - rangeTo: 'now', - refreshInterval: 0, - refreshPaused: true, }); }); diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/constants.ts b/x-pack/plugins/apm/public/context/UrlParamsContext/constants.ts index d654e60077be94..6297a560440d22 100644 --- a/x-pack/plugins/apm/public/context/UrlParamsContext/constants.ts +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/constants.ts @@ -6,9 +6,3 @@ export const TIME_RANGE_REFRESH = 'TIME_RANGE_REFRESH'; export const LOCATION_UPDATE = 'LOCATION_UPDATE'; -export const TIMEPICKER_DEFAULTS = { - rangeFrom: 'now-24h', - rangeTo: 'now', - refreshPaused: 'true', - refreshInterval: '0', -}; diff --git a/x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts b/x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts index bae7b9a796e194..2201e162904a2b 100644 --- a/x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts +++ b/x-pack/plugins/apm/public/context/UrlParamsContext/resolveUrlParams.ts @@ -16,7 +16,6 @@ import { toString, } from './helpers'; import { toQuery } from '../../components/shared/Links/url_helpers'; -import { TIMEPICKER_DEFAULTS } from './constants'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; import { pickKeys } from '../../../common/utils/pick_keys'; @@ -51,10 +50,10 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { sortDirection, sortField, kuery, - refreshPaused = TIMEPICKER_DEFAULTS.refreshPaused, - refreshInterval = TIMEPICKER_DEFAULTS.refreshInterval, - rangeFrom = TIMEPICKER_DEFAULTS.rangeFrom, - rangeTo = TIMEPICKER_DEFAULTS.rangeTo, + refreshPaused, + refreshInterval, + rangeFrom, + rangeTo, environment, searchTerm, } = query; @@ -67,8 +66,8 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { end: getEnd(state, rangeTo), rangeFrom, rangeTo, - refreshPaused: toBoolean(refreshPaused), - refreshInterval: toNumber(refreshInterval), + refreshPaused: refreshPaused ? toBoolean(refreshPaused) : undefined, + refreshInterval: refreshInterval ? toNumber(refreshInterval) : undefined, // query params sortDirection, diff --git a/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts b/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts index 6c1783054a3126..6008c52d0324bf 100644 --- a/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts +++ b/x-pack/plugins/beats_management/public/lib/adapters/framework/kibana_framework_adapter.ts @@ -22,7 +22,6 @@ import { import { ManagementSetup, RegisterManagementAppArgs, - ManagementSectionId, } from '../../../../../../../src/plugins/management/public'; import { LicensingPluginSetup } from '../../../../../licensing/public'; import { BeatsManagementConfigType } from '../../../../common'; @@ -105,7 +104,7 @@ export class KibanaFrameworkAdapter implements FrameworkAdapter { } public registerManagementUI(mount: RegisterManagementAppArgs['mount']) { - const section = this.management.sections.getSection(ManagementSectionId.Ingest); + const section = this.management.sections.section.ingest; section.registerApp({ id: 'beats_management', title: i18n.translate('xpack.beatsManagement.centralManagementLinkLabel', { diff --git a/x-pack/plugins/cross_cluster_replication/public/plugin.ts b/x-pack/plugins/cross_cluster_replication/public/plugin.ts index 8bf0d519e685dc..7aa0d19fa976f7 100644 --- a/x-pack/plugins/cross_cluster_replication/public/plugin.ts +++ b/x-pack/plugins/cross_cluster_replication/public/plugin.ts @@ -9,7 +9,6 @@ import { get } from 'lodash'; import { first } from 'rxjs/operators'; import { CoreSetup, Plugin, PluginInitializerContext } from 'src/core/public'; -import { ManagementSectionId } from '../../../../src/plugins/management/public'; import { PLUGIN, MANAGEMENT_ID } from '../common/constants'; import { init as initUiMetric } from './app/services/track_ui_metric'; import { init as initNotification } from './app/services/notifications'; @@ -23,7 +22,7 @@ export class CrossClusterReplicationPlugin implements Plugin { public setup(coreSetup: CoreSetup, plugins: PluginDependencies) { const { licensing, remoteClusters, usageCollection, management, indexManagement } = plugins; - const esSection = management.sections.getSection(ManagementSectionId.Data); + const esSection = management.sections.section.data; const { http, diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.js b/x-pack/plugins/graph/public/angular/graph_client_workspace.js index cfa125fcc49ee6..5cc06bad4c4238 100644 --- a/x-pack/plugins/graph/public/angular/graph_client_workspace.js +++ b/x-pack/plugins/graph/public/angular/graph_client_workspace.js @@ -107,7 +107,7 @@ function UnGroupOperation(parent, child) { // The main constructor for our GraphWorkspace function GraphWorkspace(options) { const self = this; - this.blacklistedNodes = []; + this.blocklistedNodes = []; this.options = options; this.undoLog = []; this.redoLog = []; @@ -379,7 +379,7 @@ function GraphWorkspace(options) { this.redoLog = []; this.nodesMap = {}; this.edgesMap = {}; - this.blacklistedNodes = []; + this.blocklistedNodes = []; this.selectedNodes = []; this.lastResponse = null; }; @@ -630,11 +630,11 @@ function GraphWorkspace(options) { self.runLayout(); }; - this.unblacklist = function (node) { - self.arrRemove(self.blacklistedNodes, node); + this.unblocklist = function (node) { + self.arrRemove(self.blocklistedNodes, node); }; - this.blacklistSelection = function () { + this.blocklistSelection = function () { const selection = self.getAllSelectedNodes(); const danglingEdges = []; self.edges.forEach(function (edge) { @@ -645,7 +645,7 @@ function GraphWorkspace(options) { }); selection.forEach((node) => { delete self.nodesMap[node.id]; - self.blacklistedNodes.push(node); + self.blocklistedNodes.push(node); node.isSelected = false; }); self.arrRemoveAll(self.nodes, selection); @@ -671,10 +671,10 @@ function GraphWorkspace(options) { } let step = {}; - //Add any blacklisted nodes to exclusion list + //Add any blocklisted nodes to exclusion list const excludeNodesByField = {}; const nots = []; - const avoidNodes = this.blacklistedNodes; + const avoidNodes = this.blocklistedNodes; for (let i = 0; i < avoidNodes.length; i++) { const n = avoidNodes[i]; let arr = excludeNodesByField[n.data.field]; @@ -914,8 +914,8 @@ function GraphWorkspace(options) { const nodesByField = {}; const excludeNodesByField = {}; - //Add any blacklisted nodes to exclusion list - const avoidNodes = this.blacklistedNodes; + //Add any blocklisted nodes to exclusion list + const avoidNodes = this.blocklistedNodes; for (let i = 0; i < avoidNodes.length; i++) { const n = avoidNodes[i]; let arr = excludeNodesByField[n.data.field]; @@ -1320,12 +1320,12 @@ function GraphWorkspace(options) { allExistingNodes.forEach((existingNode) => { addTermToFieldList(excludeNodesByField, existingNode.data.field, existingNode.data.term); }); - const blacklistedNodes = self.blacklistedNodes; - blacklistedNodes.forEach((blacklistedNode) => { + const blocklistedNodes = self.blocklistedNodes; + blocklistedNodes.forEach((blocklistedNode) => { addTermToFieldList( excludeNodesByField, - blacklistedNode.data.field, - blacklistedNode.data.term + blocklistedNode.data.field, + blocklistedNode.data.term ); }); diff --git a/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js b/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js index fe6a782373eb29..65766cbefaad34 100644 --- a/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js +++ b/x-pack/plugins/graph/public/angular/graph_client_workspace.test.js @@ -82,7 +82,7 @@ describe('graphui-workspace', function () { expect(workspace.nodes.length).toEqual(2); expect(workspace.edges.length).toEqual(1); expect(workspace.selectedNodes.length).toEqual(0); - expect(workspace.blacklistedNodes.length).toEqual(0); + expect(workspace.blocklistedNodes.length).toEqual(0); const nodeA = workspace.getNode(workspace.makeNodeId('field1', 'a')); expect(typeof nodeA).toBe('object'); @@ -124,7 +124,7 @@ describe('graphui-workspace', function () { expect(workspace.nodes.length).toEqual(2); expect(workspace.edges.length).toEqual(1); expect(workspace.selectedNodes.length).toEqual(0); - expect(workspace.blacklistedNodes.length).toEqual(0); + expect(workspace.blocklistedNodes.length).toEqual(0); mockedResult = { vertices: [ diff --git a/x-pack/plugins/graph/public/angular/templates/index.html b/x-pack/plugins/graph/public/angular/templates/index.html index 939d92518e271c..50385008d7b2b6 100644 --- a/x-pack/plugins/graph/public/angular/templates/index.html +++ b/x-pack/plugins/graph/public/angular/templates/index.html @@ -124,7 +124,7 @@ diff --git a/x-pack/plugins/graph/public/app.js b/x-pack/plugins/graph/public/app.js index 08b13e9d5c5417..fd2b96e0570f66 100644 --- a/x-pack/plugins/graph/public/app.js +++ b/x-pack/plugins/graph/public/app.js @@ -562,8 +562,8 @@ export function initGraphApp(angularModule, deps) { run: () => { const settingsObservable = asAngularSyncedObservable( () => ({ - blacklistedNodes: $scope.workspace ? [...$scope.workspace.blacklistedNodes] : undefined, - unblacklistNode: $scope.workspace ? $scope.workspace.unblacklist : undefined, + blocklistedNodes: $scope.workspace ? [...$scope.workspace.blocklistedNodes] : undefined, + unblocklistNode: $scope.workspace ? $scope.workspace.unblocklist : undefined, canEditDrillDownUrls: canEditDrillDownUrls, }), $scope.$digest.bind($scope) diff --git a/x-pack/plugins/graph/public/components/settings/blacklist_form.tsx b/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx similarity index 72% rename from x-pack/plugins/graph/public/components/settings/blacklist_form.tsx rename to x-pack/plugins/graph/public/components/settings/blocklist_form.tsx index 68cdcc1fbb7b17..29ab7611fcee86 100644 --- a/x-pack/plugins/graph/public/components/settings/blacklist_form.tsx +++ b/x-pack/plugins/graph/public/components/settings/blocklist_form.tsx @@ -20,16 +20,16 @@ import { SettingsProps } from './settings'; import { LegacyIcon } from '../legacy_icon'; import { useListKeys } from './use_list_keys'; -export function BlacklistForm({ - blacklistedNodes, - unblacklistNode, -}: Pick) { - const getListKey = useListKeys(blacklistedNodes || []); +export function BlocklistForm({ + blocklistedNodes, + unblocklistNode, +}: Pick) { + const getListKey = useListKeys(blocklistedNodes || []); return ( <> - {blacklistedNodes && blacklistedNodes.length > 0 ? ( + {blocklistedNodes && blocklistedNodes.length > 0 ? ( - {i18n.translate('xpack.graph.settings.blacklist.blacklistHelpText', { + {i18n.translate('xpack.graph.settings.blocklist.blocklistHelpText', { defaultMessage: 'These terms are not allowed in the graph.', })} @@ -37,7 +37,7 @@ export function BlacklistForm({ }} /> @@ -45,25 +45,25 @@ export function BlacklistForm({ /> )} - {blacklistedNodes && unblacklistNode && blacklistedNodes.length > 0 && ( + {blocklistedNodes && unblocklistNode && blocklistedNodes.length > 0 && ( <> - {blacklistedNodes.map((node) => ( + {blocklistedNodes.map((node) => ( } key={getListKey(node)} label={node.label} extraAction={{ iconType: 'trash', - 'aria-label': i18n.translate('xpack.graph.blacklist.removeButtonAriaLabel', { + 'aria-label': i18n.translate('xpack.graph.blocklist.removeButtonAriaLabel', { defaultMessage: 'Delete', }), - title: i18n.translate('xpack.graph.blacklist.removeButtonAriaLabel', { + title: i18n.translate('xpack.graph.blocklist.removeButtonAriaLabel', { defaultMessage: 'Delete', }), color: 'danger', onClick: () => { - unblacklistNode(node); + unblocklistNode(node); }, }} /> @@ -71,18 +71,18 @@ export function BlacklistForm({ { - blacklistedNodes.forEach((node) => { - unblacklistNode(node); + blocklistedNodes.forEach((node) => { + unblocklistNode(node); }); }} > - {i18n.translate('xpack.graph.settings.blacklist.clearButtonLabel', { + {i18n.translate('xpack.graph.settings.blocklist.clearButtonLabel', { defaultMessage: 'Delete all', })} diff --git a/x-pack/plugins/graph/public/components/settings/settings.test.tsx b/x-pack/plugins/graph/public/components/settings/settings.test.tsx index 1efaead002b52f..7d13249288d537 100644 --- a/x-pack/plugins/graph/public/components/settings/settings.test.tsx +++ b/x-pack/plugins/graph/public/components/settings/settings.test.tsx @@ -46,7 +46,7 @@ describe('settings', () => { }; const angularProps: jest.Mocked = { - blacklistedNodes: [ + blocklistedNodes: [ { x: 0, y: 0, @@ -57,7 +57,7 @@ describe('settings', () => { field: 'A', term: '1', }, - label: 'blacklisted node 1', + label: 'blocklisted node 1', icon: { class: 'test', code: '1', @@ -74,7 +74,7 @@ describe('settings', () => { field: 'A', term: '1', }, - label: 'blacklisted node 2', + label: 'blocklisted node 2', icon: { class: 'test', code: '1', @@ -82,7 +82,7 @@ describe('settings', () => { }, }, ], - unblacklistNode: jest.fn(), + unblocklistNode: jest.fn(), canEditDrillDownUrls: true, }; @@ -201,15 +201,15 @@ describe('settings', () => { }); }); - describe('blacklist', () => { + describe('blocklist', () => { beforeEach(() => { toTab('Block list'); }); - it('should switch tab to blacklist', () => { + it('should switch tab to blocklist', () => { expect(instance.find(EuiListGroupItem).map((item) => item.prop('label'))).toEqual([ - 'blacklisted node 1', - 'blacklisted node 2', + 'blocklisted node 1', + 'blocklisted node 2', ]); }); @@ -217,7 +217,7 @@ describe('settings', () => { act(() => { subject.next({ ...angularProps, - blacklistedNodes: [ + blocklistedNodes: [ { x: 0, y: 0, @@ -228,7 +228,7 @@ describe('settings', () => { field: 'A', term: '1', }, - label: 'blacklisted node 3', + label: 'blocklisted node 3', icon: { class: 'test', code: '1', @@ -242,21 +242,21 @@ describe('settings', () => { instance.update(); expect(instance.find(EuiListGroupItem).map((item) => item.prop('label'))).toEqual([ - 'blacklisted node 3', + 'blocklisted node 3', ]); }); it('should delete node', () => { instance.find(EuiListGroupItem).at(0).prop('extraAction')!.onClick!({} as any); - expect(angularProps.unblacklistNode).toHaveBeenCalledWith(angularProps.blacklistedNodes![0]); + expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![0]); }); it('should delete all nodes', () => { - instance.find('[data-test-subj="graphUnblacklistAll"]').find(EuiButton).simulate('click'); + instance.find('[data-test-subj="graphUnblocklistAll"]').find(EuiButton).simulate('click'); - expect(angularProps.unblacklistNode).toHaveBeenCalledWith(angularProps.blacklistedNodes![0]); - expect(angularProps.unblacklistNode).toHaveBeenCalledWith(angularProps.blacklistedNodes![1]); + expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![0]); + expect(angularProps.unblocklistNode).toHaveBeenCalledWith(angularProps.blocklistedNodes![1]); }); }); diff --git a/x-pack/plugins/graph/public/components/settings/settings.tsx b/x-pack/plugins/graph/public/components/settings/settings.tsx index 3baf6b6a0a2e3c..3a9ea6e96859b5 100644 --- a/x-pack/plugins/graph/public/components/settings/settings.tsx +++ b/x-pack/plugins/graph/public/components/settings/settings.tsx @@ -11,7 +11,7 @@ import * as Rx from 'rxjs'; import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import { AdvancedSettingsForm } from './advanced_settings_form'; -import { BlacklistForm } from './blacklist_form'; +import { BlocklistForm } from './blocklist_form'; import { UrlTemplateList } from './url_template_list'; import { WorkspaceNode, AdvancedSettings, UrlTemplate, WorkspaceField } from '../../types'; import { @@ -33,9 +33,9 @@ const tabs = [ component: AdvancedSettingsForm, }, { - id: 'blacklist', - title: i18n.translate('xpack.graph.settings.blacklistTitle', { defaultMessage: 'Block list' }), - component: BlacklistForm, + id: 'blocklist', + title: i18n.translate('xpack.graph.settings.blocklistTitle', { defaultMessage: 'Block list' }), + component: BlocklistForm, }, { id: 'drillDowns', @@ -51,8 +51,8 @@ const tabs = [ * to catch update outside updates */ export interface AngularProps { - blacklistedNodes: WorkspaceNode[]; - unblacklistNode: (node: WorkspaceNode) => void; + blocklistedNodes: WorkspaceNode[]; + unblocklistNode: (node: WorkspaceNode) => void; canEditDrillDownUrls: boolean; } diff --git a/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts b/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts index 3dda41fcdbdb62..e9f116b79f9909 100644 --- a/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts +++ b/x-pack/plugins/graph/public/services/persistence/deserialize.test.ts @@ -26,7 +26,7 @@ describe('deserialize', () => { { color: 'black', name: 'field1', selected: true, iconClass: 'a' }, { color: 'black', name: 'field2', selected: true, iconClass: 'b' }, ], - blacklist: [ + blocklist: [ { color: 'black', label: 'Z', @@ -192,7 +192,7 @@ describe('deserialize', () => { it('should deserialize nodes and edges', () => { callSavedWorkspaceToAppState(); - expect(workspace.blacklistedNodes.length).toEqual(1); + expect(workspace.blocklistedNodes.length).toEqual(1); expect(workspace.nodes.length).toEqual(5); expect(workspace.edges.length).toEqual(2); diff --git a/x-pack/plugins/graph/public/services/persistence/deserialize.ts b/x-pack/plugins/graph/public/services/persistence/deserialize.ts index 6fd720a60edc03..324bf10cdd99c3 100644 --- a/x-pack/plugins/graph/public/services/persistence/deserialize.ts +++ b/x-pack/plugins/graph/public/services/persistence/deserialize.ts @@ -128,11 +128,11 @@ function getFieldsWithWorkspaceSettings( return allFields; } -function getBlacklistedNodes( +function getBlocklistedNodes( serializedWorkspaceState: SerializedWorkspaceState, allFields: WorkspaceField[] ) { - return serializedWorkspaceState.blacklist.map((serializedNode) => { + return serializedWorkspaceState.blocklist.map((serializedNode) => { const currentField = allFields.find((field) => field.name === serializedNode.field)!; return { x: 0, @@ -235,9 +235,9 @@ export function savedWorkspaceToAppState( workspaceInstance.mergeGraph(graph); resolveGroups(persistedWorkspaceState.vertices, workspaceInstance); - // ================== blacklist ============================= - const blacklistedNodes = getBlacklistedNodes(persistedWorkspaceState, allFields); - workspaceInstance.blacklistedNodes.push(...blacklistedNodes); + // ================== blocklist ============================= + const blocklistedNodes = getBlocklistedNodes(persistedWorkspaceState, allFields); + workspaceInstance.blocklistedNodes.push(...blocklistedNodes); return { urlTemplates, diff --git a/x-pack/plugins/graph/public/services/persistence/serialize.test.ts b/x-pack/plugins/graph/public/services/persistence/serialize.test.ts index a3942eccfdac36..0c9de0418a7381 100644 --- a/x-pack/plugins/graph/public/services/persistence/serialize.test.ts +++ b/x-pack/plugins/graph/public/services/persistence/serialize.test.ts @@ -118,7 +118,7 @@ describe('serialize', () => { parent: null, }, ], - blacklistedNodes: [ + blocklistedNodes: [ { color: 'black', data: { field: 'field1', term: 'Z' }, @@ -165,7 +165,7 @@ describe('serialize', () => { const workspaceState = JSON.parse(savedWorkspace.wsState); expect(workspaceState).toMatchInlineSnapshot(` Object { - "blacklist": Array [ + "blocklist": Array [ Object { "color": "black", "field": "field1", diff --git a/x-pack/plugins/graph/public/services/persistence/serialize.ts b/x-pack/plugins/graph/public/services/persistence/serialize.ts index 6cbebc995d84a5..a3a76a8a08eba0 100644 --- a/x-pack/plugins/graph/public/services/persistence/serialize.ts +++ b/x-pack/plugins/graph/public/services/persistence/serialize.ts @@ -96,8 +96,8 @@ export function appStateToSavedWorkspace( }, canSaveData: boolean ) { - const blacklist: SerializedNode[] = canSaveData - ? workspace.blacklistedNodes.map((node) => serializeNode(node)) + const blocklist: SerializedNode[] = canSaveData + ? workspace.blocklistedNodes.map((node) => serializeNode(node)) : []; const vertices: SerializedNode[] = canSaveData ? workspace.nodes.map((node) => serializeNode(node, workspace.nodes)) @@ -111,7 +111,7 @@ export function appStateToSavedWorkspace( const persistedWorkspaceState: SerializedWorkspaceState = { indexPattern: selectedIndex.title, selectedFields: selectedFields.map(serializeField), - blacklist, + blocklist, vertices, links, urlTemplates: mappedUrlTemplates, diff --git a/x-pack/plugins/graph/public/state_management/mocks.ts b/x-pack/plugins/graph/public/state_management/mocks.ts index 5a0269d691de25..d32bc9a175a47c 100644 --- a/x-pack/plugins/graph/public/state_management/mocks.ts +++ b/x-pack/plugins/graph/public/state_management/mocks.ts @@ -46,7 +46,7 @@ export function createMockGraphStore({ nodes: [], edges: [], options: {}, - blacklistedNodes: [], + blocklistedNodes: [], } as unknown) as Workspace; const savedWorkspace = ({ diff --git a/x-pack/plugins/graph/public/state_management/persistence.ts b/x-pack/plugins/graph/public/state_management/persistence.ts index cd2c6680c1fd21..cf6566f0c5f86e 100644 --- a/x-pack/plugins/graph/public/state_management/persistence.ts +++ b/x-pack/plugins/graph/public/state_management/persistence.ts @@ -198,7 +198,7 @@ function showModal( openSaveModal({ savePolicy: deps.savePolicy, - hasData: workspace.nodes.length > 0 || workspace.blacklistedNodes.length > 0, + hasData: workspace.nodes.length > 0 || workspace.blocklistedNodes.length > 0, workspace: savedWorkspace, showSaveModal: deps.showSaveModal, saveWorkspace: saveWorkspaceHandler, diff --git a/x-pack/plugins/graph/public/types/persistence.ts b/x-pack/plugins/graph/public/types/persistence.ts index 6847199d5878c9..8e7e9c7e8878e4 100644 --- a/x-pack/plugins/graph/public/types/persistence.ts +++ b/x-pack/plugins/graph/public/types/persistence.ts @@ -33,7 +33,7 @@ export interface GraphWorkspaceSavedObject { export interface SerializedWorkspaceState { indexPattern: string; selectedFields: SerializedField[]; - blacklist: SerializedNode[]; + blocklist: SerializedNode[]; vertices: SerializedNode[]; links: SerializedEdge[]; urlTemplates: SerializedUrlTemplate[]; diff --git a/x-pack/plugins/graph/public/types/workspace_state.ts b/x-pack/plugins/graph/public/types/workspace_state.ts index 8c4178eda890f7..b5ee48311ddc83 100644 --- a/x-pack/plugins/graph/public/types/workspace_state.ts +++ b/x-pack/plugins/graph/public/types/workspace_state.ts @@ -63,7 +63,7 @@ export interface Workspace { nodesMap: Record; nodes: WorkspaceNode[]; edges: WorkspaceEdge[]; - blacklistedNodes: WorkspaceNode[]; + blocklistedNodes: WorkspaceNode[]; getQuery(startNodes?: WorkspaceNode[], loose?: boolean): JsonObject; getSelectedOrAllNodes(): WorkspaceNode[]; diff --git a/x-pack/plugins/graph/server/sample_data/ecommerce.ts b/x-pack/plugins/graph/server/sample_data/ecommerce.ts index 7543e9471f05cd..b9b4e063cb28f4 100644 --- a/x-pack/plugins/graph/server/sample_data/ecommerce.ts +++ b/x-pack/plugins/graph/server/sample_data/ecommerce.ts @@ -37,7 +37,7 @@ const wsState: any = { iconClass: 'fa-heart', }, ], - blacklist: [ + blocklist: [ { x: 491.3880229084531, y: 572.375603969653, diff --git a/x-pack/plugins/graph/server/sample_data/flights.ts b/x-pack/plugins/graph/server/sample_data/flights.ts index bca1d0d093a8ec..209b7108266cf2 100644 --- a/x-pack/plugins/graph/server/sample_data/flights.ts +++ b/x-pack/plugins/graph/server/sample_data/flights.ts @@ -37,7 +37,7 @@ const wsState: any = { iconClass: 'fa-cube', }, ], - blacklist: [], + blocklist: [], vertices: [ { x: 324.55695700802687, diff --git a/x-pack/plugins/graph/server/sample_data/logs.ts b/x-pack/plugins/graph/server/sample_data/logs.ts index 5ca810b397cd27..c3cc2ecd2fc658 100644 --- a/x-pack/plugins/graph/server/sample_data/logs.ts +++ b/x-pack/plugins/graph/server/sample_data/logs.ts @@ -45,7 +45,7 @@ const wsState: any = { iconClass: 'fa-key', }, ], - blacklist: [ + blocklist: [ { x: 349.9814471314239, y: 274.1259761174194, diff --git a/x-pack/plugins/graph/server/saved_objects/migrations.ts b/x-pack/plugins/graph/server/saved_objects/migrations.ts index beb31d548c6702..34cd59e2220e97 100644 --- a/x-pack/plugins/graph/server/saved_objects/migrations.ts +++ b/x-pack/plugins/graph/server/saved_objects/migrations.ts @@ -37,4 +37,23 @@ export const graphMigrations = { }); return doc; }, + '7.10.0': (doc: SavedObjectUnsanitizedDoc) => { + const wsState = get(doc, 'attributes.wsState'); + if (typeof wsState !== 'string') { + return doc; + } + let state; + try { + state = JSON.parse(JSON.parse(wsState)); + } catch (e) { + // Let it go, the data is invalid and we'll leave it as is + return doc; + } + if (state.blacklist) { + state.blocklist = state.blacklist; + delete state.blacklist; + } + doc.attributes.wsState = JSON.stringify(JSON.stringify(state)); + return doc; + }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx index 49856dee47fba8..832d066dfa33b9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/plugin.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/plugin.tsx @@ -6,7 +6,6 @@ import { CoreSetup, PluginInitializerContext } from 'src/core/public'; -import { ManagementSectionId } from '../../../../src/plugins/management/public'; import { PLUGIN } from '../common/constants'; import { init as initHttp } from './application/services/http'; import { init as initDocumentation } from './application/services/documentation'; @@ -38,7 +37,7 @@ export class IndexLifecycleManagementPlugin { initUiMetric(usageCollection); initNotification(toasts, fatalErrors); - management.sections.getSection(ManagementSectionId.Data).registerApp({ + management.sections.section.data.registerApp({ id: PLUGIN.ID, title: PLUGIN.TITLE, order: 2, diff --git a/x-pack/plugins/index_management/public/plugin.ts b/x-pack/plugins/index_management/public/plugin.ts index aec25ee3247d69..6139ed5d2e6ad0 100644 --- a/x-pack/plugins/index_management/public/plugin.ts +++ b/x-pack/plugins/index_management/public/plugin.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup } from '../../../../src/core/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { IngestManagerSetup } from '../../ingest_manager/public'; import { UIM_APP_NAME, PLUGIN } from '../common/constants'; @@ -51,7 +51,7 @@ export class IndexMgmtUIPlugin { notificationService.setup(notifications); this.uiMetricService.setup(usageCollection); - management.sections.getSection(ManagementSectionId.Data).registerApp({ + management.sections.section.data.registerApp({ id: PLUGIN.id, title: i18n.translate('xpack.idxMgmt.appTitle', { defaultMessage: 'Index Management' }), order: 0, diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts index f0ff4c61254524..abd2ba777e516b 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.test.ts @@ -269,6 +269,181 @@ describe('processFields', () => { expect(processFields(nested)).toEqual(nestedExpanded); }); + test('correctly handles properties of nested and object type fields together', () => { + const fields = [ + { + name: 'a', + type: 'object', + }, + { + name: 'a.b', + type: 'nested', + }, + { + name: 'a.b.c', + type: 'boolean', + }, + { + name: 'a.b.d', + type: 'keyword', + }, + ]; + + const fieldsExpanded = [ + { + name: 'a', + type: 'group', + fields: [ + { + name: 'b', + type: 'group-nested', + fields: [ + { + name: 'c', + type: 'boolean', + }, + { + name: 'd', + type: 'keyword', + }, + ], + }, + ], + }, + ]; + expect(processFields(fields)).toEqual(fieldsExpanded); + }); + + test('correctly handles properties of nested and object type fields in large depth', () => { + const fields = [ + { + name: 'a.h-object', + type: 'object', + dynamic: false, + }, + { + name: 'a.b-nested.c-nested', + type: 'nested', + }, + { + name: 'a.b-nested', + type: 'nested', + }, + { + name: 'a', + type: 'object', + }, + { + name: 'a.b-nested.d', + type: 'keyword', + }, + { + name: 'a.b-nested.c-nested.e', + type: 'boolean', + dynamic: true, + }, + { + name: 'a.b-nested.c-nested.f-object', + type: 'object', + }, + { + name: 'a.b-nested.c-nested.f-object.g', + type: 'keyword', + }, + ]; + + const fieldsExpanded = [ + { + name: 'a', + type: 'group', + fields: [ + { + name: 'h-object', + type: 'object', + dynamic: false, + }, + { + name: 'b-nested', + type: 'group-nested', + fields: [ + { + name: 'c-nested', + type: 'group-nested', + fields: [ + { + name: 'e', + type: 'boolean', + dynamic: true, + }, + { + name: 'f-object', + type: 'group', + fields: [ + { + name: 'g', + type: 'keyword', + }, + ], + }, + ], + }, + { + name: 'd', + type: 'keyword', + }, + ], + }, + ], + }, + ]; + expect(processFields(fields)).toEqual(fieldsExpanded); + }); + + test('correctly handles properties of nested and object type fields together in different order', () => { + const fields = [ + { + name: 'a.b.c', + type: 'boolean', + }, + { + name: 'a.b', + type: 'nested', + }, + { + name: 'a', + type: 'object', + }, + { + name: 'a.b.d', + type: 'keyword', + }, + ]; + + const fieldsExpanded = [ + { + name: 'a', + type: 'group', + fields: [ + { + name: 'b', + type: 'group-nested', + fields: [ + { + name: 'c', + type: 'boolean', + }, + { + name: 'd', + type: 'keyword', + }, + ], + }, + ], + }, + ]; + expect(processFields(fields)).toEqual(fieldsExpanded); + }); + test('correctly handles properties of nested type where nested top level comes second', () => { const nested = [ { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts index e7c0eca2a9613d..a44e5e4221f9fb 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts @@ -126,10 +126,21 @@ function dedupFields(fields: Fields): Fields { if ( // only merge if found is a group and field is object, nested, or group. // Or if found is object, or nested, and field is a group. - // This is to avoid merging two objects, or nested, or object with a nested. + // This is to avoid merging two objects, or two nested, or object with a nested. + + // we do not need to check for group-nested in this part because `field` will never have group-nested + // it can only exist on `found` (found.type === 'group' && (field.type === 'object' || field.type === 'nested' || field.type === 'group')) || - ((found.type === 'object' || found.type === 'nested') && field.type === 'group') + // as part of the loop we will be marking found.type as group-nested so found could be group-nested if it was + // already processed. If we had an explicit definition of nested, and it showed up before a descendant field: + // - name: a + // type: nested + // - name: a.b + // type: keyword + // then found.type will be nested and not group-nested because it won't have any fields yet until a.b is processed + ((found.type === 'object' || found.type === 'nested' || found.type === 'group-nested') && + field.type === 'group') ) { // if the new field has properties let's dedup and concat them with the already existing found variable in // the array @@ -148,10 +159,10 @@ function dedupFields(fields: Fields): Fields { // supposed to be `nested` for when the template is actually generated if (found.type === 'nested' || field.type === 'nested') { found.type = 'group-nested'; - } else { - // found was either `group` already or `object` so just set it to `group` + } else if (found.type === 'object') { found.type = 'group'; } + // found.type could be group-nested or group, in those cases just leave it } // we need to merge in other properties (like `dynamic`) that might exist Object.assign(found, importantFieldProps); diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts index ea906517f6dec1..7fb13e5e671d05 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/index.ts @@ -21,6 +21,7 @@ import { ArchiveEntry, untarBuffer } from './extract'; import { fetchUrl, getResponse, getResponseStream } from './requests'; import { streamToBuffer } from './streams'; import { getRegistryUrl } from './registry_url'; +import { appContextService } from '../..'; export { ArchiveEntry } from './extract'; @@ -47,6 +48,10 @@ export async function fetchList(params?: SearchParams): Promise { - const anchor = '2020-06-17T20:34:51.337Z'; - const unix = moment(anchor).valueOf(); + const oldDate = '2020-03-17T20:34:51.337Z'; + const dateNow = '2020-06-17T20:34:51.337Z'; + const unix = moment(dateNow).valueOf(); let clock: sinon.SinonFakeTimers; beforeEach(() => { @@ -42,11 +43,11 @@ describe('utils', () => { test('it formats newly added comments', () => { const comments = transformUpdateCommentsToComments({ comments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'bane' }, { comment: 'Im a new comment' }, ], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'bane' }, ], user: 'lily', }); @@ -54,12 +55,12 @@ describe('utils', () => { expect(comments).toEqual([ { comment: 'Im an old comment', - created_at: anchor, - created_by: 'lily', + created_at: oldDate, + created_by: 'bane', }, { comment: 'Im a new comment', - created_at: anchor, + created_at: dateNow, created_by: 'lily', }, ]); @@ -68,12 +69,12 @@ describe('utils', () => { test('it formats multiple newly added comments', () => { const comments = transformUpdateCommentsToComments({ comments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, { comment: 'Im a new comment' }, { comment: 'Im another new comment' }, ], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, ], user: 'lily', }); @@ -81,17 +82,17 @@ describe('utils', () => { expect(comments).toEqual([ { comment: 'Im an old comment', - created_at: anchor, + created_at: oldDate, created_by: 'lily', }, { comment: 'Im a new comment', - created_at: anchor, + created_at: dateNow, created_by: 'lily', }, { comment: 'Im another new comment', - created_at: anchor, + created_at: dateNow, created_by: 'lily', }, ]); @@ -99,9 +100,9 @@ describe('utils', () => { test('it should not throw if comments match existing comments', () => { const comments = transformUpdateCommentsToComments({ - comments: [{ comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }], + comments: [{ comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, ], user: 'lily', }); @@ -109,7 +110,7 @@ describe('utils', () => { expect(comments).toEqual([ { comment: 'Im an old comment', - created_at: anchor, + created_at: oldDate, created_by: 'lily', }, ]); @@ -120,12 +121,12 @@ describe('utils', () => { comments: [ { comment: 'Im an old comment that is trying to be updated', - created_at: DATE_NOW, + created_at: oldDate, created_by: 'lily', }, ], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, ], user: 'lily', }); @@ -133,9 +134,9 @@ describe('utils', () => { expect(comments).toEqual([ { comment: 'Im an old comment that is trying to be updated', - created_at: DATE_NOW, + created_at: oldDate, created_by: 'lily', - updated_at: anchor, + updated_at: dateNow, updated_by: 'lily', }, ]); @@ -150,7 +151,7 @@ describe('utils', () => { }, ], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, ], user: 'lily', }) @@ -164,7 +165,7 @@ describe('utils', () => { transformUpdateCommentsToComments({ comments: [], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, ], user: 'lily', }) @@ -176,9 +177,9 @@ describe('utils', () => { test('it throws if user tries to update existing comment timestamp', () => { expect(() => transformUpdateCommentsToComments({ - comments: [{ comment: 'Im an old comment', created_at: anchor, created_by: 'lily' }], + comments: [{ comment: 'Im an old comment', created_at: dateNow, created_by: 'lily' }], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, ], user: 'bane', }) @@ -188,9 +189,9 @@ describe('utils', () => { test('it throws if user tries to update existing comment author', () => { expect(() => transformUpdateCommentsToComments({ - comments: [{ comment: 'Im an old comment', created_at: anchor, created_by: 'lily' }], + comments: [{ comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'me!' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'me!' }, ], user: 'bane', }) @@ -203,12 +204,12 @@ describe('utils', () => { comments: [ { comment: 'Im an old comment that is trying to be updated', - created_at: DATE_NOW, + created_at: oldDate, created_by: 'lily', }, ], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, ], user: 'bane', }) @@ -220,10 +221,10 @@ describe('utils', () => { transformUpdateCommentsToComments({ comments: [ { comment: 'Im a new comment' }, - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, ], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, ], user: 'lily', }) @@ -236,7 +237,7 @@ describe('utils', () => { expect(() => transformUpdateCommentsToComments({ comments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, { comment: 'Im a new comment' }, ], existingComments: [], @@ -249,11 +250,11 @@ describe('utils', () => { expect(() => transformUpdateCommentsToComments({ comments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, { comment: ' ' }, ], existingComments: [ - { comment: 'Im an old comment', created_at: DATE_NOW, created_by: 'lily' }, + { comment: 'Im an old comment', created_at: oldDate, created_by: 'lily' }, ], user: 'lily', }) @@ -280,12 +281,12 @@ describe('utils', () => { expect(comments).toEqual([ { comment: 'Im a new comment', - created_at: anchor, + created_at: dateNow, created_by: 'lily', }, { comment: 'Im another new comment', - created_at: anchor, + created_at: dateNow, created_by: 'lily', }, ]); @@ -302,16 +303,16 @@ describe('utils', () => { }); describe('#transformUpdateComments', () => { - test('it updates comment and adds "updated_at" and "updated_by"', () => { + test('it updates comment and adds "updated_at" and "updated_by" if content differs', () => { const comments = transformUpdateComments({ comment: { comment: 'Im an old comment that is trying to be updated', - created_at: DATE_NOW, + created_at: oldDate, created_by: 'lily', }, existingComment: { comment: 'Im an old comment', - created_at: DATE_NOW, + created_at: oldDate, created_by: 'lily', }, user: 'lily', @@ -319,24 +320,46 @@ describe('utils', () => { expect(comments).toEqual({ comment: 'Im an old comment that is trying to be updated', - created_at: '2020-04-20T15:25:31.830Z', + created_at: oldDate, created_by: 'lily', - updated_at: anchor, + updated_at: dateNow, updated_by: 'lily', }); }); + test('it does not update comment and add "updated_at" and "updated_by" if content is the same', () => { + const comments = transformUpdateComments({ + comment: { + comment: 'Im an old comment ', + created_at: oldDate, + created_by: 'lily', + }, + existingComment: { + comment: 'Im an old comment', + created_at: oldDate, + created_by: 'lily', + }, + user: 'lily', + }); + + expect(comments).toEqual({ + comment: 'Im an old comment', + created_at: oldDate, + created_by: 'lily', + }); + }); + test('it throws if user tries to update an existing comment that is not their own', () => { expect(() => transformUpdateComments({ comment: { comment: 'Im an old comment that is trying to be updated', - created_at: DATE_NOW, + created_at: oldDate, created_by: 'lily', }, existingComment: { comment: 'Im an old comment', - created_at: DATE_NOW, + created_at: oldDate, created_by: 'lily', }, user: 'bane', @@ -348,13 +371,13 @@ describe('utils', () => { expect(() => transformUpdateComments({ comment: { - comment: 'Im an old comment that is trying to be updated', - created_at: anchor, + comment: 'Im an old comment', + created_at: dateNow, created_by: 'lily', }, existingComment: { comment: 'Im an old comment', - created_at: DATE_NOW, + created_at: oldDate, created_by: 'lily', }, user: 'lily', @@ -368,12 +391,12 @@ describe('utils', () => { const result = isCommentEqual( { comment: 'some old comment', - created_at: DATE_NOW, + created_at: oldDate, created_by: USER, }, { comment: 'some older comment', - created_at: DATE_NOW, + created_at: oldDate, created_by: USER, } ); @@ -385,12 +408,12 @@ describe('utils', () => { const result = isCommentEqual( { comment: 'some old comment', - created_at: DATE_NOW, + created_at: oldDate, created_by: USER, }, { comment: 'some old comment', - created_at: anchor, + created_at: dateNow, created_by: USER, } ); @@ -402,12 +425,12 @@ describe('utils', () => { const result = isCommentEqual( { comment: 'some old comment', - created_at: DATE_NOW, + created_at: oldDate, created_by: USER, }, { comment: 'some old comment', - created_at: DATE_NOW, + created_at: oldDate, created_by: 'lily', } ); @@ -419,11 +442,11 @@ describe('utils', () => { const result = isCommentEqual( { comment: 'some old comment', - created_at: DATE_NOW, + created_at: oldDate, created_by: USER, }, { - created_at: DATE_NOW, + created_at: oldDate, created_by: USER, // Disabling to assure that order doesn't matter // eslint-disable-next-line sort-keys diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts index ad1e1a3439d7c1..3ef2c337e80b66 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts @@ -316,13 +316,15 @@ export const transformUpdateCommentsToComments = ({ 'When trying to update a comment, "created_at" and "created_by" must be present', 403 ); - } else if (commentsSchema.is(c) && existingComment == null) { + } else if (existingComment == null && commentsSchema.is(c)) { throw new ErrorWithStatusCode('Only new comments may be added', 403); } else if ( commentsSchema.is(c) && existingComment != null && - !isCommentEqual(c, existingComment) + isCommentEqual(c, existingComment) ) { + return existingComment; + } else if (commentsSchema.is(c) && existingComment != null) { return transformUpdateComments({ comment: c, existingComment, user }); } else { return transformCreateCommentsToComments({ comments: [c], user }) ?? []; @@ -347,14 +349,17 @@ export const transformUpdateComments = ({ throw new ErrorWithStatusCode('Unable to update comment', 403); } else if (comment.comment.trim().length === 0) { throw new ErrorWithStatusCode('Empty comments not allowed', 403); - } else { + } else if (comment.comment.trim() !== existingComment.comment) { const dateNow = new Date().toISOString(); return { - ...comment, + ...existingComment, + comment: comment.comment, updated_at: dateNow, updated_by: user, }; + } else { + return existingComment; } }; diff --git a/x-pack/plugins/logstash/public/plugin.ts b/x-pack/plugins/logstash/public/plugin.ts index ade6abdb63f431..59f92ee0a7ffca 100644 --- a/x-pack/plugins/logstash/public/plugin.ts +++ b/x-pack/plugins/logstash/public/plugin.ts @@ -14,7 +14,7 @@ import { HomePublicPluginSetup, FeatureCatalogueCategory, } from '../../../../src/plugins/home/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { LicensingPluginSetup } from '../../licensing/public'; // @ts-ignore @@ -35,22 +35,20 @@ export class LogstashPlugin implements Plugin { map((license) => new LogstashLicenseService(license)) ); - const managementApp = plugins.management.sections - .getSection(ManagementSectionId.Ingest) - .registerApp({ - id: 'pipelines', - title: i18n.translate('xpack.logstash.managementSection.pipelinesTitle', { - defaultMessage: 'Logstash Pipelines', - }), - order: 1, - mount: async (params) => { - const [coreStart] = await core.getStartServices(); - const { renderApp } = await import('./application'); - const isMonitoringEnabled = 'monitoring' in plugins; + const managementApp = plugins.management.sections.section.ingest.registerApp({ + id: 'pipelines', + title: i18n.translate('xpack.logstash.managementSection.pipelinesTitle', { + defaultMessage: 'Logstash Pipelines', + }), + order: 1, + mount: async (params) => { + const [coreStart] = await core.getStartServices(); + const { renderApp } = await import('./application'); + const isMonitoringEnabled = 'monitoring' in plugins; - return renderApp(coreStart, params, isMonitoringEnabled, logstashLicense$); - }, - }); + return renderApp(coreStart, params, isMonitoringEnabled, logstashLicense$); + }, + }); this.licenseSubscription = logstashLicense$.subscribe((license: any) => { if (license.enableLinks) { diff --git a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts index 1bd8c5401eb1d0..35b33da12d3846 100644 --- a/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts +++ b/x-pack/plugins/maps/common/descriptor_types/data_request_descriptor_types.ts @@ -5,7 +5,7 @@ */ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { RENDER_AS, SORT_ORDER, SCALING_TYPES, SOURCE_TYPES } from '../constants'; +import { RENDER_AS, SORT_ORDER, SCALING_TYPES } from '../constants'; import { MapExtent, MapQuery } from './map_descriptor'; import { Filter, TimeRange } from '../../../../../src/plugins/data/common'; @@ -26,12 +26,10 @@ type ESSearchSourceSyncMeta = { scalingType: SCALING_TYPES; topHitsSplitField: string; topHitsSize: number; - sourceType: SOURCE_TYPES.ES_SEARCH; }; type ESGeoGridSourceSyncMeta = { requestType: RENDER_AS; - sourceType: SOURCE_TYPES.ES_GEO_GRID; }; export type VectorSourceSyncMeta = ESSearchSourceSyncMeta | ESGeoGridSourceSyncMeta | null; diff --git a/x-pack/plugins/maps/public/api/index.ts b/x-pack/plugins/maps/public/api/index.ts index 8b45d31b41d44f..ec5aa124fb7f9c 100644 --- a/x-pack/plugins/maps/public/api/index.ts +++ b/x-pack/plugins/maps/public/api/index.ts @@ -5,3 +5,5 @@ */ export { MapsStartApi } from './start_api'; +export { createSecurityLayerDescriptors } from './create_security_layer_descriptors'; +export { registerLayerWizard, registerSource } from './register'; diff --git a/x-pack/plugins/maps/public/api/register.ts b/x-pack/plugins/maps/public/api/register.ts new file mode 100644 index 00000000000000..4846b6a198c713 --- /dev/null +++ b/x-pack/plugins/maps/public/api/register.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SourceRegistryEntry } from '../classes/sources/source_registry'; +import { LayerWizard } from '../classes/layers/layer_wizard_registry'; +import { lazyLoadMapModules } from '../lazy_load_bundle'; + +export async function registerLayerWizard(layerWizard: LayerWizard): Promise { + const mapModules = await lazyLoadMapModules(); + return mapModules.registerLayerWizard(layerWizard); +} + +export async function registerSource(entry: SourceRegistryEntry): Promise { + const mapModules = await lazyLoadMapModules(); + return mapModules.registerSource(entry); +} diff --git a/x-pack/plugins/maps/public/api/start_api.ts b/x-pack/plugins/maps/public/api/start_api.ts index d45b0df63c839f..32db3bc771a3b2 100644 --- a/x-pack/plugins/maps/public/api/start_api.ts +++ b/x-pack/plugins/maps/public/api/start_api.ts @@ -5,10 +5,14 @@ */ import { LayerDescriptor } from '../../common/descriptor_types'; +import { SourceRegistryEntry } from '../classes/sources/source_registry'; +import { LayerWizard } from '../classes/layers/layer_wizard_registry'; export interface MapsStartApi { createSecurityLayerDescriptors: ( indexPatternId: string, indexPatternTitle: string ) => Promise; + registerLayerWizard(layerWizard: LayerWizard): Promise; + registerSource(entry: SourceRegistryEntry): Promise; } diff --git a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts index 26a0ffc1b1a37c..5388a82e5924d1 100644 --- a/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts +++ b/x-pack/plugins/maps/public/classes/layers/blended_vector_layer/blended_vector_layer.ts @@ -11,7 +11,6 @@ import { getDefaultDynamicProperties } from '../../styles/vector/vector_style_de import { IDynamicStyleProperty } from '../../styles/vector/properties/dynamic_style_property'; import { IStyleProperty } from '../../styles/vector/properties/style_property'; import { - SOURCE_TYPES, COUNT_PROP_LABEL, COUNT_PROP_NAME, LAYER_TYPE, @@ -41,6 +40,10 @@ import { IVectorSource } from '../../sources/vector_source'; const ACTIVE_COUNT_DATA_ID = 'ACTIVE_COUNT_DATA_ID'; +interface CountData { + isSyncClustered: boolean; +} + function getAggType(dynamicProperty: IDynamicStyleProperty): AGG_TYPE { return dynamicProperty.isOrdinal() ? AGG_TYPE.AVG : AGG_TYPE.TERMS; } @@ -187,14 +190,10 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { this._clusterStyle = new VectorStyle(clusterStyleDescriptor, this._clusterSource, this); let isClustered = false; - const sourceDataRequest = this.getSourceDataRequest(); - if (sourceDataRequest) { - const requestMeta = sourceDataRequest.getMeta(); - if ( - requestMeta && - requestMeta.sourceMeta && - requestMeta.sourceMeta.sourceType === SOURCE_TYPES.ES_GEO_GRID - ) { + const countDataRequest = this.getDataRequest(ACTIVE_COUNT_DATA_ID); + if (countDataRequest) { + const requestData = countDataRequest.getData() as CountData; + if (requestData && requestData.isSyncClustered) { isClustered = true; } } @@ -284,7 +283,8 @@ export class BlendedVectorLayer extends VectorLayer implements IVectorLayer { const resp = await searchSource.fetch(); const maxResultWindow = await this._documentSource.getMaxResultWindow(); isSyncClustered = resp.hits.total > maxResultWindow; - syncContext.stopLoading(dataRequestId, requestToken, { isSyncClustered }, searchFilters); + const countData = { isSyncClustered } as CountData; + syncContext.stopLoading(dataRequestId, requestToken, countData, searchFilters); } catch (error) { if (!(error instanceof DataRequestAbortError)) { syncContext.onLoadError(dataRequestId, requestToken, error.message); diff --git a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js index 1be74140fe1bf2..3902709eeb8414 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_geo_grid_source/es_geo_grid_source.js @@ -63,7 +63,6 @@ export class ESGeoGridSource extends AbstractESAggSource { getSyncMeta() { return { requestType: this._descriptor.requestType, - sourceType: SOURCE_TYPES.ES_GEO_GRID, }; } diff --git a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js index 330fa6e8318ed0..256becf70ffb0c 100644 --- a/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js +++ b/x-pack/plugins/maps/public/classes/sources/es_search_source/es_search_source.js @@ -540,7 +540,6 @@ export class ESSearchSource extends AbstractESSource { scalingType: this._descriptor.scalingType, topHitsSplitField: this._descriptor.topHitsSplitField, topHitsSize: this._descriptor.topHitsSize, - sourceType: SOURCE_TYPES.ES_SEARCH, }; } diff --git a/x-pack/plugins/maps/public/classes/sources/source_registry.ts b/x-pack/plugins/maps/public/classes/sources/source_registry.ts index 3b334d45092ad7..462624dfa6ec94 100644 --- a/x-pack/plugins/maps/public/classes/sources/source_registry.ts +++ b/x-pack/plugins/maps/public/classes/sources/source_registry.ts @@ -7,7 +7,7 @@ import { ISource } from './source'; -type SourceRegistryEntry = { +export type SourceRegistryEntry = { ConstructorFunction: new ( sourceDescriptor: any, // this is the source-descriptor that corresponds specifically to the particular ISource instance inspectorAdapters?: object diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts index ca4098ebfa8053..12d6d75ac57ba0 100644 --- a/x-pack/plugins/maps/public/lazy_load_bundle/index.ts +++ b/x-pack/plugins/maps/public/lazy_load_bundle/index.ts @@ -14,6 +14,8 @@ import { MapStore, MapStoreState } from '../reducers/store'; import { EventHandlers } from '../reducers/non_serializable_instances'; import { RenderToolTipContent } from '../classes/tooltips/tooltip_property'; import { MapEmbeddableConfig, MapEmbeddableInput, MapEmbeddableOutput } from '../embeddable/types'; +import { SourceRegistryEntry } from '../classes/sources/source_registry'; +import { LayerWizard } from '../classes/layers/layer_wizard_registry'; let loadModulesPromise: Promise; @@ -42,6 +44,8 @@ interface LazyLoadedMapModules { indexPatternId: string, indexPatternTitle: string ) => LayerDescriptor[]; + registerLayerWizard(layerWizard: LayerWizard): void; + registerSource(entry: SourceRegistryEntry): void; } export async function lazyLoadMapModules(): Promise { @@ -65,6 +69,8 @@ export async function lazyLoadMapModules(): Promise { // @ts-expect-error renderApp, createSecurityLayerDescriptors, + registerLayerWizard, + registerSource, } = await import('./lazy'); resolve({ @@ -80,6 +86,8 @@ export async function lazyLoadMapModules(): Promise { mergeInputWithSavedMap, renderApp, createSecurityLayerDescriptors, + registerLayerWizard, + registerSource, }); }); return loadModulesPromise; diff --git a/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts b/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts index 4f9f01f8a1b37c..c839122ab90b19 100644 --- a/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts +++ b/x-pack/plugins/maps/public/lazy_load_bundle/lazy/index.ts @@ -19,3 +19,5 @@ export * from '../../embeddable/merge_input_with_saved_map'; // @ts-expect-error export * from '../../routing/maps_router'; export * from '../../classes/layers/solution_layers/security'; +export { registerLayerWizard } from '../../classes/layers/layer_wizard_registry'; +export { registerSource } from '../../classes/sources/source_registry'; diff --git a/x-pack/plugins/maps/public/plugin.ts b/x-pack/plugins/maps/public/plugin.ts index 412e8832453bc6..8428a31d8b408a 100644 --- a/x-pack/plugins/maps/public/plugin.ts +++ b/x-pack/plugins/maps/public/plugin.ts @@ -55,7 +55,7 @@ import { getAppTitle } from '../common/i18n_getters'; import { ILicense } from '../../licensing/common/types'; import { lazyLoadMapModules } from './lazy_load_bundle'; import { MapsStartApi } from './api'; -import { createSecurityLayerDescriptors } from './api/create_security_layer_descriptors'; +import { createSecurityLayerDescriptors, registerLayerWizard, registerSource } from './api'; export interface MapsPluginSetupDependencies { inspector: InspectorSetupContract; @@ -170,6 +170,8 @@ export class MapsPlugin bindStartCoreAndPlugins(core, plugins); return { createSecurityLayerDescriptors, + registerLayerWizard, + registerSource, }; } } diff --git a/x-pack/plugins/ml/common/constants/annotations.ts b/x-pack/plugins/ml/common/constants/annotations.ts index 936ff610361afd..4929dfb28eb154 100644 --- a/x-pack/plugins/ml/common/constants/annotations.ts +++ b/x-pack/plugins/ml/common/constants/annotations.ts @@ -13,3 +13,6 @@ export const ANNOTATION_USER_UNKNOWN = ''; // UI enforced limit to the maximum number of characters that can be entered for an annotation. export const ANNOTATION_MAX_LENGTH_CHARS = 1000; + +export const ANNOTATION_EVENT_USER = 'user'; +export const ANNOTATION_EVENT_DELAYED_DATA = 'delayed_data'; diff --git a/x-pack/plugins/ml/common/constants/anomalies.ts b/x-pack/plugins/ml/common/constants/anomalies.ts index bbf3616c058805..d15033b738b0f5 100644 --- a/x-pack/plugins/ml/common/constants/anomalies.ts +++ b/x-pack/plugins/ml/common/constants/anomalies.ts @@ -20,3 +20,5 @@ export enum ANOMALY_THRESHOLD { WARNING = 3, LOW = 0, } + +export const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const; diff --git a/x-pack/plugins/ml/common/types/annotations.ts b/x-pack/plugins/ml/common/types/annotations.ts index f2f6fe111f5cc5..159a598f16bf55 100644 --- a/x-pack/plugins/ml/common/types/annotations.ts +++ b/x-pack/plugins/ml/common/types/annotations.ts @@ -58,8 +58,20 @@ // ] // } +import { PartitionFieldsType } from './anomalies'; import { ANNOTATION_TYPE } from '../constants/annotations'; +export type AnnotationFieldName = 'partition_field_name' | 'over_field_name' | 'by_field_name'; +export type AnnotationFieldValue = 'partition_field_value' | 'over_field_value' | 'by_field_value'; + +export function getAnnotationFieldName(fieldType: PartitionFieldsType): AnnotationFieldName { + return `${fieldType}_name` as AnnotationFieldName; +} + +export function getAnnotationFieldValue(fieldType: PartitionFieldsType): AnnotationFieldValue { + return `${fieldType}_value` as AnnotationFieldValue; +} + export interface Annotation { _id?: string; create_time?: number; @@ -73,8 +85,15 @@ export interface Annotation { annotation: string; job_id: string; type: ANNOTATION_TYPE.ANNOTATION | ANNOTATION_TYPE.COMMENT; + event?: string; + detector_index?: number; + partition_field_name?: string; + partition_field_value?: string; + over_field_name?: string; + over_field_value?: string; + by_field_name?: string; + by_field_value?: string; } - export function isAnnotation(arg: any): arg is Annotation { return ( arg.timestamp !== undefined && @@ -93,3 +112,27 @@ export function isAnnotations(arg: any): arg is Annotations { } return arg.every((d: Annotation) => isAnnotation(d)); } + +export interface FieldToBucket { + field: string; + missing?: string | number; +} + +export interface FieldToBucketResult { + key: string; + doc_count: number; +} + +export interface TermAggregationResult { + doc_count_error_upper_bound: number; + sum_other_doc_count: number; + buckets: FieldToBucketResult[]; +} + +export type EsAggregationResult = Record; + +export interface GetAnnotationsResponse { + aggregations?: EsAggregationResult; + annotations: Record; + success: boolean; +} diff --git a/x-pack/plugins/ml/common/types/anomalies.ts b/x-pack/plugins/ml/common/types/anomalies.ts index 639d9b3b25faea..a23886e8fcdc67 100644 --- a/x-pack/plugins/ml/common/types/anomalies.ts +++ b/x-pack/plugins/ml/common/types/anomalies.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { PARTITION_FIELDS } from '../constants/anomalies'; + export interface Influencer { influencer_field_name: string; influencer_field_values: string[]; @@ -53,3 +55,5 @@ export interface AnomaliesTableRecord { typicalSort?: any; metricDescriptionSort?: number; } + +export type PartitionFieldsType = typeof PARTITION_FIELDS[number]; diff --git a/x-pack/plugins/ml/common/util/errors.test.ts b/x-pack/plugins/ml/common/util/errors.test.ts index 00af27248ccceb..0b99799e3b6ece 100644 --- a/x-pack/plugins/ml/common/util/errors.test.ts +++ b/x-pack/plugins/ml/common/util/errors.test.ts @@ -30,6 +30,8 @@ describe('ML - error message utils', () => { const bodyWithStringMsg: MLCustomHttpResponseOptions = { body: { msg: testMsg, + statusCode: 404, + response: `{"error":{"reason":"${testMsg}"}}`, }, statusCode: 404, }; diff --git a/x-pack/plugins/ml/common/util/errors.ts b/x-pack/plugins/ml/common/util/errors.ts index e165e15d7c64e9..6c5fa7bd75daf8 100644 --- a/x-pack/plugins/ml/common/util/errors.ts +++ b/x-pack/plugins/ml/common/util/errors.ts @@ -41,7 +41,7 @@ export type MLResponseError = msg: string; }; } - | { msg: string }; + | { msg: string; statusCode: number; response: string }; export interface MLCustomHttpResponseOptions< T extends ResponseError | MLResponseError | BoomResponse @@ -53,42 +53,118 @@ export interface MLCustomHttpResponseOptions< statusCode: number; } -export const extractErrorMessage = ( +export interface MLErrorObject { + message: string; + fullErrorMessage?: string; // For use in a 'See full error' popover. + statusCode?: number; +} + +export const extractErrorProperties = ( error: | MLCustomHttpResponseOptions - | undefined | string -): string => { - // extract only the error message within the response error coming from Kibana, Elasticsearch, and our own ML messages + | undefined +): MLErrorObject => { + // extract properties of the error object from within the response error + // coming from Kibana, Elasticsearch, and our own ML messages + let message = ''; + let fullErrorMessage; + let statusCode; if (typeof error === 'string') { - return error; + return { + message: error, + }; + } + if (error?.body === undefined) { + return { + message: '', + }; } - if (error?.body === undefined) return ''; if (typeof error.body === 'string') { - return error.body; + return { + message: error.body, + }; } if ( typeof error.body === 'object' && 'output' in error.body && error.body.output.payload.message ) { - return error.body.output.payload.message; + return { + message: error.body.output.payload.message, + }; + } + + if ( + typeof error.body === 'object' && + 'response' in error.body && + typeof error.body.response === 'string' + ) { + const errorResponse = JSON.parse(error.body.response); + if ('error' in errorResponse && typeof errorResponse === 'object') { + const errorResponseError = errorResponse.error; + if ('reason' in errorResponseError) { + message = errorResponseError.reason; + } + if ('caused_by' in errorResponseError) { + const causedByMessage = JSON.stringify(errorResponseError.caused_by); + // Only add a fullErrorMessage if different to the message. + if (causedByMessage !== message) { + fullErrorMessage = causedByMessage; + } + } + return { + message, + fullErrorMessage, + statusCode: error.statusCode, + }; + } } if (typeof error.body === 'object' && 'msg' in error.body && typeof error.body.msg === 'string') { - return error.body.msg; + return { + message: error.body.msg, + }; } if (typeof error.body === 'object' && 'message' in error.body) { + if ( + 'attributes' in error.body && + typeof error.body.attributes === 'object' && + error.body.attributes.body?.status !== undefined + ) { + statusCode = error.body.attributes.body?.status; + } + if (typeof error.body.message === 'string') { - return error.body.message; + return { + message: error.body.message, + statusCode, + }; } if (!(error.body.message instanceof Error) && typeof (error.body.message.msg === 'string')) { - return error.body.message.msg; + return { + message: error.body.message.msg, + statusCode, + }; } } + // If all else fail return an empty message instead of JSON.stringify - return ''; + return { + message: '', + }; +}; + +export const extractErrorMessage = ( + error: + | MLCustomHttpResponseOptions + | undefined + | string +): string => { + // extract only the error message within the response error coming from Kibana, Elasticsearch, and our own ML messages + const errorObj = extractErrorProperties(error); + return errorObj.message; }; diff --git a/x-pack/plugins/ml/kibana.json b/x-pack/plugins/ml/kibana.json index a08b9b6d97116a..c61db9fb1ad8da 100644 --- a/x-pack/plugins/ml/kibana.json +++ b/x-pack/plugins/ml/kibana.json @@ -30,7 +30,6 @@ "esUiShared", "kibanaUtils", "kibanaReact", - "management", "dashboard", "savedObjects" ] diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotation_description_list/index.tsx b/x-pack/plugins/ml/public/application/components/annotations/annotation_description_list/index.tsx index cf8fd299c07d70..eee2f8dca244d7 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotation_description_list/index.tsx +++ b/x-pack/plugins/ml/public/application/components/annotations/annotation_description_list/index.tsx @@ -19,9 +19,10 @@ import { formatHumanReadableDateTimeSeconds } from '../../../util/date_utils'; interface Props { annotation: Annotation; + detectorDescription?: string; } -export const AnnotationDescriptionList = ({ annotation }: Props) => { +export const AnnotationDescriptionList = ({ annotation, detectorDescription }: Props) => { const listItems = [ { title: i18n.translate('xpack.ml.timeSeriesExplorer.annotationDescriptionList.jobIdTitle', { @@ -81,6 +82,33 @@ export const AnnotationDescriptionList = ({ annotation }: Props) => { description: annotation.modified_username, }); } + if (detectorDescription !== undefined) { + listItems.push({ + title: i18n.translate('xpack.ml.timeSeriesExplorer.annotationDescriptionList.detectorTitle', { + defaultMessage: 'Detector', + }), + description: detectorDescription, + }); + } + + if (annotation.partition_field_name !== undefined) { + listItems.push({ + title: annotation.partition_field_name, + description: annotation.partition_field_value, + }); + } + if (annotation.over_field_name !== undefined) { + listItems.push({ + title: annotation.over_field_name, + description: annotation.over_field_value, + }); + } + if (annotation.by_field_name !== undefined) { + listItems.push({ + title: annotation.by_field_name, + description: annotation.by_field_value, + }); + } return ( { public state: State = { isDeleteModalVisible: false, + applyAnnotationToSeries: true, }; public annotationSub: Rx.Subscription | null = null; @@ -150,11 +178,31 @@ class AnnotationFlyoutUI extends Component { }; public saveOrUpdateAnnotation = () => { - const { annotation } = this.props; - - if (annotation === null) { + const { annotation: originalAnnotation, chartDetails, detectorIndex } = this.props; + if (originalAnnotation === null) { return; } + const annotation = cloneDeep(originalAnnotation); + + if (this.state.applyAnnotationToSeries && chartDetails?.entityData?.entities) { + chartDetails.entityData.entities.forEach((entity: Entity) => { + const { fieldName, fieldValue } = entity; + const fieldType = entity.fieldType as PartitionFieldsType; + annotation[getAnnotationFieldName(fieldType)] = fieldName; + annotation[getAnnotationFieldValue(fieldType)] = fieldValue; + }); + annotation.detector_index = detectorIndex; + } + // if unchecked, remove all the partitions before indexing + if (!this.state.applyAnnotationToSeries) { + delete annotation.detector_index; + PARTITION_FIELDS.forEach((fieldType) => { + delete annotation[getAnnotationFieldName(fieldType)]; + delete annotation[getAnnotationFieldValue(fieldType)]; + }); + } + // Mark the annotation created by `user` if and only if annotation is being created, not updated + annotation.event = annotation.event ?? ANNOTATION_EVENT_USER; annotation$.next(null); @@ -214,7 +262,7 @@ class AnnotationFlyoutUI extends Component { }; public render(): ReactNode { - const { annotation } = this.props; + const { annotation, detectors, detectorIndex } = this.props; const { isDeleteModalVisible } = this.state; if (annotation === null) { @@ -242,10 +290,13 @@ class AnnotationFlyoutUI extends Component { } ); } + const detector = detectors ? detectors.find((d) => d.index === detectorIndex) : undefined; + const detectorDescription = + detector && 'detector_description' in detector ? detector.detector_description : ''; return ( - +

@@ -264,7 +315,10 @@ class AnnotationFlyoutUI extends Component { - + { value={annotation.annotation} /> + + + } + checked={this.state.applyAnnotationToSeries} + onChange={() => + this.setState({ + applyAnnotationToSeries: !this.state.applyAnnotationToSeries, + }) + } + /> + diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap index 3b93213da40335..63ec1744b62d0b 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/__snapshots__/annotations_table.test.js.snap @@ -11,7 +11,7 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = ` "name": "Annotation", "scope": "row", "sortable": true, - "width": "50%", + "width": "40%", }, Object { "dataType": "date", @@ -39,6 +39,27 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = ` "name": "Last modified by", "sortable": true, }, + Object { + "field": "event", + "name": "Event", + "sortable": true, + "width": "10%", + }, + Object { + "field": "partition_field_value", + "name": "Partition", + "sortable": true, + }, + Object { + "field": "over_field_value", + "name": "Over", + "sortable": true, + }, + Object { + "field": "by_field_value", + "name": "By", + "sortable": true, + }, Object { "actions": Array [ Object { @@ -52,6 +73,12 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = ` "name": "Actions", "width": "60px", }, + Object { + "dataType": "boolean", + "field": "current_series", + "name": "current_series", + "width": "0px", + }, ] } compressed={true} @@ -82,6 +109,24 @@ exports[`AnnotationsTable Initialization with annotations prop. 1`] = ` } responsive={true} rowProps={[Function]} + search={ + Object { + "box": Object { + "incremental": true, + "schema": true, + }, + "defaultQuery": "event:(user or delayed_data)", + "filters": Array [ + Object { + "field": "event", + "multiSelect": "or", + "name": "Event", + "options": Array [], + "type": "field_value_selection", + }, + ], + } + } sorting={ Object { "sort": Object { diff --git a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js index a091da6c359d12..cf4d25f159a1ae 100644 --- a/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js +++ b/x-pack/plugins/ml/public/application/components/annotations/annotations_table/annotations_table.js @@ -9,11 +9,9 @@ * This version supports both fetching the annotations by itself (used in the jobs list) and * getting the annotations via props (used in Anomaly Explorer and Single Series Viewer). */ - import _ from 'lodash'; import PropTypes from 'prop-types'; import rison from 'rison-node'; - import React, { Component, Fragment } from 'react'; import { @@ -50,7 +48,12 @@ import { annotationsRefresh$, annotationsRefreshed, } from '../../../services/annotations_service'; +import { + ANNOTATION_EVENT_USER, + ANNOTATION_EVENT_DELAYED_DATA, +} from '../../../../../common/constants/annotations'; +const CURRENT_SERIES = 'current_series'; /** * Table component for rendering the lists of annotations for an ML job. */ @@ -66,7 +69,10 @@ export class AnnotationsTable extends Component { super(props); this.state = { annotations: [], + aggregations: null, isLoading: false, + queryText: `event:(${ANNOTATION_EVENT_USER} or ${ANNOTATION_EVENT_DELAYED_DATA})`, + searchError: undefined, jobId: Array.isArray(this.props.jobs) && this.props.jobs.length > 0 && @@ -74,6 +80,9 @@ export class AnnotationsTable extends Component { ? this.props.jobs[0].job_id : undefined, }; + this.sorting = { + sort: { field: 'timestamp', direction: 'asc' }, + }; } getAnnotations() { @@ -92,11 +101,18 @@ export class AnnotationsTable extends Component { earliestMs: null, latestMs: null, maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + fields: [ + { + field: 'event', + missing: ANNOTATION_EVENT_USER, + }, + ], }) .toPromise() .then((resp) => { this.setState((prevState, props) => ({ annotations: resp.annotations[props.jobs[0].job_id] || [], + aggregations: resp.aggregations, errorMessage: undefined, isLoading: false, jobId: props.jobs[0].job_id, @@ -114,6 +130,25 @@ export class AnnotationsTable extends Component { } } + getAnnotationsWithExtraInfo(annotations) { + // if there is a specific view/chart entities that the annotations can be scoped to + // add a new column called 'current_series' + if (Array.isArray(this.props.chartDetails?.entityData?.entities)) { + return annotations.map((annotation) => { + const allMatched = this.props.chartDetails?.entityData?.entities.every( + ({ fieldType, fieldValue }) => { + const field = `${fieldType}_value`; + return !(!annotation[field] || annotation[field] !== fieldValue); + } + ); + return { ...annotation, [CURRENT_SERIES]: allMatched }; + }); + } else { + // if not make it return the original annotations + return annotations; + } + } + getJob(jobId) { // check if the job was supplied via props and matches the supplied jobId if (Array.isArray(this.props.jobs) && this.props.jobs.length > 0) { @@ -134,9 +169,9 @@ export class AnnotationsTable extends Component { Array.isArray(this.props.jobs) && this.props.jobs.length > 0 ) { - this.annotationsRefreshSubscription = annotationsRefresh$.subscribe(() => - this.getAnnotations() - ); + this.annotationsRefreshSubscription = annotationsRefresh$.subscribe(() => { + this.getAnnotations(); + }); annotationsRefreshed(); } } @@ -198,9 +233,11 @@ export class AnnotationsTable extends Component { }, }, }; + let mlTimeSeriesExplorer = {}; + const entityCondition = {}; if (annotation.timestamp !== undefined && annotation.end_timestamp !== undefined) { - appState.mlTimeSeriesExplorer = { + mlTimeSeriesExplorer = { zoom: { from: new Date(annotation.timestamp).toISOString(), to: new Date(annotation.end_timestamp).toISOString(), @@ -216,6 +253,27 @@ export class AnnotationsTable extends Component { } } + // if the annotation is at the series level + // then pass the partitioning field(s) and detector index to the Single Metric Viewer + if (_.has(annotation, 'detector_index')) { + mlTimeSeriesExplorer.detector_index = annotation.detector_index; + } + if (_.has(annotation, 'partition_field_value')) { + entityCondition[annotation.partition_field_name] = annotation.partition_field_value; + } + + if (_.has(annotation, 'over_field_value')) { + entityCondition[annotation.over_field_name] = annotation.over_field_value; + } + + if (_.has(annotation, 'by_field_value')) { + // Note that analyses with by and over fields, will have a top-level by_field_name, + // but the by_field_value(s) will be in the nested causes array. + entityCondition[annotation.by_field_name] = annotation.by_field_value; + } + mlTimeSeriesExplorer.entities = entityCondition; + appState.mlTimeSeriesExplorer = mlTimeSeriesExplorer; + const _g = rison.encode(globalSettings); const _a = rison.encode(appState); @@ -251,6 +309,8 @@ export class AnnotationsTable extends Component { render() { const { isSingleMetricViewerLinkVisible = true, isNumberBadgeVisible = false } = this.props; + const { queryText, searchError } = this.state; + if (this.props.annotations === undefined) { if (this.state.isLoading === true) { return ( @@ -314,7 +374,7 @@ export class AnnotationsTable extends Component { defaultMessage: 'Annotation', }), sortable: true, - width: '50%', + width: '40%', scope: 'row', }, { @@ -351,6 +411,14 @@ export class AnnotationsTable extends Component { }), sortable: true, }, + { + field: 'event', + name: i18n.translate('xpack.ml.annotationsTable.eventColumnName', { + defaultMessage: 'Event', + }), + sortable: true, + width: '10%', + }, ]; const jobIds = _.uniq(annotations.map((a) => a.job_id)); @@ -382,22 +450,23 @@ export class AnnotationsTable extends Component { actions.push({ render: (annotation) => { + // find the original annotation because the table might not show everything + const annotationId = annotation._id; + const originalAnnotation = annotations.find((d) => d._id === annotationId); const editAnnotationsTooltipText = ( ); - const editAnnotationsTooltipAriaLabelText = ( - + const editAnnotationsTooltipAriaLabelText = i18n.translate( + 'xpack.ml.annotationsTable.editAnnotationsTooltipAriaLabel', + { defaultMessage: 'Edit annotation' } ); return ( annotation$.next(annotation)} + onClick={() => annotation$.next(originalAnnotation ?? annotation)} iconType="pencil" aria-label={editAnnotationsTooltipAriaLabelText} /> @@ -421,17 +490,14 @@ export class AnnotationsTable extends Component { defaultMessage="Job configuration not supported in Single Metric Viewer" /> ); - const openInSingleMetricViewerAriaLabelText = isDrillDownAvailable ? ( - - ) : ( - - ); + const openInSingleMetricViewerAriaLabelText = isDrillDownAvailable + ? i18n.translate('xpack.ml.annotationsTable.openInSingleMetricViewerAriaLabel', { + defaultMessage: 'Open in Single Metric Viewer', + }) + : i18n.translate( + 'xpack.ml.annotationsTable.jobConfigurationNotSupportedInSingleMetricViewerAriaLabel', + { defaultMessage: 'Job configuration not supported in Single Metric Viewer' } + ); return ( @@ -447,38 +513,152 @@ export class AnnotationsTable extends Component { }); } - columns.push({ - align: RIGHT_ALIGNMENT, - width: '60px', - name: i18n.translate('xpack.ml.annotationsTable.actionsColumnName', { - defaultMessage: 'Actions', - }), - actions, - }); - const getRowProps = (item) => { return { onMouseOver: () => this.onMouseOverRow(item), onMouseLeave: () => this.onMouseLeaveRow(), }; }; + let filterOptions = []; + const aggregations = this.props.aggregations ?? this.state.aggregations; + if (aggregations) { + const buckets = aggregations.event.buckets; + const foundUser = buckets.findIndex((d) => d.key === ANNOTATION_EVENT_USER) > -1; + filterOptions = foundUser + ? buckets + : [{ key: ANNOTATION_EVENT_USER, doc_count: 0 }, ...buckets]; + } + const filters = [ + { + type: 'field_value_selection', + field: 'event', + name: 'Event', + multiSelect: 'or', + options: filterOptions.map((field) => ({ + value: field.key, + name: field.key, + view: `${field.key} (${field.doc_count})`, + })), + }, + ]; + + if (this.props.detectors) { + columns.push({ + name: i18n.translate('xpack.ml.annotationsTable.detectorColumnName', { + defaultMessage: 'Detector', + }), + width: '10%', + render: (item) => { + if ('detector_index' in item) { + return this.props.detectors[item.detector_index].detector_description; + } + return ''; + }, + }); + } + if (Array.isArray(this.props.chartDetails?.entityData?.entities)) { + // only show the column if the field exists in that job in SMV + this.props.chartDetails?.entityData?.entities.forEach((entity) => { + if (entity.fieldType === 'partition_field') { + columns.push({ + field: 'partition_field_value', + name: i18n.translate('xpack.ml.annotationsTable.partitionSMVColumnName', { + defaultMessage: 'Partition', + }), + sortable: true, + }); + } + if (entity.fieldType === 'over_field') { + columns.push({ + field: 'over_field_value', + name: i18n.translate('xpack.ml.annotationsTable.overColumnSMVName', { + defaultMessage: 'Over', + }), + sortable: true, + }); + } + if (entity.fieldType === 'by_field') { + columns.push({ + field: 'by_field_value', + name: i18n.translate('xpack.ml.annotationsTable.byColumnSMVName', { + defaultMessage: 'By', + }), + sortable: true, + }); + } + }); + filters.push({ + type: 'is', + field: CURRENT_SERIES, + name: i18n.translate('xpack.ml.annotationsTable.seriesOnlyFilterName', { + defaultMessage: 'Filter to series', + }), + }); + } else { + // else show all the partition columns in AE because there might be multiple jobs + columns.push({ + field: 'partition_field_value', + name: i18n.translate('xpack.ml.annotationsTable.partitionAEColumnName', { + defaultMessage: 'Partition', + }), + sortable: true, + }); + columns.push({ + field: 'over_field_value', + name: i18n.translate('xpack.ml.annotationsTable.overAEColumnName', { + defaultMessage: 'Over', + }), + sortable: true, + }); + + columns.push({ + field: 'by_field_value', + name: i18n.translate('xpack.ml.annotationsTable.byAEColumnName', { + defaultMessage: 'By', + }), + sortable: true, + }); + } + const search = { + defaultQuery: queryText, + box: { + incremental: true, + schema: true, + }, + filters: filters, + }; + + columns.push( + { + align: RIGHT_ALIGNMENT, + width: '60px', + name: i18n.translate('xpack.ml.annotationsTable.actionsColumnName', { + defaultMessage: 'Actions', + }), + actions, + }, + { + // hidden column, for search only + field: CURRENT_SERIES, + name: CURRENT_SERIES, + dataType: 'boolean', + width: '0px', + } + ); return ( diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx index 8d6272c5df8600..6b745a2c5ff3be 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/action_delete.test.tsx @@ -31,7 +31,13 @@ jest.mock('../../../../../contexts/kibana', () => ({ useMlKibana: () => ({ services: mockCoreServices.createStart(), }), + useNotifications: () => { + return { + toasts: { addSuccess: jest.fn(), addDanger: jest.fn(), addError: jest.fn() }, + }; + }, })); + export const MockI18nService = i18nServiceMock.create(); export const I18nServiceConstructor = jest.fn().mockImplementation(() => MockI18nService); jest.doMock('@kbn/i18n', () => ({ diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts index f924cf3afcba53..4fc7b5e1367c4f 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_delete/use_delete_action.ts @@ -13,6 +13,7 @@ import { IIndexPattern } from 'src/plugins/data/common'; import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { useMlKibana } from '../../../../../contexts/kibana'; +import { useToastNotificationService } from '../../../../../services/toast_notification_service'; import { deleteAnalytics, @@ -37,6 +38,8 @@ export const useDeleteAction = () => { const indexName = item?.config.dest.index ?? ''; + const toastNotificationService = useToastNotificationService(); + const checkIndexPatternExists = async () => { try { const response = await savedObjectsClient.find({ @@ -109,10 +112,11 @@ export const useDeleteAction = () => { deleteAnalyticsAndDestIndex( item, deleteTargetIndex, - indexPatternExists && deleteIndexPattern + indexPatternExists && deleteIndexPattern, + toastNotificationService ); } else { - deleteAnalytics(item); + deleteAnalytics(item, toastNotificationService); } } }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx index 4b708d48ca0eca..86b1c879417bbf 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_edit/edit_button_flyout.tsx @@ -28,11 +28,11 @@ import { import { useMlKibana } from '../../../../../contexts/kibana'; import { ml } from '../../../../../services/ml_api_service'; +import { useToastNotificationService } from '../../../../../services/toast_notification_service'; import { memoryInputValidator, MemoryInputValidatorResult, } from '../../../../../../../common/util/validators'; -import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { DATA_FRAME_TASK_STATE } from '../analytics_list/common'; import { useRefreshAnalyticsList, @@ -60,6 +60,8 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } } = useMlKibana(); const { refresh } = useRefreshAnalyticsList(); + const toastNotificationService = useToastNotificationService(); + // Disable if mml is not valid const updateButtonDisabled = mmlValidationError !== undefined || maxNumThreads === 0; @@ -113,15 +115,15 @@ export const EditButtonFlyout: FC> = ({ closeFlyout, item } // eslint-disable-next-line console.error(e); - notifications.toasts.addDanger({ - title: i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutErrorMessage', { + toastNotificationService.displayErrorToast( + e, + i18n.translate('xpack.ml.dataframe.analyticsList.editFlyoutErrorMessage', { defaultMessage: 'Could not save changes to analytics job {jobId}', values: { jobId, }, - }), - text: extractErrorMessage(e), - }); + }) + ); } }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/use_start_action.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/use_start_action.ts index 8eb6b990827ace..3c1087ff587d8d 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/use_start_action.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/action_start/use_start_action.ts @@ -8,6 +8,7 @@ import { useState } from 'react'; import { DataFrameAnalyticsListRow } from '../analytics_list/common'; import { startAnalytics } from '../../services/analytics_service'; +import { useToastNotificationService } from '../../../../../services/toast_notification_service'; export type StartAction = ReturnType; export const useStartAction = () => { @@ -15,11 +16,13 @@ export const useStartAction = () => { const [item, setItem] = useState(); + const toastNotificationService = useToastNotificationService(); + const closeModal = () => setModalVisible(false); const startAndCloseModal = () => { if (item !== undefined) { setModalVisible(false); - startAnalytics(item); + startAnalytics(item, toastNotificationService); } }; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts index ebd3fa8982604c..7d3ee986a4ef1b 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/delete_analytics.ts @@ -7,13 +7,17 @@ import { i18n } from '@kbn/i18n'; import { extractErrorMessage } from '../../../../../../../common/util/errors'; import { getToastNotifications } from '../../../../../util/dependency_cache'; import { ml } from '../../../../../services/ml_api_service'; +import { ToastNotificationService } from '../../../../../services/toast_notification_service'; import { refreshAnalyticsList$, REFRESH_ANALYTICS_LIST_STATE } from '../../../../common'; import { isDataFrameAnalyticsFailed, DataFrameAnalyticsListRow, } from '../../components/analytics_list/common'; -export const deleteAnalytics = async (d: DataFrameAnalyticsListRow) => { +export const deleteAnalytics = async ( + d: DataFrameAnalyticsListRow, + toastNotificationService: ToastNotificationService +) => { const toastNotifications = getToastNotifications(); try { if (isDataFrameAnalyticsFailed(d.stats.state)) { @@ -27,13 +31,11 @@ export const deleteAnalytics = async (d: DataFrameAnalyticsListRow) => { }) ); } catch (e) { - const error = extractErrorMessage(e); - - toastNotifications.addDanger( + toastNotificationService.displayErrorToast( + e, i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', { - defaultMessage: - 'An error occurred deleting the data frame analytics job {analyticsId}: {error}', - values: { analyticsId: d.config.id, error }, + defaultMessage: 'An error occurred deleting the data frame analytics job {analyticsId}', + values: { analyticsId: d.config.id }, }) ); } @@ -43,7 +45,8 @@ export const deleteAnalytics = async (d: DataFrameAnalyticsListRow) => { export const deleteAnalyticsAndDestIndex = async ( d: DataFrameAnalyticsListRow, deleteDestIndex: boolean, - deleteDestIndexPattern: boolean + deleteDestIndexPattern: boolean, + toastNotificationService: ToastNotificationService ) => { const toastNotifications = getToastNotifications(); const destinationIndex = Array.isArray(d.config.dest.index) @@ -67,12 +70,11 @@ export const deleteAnalyticsAndDestIndex = async ( ); } if (status.analyticsJobDeleted?.error) { - const error = extractErrorMessage(status.analyticsJobDeleted.error); - toastNotifications.addDanger( + toastNotificationService.displayErrorToast( + status.analyticsJobDeleted.error, i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', { - defaultMessage: - 'An error occurred deleting the data frame analytics job {analyticsId}: {error}', - values: { analyticsId: d.config.id, error }, + defaultMessage: 'An error occurred deleting the data frame analytics job {analyticsId}', + values: { analyticsId: d.config.id }, }) ); } @@ -120,13 +122,11 @@ export const deleteAnalyticsAndDestIndex = async ( ); } } catch (e) { - const error = extractErrorMessage(e); - - toastNotifications.addDanger( + toastNotificationService.displayErrorToast( + e, i18n.translate('xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage', { - defaultMessage: - 'An error occurred deleting the data frame analytics job {analyticsId}: {error}', - values: { analyticsId: d.config.id, error }, + defaultMessage: 'An error occurred deleting the data frame analytics job {analyticsId}', + values: { analyticsId: d.config.id }, }) ); } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/start_analytics.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/start_analytics.ts index 6513cad808485d..dfaac8f391f3ca 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/start_analytics.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/services/analytics_service/start_analytics.ts @@ -5,29 +5,30 @@ */ import { i18n } from '@kbn/i18n'; -import { getToastNotifications } from '../../../../../util/dependency_cache'; import { ml } from '../../../../../services/ml_api_service'; +import { ToastNotificationService } from '../../../../../services/toast_notification_service'; import { refreshAnalyticsList$, REFRESH_ANALYTICS_LIST_STATE } from '../../../../common'; import { DataFrameAnalyticsListRow } from '../../components/analytics_list/common'; -export const startAnalytics = async (d: DataFrameAnalyticsListRow) => { - const toastNotifications = getToastNotifications(); +export const startAnalytics = async ( + d: DataFrameAnalyticsListRow, + toastNotificationService: ToastNotificationService +) => { try { await ml.dataFrameAnalytics.startDataFrameAnalytics(d.config.id); - toastNotifications.addSuccess( + toastNotificationService.displaySuccessToast( i18n.translate('xpack.ml.dataframe.analyticsList.startAnalyticsSuccessMessage', { defaultMessage: 'Request to start data frame analytics {analyticsId} acknowledged.', values: { analyticsId: d.config.id }, }) ); } catch (e) { - toastNotifications.addDanger( - i18n.translate('xpack.ml.dataframe.analyticsList.startAnalyticsErrorMessage', { - defaultMessage: - 'An error occurred starting the data frame analytics {analyticsId}: {error}', - values: { analyticsId: d.config.id, error: JSON.stringify(e) }, + toastNotificationService.displayErrorToast( + e, + i18n.translate('xpack.ml.dataframe.analyticsList.startAnalyticsErrorTitle', { + defaultMessage: 'Error starting job', }) ); } diff --git a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts index 095b42ffac5b78..3fcb032bd3ce16 100644 --- a/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts +++ b/x-pack/plugins/ml/public/application/explorer/actions/load_explorer_data.ts @@ -258,7 +258,7 @@ const loadExplorerDataProvider = (anomalyTimelineService: AnomalyTimelineService { influencers, viewBySwimlaneState } ): Partial => { return { - annotationsData, + annotations: annotationsData, influencers, loading: false, viewBySwimlaneDataLoading: false, diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.d.ts b/x-pack/plugins/ml/public/application/explorer/explorer.d.ts index 90fb46d3cec4ae..52181aab403283 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.d.ts +++ b/x-pack/plugins/ml/public/application/explorer/explorer.d.ts @@ -5,11 +5,6 @@ */ import { FC } from 'react'; - -import { UrlState } from '../util/url_state'; - -import { JobSelection } from '../components/job_selector/use_job_selection'; - import { ExplorerState } from './reducers'; import { AppStateSelectedCells } from './explorer_utils'; diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index df4cea0c079874..4e27c176315060 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -14,6 +14,7 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { + htmlIdGenerator, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -26,6 +27,9 @@ import { EuiSpacer, EuiTitle, EuiLoadingContent, + EuiPanel, + EuiAccordion, + EuiBadge, } from '@elastic/eui'; import { AnnotationFlyout } from '../components/annotations/annotation_flyout'; @@ -138,6 +142,7 @@ export class Explorer extends React.Component { }; state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG }; + htmlIdGen = htmlIdGenerator(); // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes // and will cause a syntax error when called with getKqlQueryValues @@ -202,7 +207,7 @@ export class Explorer extends React.Component { const { showCharts, severity } = this.props; const { - annotationsData, + annotations, chartsData, filterActive, filterPlaceHolder, @@ -216,6 +221,7 @@ export class Explorer extends React.Component { selectedJobs, tableData, } = this.props.explorerState; + const { annotationsData, aggregations } = annotations; const jobSelectorProps = { dateFormatTz: getDateFormatTz(), @@ -239,13 +245,12 @@ export class Explorer extends React.Component { ); } - const mainColumnWidthClassName = noInfluencersConfigured === true ? 'col-xs-12' : 'col-xs-10'; const mainColumnClasses = `column ${mainColumnWidthClassName}`; const timefilter = getTimefilter(); const bounds = timefilter.getActiveBounds(); - + const selectedJobIds = Array.isArray(selectedJobs) ? selectedJobs.map((job) => job.id) : []; return ( - {annotationsData.length > 0 && ( <> - -

- -

-
- + + +

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

+ + } + > + <> + + +
+
- + )} - {loading === false && ( - <> +

+ )} + +
{showCharts && }
+ - +
)} diff --git a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js index 23da9669ee9a59..6e0863f1a6e5bc 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer_utils.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer_utils.js @@ -34,6 +34,7 @@ import { SWIMLANE_TYPE, VIEW_BY_JOB_LABEL, } from './explorer_constants'; +import { ANNOTATION_EVENT_USER } from '../../../common/constants/annotations'; // create new job objects based on standard job config objects // new job objects just contain job id, bucket span in seconds and a selected flag. @@ -395,6 +396,12 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, earliestMs: timeRange.earliestMs, latestMs: timeRange.latestMs, maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + fields: [ + { + field: 'event', + missing: ANNOTATION_EVENT_USER, + }, + ], }) .toPromise() .then((resp) => { @@ -410,16 +417,17 @@ export function loadAnnotationsTableData(selectedCells, selectedJobs, interval, } }); - return resolve( - annotationsData + return resolve({ + annotationsData: annotationsData .sort((a, b) => { return a.timestamp - b.timestamp; }) .map((d, i) => { d.key = String.fromCharCode(65 + i); return d; - }) - ); + }), + aggregations: resp.aggregations, + }); }) .catch((resp) => { console.log('Error loading list of annotations for jobs list:', resp); diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts index c55c06c80ab81a..a38044a8b34254 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/reducer.ts @@ -113,7 +113,7 @@ export const explorerReducer = (state: ExplorerState, nextAction: Action): Explo const { annotationsData, overallState, tableData } = payload; nextState = { ...state, - annotationsData, + annotations: annotationsData, overallSwimlaneData: overallState, tableData, viewBySwimlaneData: { diff --git a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts index 892b46467345b3..889d572f4fabcc 100644 --- a/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts +++ b/x-pack/plugins/ml/public/application/explorer/reducers/explorer_reducer/state.ts @@ -21,10 +21,14 @@ import { SwimlaneData, ViewBySwimLaneData, } from '../../explorer_utils'; +import { Annotations, EsAggregationResult } from '../../../../../common/types/annotations'; import { SWIM_LANE_DEFAULT_PAGE_SIZE } from '../../explorer_constants'; export interface ExplorerState { - annotationsData: any[]; + annotations: { + annotationsData: Annotations; + aggregations: EsAggregationResult; + }; bounds: TimeRangeBounds | undefined; chartsData: ExplorerChartsData; fieldFormatsLoading: boolean; @@ -62,7 +66,10 @@ function getDefaultIndexPattern() { export function getExplorerDefaultState(): ExplorerState { return { - annotationsData: [], + annotations: { + annotationsData: [], + aggregations: {}, + }, bounds: undefined, chartsData: getDefaultChartsData(), fieldFormatsLoading: false, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js index 3508d69ee2212c..9d0082ffcb5682 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/edit_job_flyout/edit_job_flyout.js @@ -26,7 +26,7 @@ import { JobDetails, Detectors, Datafeed, CustomUrls } from './tabs'; import { saveJob } from './edit_utils'; import { loadFullJob } from '../utils'; import { validateModelMemoryLimit, validateGroupNames, isValidCustomUrls } from '../validate_job'; -import { mlMessageBarService } from '../../../../components/messagebar'; +import { toastNotificationServiceProvider } from '../../../../services/toast_notification_service'; import { withKibana } from '../../../../../../../../../src/plugins/kibana_react/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; @@ -255,6 +255,8 @@ export class EditJobFlyoutUI extends Component { }; const { toasts } = this.props.kibana.services.notifications; + const toastNotificationService = toastNotificationServiceProvider(toasts); + saveJob(this.state.job, newJobData) .then(() => { toasts.addSuccess( @@ -270,7 +272,8 @@ export class EditJobFlyoutUI extends Component { }) .catch((error) => { console.error(error); - toasts.addDanger( + toastNotificationService.displayErrorToast( + error, i18n.translate('xpack.ml.jobsList.editJobFlyout.changesNotSavedNotificationMessage', { defaultMessage: 'Could not save changes to {jobId}', values: { @@ -278,7 +281,6 @@ export class EditJobFlyoutUI extends Component { }, }) ); - mlMessageBarService.notify.error(error); }); }; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js index 569eca4aba949c..6fabd0299a936f 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js @@ -9,6 +9,7 @@ import { mlMessageBarService } from '../../../components/messagebar'; import rison from 'rison-node'; import { mlJobService } from '../../../services/job_service'; +import { toastNotificationServiceProvider } from '../../../services/toast_notification_service'; import { ml } from '../../../services/ml_api_service'; import { getToastNotifications } from '../../../util/dependency_cache'; import { stringMatch } from '../../../util/string_utils'; @@ -158,8 +159,9 @@ function showResults(resp, action) { if (failures.length > 0) { failures.forEach((f) => { - mlMessageBarService.notify.error(f.result.error); - toastNotifications.addDanger( + const toastNotificationService = toastNotificationServiceProvider(toastNotifications); + toastNotificationService.displayErrorToast( + f.result.error, i18n.translate('xpack.ml.jobsList.actionFailedNotificationMessage', { defaultMessage: '{failureId} failed to {actionText}', values: { diff --git a/x-pack/plugins/ml/public/application/management/index.ts b/x-pack/plugins/ml/public/application/management/index.ts index 480e2fe4889800..897731304ee7a4 100644 --- a/x-pack/plugins/ml/public/application/management/index.ts +++ b/x-pack/plugins/ml/public/application/management/index.ts @@ -16,10 +16,7 @@ import { take } from 'rxjs/operators'; import { CoreSetup } from 'kibana/public'; import { MlStartDependencies, MlSetupDependencies } from '../../plugin'; -import { - ManagementAppMountParams, - ManagementSectionId, -} from '../../../../../../src/plugins/management/public'; +import { ManagementAppMountParams } from '../../../../../../src/plugins/management/public'; import { PLUGIN_ID } from '../../../common/constants/app'; import { MINIMUM_FULL_LICENSE } from '../../../common/license'; @@ -34,7 +31,7 @@ export function initManagementSection( management !== undefined && license.check(PLUGIN_ID, MINIMUM_FULL_LICENSE).state === 'valid' ) { - management.sections.getSection(ManagementSectionId.InsightsAndAlerting).registerApp({ + management.sections.section.insightsAndAlerting.registerApp({ id: 'jobsListLink', title: i18n.translate('xpack.ml.management.jobsListTitle', { defaultMessage: 'Machine Learning Jobs', diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 5c22a440a103ee..7d09797a0ff1b4 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -157,7 +157,6 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim }, [explorerAppState]); const explorerState = useObservable(explorerService.state$); - const [showCharts] = useShowCharts(); const [tableInterval] = useTableInterval(); const [tableSeverity] = useTableSeverity(); diff --git a/x-pack/plugins/ml/public/application/services/job_service.js b/x-pack/plugins/ml/public/application/services/job_service.js index 6c0f393c267aa1..7e90758ffd7db0 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -11,10 +11,12 @@ import { i18n } from '@kbn/i18n'; import { ml } from './ml_api_service'; import { mlMessageBarService } from '../components/messagebar'; +import { getToastNotifications } from '../util/dependency_cache'; import { isWebUrl } from '../util/url_utils'; import { ML_DATA_PREVIEW_COUNT } from '../../../common/util/job_utils'; import { TIME_FORMAT } from '../../../common/constants/time_format'; import { parseInterval } from '../../../common/util/parse_interval'; +import { toastNotificationServiceProvider } from '../services/toast_notification_service'; const msgs = mlMessageBarService; let jobs = []; @@ -417,14 +419,21 @@ class JobService { return { success: true }; }) .catch((err) => { - msgs.notify.error( - i18n.translate('xpack.ml.jobService.couldNotUpdateJobErrorMessage', { + // TODO - all the functions in here should just return the error and not + // display the toast, as currently both the component and this service display + // errors, so we end up with duplicate toasts. + const toastNotifications = getToastNotifications(); + const toastNotificationService = toastNotificationServiceProvider(toastNotifications); + toastNotificationService.displayErrorToast( + err, + i18n.translate('xpack.ml.jobService.updateJobErrorTitle', { defaultMessage: 'Could not update job: {jobId}', values: { jobId }, }) ); + console.error('update job', err); - return { success: false, message: err.message }; + return { success: false, message: err }; }); } @@ -436,12 +445,15 @@ class JobService { return { success: true, messages }; }) .catch((err) => { - msgs.notify.error( - i18n.translate('xpack.ml.jobService.jobValidationErrorMessage', { - defaultMessage: 'Job Validation Error: {errorMessage}', - values: { errorMessage: err.message }, + const toastNotifications = getToastNotifications(); + const toastNotificationService = toastNotificationServiceProvider(toastNotifications); + toastNotificationService.displayErrorToast( + err, + i18n.translate('xpack.ml.jobService.validateJobErrorTitle', { + defaultMessage: 'Job Validation Error', }) ); + console.log('validate job', err); return { success: false, diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts index 29a57320267613..f9e19ba6f757ea 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/annotations.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Annotation } from '../../../../common/types/annotations'; +import { + Annotation, + FieldToBucket, + GetAnnotationsResponse, +} from '../../../../common/types/annotations'; import { http, http$ } from '../http_service'; import { basePath } from './index'; @@ -14,15 +18,19 @@ export const annotations = { earliestMs: number; latestMs: number; maxAnnotations: number; + fields: FieldToBucket[]; + detectorIndex: number; + entities: any[]; }) { const body = JSON.stringify(obj); - return http$<{ annotations: Record }>({ + return http$({ path: `${basePath()}/annotations`, method: 'POST', body, }); }, - indexAnnotation(obj: any) { + + indexAnnotation(obj: Annotation) { const body = JSON.stringify(obj); return http({ path: `${basePath()}/annotations/index`, diff --git a/x-pack/plugins/ml/public/application/services/toast_notification_service.ts b/x-pack/plugins/ml/public/application/services/toast_notification_service.ts new file mode 100644 index 00000000000000..d93d6833c7cb41 --- /dev/null +++ b/x-pack/plugins/ml/public/application/services/toast_notification_service.ts @@ -0,0 +1,84 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ToastInput, ToastOptions, ToastsStart } from 'kibana/public'; +import { ResponseError } from 'kibana/server'; +import { useMemo } from 'react'; +import { useNotifications } from '../contexts/kibana'; +import { + BoomResponse, + extractErrorProperties, + MLCustomHttpResponseOptions, + MLErrorObject, + MLResponseError, +} from '../../../common/util/errors'; + +export type ToastNotificationService = ReturnType; + +export function toastNotificationServiceProvider(toastNotifications: ToastsStart) { + return { + displaySuccessToast(toastOrTitle: ToastInput, options?: ToastOptions) { + toastNotifications.addSuccess(toastOrTitle, options); + }, + + displayErrorToast(error: any, toastTitle: string) { + const errorObj = this.parseErrorMessage(error); + if (errorObj.fullErrorMessage !== undefined) { + // Provide access to the full error message via the 'See full error' button. + toastNotifications.addError(new Error(errorObj.fullErrorMessage), { + title: toastTitle, + toastMessage: errorObj.message, + }); + } else { + toastNotifications.addDanger( + { + title: toastTitle, + text: errorObj.message, + }, + { toastLifeTimeMs: 30000 } + ); + } + }, + + parseErrorMessage( + error: + | MLCustomHttpResponseOptions + | undefined + | string + | MLResponseError + ): MLErrorObject { + if ( + typeof error === 'object' && + 'response' in error && + typeof error.response === 'string' && + error.statusCode !== undefined + ) { + // MLResponseError which has been received back as part of a 'successful' response + // where the error was passed in a separate property in the response. + const wrapMlResponseError = { + body: error, + statusCode: error.statusCode, + }; + return extractErrorProperties(wrapMlResponseError); + } + + return extractErrorProperties( + error as + | MLCustomHttpResponseOptions + | undefined + | string + ); + }, + }; +} + +/** + * Hook to use {@link ToastNotificationService} in React components. + */ +export function useToastNotificationService(): ToastNotificationService { + const { toasts } = useNotifications(); + return useMemo(() => toastNotificationServiceProvider(toasts), []); +} diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index d4470e7502e0df..95dc1ed6988f6c 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -28,6 +28,8 @@ import { EuiSelect, EuiSpacer, EuiTitle, + EuiAccordion, + EuiBadge, } from '@elastic/eui'; import { getToastNotifications } from '../util/dependency_cache'; @@ -125,6 +127,8 @@ function getTimeseriesexplorerDefaultState() { entitiesLoading: false, entityValues: {}, focusAnnotationData: [], + focusAggregations: {}, + focusAggregationInterval: {}, focusChartData: undefined, focusForecastData: undefined, fullRefresh: true, @@ -1025,6 +1029,7 @@ export class TimeSeriesExplorer extends React.Component { entityValues, focusAggregationInterval, focusAnnotationData, + focusAggregations, focusChartData, focusForecastData, fullRefresh, @@ -1075,8 +1080,8 @@ export class TimeSeriesExplorer extends React.Component { const entityControls = this.getControlsForDetector(); const fieldNamesWithEmptyValues = this.getFieldNamesWithEmptyValues(); const arePartitioningFieldsProvided = this.arePartitioningFieldsProvided(); - - const detectorSelectOptions = getViewableDetectors(selectedJob).map((d) => ({ + const detectors = getViewableDetectors(selectedJob); + const detectorSelectOptions = detectors.map((d) => ({ value: d.index, text: d.detector_description, })); @@ -1311,25 +1316,49 @@ export class TimeSeriesExplorer extends React.Component { )} - {showAnnotations && focusAnnotationData.length > 0 && ( -
- -

- -

-
+ {focusAnnotationData && focusAnnotationData.length > 0 && ( + +

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

+ + } + > -
+ )} - +

number; @@ -37,6 +38,7 @@ export interface FocusData { showForecastCheckbox?: any; focusAnnotationData?: any; focusForecastData?: any; + focusAggregations?: any; } export function getFocusData( @@ -84,11 +86,23 @@ export function getFocusData( earliestMs: searchBounds.min.valueOf(), latestMs: searchBounds.max.valueOf(), maxAnnotations: ANNOTATIONS_TABLE_DEFAULT_QUERY_SIZE, + fields: [ + { + field: 'event', + missing: ANNOTATION_EVENT_USER, + }, + ], + detectorIndex, + entities: nonBlankEntities, }) .pipe( catchError(() => { // silent fail - return of({ annotations: {} as Record }); + return of({ + annotations: {} as Record, + aggregations: {}, + success: false, + }); }) ), // Plus query for forecast data if there is a forecastId stored in the appState. @@ -146,13 +160,14 @@ export function getFocusData( d.key = String.fromCharCode(65 + i); return d; }); + + refreshFocusData.focusAggregations = annotations.aggregations; } if (forecastData) { refreshFocusData.focusForecastData = processForecastResults(forecastData.results); refreshFocusData.showForecastCheckbox = refreshFocusData.focusForecastData.length > 0; } - return refreshFocusData; }) ); diff --git a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts index c2582107062bb0..f7353034b74531 100644 --- a/x-pack/plugins/ml/server/models/annotation_service/annotation.ts +++ b/x-pack/plugins/ml/server/models/annotation_service/annotation.ts @@ -8,7 +8,8 @@ import Boom from 'boom'; import _ from 'lodash'; import { ILegacyScopedClusterClient } from 'kibana/server'; -import { ANNOTATION_TYPE } from '../../../common/constants/annotations'; +import { ANNOTATION_EVENT_USER, ANNOTATION_TYPE } from '../../../common/constants/annotations'; +import { PARTITION_FIELDS } from '../../../common/constants/anomalies'; import { ML_ANNOTATIONS_INDEX_ALIAS_READ, ML_ANNOTATIONS_INDEX_ALIAS_WRITE, @@ -19,20 +20,35 @@ import { Annotations, isAnnotation, isAnnotations, + getAnnotationFieldName, + getAnnotationFieldValue, + EsAggregationResult, } from '../../../common/types/annotations'; // TODO All of the following interface/type definitions should // eventually be replaced by the proper upstream definitions interface EsResult { - _source: object; + _source: Annotation; _id: string; } +export interface FieldToBucket { + field: string; + missing?: string | number; +} + export interface IndexAnnotationArgs { jobIds: string[]; earliestMs: number; latestMs: number; maxAnnotations: number; + fields?: FieldToBucket[]; + detectorIndex?: number; + entities?: any[]; +} + +export interface AggTerm { + terms: FieldToBucket; } export interface GetParams { @@ -43,9 +59,8 @@ export interface GetParams { export interface GetResponse { success: true; - annotations: { - [key: string]: Annotations; - }; + annotations: Record; + aggregations: EsAggregationResult; } export interface IndexParams { @@ -96,10 +111,14 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl earliestMs, latestMs, maxAnnotations, + fields, + detectorIndex, + entities, }: IndexAnnotationArgs) { const obj: GetResponse = { success: true, annotations: {}, + aggregations: {}, }; const boolCriteria: object[] = []; @@ -182,6 +201,64 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl }); } + // Find unique buckets (e.g. events) from the queried annotations to show in dropdowns + const aggs: Record = {}; + if (fields) { + fields.forEach((fieldToBucket) => { + aggs[fieldToBucket.field] = { + terms: { + ...fieldToBucket, + }, + }; + }); + } + + // Build should clause to further query for annotations in SMV + // we want to show either the exact match with detector index and by/over/partition fields + // OR annotations without any partition fields defined + let shouldClauses; + if (detectorIndex !== undefined && Array.isArray(entities)) { + // build clause to get exact match of detector index and by/over/partition fields + const beExactMatch = []; + beExactMatch.push({ + term: { + detector_index: detectorIndex, + }, + }); + + entities.forEach(({ fieldName, fieldType, fieldValue }) => { + beExactMatch.push({ + term: { + [getAnnotationFieldName(fieldType)]: fieldName, + }, + }); + beExactMatch.push({ + term: { + [getAnnotationFieldValue(fieldType)]: fieldValue, + }, + }); + }); + + // clause to get annotations that have no partition fields + const haveAnyPartitionFields: object[] = []; + PARTITION_FIELDS.forEach((field) => { + haveAnyPartitionFields.push({ + exists: { + field: getAnnotationFieldName(field), + }, + }); + haveAnyPartitionFields.push({ + exists: { + field: getAnnotationFieldValue(field), + }, + }); + }); + shouldClauses = [ + { bool: { must_not: haveAnyPartitionFields } }, + { bool: { must: beExactMatch } }, + ]; + } + const params: GetParams = { index: ML_ANNOTATIONS_INDEX_ALIAS_READ, size: maxAnnotations, @@ -201,8 +278,10 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl }, }, ], + ...(shouldClauses ? { should: shouldClauses, minimum_should_match: 1 } : {}), }, }, + ...(fields ? { aggs } : {}), }, }; @@ -217,9 +296,19 @@ export function annotationProvider({ callAsCurrentUser }: ILegacyScopedClusterCl const docs: Annotations = _.get(resp, ['hits', 'hits'], []).map((d: EsResult) => { // get the original source document and the document id, we need it // to identify the annotation when editing/deleting it. - return { ...d._source, _id: d._id } as Annotation; + // if original `event` is undefined then substitute with 'user` by default + // since annotation was probably generated by user on the UI + return { + ...d._source, + event: d._source?.event ?? ANNOTATION_EVENT_USER, + _id: d._id, + } as Annotation; }); + const aggregations = _.get(resp, ['aggregations'], {}) as EsAggregationResult; + if (fields) { + obj.aggregations = aggregations; + } if (isAnnotations(docs) === false) { // No need to translate, this will not be exposed in the UI. throw new Error(`Annotations didn't pass integrity check.`); diff --git a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts index d7403c45f1be2e..663ee846571e73 100644 --- a/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts +++ b/x-pack/plugins/ml/server/models/results_service/get_partition_fields_values.ts @@ -6,13 +6,11 @@ import Boom from 'boom'; import { ILegacyScopedClusterClient } from 'kibana/server'; +import { PARTITION_FIELDS } from '../../../common/constants/anomalies'; +import { PartitionFieldsType } from '../../../common/types/anomalies'; import { ML_RESULTS_INDEX_PATTERN } from '../../../common/constants/index_patterns'; import { CriteriaField } from './results_service'; -const PARTITION_FIELDS = ['partition_field', 'over_field', 'by_field'] as const; - -type PartitionFieldsType = typeof PARTITION_FIELDS[number]; - type SearchTerm = | { [key in PartitionFieldsType]?: string; diff --git a/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts b/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts index fade2093ac8428..14a2f632419bc2 100644 --- a/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/annotations_schema.ts @@ -16,6 +16,14 @@ export const indexAnnotationSchema = schema.object({ create_username: schema.maybe(schema.string()), modified_time: schema.maybe(schema.number()), modified_username: schema.maybe(schema.string()), + event: schema.maybe(schema.string()), + detector_index: schema.maybe(schema.number()), + partition_field_name: schema.maybe(schema.string()), + partition_field_value: schema.maybe(schema.string()), + over_field_name: schema.maybe(schema.string()), + over_field_value: schema.maybe(schema.string()), + by_field_name: schema.maybe(schema.string()), + by_field_value: schema.maybe(schema.string()), /** Document id */ _id: schema.maybe(schema.string()), key: schema.maybe(schema.string()), @@ -26,6 +34,25 @@ export const getAnnotationsSchema = schema.object({ earliestMs: schema.oneOf([schema.nullable(schema.number()), schema.maybe(schema.number())]), latestMs: schema.oneOf([schema.nullable(schema.number()), schema.maybe(schema.number())]), maxAnnotations: schema.number(), + /** Fields to find unique values for (e.g. events or created_by) */ + fields: schema.maybe( + schema.arrayOf( + schema.object({ + field: schema.string(), + missing: schema.maybe(schema.string()), + }) + ) + ), + detectorIndex: schema.maybe(schema.number()), + entities: schema.maybe( + schema.arrayOf( + schema.object({ + fieldType: schema.maybe(schema.string()), + fieldName: schema.maybe(schema.string()), + fieldValue: schema.maybe(schema.string()), + }) + ) + ), }); export const deleteAnnotationSchema = schema.object({ annotationId: schema.string() }); diff --git a/x-pack/plugins/ml/server/shared_services/providers/modules.ts b/x-pack/plugins/ml/server/shared_services/providers/modules.ts index 33c8d28399a32e..fb7d59f9c8218d 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/modules.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/modules.ts @@ -13,6 +13,7 @@ import { TypeOf } from '@kbn/config-schema'; import { DataRecognizer } from '../../models/data_recognizer'; import { SharedServicesChecks } from '../shared_services'; import { moduleIdParamSchema, setupModuleBodySchema } from '../../routes/schemas/modules'; +import { HasMlCapabilities } from '../../lib/capabilities'; export type ModuleSetupPayload = TypeOf & TypeOf; @@ -40,8 +41,14 @@ export function getModulesProvider({ request: KibanaRequest, savedObjectsClient: SavedObjectsClientContract ) { - const hasMlCapabilities = getHasMlCapabilities(request); + let hasMlCapabilities: HasMlCapabilities; + if (request.params === 'DummyKibanaRequest') { + hasMlCapabilities = () => Promise.resolve(); + } else { + hasMlCapabilities = getHasMlCapabilities(request); + } const dr = dataRecognizerFactory(mlClusterClient, savedObjectsClient, request); + return { async recognize(...args) { isFullLicense(); diff --git a/x-pack/plugins/ml/server/shared_services/providers/results_service.ts b/x-pack/plugins/ml/server/shared_services/providers/results_service.ts index 366a1f8b8c6f41..6af4eb008567ac 100644 --- a/x-pack/plugins/ml/server/shared_services/providers/results_service.ts +++ b/x-pack/plugins/ml/server/shared_services/providers/results_service.ts @@ -25,7 +25,14 @@ export function getResultsServiceProvider({ }: SharedServicesChecks): ResultsServiceProvider { return { resultsServiceProvider(mlClusterClient: ILegacyScopedClusterClient, request: KibanaRequest) { - const hasMlCapabilities = getHasMlCapabilities(request); + // Uptime is using this service in anomaly alert, kibana alerting doesn't provide request object + // So we are adding a dummy request for now + // TODO: Remove this once kibana alerting provides request object + const hasMlCapabilities = + request.params !== 'DummyKibanaRequest' + ? getHasMlCapabilities(request) + : (_caps: string[]) => Promise.resolve(); + const { getAnomaliesTableData } = resultsServiceProvider(mlClusterClient); return { async getAnomaliesTableData(...args) { diff --git a/x-pack/plugins/observability/public/application/index.tsx b/x-pack/plugins/observability/public/application/index.tsx index 5bc8d96656ed46..8cfbca37e8d056 100644 --- a/x-pack/plugins/observability/public/application/index.tsx +++ b/x-pack/plugins/observability/public/application/index.tsx @@ -12,7 +12,7 @@ import { RedirectAppLinks } from '../../../../../src/plugins/kibana_react/public import { AppMountParameters, CoreStart } from '../../../../../src/core/public'; import { EuiThemeProvider } from '../../../../legacy/common/eui_styled_components'; import { PluginContext } from '../context/plugin_context'; -import { useUrlParams } from '../hooks/use_url_params'; +import { useRouteParams } from '../hooks/use_route_params'; import { routes } from '../routes'; import { usePluginContext } from '../hooks/use_plugin_context'; @@ -36,7 +36,7 @@ const App = () => { ]); }, [core]); - const { query, path: pathParams } = useUrlParams(route.params); + const { query, path: pathParams } = useRouteParams(route.params); return route.handler({ query, path: pathParams }); }; return ; diff --git a/x-pack/plugins/observability/public/components/app/ingest_manager_panel/index.tsx b/x-pack/plugins/observability/public/components/app/ingest_manager_panel/index.tsx new file mode 100644 index 00000000000000..f7a1deb83fbe45 --- /dev/null +++ b/x-pack/plugins/observability/public/components/app/ingest_manager_panel/index.tsx @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup } from '@elastic/eui'; +import { EuiFlexItem } from '@elastic/eui'; +import { EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { EuiText } from '@elastic/eui'; +import { EuiLink } from '@elastic/eui'; + +export const IngestManagerPanel = () => { + return ( + + + + +

+ {i18n.translate('xpack.observability.ingestManafer.title', { + defaultMessage: 'Have you seen our new Ingest Manager?', + })} +

+
+
+ + + {i18n.translate('xpack.observability.ingestManafer.text', { + defaultMessage: + 'The Elastic Agent provides a simple, unified way to add monitoring for logs, metrics, and other types of data to your hosts. You no longer need to install multiple Beats and other agents, making it easier and faster to deploy configurations across your infrastructure.', + })} + + + + + {i18n.translate('xpack.observability.ingestManafer.button', { + defaultMessage: 'Try Ingest Manager Beta', + })} + + +
+
+ ); +}; diff --git a/x-pack/plugins/observability/public/hooks/use_url_params.tsx b/x-pack/plugins/observability/public/hooks/use_route_params.tsx similarity index 97% rename from x-pack/plugins/observability/public/hooks/use_url_params.tsx rename to x-pack/plugins/observability/public/hooks/use_route_params.tsx index 680a32fb496773..93a79bfda7fc13 100644 --- a/x-pack/plugins/observability/public/hooks/use_url_params.tsx +++ b/x-pack/plugins/observability/public/hooks/use_route_params.tsx @@ -23,7 +23,7 @@ function getQueryParams(location: ReturnType) { * It removes any aditional item which is not declared in the type. * @param params */ -export function useUrlParams(params: Params) { +export function useRouteParams(params: Params) { const location = useLocation(); const pathParams = useParams(); const queryParams = getQueryParams(location); diff --git a/x-pack/plugins/observability/public/pages/landing/index.tsx b/x-pack/plugins/observability/public/pages/landing/index.tsx index 512f4428d9bf2e..da46791d9e855f 100644 --- a/x-pack/plugins/observability/public/pages/landing/index.tsx +++ b/x-pack/plugins/observability/public/pages/landing/index.tsx @@ -22,6 +22,7 @@ import styled, { ThemeContext } from 'styled-components'; import { WithHeaderLayout } from '../../components/app/layout/with_header'; import { usePluginContext } from '../../hooks/use_plugin_context'; import { appsSection } from '../home/section'; +import { IngestManagerPanel } from '../../components/app/ingest_manager_panel'; const EuiCardWithoutPadding = styled(EuiCard)` padding: 0; @@ -112,6 +113,16 @@ export const LandingPage = () => { + + + + + + + + + + ); diff --git a/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts b/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts index dd3f476fe7d538..36ef1241983a52 100644 --- a/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts +++ b/x-pack/plugins/observability/public/services/get_observability_alerts.test.ts @@ -7,6 +7,8 @@ import { AppMountContext } from 'kibana/public'; import { getObservabilityAlerts } from './get_observability_alerts'; +const basePath = { prepend: (path: string) => path }; + describe('getObservabilityAlerts', () => { it('Returns empty array when api throws exception', async () => { const core = ({ @@ -14,6 +16,7 @@ describe('getObservabilityAlerts', () => { get: async () => { throw new Error('Boom'); }, + basePath, }, } as unknown) as AppMountContext['core']; @@ -29,6 +32,7 @@ describe('getObservabilityAlerts', () => { data: undefined, }; }, + basePath, }, } as unknown) as AppMountContext['core']; @@ -65,6 +69,7 @@ describe('getObservabilityAlerts', () => { ], }; }, + basePath, }, } as unknown) as AppMountContext['core']; diff --git a/x-pack/plugins/observability/public/services/get_observability_alerts.ts b/x-pack/plugins/observability/public/services/get_observability_alerts.ts index 49855a30c16f6d..58ff9c92acbff0 100644 --- a/x-pack/plugins/observability/public/services/get_observability_alerts.ts +++ b/x-pack/plugins/observability/public/services/get_observability_alerts.ts @@ -9,12 +9,15 @@ import { Alert } from '../../../alerts/common'; export async function getObservabilityAlerts({ core }: { core: AppMountContext['core'] }) { try { - const { data = [] }: { data: Alert[] } = await core.http.get('/api/alerts/_find', { - query: { - page: 1, - per_page: 20, - }, - }); + const { data = [] }: { data: Alert[] } = await core.http.get( + core.http.basePath.prepend('/api/alerts/_find'), + { + query: { + page: 1, + per_page: 20, + }, + } + ); return data.filter(({ consumer }) => { return ( diff --git a/x-pack/plugins/remote_clusters/public/plugin.ts b/x-pack/plugins/remote_clusters/public/plugin.ts index 8881db0f9196e7..33222dd7052e9c 100644 --- a/x-pack/plugins/remote_clusters/public/plugin.ts +++ b/x-pack/plugins/remote_clusters/public/plugin.ts @@ -7,7 +7,6 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, Plugin, CoreStart, PluginInitializerContext } from 'kibana/public'; -import { ManagementSectionId } from '../../../../src/plugins/management/public'; import { init as initBreadcrumbs } from './application/services/breadcrumb'; import { init as initDocumentation } from './application/services/documentation'; import { init as initHttp } from './application/services/http'; @@ -33,7 +32,7 @@ export class RemoteClustersUIPlugin } = this.initializerContext.config.get(); if (isRemoteClustersUiEnabled) { - const esSection = management.sections.getSection(ManagementSectionId.Data); + const esSection = management.sections.section.data; esSection.registerApp({ id: 'remote_clusters', diff --git a/x-pack/plugins/reporting/public/plugin.tsx b/x-pack/plugins/reporting/public/plugin.tsx index 8a25df0a74bbf7..d003d4c581699f 100644 --- a/x-pack/plugins/reporting/public/plugin.tsx +++ b/x-pack/plugins/reporting/public/plugin.tsx @@ -23,7 +23,7 @@ import { FeatureCatalogueCategory, HomePublicPluginSetup, } from '../../../../src/plugins/home/public'; -import { ManagementSectionId, ManagementSetup } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { SharePluginSetup } from '../../../../src/plugins/share/public'; import { LicensingPluginSetup } from '../../licensing/public'; import { JobId, JobStatusBuckets, ReportingConfigType } from '../common/types'; @@ -115,8 +115,7 @@ export class ReportingPublicPlugin implements Plugin { showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); - - management.sections.getSection(ManagementSectionId.InsightsAndAlerting).registerApp({ + management.sections.section.insightsAndAlerting.registerApp({ id: 'reporting', title: this.title, order: 1, diff --git a/x-pack/plugins/rollup/public/plugin.ts b/x-pack/plugins/rollup/public/plugin.ts index b55760c5cc5aa6..73ee675b089c82 100644 --- a/x-pack/plugins/rollup/public/plugin.ts +++ b/x-pack/plugins/rollup/public/plugin.ts @@ -16,7 +16,7 @@ import { FeatureCatalogueCategory, HomePublicPluginSetup, } from '../../../../src/plugins/home/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { IndexManagementPluginSetup } from '../../index_management/public'; import { IndexPatternManagementSetup } from '../../../../src/plugins/index_pattern_management/public'; // @ts-ignore @@ -75,7 +75,7 @@ export class RollupPlugin implements Plugin { }); } - management.sections.getSection(ManagementSectionId.Data).registerApp({ + management.sections.section.data.registerApp({ id: 'rollup_jobs', title: i18n.translate('xpack.rollupJobs.appTitle', { defaultMessage: 'Rollup Jobs' }), order: 4, diff --git a/x-pack/plugins/security/kibana.json b/x-pack/plugins/security/kibana.json index 0daab9d5dbce31..064ff5b6a67115 100644 --- a/x-pack/plugins/security/kibana.json +++ b/x-pack/plugins/security/kibana.json @@ -9,10 +9,8 @@ "ui": true, "requiredBundles": [ "home", - "management", "kibanaReact", "spaces", - "esUiShared", - "management" + "esUiShared" ] } diff --git a/x-pack/plugins/security/public/management/management_service.test.ts b/x-pack/plugins/security/public/management/management_service.test.ts index c707206569bf55..ce93fb7c98f416 100644 --- a/x-pack/plugins/security/public/management/management_service.test.ts +++ b/x-pack/plugins/security/public/management/management_service.test.ts @@ -8,8 +8,9 @@ import { BehaviorSubject } from 'rxjs'; import { ManagementApp, ManagementSetup, - ManagementStart, + DefinedSections, } from '../../../../../src/plugins/management/public'; +import { createManagementSectionMock } from '../../../../../src/plugins/management/public/mocks'; import { SecurityLicenseFeatures } from '../../common/licensing/license_features'; import { ManagementService } from './management_service'; import { usersManagementApp } from './users'; @@ -21,7 +22,7 @@ import { rolesManagementApp } from './roles'; import { apiKeysManagementApp } from './api_keys'; import { roleMappingsManagementApp } from './role_mappings'; -const mockSection = { registerApp: jest.fn() }; +const mockSection = createManagementSectionMock(); describe('ManagementService', () => { describe('setup()', () => { @@ -32,8 +33,10 @@ describe('ManagementService', () => { const managementSetup: ManagementSetup = { sections: { - register: jest.fn(), - getSection: jest.fn().mockReturnValue(mockSection), + register: jest.fn(() => mockSection), + section: { + security: mockSection, + } as DefinedSections, }, }; @@ -88,8 +91,10 @@ describe('ManagementService', () => { const managementSetup: ManagementSetup = { sections: { - register: jest.fn(), - getSection: jest.fn().mockReturnValue(mockSection), + register: jest.fn(() => mockSection), + section: { + security: mockSection, + } as DefinedSections, }, }; @@ -116,6 +121,7 @@ describe('ManagementService', () => { }), } as unknown) as jest.Mocked; }; + mockSection.getApp = jest.fn().mockImplementation((id) => mockApps.get(id)); const mockApps = new Map>([ [usersManagementApp.id, getMockedApp()], [rolesManagementApp.id, getMockedApp()], @@ -123,19 +129,7 @@ describe('ManagementService', () => { [roleMappingsManagementApp.id, getMockedApp()], ] as Array<[string, jest.Mocked]>); - const managementStart: ManagementStart = { - sections: { - getSection: jest - .fn() - .mockReturnValue({ getApp: jest.fn().mockImplementation((id) => mockApps.get(id)) }), - getAllSections: jest.fn(), - getSectionsEnabled: jest.fn(), - }, - }; - - service.start({ - management: managementStart, - }); + service.start(); return { mockApps, diff --git a/x-pack/plugins/security/public/management/management_service.ts b/x-pack/plugins/security/public/management/management_service.ts index 148d2855ba9b74..199fd917da0714 100644 --- a/x-pack/plugins/security/public/management/management_service.ts +++ b/x-pack/plugins/security/public/management/management_service.ts @@ -9,8 +9,7 @@ import { StartServicesAccessor, FatalErrorsSetup } from 'src/core/public'; import { ManagementApp, ManagementSetup, - ManagementStart, - ManagementSectionId, + ManagementSection, } from '../../../../../src/plugins/management/public'; import { SecurityLicense } from '../../common/licensing'; import { AuthenticationServiceSetup } from '../authentication'; @@ -28,30 +27,26 @@ interface SetupParams { getStartServices: StartServicesAccessor; } -interface StartParams { - management: ManagementStart; -} - export class ManagementService { private license!: SecurityLicense; private licenseFeaturesSubscription?: Subscription; + private securitySection?: ManagementSection; setup({ getStartServices, management, authc, license, fatalErrors }: SetupParams) { this.license = license; + this.securitySection = management.sections.section.security; - const securitySection = management.sections.getSection(ManagementSectionId.Security); - - securitySection.registerApp(usersManagementApp.create({ authc, getStartServices })); - securitySection.registerApp( + this.securitySection.registerApp(usersManagementApp.create({ authc, getStartServices })); + this.securitySection.registerApp( rolesManagementApp.create({ fatalErrors, license, getStartServices }) ); - securitySection.registerApp(apiKeysManagementApp.create({ getStartServices })); - securitySection.registerApp(roleMappingsManagementApp.create({ getStartServices })); + this.securitySection.registerApp(apiKeysManagementApp.create({ getStartServices })); + this.securitySection.registerApp(roleMappingsManagementApp.create({ getStartServices })); } - start({ management }: StartParams) { + start() { this.licenseFeaturesSubscription = this.license.features$.subscribe(async (features) => { - const securitySection = management.sections.getSection(ManagementSectionId.Security); + const securitySection = this.securitySection!; const securityManagementAppsStatuses: Array<[ManagementApp, boolean]> = [ [securitySection.getApp(usersManagementApp.id)!, features.showLinks], diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 7c57c4dd997a25..8cec4fbc2f5a2c 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -33,7 +33,9 @@ describe('Security Plugin', () => { coreMock.createSetup({ basePath: '/some-base-path' }) as CoreSetup< PluginStartDependencies >, - { licensing: licensingMock.createSetup() } + { + licensing: licensingMock.createSetup(), + } ) ).toEqual({ __legacyCompat: { logoutUrl: '/some-base-path/logout', tenant: '/some-base-path' }, @@ -117,7 +119,6 @@ describe('Security Plugin', () => { }); expect(startManagementServiceMock).toHaveBeenCalledTimes(1); - expect(startManagementServiceMock).toHaveBeenCalledWith({ management: managementStartMock }); }); }); diff --git a/x-pack/plugins/security/public/plugin.tsx b/x-pack/plugins/security/public/plugin.tsx index da69dd051c11d3..bef183bd97e8c7 100644 --- a/x-pack/plugins/security/public/plugin.tsx +++ b/x-pack/plugins/security/public/plugin.tsx @@ -139,9 +139,8 @@ export class SecurityPlugin public start(core: CoreStart, { management }: PluginStartDependencies) { this.sessionTimeout.start(); this.navControlService.start({ core }); - if (management) { - this.managementService.start({ management }); + this.managementService.start(); } } diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 516ee19dd3b03a..e5dd109007eab4 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -117,6 +117,7 @@ export const TIMELINE_URL = '/api/timeline'; export const TIMELINE_DRAFT_URL = `${TIMELINE_URL}/_draft`; export const TIMELINE_EXPORT_URL = `${TIMELINE_URL}/_export`; export const TIMELINE_IMPORT_URL = `${TIMELINE_URL}/_import`; +export const TIMELINE_PREPACKAGED_URL = `${TIMELINE_URL}/_prepackaged`; /** * Default signals index key for kibana.dev.yml diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts index 6e43bd645fd7bc..542cbe89160329 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/common/schemas.ts @@ -275,7 +275,12 @@ export type To = t.TypeOf; export const toOrUndefined = t.union([to, t.undefined]); export type ToOrUndefined = t.TypeOf; -export const type = t.keyof({ machine_learning: null, query: null, saved_query: null }); +export const type = t.keyof({ + machine_learning: null, + query: null, + saved_query: null, + threshold: null, +}); export type Type = t.TypeOf; export const typeOrUndefined = t.union([type, t.undefined]); @@ -369,6 +374,17 @@ export type Threat = t.TypeOf; export const threatOrUndefined = t.union([threat, t.undefined]); export type ThreatOrUndefined = t.TypeOf; +export const threshold = t.exact( + t.type({ + field: t.string, + value: PositiveIntegerGreaterThanZero, + }) +); +export type Threshold = t.TypeOf; + +export const thresholdOrUndefined = t.union([threshold, t.undefined]); +export type ThresholdOrUndefined = t.TypeOf; + export const created_at = IsoDateString; export const updated_at = IsoDateString; export const updated_by = t.string; @@ -407,6 +423,11 @@ export const rules_custom_installed = PositiveInteger; export const rules_not_installed = PositiveInteger; export const rules_not_updated = PositiveInteger; +export const timelines_installed = PositiveInteger; +export const timelines_updated = PositiveInteger; +export const timelines_not_installed = PositiveInteger; +export const timelines_not_updated = PositiveInteger; + export const note = t.string; export type Note = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts index bf96be5e688fa0..aebc3361f6e49b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_schema.ts @@ -25,6 +25,7 @@ import { To, type, Threat, + threshold, ThrottleOrNull, note, References, @@ -111,6 +112,7 @@ export const addPrepackagedRulesSchema = t.intersection([ tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode threat: DefaultThreatArray, // defaults to empty array if not set during decode + threshold, // defaults to "undefined" if not set during decode throttle: DefaultThrottleNull, // defaults to "null" if not set during decode timestamp_override, // defaults to "undefined" if not set during decode references: DefaultStringArray, // defaults to empty array of strings if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts index 793d4b04ed0e52..f844d0e86e1f92 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.test.ts @@ -8,7 +8,7 @@ import { AddPrepackagedRulesSchema } from './add_prepackaged_rules_schema'; import { addPrepackagedRuleValidateTypeDependents } from './add_prepackaged_rules_type_dependents'; import { getAddPrepackagedRulesSchemaMock } from './add_prepackaged_rules_schema.mock'; -describe('create_rules_type_dependents', () => { +describe('add_prepackaged_rules_type_dependents', () => { test('saved_id is required when type is saved_query and will not validate without out', () => { const schema: AddPrepackagedRulesSchema = { ...getAddPrepackagedRulesSchemaMock(), @@ -68,4 +68,26 @@ describe('create_rules_type_dependents', () => { const errors = addPrepackagedRuleValidateTypeDependents(schema); expect(errors).toEqual(['when "timeline_title" exists, "timeline_id" must also exist']); }); + + test('threshold is required when type is threshold and validates with it', () => { + const schema: AddPrepackagedRulesSchema = { + ...getAddPrepackagedRulesSchemaMock(), + type: 'threshold', + }; + const errors = addPrepackagedRuleValidateTypeDependents(schema); + expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); + }); + + test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { + const schema: AddPrepackagedRulesSchema = { + ...getAddPrepackagedRulesSchemaMock(), + type: 'threshold', + threshold: { + field: '', + value: -1, + }, + }; + const errors = addPrepackagedRuleValidateTypeDependents(schema); + expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts index 2788c331154d21..6a51f724fc9e6d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/add_prepackaged_rules_type_dependents.ts @@ -92,6 +92,19 @@ export const validateTimelineTitle = (rule: AddPrepackagedRulesSchema): string[] return []; }; +export const validateThreshold = (rule: AddPrepackagedRulesSchema): string[] => { + if (rule.type === 'threshold') { + if (!rule.threshold) { + return ['when "type" is "threshold", "threshold" is required']; + } else if (rule.threshold.value <= 0) { + return ['"threshold.value" has to be bigger than 0']; + } else { + return []; + } + } + return []; +}; + export const addPrepackagedRuleValidateTypeDependents = ( schema: AddPrepackagedRulesSchema ): string[] => { @@ -103,5 +116,6 @@ export const addPrepackagedRuleValidateTypeDependents = ( ...validateMachineLearningJobId(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema), + ...validateThreshold(schema), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts index 0debe01e5a4d74..308b3c24010fbd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_schema.ts @@ -28,6 +28,7 @@ import { To, type, Threat, + threshold, ThrottleOrNull, note, Version, @@ -106,6 +107,7 @@ export const createRulesSchema = t.intersection([ tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode threat: DefaultThreatArray, // defaults to empty array if not set during decode + threshold, // defaults to "undefined" if not set during decode throttle: DefaultThrottleNull, // defaults to "null" if not set during decode timestamp_override, // defaults to "undefined" if not set during decode references: DefaultStringArray, // defaults to empty array of strings if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts index ebf0b2e591ca9f..43f0901912271c 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.test.ts @@ -65,4 +65,26 @@ describe('create_rules_type_dependents', () => { const errors = createRuleValidateTypeDependents(schema); expect(errors).toEqual(['when "timeline_title" exists, "timeline_id" must also exist']); }); + + test('threshold is required when type is threshold and validates with it', () => { + const schema: CreateRulesSchema = { + ...getCreateRulesSchemaMock(), + type: 'threshold', + }; + const errors = createRuleValidateTypeDependents(schema); + expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); + }); + + test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { + const schema: CreateRulesSchema = { + ...getCreateRulesSchemaMock(), + type: 'threshold', + threshold: { + field: '', + value: -1, + }, + }; + const errors = createRuleValidateTypeDependents(schema); + expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts index aad2a2c4a92064..af665ff8c81d2d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/create_rules_type_dependents.ts @@ -92,6 +92,19 @@ export const validateTimelineTitle = (rule: CreateRulesSchema): string[] => { return []; }; +export const validateThreshold = (rule: CreateRulesSchema): string[] => { + if (rule.type === 'threshold') { + if (!rule.threshold) { + return ['when "type" is "threshold", "threshold" is required']; + } else if (rule.threshold.value <= 0) { + return ['"threshold.value" has to be bigger than 0']; + } else { + return []; + } + } + return []; +}; + export const createRuleValidateTypeDependents = (schema: CreateRulesSchema): string[] => { return [ ...validateAnomalyThreshold(schema), @@ -101,5 +114,6 @@ export const createRuleValidateTypeDependents = (schema: CreateRulesSchema): str ...validateMachineLearningJobId(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema), + ...validateThreshold(schema), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts index f61a1546e3e8a3..d141ca56828b6a 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_schema.ts @@ -27,6 +27,7 @@ import { To, type, Threat, + threshold, ThrottleOrNull, note, Version, @@ -125,6 +126,7 @@ export const importRulesSchema = t.intersection([ tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode threat: DefaultThreatArray, // defaults to empty array if not set during decode + threshold, // defaults to "undefined" if not set during decode throttle: DefaultThrottleNull, // defaults to "null" if not set during decode timestamp_override, // defaults to "undefined" if not set during decode references: DefaultStringArray, // defaults to empty array of strings if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts index f9b989c81e5337..4b047ee6b71987 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.test.ts @@ -65,4 +65,26 @@ describe('import_rules_type_dependents', () => { const errors = importRuleValidateTypeDependents(schema); expect(errors).toEqual(['when "timeline_title" exists, "timeline_id" must also exist']); }); + + test('threshold is required when type is threshold and validates with it', () => { + const schema: ImportRulesSchema = { + ...getImportRulesSchemaMock(), + type: 'threshold', + }; + const errors = importRuleValidateTypeDependents(schema); + expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); + }); + + test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { + const schema: ImportRulesSchema = { + ...getImportRulesSchemaMock(), + type: 'threshold', + threshold: { + field: '', + value: -1, + }, + }; + const errors = importRuleValidateTypeDependents(schema); + expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts index 59191a4fe3121d..269181449e9e94 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/import_rules_type_dependents.ts @@ -92,6 +92,19 @@ export const validateTimelineTitle = (rule: ImportRulesSchema): string[] => { return []; }; +export const validateThreshold = (rule: ImportRulesSchema): string[] => { + if (rule.type === 'threshold') { + if (!rule.threshold) { + return ['when "type" is "threshold", "threshold" is required']; + } else if (rule.threshold.value <= 0) { + return ['"threshold.value" has to be bigger than 0']; + } else { + return []; + } + } + return []; +}; + export const importRuleValidateTypeDependents = (schema: ImportRulesSchema): string[] => { return [ ...validateAnomalyThreshold(schema), @@ -101,5 +114,6 @@ export const importRuleValidateTypeDependents = (schema: ImportRulesSchema): str ...validateMachineLearningJobId(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema), + ...validateThreshold(schema), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts index 070f3ccfd03b06..dd325c1a5034fd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_schema.ts @@ -33,6 +33,7 @@ import { enabled, tags, threat, + threshold, throttle, references, to, @@ -89,6 +90,7 @@ export const patchRulesSchema = t.exact( tags, to, threat, + threshold, throttle, timestamp_override, references, diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rule_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.test.ts similarity index 79% rename from x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rule_type_dependents.test.ts rename to x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.test.ts index a388e69332072a..bafaf6f9e22035 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rule_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.test.ts @@ -78,4 +78,26 @@ describe('patch_rules_type_dependents', () => { const errors = patchRuleValidateTypeDependents(schema); expect(errors).toEqual(['either "id" or "rule_id" must be set']); }); + + test('threshold is required when type is threshold and validates with it', () => { + const schema: PatchRulesSchema = { + ...getPatchRulesSchemaMock(), + type: 'threshold', + }; + const errors = patchRuleValidateTypeDependents(schema); + expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); + }); + + test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { + const schema: PatchRulesSchema = { + ...getPatchRulesSchemaMock(), + type: 'threshold', + threshold: { + field: '', + value: -1, + }, + }; + const errors = patchRuleValidateTypeDependents(schema); + expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts index 554cdb822762f8..a229771a7c05cd 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/patch_rules_type_dependents.ts @@ -66,6 +66,19 @@ export const validateId = (rule: PatchRulesSchema): string[] => { } }; +export const validateThreshold = (rule: PatchRulesSchema): string[] => { + if (rule.type === 'threshold') { + if (!rule.threshold) { + return ['when "type" is "threshold", "threshold" is required']; + } else if (rule.threshold.value <= 0) { + return ['"threshold.value" has to be bigger than 0']; + } else { + return []; + } + } + return []; +}; + export const patchRuleValidateTypeDependents = (schema: PatchRulesSchema): string[] => { return [ ...validateId(schema), @@ -73,5 +86,6 @@ export const patchRuleValidateTypeDependents = (schema: PatchRulesSchema): strin ...validateLanguage(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema), + ...validateThreshold(schema), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts index 98082c2de838a3..4f284eedef3fda 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_schema.ts @@ -28,6 +28,7 @@ import { To, type, Threat, + threshold, ThrottleOrNull, note, version, @@ -114,6 +115,7 @@ export const updateRulesSchema = t.intersection([ tags: DefaultStringArray, // defaults to empty string array if not set during decode to: DefaultToString, // defaults to "now" if not set during decode threat: DefaultThreatArray, // defaults to empty array if not set during decode + threshold, // defaults to "undefined" if not set during decode throttle: DefaultThrottleNull, // defaults to "null" if not set during decode timestamp_override, // defaults to "undefined" if not set during decode references: DefaultStringArray, // defaults to empty array of strings if not set during decode diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.test.ts index a63c8243cb5f15..91b11ea758e93f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.test.ts @@ -85,4 +85,26 @@ describe('update_rules_type_dependents', () => { const errors = updateRuleValidateTypeDependents(schema); expect(errors).toEqual(['either "id" or "rule_id" must be set']); }); + + test('threshold is required when type is threshold and validates with it', () => { + const schema: UpdateRulesSchema = { + ...getUpdateRulesSchemaMock(), + type: 'threshold', + }; + const errors = updateRuleValidateTypeDependents(schema); + expect(errors).toEqual(['when "type" is "threshold", "threshold" is required']); + }); + + test('threshold.value is required and has to be bigger than 0 when type is threshold and validates with it', () => { + const schema: UpdateRulesSchema = { + ...getUpdateRulesSchemaMock(), + type: 'threshold', + threshold: { + field: '', + value: -1, + }, + }; + const errors = updateRuleValidateTypeDependents(schema); + expect(errors).toEqual(['"threshold.value" has to be bigger than 0']); + }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts index 9204f727b2660a..44182d250c8013 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/update_rules_type_dependents.ts @@ -102,6 +102,19 @@ export const validateId = (rule: UpdateRulesSchema): string[] => { } }; +export const validateThreshold = (rule: UpdateRulesSchema): string[] => { + if (rule.type === 'threshold') { + if (!rule.threshold) { + return ['when "type" is "threshold", "threshold" is required']; + } else if (rule.threshold.value <= 0) { + return ['"threshold.value" has to be bigger than 0']; + } else { + return []; + } + } + return []; +}; + export const updateRuleValidateTypeDependents = (schema: UpdateRulesSchema): string[] => { return [ ...validateId(schema), @@ -112,5 +125,6 @@ export const updateRuleValidateTypeDependents = (schema: UpdateRulesSchema): str ...validateMachineLearningJobId(schema), ...validateTimelineId(schema), ...validateTimelineTitle(schema), + ...validateThreshold(schema), ]; }; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.test.ts index fc3f89996daf10..61d3ede852ee1d 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.test.ts @@ -6,14 +6,22 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { left } from 'fp-ts/lib/Either'; -import { PrePackagedRulesSchema, prePackagedRulesSchema } from './prepackaged_rules_schema'; +import { + PrePackagedRulesAndTimelinesSchema, + prePackagedRulesAndTimelinesSchema, +} from './prepackaged_rules_schema'; import { exactCheck } from '../../../exact_check'; import { foldLeftRight, getPaths } from '../../../test_utils'; describe('prepackaged_rules_schema', () => { test('it should validate an empty prepackaged response with defaults', () => { - const payload: PrePackagedRulesSchema = { rules_installed: 0, rules_updated: 0 }; - const decoded = prePackagedRulesSchema.decode(payload); + const payload: PrePackagedRulesAndTimelinesSchema = { + rules_installed: 0, + rules_updated: 0, + timelines_installed: 0, + timelines_updated: 0, + }; + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -22,12 +30,14 @@ describe('prepackaged_rules_schema', () => { }); test('it should not validate an extra invalid field added', () => { - const payload: PrePackagedRulesSchema & { invalid_field: string } = { + const payload: PrePackagedRulesAndTimelinesSchema & { invalid_field: string } = { rules_installed: 0, rules_updated: 0, invalid_field: 'invalid', + timelines_installed: 0, + timelines_updated: 0, }; - const decoded = prePackagedRulesSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -36,8 +46,13 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_installed" number', () => { - const payload: PrePackagedRulesSchema = { rules_installed: -1, rules_updated: 0 }; - const decoded = prePackagedRulesSchema.decode(payload); + const payload: PrePackagedRulesAndTimelinesSchema = { + rules_installed: -1, + rules_updated: 0, + timelines_installed: 0, + timelines_updated: 0, + }; + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -48,8 +63,13 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_updated"', () => { - const payload: PrePackagedRulesSchema = { rules_installed: 0, rules_updated: -1 }; - const decoded = prePackagedRulesSchema.decode(payload); + const payload: PrePackagedRulesAndTimelinesSchema = { + rules_installed: 0, + rules_updated: -1, + timelines_installed: 0, + timelines_updated: 0, + }; + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -60,9 +80,14 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response if "rules_installed" is not there', () => { - const payload: PrePackagedRulesSchema = { rules_installed: 0, rules_updated: 0 }; + const payload: PrePackagedRulesAndTimelinesSchema = { + rules_installed: 0, + rules_updated: 0, + timelines_installed: 0, + timelines_updated: 0, + }; delete payload.rules_installed; - const decoded = prePackagedRulesSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -73,9 +98,14 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response if "rules_updated" is not there', () => { - const payload: PrePackagedRulesSchema = { rules_installed: 0, rules_updated: 0 }; + const payload: PrePackagedRulesAndTimelinesSchema = { + rules_installed: 0, + rules_updated: 0, + timelines_installed: 0, + timelines_updated: 0, + }; delete payload.rules_updated; - const decoded = prePackagedRulesSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.ts index 3b0107c91fee00..73d144500e0038 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_schema.ts @@ -7,14 +7,28 @@ import * as t from 'io-ts'; /* eslint-disable @typescript-eslint/camelcase */ -import { rules_installed, rules_updated } from '../common/schemas'; +import { + rules_installed, + rules_updated, + timelines_installed, + timelines_updated, +} from '../common/schemas'; /* eslint-enable @typescript-eslint/camelcase */ -export const prePackagedRulesSchema = t.exact( - t.type({ - rules_installed, - rules_updated, - }) +const prePackagedRulesSchema = t.type({ + rules_installed, + rules_updated, +}); + +const prePackagedTimelinesSchema = t.type({ + timelines_installed, + timelines_updated, +}); + +export const prePackagedRulesAndTimelinesSchema = t.exact( + t.intersection([prePackagedRulesSchema, prePackagedTimelinesSchema]) ); -export type PrePackagedRulesSchema = t.TypeOf; +export type PrePackagedRulesAndTimelinesSchema = t.TypeOf< + typeof prePackagedRulesAndTimelinesSchema +>; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.test.ts index eeae72209829e1..09cb7148fe90a0 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.test.ts @@ -7,21 +7,24 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { left } from 'fp-ts/lib/Either'; import { - PrePackagedRulesStatusSchema, - prePackagedRulesStatusSchema, + PrePackagedRulesAndTimelinesStatusSchema, + prePackagedRulesAndTimelinesStatusSchema, } from './prepackaged_rules_status_schema'; import { exactCheck } from '../../../exact_check'; import { foldLeftRight, getPaths } from '../../../test_utils'; describe('prepackaged_rules_schema', () => { test('it should validate an empty prepackaged response with defaults', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: 0, rules_not_installed: 0, rules_not_updated: 0, rules_custom_installed: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -30,14 +33,17 @@ describe('prepackaged_rules_schema', () => { }); test('it should not validate an extra invalid field added', () => { - const payload: PrePackagedRulesStatusSchema & { invalid_field: string } = { + const payload: PrePackagedRulesAndTimelinesStatusSchema & { invalid_field: string } = { rules_installed: 0, rules_not_installed: 0, rules_not_updated: 0, rules_custom_installed: 0, invalid_field: 'invalid', + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -46,13 +52,16 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_installed" number', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: -1, rules_not_installed: 0, rules_not_updated: 0, rules_custom_installed: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -63,13 +72,16 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_not_installed"', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: 0, rules_not_installed: -1, rules_not_updated: 0, rules_custom_installed: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -80,13 +92,16 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_not_updated"', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: 0, rules_not_installed: 0, rules_not_updated: -1, rules_custom_installed: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -97,13 +112,16 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response with a negative "rules_custom_installed"', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: 0, rules_not_installed: 0, rules_not_updated: 0, rules_custom_installed: -1, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); @@ -114,14 +132,17 @@ describe('prepackaged_rules_schema', () => { }); test('it should NOT validate an empty prepackaged response if "rules_installed" is not there', () => { - const payload: PrePackagedRulesStatusSchema = { + const payload: PrePackagedRulesAndTimelinesStatusSchema = { rules_installed: 0, rules_not_installed: 0, rules_not_updated: 0, rules_custom_installed: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }; delete payload.rules_installed; - const decoded = prePackagedRulesStatusSchema.decode(payload); + const decoded = prePackagedRulesAndTimelinesStatusSchema.decode(payload); const checked = exactCheck(payload, decoded); const message = pipe(checked, foldLeftRight); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.ts index ee8e7b48a58bc9..aabdbdd7300f43 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/prepackaged_rules_status_schema.ts @@ -12,16 +12,29 @@ import { rules_custom_installed, rules_not_installed, rules_not_updated, + timelines_installed, + timelines_not_installed, + timelines_not_updated, } from '../common/schemas'; /* eslint-enable @typescript-eslint/camelcase */ -export const prePackagedRulesStatusSchema = t.exact( - t.type({ - rules_custom_installed, - rules_installed, - rules_not_installed, - rules_not_updated, - }) +export const prePackagedTimelinesStatusSchema = t.type({ + timelines_installed, + timelines_not_installed, + timelines_not_updated, +}); + +const prePackagedRulesStatusSchema = t.type({ + rules_custom_installed, + rules_installed, + rules_not_installed, + rules_not_updated, +}); + +export const prePackagedRulesAndTimelinesStatusSchema = t.exact( + t.intersection([prePackagedRulesStatusSchema, prePackagedTimelinesStatusSchema]) ); -export type PrePackagedRulesStatusSchema = t.TypeOf; +export type PrePackagedRulesAndTimelinesStatusSchema = t.TypeOf< + typeof prePackagedRulesAndTimelinesStatusSchema +>; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts index c0fec2b2eefc2d..4bd18a13e4ebb0 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/rules_schema.ts @@ -44,6 +44,7 @@ import { timeline_title, type, threat, + threshold, throttle, job_status, status_date, @@ -123,6 +124,9 @@ export const dependentRulesSchema = t.partial({ // ML fields anomaly_threshold, machine_learning_job_id, + + // Threshold fields + threshold, }); /** @@ -202,7 +206,7 @@ export const addTimelineTitle = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mi }; export const addQueryFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] => { - if (typeAndTimelineOnly.type === 'query' || typeAndTimelineOnly.type === 'saved_query') { + if (['query', 'saved_query', 'threshold'].includes(typeAndTimelineOnly.type)) { return [ t.exact(t.type({ query: dependentRulesSchema.props.query })), t.exact(t.type({ language: dependentRulesSchema.props.language })), @@ -225,6 +229,17 @@ export const addMlFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] } }; +export const addThresholdFields = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed[] => { + if (typeAndTimelineOnly.type === 'threshold') { + return [ + t.exact(t.type({ threshold: dependentRulesSchema.props.threshold })), + t.exact(t.partial({ saved_id: dependentRulesSchema.props.saved_id })), + ]; + } else { + return []; + } +}; + export const getDependents = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed => { const dependents: t.Mixed[] = [ t.exact(requiredRulesSchema), @@ -233,6 +248,7 @@ export const getDependents = (typeAndTimelineOnly: TypeAndTimelineOnly): t.Mixed ...addTimelineTitle(typeAndTimelineOnly), ...addQueryFields(typeAndTimelineOnly), ...addMlFields(typeAndTimelineOnly), + ...addThresholdFields(typeAndTimelineOnly), ]; if (dependents.length > 1) { diff --git a/x-pack/plugins/security_solution/common/detection_engine/types.ts b/x-pack/plugins/security_solution/common/detection_engine/types.ts index 431d716a9f205c..7c752bca49dbdf 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/types.ts @@ -15,5 +15,6 @@ export const RuleTypeSchema = t.keyof({ query: null, saved_query: null, machine_learning: null, + threshold: null, }); export type RuleType = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/types/timeline/index.ts b/x-pack/plugins/security_solution/common/types/timeline/index.ts index 9e7a6f46bbceca..021e5a7f00b173 100644 --- a/x-pack/plugins/security_solution/common/types/timeline/index.ts +++ b/x-pack/plugins/security_solution/common/types/timeline/index.ts @@ -7,11 +7,16 @@ /* eslint-disable @typescript-eslint/camelcase, @typescript-eslint/no-empty-interface */ import * as runtimeTypes from 'io-ts'; -import { SavedObjectsClient } from 'kibana/server'; import { stringEnum, unionWithNullType } from '../../utility_types'; import { NoteSavedObject, NoteSavedObjectToReturnRuntimeType } from './note'; import { PinnedEventToReturnSavedObjectRuntimeType, PinnedEventSavedObject } from './pinned_event'; +import { + success, + success_count as successCount, +} from '../../detection_engine/schemas/common/schemas'; +import { PositiveInteger } from '../../detection_engine/schemas/types'; +import { errorSchema } from '../../detection_engine/schemas/response/error_schema'; /* * ColumnHeader Types @@ -353,19 +358,6 @@ export interface AllTimelineSavedObject * Import/export timelines */ -export type ExportTimelineSavedObjectsClient = Pick< - SavedObjectsClient, - | 'get' - | 'errors' - | 'create' - | 'bulkCreate' - | 'delete' - | 'find' - | 'bulkGet' - | 'update' - | 'bulkUpdate' ->; - export type ExportedGlobalNotes = Array>; export type ExportedEventNotes = NoteSavedObject[]; @@ -393,3 +385,15 @@ export type NotesAndPinnedEventsByTimelineId = Record< string, { notes: NoteSavedObject[]; pinnedEvents: PinnedEventSavedObject[] } >; + +export const importTimelineResultSchema = runtimeTypes.exact( + runtimeTypes.type({ + success, + success_count: successCount, + timelines_installed: PositiveInteger, + timelines_updated: PositiveInteger, + errors: runtimeTypes.array(errorSchema), + }) +); + +export type ImportTimelineResultSchema = runtimeTypes.TypeOf; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index cedf5c53e0ddca..73933d483e2cb2 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -198,7 +198,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ - {i18n.EDIT_EXCEPTION} + {i18n.EDIT_EXCEPTION_TITLE} {ruleName} @@ -260,7 +260,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ {i18n.CANCEL} - {i18n.EDIT_EXCEPTION} + {i18n.EDIT_EXCEPTION_SAVE_BUTTON} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts index b2d01d72131b46..6c5cb733b7a73c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/translations.ts @@ -10,8 +10,15 @@ export const CANCEL = i18n.translate('xpack.securitySolution.exceptions.editExce defaultMessage: 'Cancel', }); -export const EDIT_EXCEPTION = i18n.translate( - 'xpack.securitySolution.exceptions.editException.editException', +export const EDIT_EXCEPTION_SAVE_BUTTON = i18n.translate( + 'xpack.securitySolution.exceptions.editException.editExceptionSaveButton', + { + defaultMessage: 'Save', + } +); + +export const EDIT_EXCEPTION_TITLE = i18n.translate( + 'xpack.securitySolution.exceptions.editException.editExceptionTitle', { defaultMessage: 'Edit Exception', } diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts index 03beee8ab373e8..ee3255446b3349 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/translations.ts @@ -91,7 +91,7 @@ export const ADD_TO_DETECTIONS_LIST = i18n.translate( export const EXCEPTION_EMPTY_PROMPT_TITLE = i18n.translate( 'xpack.securitySolution.exceptions.viewer.emptyPromptTitle', { - defaultMessage: 'You have no exceptions', + defaultMessage: 'This rule has no exceptions', } ); @@ -99,7 +99,7 @@ export const EXCEPTION_EMPTY_PROMPT_BODY = i18n.translate( 'xpack.securitySolution.exceptions.viewer.emptyPromptBody', { defaultMessage: - 'You can add an exception to fine tune the rule so that it suppresses alerts that meet specified conditions. Exceptions leverage detection accuracy, which can help reduce the number of false positives.', + 'You can add exceptions to fine tune the rule so that detection alerts are not created when exception conditions are met. Exceptions improve detection accuracy, which can help reduce the number of false positives.', } ); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx index 1213312e2a22c7..24bfeaa4dae1a6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.test.tsx @@ -53,6 +53,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: mockEcsDataWithAlert, + nonEcsData: [], updateTimelineIsLoading, }); @@ -65,6 +66,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: mockEcsDataWithAlert, + nonEcsData: [], updateTimelineIsLoading, }); const expected = { @@ -250,6 +252,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: mockEcsDataWithAlert, + nonEcsData: [], updateTimelineIsLoading, }); // @ts-ignore @@ -279,6 +282,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: mockEcsDataWithAlert, + nonEcsData: [], updateTimelineIsLoading, }); // @ts-ignore @@ -297,6 +301,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: mockEcsDataWithAlert, + nonEcsData: [], updateTimelineIsLoading, }); @@ -326,6 +331,7 @@ describe('alert actions', () => { apolloClient, createTimeline, ecsData: ecsDataMock, + nonEcsData: [], updateTimelineIsLoading, }); @@ -350,6 +356,7 @@ describe('alert actions', () => { await sendAlertToTimelineAction({ createTimeline, ecsData: ecsDataMock, + nonEcsData: [], updateTimelineIsLoading, }); diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx index 24f292cf9135bc..11c13c2358e940 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/actions.tsx @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable complexity */ + import dateMath from '@elastic/datemath'; -import { getOr, isEmpty } from 'lodash/fp'; +import { get, getOr, isEmpty, find } from 'lodash/fp'; import moment from 'moment'; import { updateAlertStatus } from '../../containers/detection_engine/alerts/api'; @@ -30,6 +32,8 @@ import { replaceTemplateFieldFromMatchFilters, replaceTemplateFieldFromDataProviders, } from './helpers'; +import { KueryFilterQueryKind } from '../../../common/store'; +import { DataProvider } from '../../../timelines/components/timeline/data_providers/data_provider'; export const getUpdateAlertsQuery = (eventIds: Readonly) => { return { @@ -99,10 +103,45 @@ export const determineToAndFrom = ({ ecsData }: { ecsData: Ecs }) => { return { to, from }; }; +export const getThresholdAggregationDataProvider = ( + ecsData: Ecs, + nonEcsData: TimelineNonEcsData[] +): DataProvider[] => { + const aggregationField = ecsData.signal?.rule?.threshold.field; + const aggregationValue = + get(aggregationField, ecsData) ?? find(['field', aggregationField], nonEcsData)?.value; + const dataProviderValue = Array.isArray(aggregationValue) + ? aggregationValue[0] + : aggregationValue; + + if (!dataProviderValue) { + return []; + } + + const aggregationFieldId = aggregationField.replace('.', '-'); + + return [ + { + and: [], + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-${aggregationFieldId}-${dataProviderValue}`, + name: ecsData.signal?.rule?.threshold.field, + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: aggregationField, + value: dataProviderValue, + operator: ':', + }, + }, + ]; +}; + export const sendAlertToTimelineAction = async ({ apolloClient, createTimeline, ecsData, + nonEcsData, updateTimelineIsLoading, }: SendAlertToTimelineActionProps) => { let openAlertInBasicTimeline = true; @@ -146,7 +185,7 @@ export const sendAlertToTimelineAction = async ({ timeline.timelineType ); - createTimeline({ + return createTimeline({ from, timeline: { ...timeline, @@ -186,8 +225,62 @@ export const sendAlertToTimelineAction = async ({ } } - if (openAlertInBasicTimeline) { - createTimeline({ + if ( + ecsData.signal?.rule?.type?.length && + ecsData.signal?.rule?.type[0] === 'threshold' && + openAlertInBasicTimeline + ) { + return createTimeline({ + from, + timeline: { + ...timelineDefaults, + dataProviders: [ + { + and: [], + id: `send-alert-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-alert-id-${ecsData._id}`, + name: ecsData._id, + enabled: true, + excluded: false, + kqlQuery: '', + queryMatch: { + field: '_id', + value: ecsData._id, + operator: ':', + }, + }, + ...getThresholdAggregationDataProvider(ecsData, nonEcsData), + ], + id: 'timeline-1', + dateRange: { + start: from, + end: to, + }, + eventType: 'all', + kqlQuery: { + filterQuery: { + kuery: { + kind: ecsData.signal?.rule?.language?.length + ? (ecsData.signal?.rule?.language[0] as KueryFilterQueryKind) + : 'kuery', + expression: ecsData.signal?.rule?.query?.length ? ecsData.signal?.rule?.query[0] : '', + }, + serializedQuery: ecsData.signal?.rule?.query?.length + ? ecsData.signal?.rule?.query[0] + : '', + }, + filterQueryDraft: { + kind: ecsData.signal?.rule?.language?.length + ? (ecsData.signal?.rule?.language[0] as KueryFilterQueryKind) + : 'kuery', + expression: ecsData.signal?.rule?.query?.length ? ecsData.signal?.rule?.query[0] : '', + }, + }, + }, + to, + ruleNote: noteContent, + }); + } else { + return createTimeline({ from, timeline: { ...timelineDefaults, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx index 319575c9c307f5..6f1f2e46dce3d9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/default_config.tsx @@ -309,11 +309,12 @@ export const getAlertActions = ({ displayType: 'icon', iconType: 'timeline', id: 'sendAlertToTimeline', - onClick: ({ ecsData }: TimelineRowActionOnClick) => + onClick: ({ ecsData, data }: TimelineRowActionOnClick) => sendAlertToTimelineAction({ apolloClient, createTimeline, ecsData, + nonEcsData: data, updateTimelineIsLoading, }), width: DEFAULT_ICON_BUTTON_WIDTH, diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts index b127ff04eca46d..34d18b4dedba6e 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/types.ts @@ -7,7 +7,7 @@ import ApolloClient from 'apollo-client'; import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; -import { Ecs } from '../../../graphql/types'; +import { Ecs, TimelineNonEcsData } from '../../../graphql/types'; import { TimelineModel } from '../../../timelines/store/timeline/model'; import { inputsModel } from '../../../common/store'; @@ -53,6 +53,7 @@ export interface SendAlertToTimelineActionProps { apolloClient?: ApolloClient<{}>; createTimeline: CreateTimeline; ecsData: Ecs; + nonEcsData: TimelineNonEcsData[]; updateTimelineIsLoading: UpdateTimelineLoading; } diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.test.tsx index b82d1c0a36ab28..41ee91845a8ec8 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.test.tsx @@ -403,5 +403,17 @@ describe('helpers', () => { expect(result.description).toEqual('Query'); }); + + it('returns the label for a threshold type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'threshold'); + + expect(result.title).toEqual('Test label'); + }); + + it('returns a humanized description for a threshold type', () => { + const [result]: ListItems[] = buildRuleTypeDescription('Test label', 'threshold'); + + expect(result.description).toEqual('Threshold'); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx index a0d43c3abf5c17..8393f2230dcfef 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/helpers.tsx @@ -19,6 +19,7 @@ import { isEmpty } from 'lodash/fp'; import React from 'react'; import styled from 'styled-components'; +import { Threshold } from '../../../../../common/detection_engine/schemas/common/schemas'; import { RuleType } from '../../../../../common/detection_engine/types'; import { esFilters } from '../../../../../../../../src/plugins/data/public'; @@ -132,10 +133,10 @@ export const buildThreatDescription = ({ label, threat }: BuildThreatDescription {tactic != null ? tactic.text : ''} - {singleThreat.technique.map((technique) => { + {singleThreat.technique.map((technique, listIndex) => { const myTechnique = techniquesOptions.find((t) => t.id === technique.id); return ( - + [ + { + title: label, + description: ( + <> + {isEmpty(threshold.field[0]) + ? `${i18n.THRESHOLD_RESULTS_ALL} >= ${threshold.value}` + : `${i18n.THRESHOLD_RESULTS_AGGREGATED_BY} ${threshold.field[0]} >= ${threshold.value}`} + + ), + }, +]; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx index 0a7e666d65aef1..5a2a44a284e3b4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { StepRuleDescriptionComponent, @@ -367,6 +367,52 @@ describe('description_step', () => { }); }); + describe('threshold', () => { + test('returns threshold description when threshold exist and field is empty', () => { + const mockThreshold = { + isNew: false, + threshold: { + field: [''], + value: 100, + }, + }; + const result: ListItems[] = getDescriptionItem( + 'threshold', + 'Threshold label', + mockThreshold, + mockFilterManager + ); + + expect(result[0].title).toEqual('Threshold label'); + expect(React.isValidElement(result[0].description)).toBeTruthy(); + expect(mount(result[0].description as React.ReactElement).html()).toContain( + 'All results >= 100' + ); + }); + + test('returns threshold description when threshold exist and field is set', () => { + const mockThreshold = { + isNew: false, + threshold: { + field: ['user.name'], + value: 100, + }, + }; + const result: ListItems[] = getDescriptionItem( + 'threshold', + 'Threshold label', + mockThreshold, + mockFilterManager + ); + + expect(result[0].title).toEqual('Threshold label'); + expect(React.isValidElement(result[0].description)).toBeTruthy(); + expect(mount(result[0].description as React.ReactElement).html()).toContain( + 'Results aggregated by user.name >= 100' + ); + }); + }); + describe('references', () => { test('returns array of ListItems when references exist', () => { const result: ListItems[] = getDescriptionItem( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index 8f3a76c6aea577..51624d04cb58b1 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -35,6 +35,7 @@ import { buildUrlsDescription, buildNoteDescription, buildRuleTypeDescription, + buildThresholdDescription, } from './helpers'; import { useSiemJobs } from '../../../../common/components/ml_popover/hooks/use_siem_jobs'; import { buildMlJobDescription } from './ml_job_description'; @@ -179,6 +180,9 @@ export const getDescriptionItem = ( (singleThreat: IMitreEnterpriseAttack) => singleThreat.tactic.name !== 'none' ); return buildThreatDescription({ label, threat }); + } else if (field === 'threshold') { + const threshold = get(field, data); + return buildThresholdDescription(label, threshold); } else if (field === 'references') { const urls: string[] = get(field, data); return buildUrlsDescription(label, urls); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx index 3e639ede7a18b4..76217964a87cb4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/translations.tsx @@ -41,6 +41,13 @@ export const QUERY_TYPE_DESCRIPTION = i18n.translate( } ); +export const THRESHOLD_TYPE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.thresholdRuleTypeDescription', + { + defaultMessage: 'Threshold', + } +); + export const ML_JOB_STARTED = i18n.translate( 'xpack.securitySolution.detectionEngine.ruleDescription.mlJobStartedDescription', { @@ -54,3 +61,17 @@ export const ML_JOB_STOPPED = i18n.translate( defaultMessage: 'Stopped', } ); + +export const THRESHOLD_RESULTS_ALL = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.thresholdResultsAllDescription', + { + defaultMessage: 'All results', + } +); + +export const THRESHOLD_RESULTS_AGGREGATED_BY = i18n.translate( + 'xpack.securitySolution.detectionEngine.ruleDescription.thresholdResultsAggregatedByDescription', + { + defaultMessage: 'Results aggregated by', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts index 4d2ba8b861cce9..37c1715c05d71d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/translations.ts @@ -38,7 +38,7 @@ export const CREATE_RULE_ACTION = i18n.translate( export const UPDATE_PREPACKAGED_RULES_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesTitle', { - defaultMessage: 'Update available for Elastic prebuilt rules', + defaultMessage: 'Update available for Elastic prebuilt rules or timeline templates', } ); @@ -46,16 +46,56 @@ export const UPDATE_PREPACKAGED_RULES_MSG = (updateRules: number) => i18n.translate('xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesMsg', { values: { updateRules }, defaultMessage: - 'You can update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}}. Note that this will reload deleted Elastic prebuilt rules.', + 'You can update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}}', }); +export const UPDATE_PREPACKAGED_TIMELINES_MSG = (updateTimelines: number) => + i18n.translate('xpack.securitySolution.detectionEngine.rules.updatePrePackagedTimelinesMsg', { + values: { updateTimelines }, + defaultMessage: + 'You can update {updateTimelines} Elastic prebuilt {updateTimelines, plural, =1 {timeline} other {timelines}}', + }); + +export const UPDATE_PREPACKAGED_RULES_AND_TIMELINES_MSG = ( + updateRules: number, + updateTimelines: number +) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesAndTimelinesMsg', + { + values: { updateRules, updateTimelines }, + defaultMessage: + 'You can update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}} and {updateTimelines} Elastic prebuilt {updateTimelines, plural, =1 {timeline} other {timelines}}. Note that this will reload deleted Elastic prebuilt rules.', + } + ); + export const UPDATE_PREPACKAGED_RULES = (updateRules: number) => i18n.translate('xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesButton', { values: { updateRules }, defaultMessage: - 'Update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}} ', + 'Update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}}', }); +export const UPDATE_PREPACKAGED_TIMELINES = (updateTimelines: number) => + i18n.translate('xpack.securitySolution.detectionEngine.rules.updatePrePackagedTimelinesButton', { + values: { updateTimelines }, + defaultMessage: + 'Update {updateTimelines} Elastic prebuilt {updateTimelines, plural, =1 {timeline} other {timelines}}', + }); + +export const UPDATE_PREPACKAGED_RULES_AND_TIMELINES = ( + updateRules: number, + updateTimelines: number +) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.updatePrePackagedRulesAndTimelinesButton', + { + values: { updateRules, updateTimelines }, + defaultMessage: + 'Update {updateRules} Elastic prebuilt {updateRules, plural, =1 {rule} other {rules}} and {updateTimelines} Elastic prebuilt {updateTimelines, plural, =1 {timeline} other {timelines}}', + } + ); + export const RELEASE_NOTES_HELP = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.releaseNotesHelp', { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.test.tsx index b5dca70ad95758..5033fcd11dc7ca 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.test.tsx @@ -27,6 +27,7 @@ describe('UpdatePrePackagedRulesCallOut', () => { ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx index 0faf4074ed890d..3be2b853925f62 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/pre_packaged_rules/update_callout.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo } from 'react'; +import React, { memo, useMemo } from 'react'; import { EuiCallOut, EuiButton, EuiLink } from '@elastic/eui'; @@ -14,19 +14,46 @@ import * as i18n from './translations'; interface UpdatePrePackagedRulesCallOutProps { loading: boolean; numberOfUpdatedRules: number; + numberOfUpdatedTimelines: number; updateRules: () => void; } const UpdatePrePackagedRulesCallOutComponent: React.FC = ({ loading, numberOfUpdatedRules, + numberOfUpdatedTimelines, updateRules, }) => { const { services } = useKibana(); + + const prepackagedRulesOrTimelines = useMemo(() => { + if (numberOfUpdatedRules > 0 && numberOfUpdatedTimelines === 0) { + return { + callOutMessage: i18n.UPDATE_PREPACKAGED_RULES_MSG(numberOfUpdatedRules), + buttonTitle: i18n.UPDATE_PREPACKAGED_RULES(numberOfUpdatedRules), + }; + } else if (numberOfUpdatedRules === 0 && numberOfUpdatedTimelines > 0) { + return { + callOutMessage: i18n.UPDATE_PREPACKAGED_TIMELINES_MSG(numberOfUpdatedTimelines), + buttonTitle: i18n.UPDATE_PREPACKAGED_TIMELINES(numberOfUpdatedTimelines), + }; + } else if (numberOfUpdatedRules > 0 && numberOfUpdatedTimelines > 0) + return { + callOutMessage: i18n.UPDATE_PREPACKAGED_RULES_AND_TIMELINES_MSG( + numberOfUpdatedRules, + numberOfUpdatedTimelines + ), + buttonTitle: i18n.UPDATE_PREPACKAGED_RULES_AND_TIMELINES( + numberOfUpdatedRules, + numberOfUpdatedTimelines + ), + }; + }, [numberOfUpdatedRules, numberOfUpdatedTimelines]); + return (

- {i18n.UPDATE_PREPACKAGED_RULES_MSG(numberOfUpdatedRules)} + {prepackagedRulesOrTimelines?.callOutMessage}

- {i18n.UPDATE_PREPACKAGED_RULES(numberOfUpdatedRules)} + {prepackagedRulesOrTimelines?.buttonTitle}
); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx index 3dad53f532a5b5..6546c1ba59d84f 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/index.tsx @@ -4,52 +4,17 @@ * 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 { - EuiCard, - EuiFlexGrid, - EuiFlexItem, - EuiFormRow, - EuiIcon, - EuiLink, - EuiText, -} from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; +import { EuiCard, EuiFlexGrid, EuiFlexItem, EuiFormRow, EuiIcon } from '@elastic/eui'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { RuleType } from '../../../../../common/detection_engine/types'; import { FieldHook } from '../../../../shared_imports'; import { useKibana } from '../../../../common/lib/kibana'; import * as i18n from './translations'; +import { MlCardDescription } from './ml_card_description'; -const MlCardDescription = ({ - subscriptionUrl, - hasValidLicense = false, -}: { - subscriptionUrl: string; - hasValidLicense?: boolean; -}) => ( - - {hasValidLicense ? ( - i18n.ML_TYPE_DESCRIPTION - ) : ( - - - - ), - }} - /> - )} - -); +const isThresholdRule = (ruleType: RuleType) => ruleType === 'threshold'; interface SelectRuleTypeProps { describedByIds?: string[]; @@ -75,11 +40,39 @@ export const SelectRuleType: React.FC = ({ ); const setMl = useCallback(() => setType('machine_learning'), [setType]); const setQuery = useCallback(() => setType('query'), [setType]); + const setThreshold = useCallback(() => setType('threshold'), [setType]); const mlCardDisabled = isReadOnly || !hasValidLicense || !isMlAdmin; const licensingUrl = useKibana().services.application.getUrlForApp('kibana', { path: '#/management/stack/license_management', }); + const querySelectableConfig = useMemo( + () => ({ + isDisabled: isReadOnly, + onClick: setQuery, + isSelected: !isMlRule(ruleType) && !isThresholdRule(ruleType), + }), + [isReadOnly, ruleType, setQuery] + ); + + const mlSelectableConfig = useMemo( + () => ({ + isDisabled: mlCardDisabled, + onClick: setMl, + isSelected: isMlRule(ruleType), + }), + [mlCardDisabled, ruleType, setMl] + ); + + const thresholdSelectableConfig = useMemo( + () => ({ + isDisabled: isReadOnly, + onClick: setThreshold, + isSelected: isThresholdRule(ruleType), + }), + [isReadOnly, ruleType, setThreshold] + ); + return ( = ({ title={i18n.QUERY_TYPE_TITLE} description={i18n.QUERY_TYPE_DESCRIPTION} icon={} - selectable={{ - isDisabled: isReadOnly, - onClick: setQuery, - isSelected: !isMlRule(ruleType), - }} + isDisabled={querySelectableConfig.isDisabled && !querySelectableConfig.isSelected} + selectable={querySelectableConfig} />
@@ -109,12 +99,20 @@ export const SelectRuleType: React.FC = ({ } icon={} - isDisabled={mlCardDisabled} - selectable={{ - isDisabled: mlCardDisabled, - onClick: setMl, - isSelected: isMlRule(ruleType), - }} + isDisabled={mlSelectableConfig.isDisabled && !mlSelectableConfig.isSelected} + selectable={mlSelectableConfig} + /> + + + } + isDisabled={ + thresholdSelectableConfig.isDisabled && !thresholdSelectableConfig.isSelected + } + selectable={thresholdSelectableConfig} /> diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/ml_card_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/ml_card_description.tsx new file mode 100644 index 00000000000000..2171c93e47d63f --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/ml_card_description.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiText, EuiLink } from '@elastic/eui'; +import React from 'react'; + +import { ML_TYPE_DESCRIPTION } from './translations'; + +interface MlCardDescriptionProps { + subscriptionUrl: string; + hasValidLicense?: boolean; +} + +const MlCardDescriptionComponent: React.FC = ({ + subscriptionUrl, + hasValidLicense = false, +}) => ( + + {hasValidLicense ? ( + ML_TYPE_DESCRIPTION + ) : ( + + + + ), + }} + /> + )} + +); + +MlCardDescriptionComponent.displayName = 'MlCardDescriptionComponent'; + +export const MlCardDescription = React.memo(MlCardDescriptionComponent); + +MlCardDescription.displayName = 'MlCardDescription'; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts index 8b92d20616f7cf..3b85a7dfc765c7 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/select_rule_type/translations.ts @@ -33,3 +33,17 @@ export const ML_TYPE_DESCRIPTION = i18n.translate( defaultMessage: 'Select ML job to detect anomalous activity.', } ); + +export const THRESHOLD_TYPE_TITLE = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.ruleTypeField.thresholdTypeTitle', + { + defaultMessage: 'Threshold', + } +); + +export const THRESHOLD_TYPE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.ruleTypeField.thresholdTypeDescription', + { + defaultMessage: 'Aggregate query results to detect when number of matches exceeds threshold.', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 864f953bff1e1e..c7d70684b34cfd 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -35,12 +35,14 @@ import { MlJobSelect } from '../ml_job_select'; import { PickTimeline } from '../pick_timeline'; import { StepContentWrapper } from '../step_content_wrapper'; import { NextStep } from '../next_step'; +import { ThresholdInput } from '../threshold_input'; import { Field, Form, - FormDataProvider, getUseField, UseField, + UseMultiFields, + FormDataProvider, useForm, FormSchema, } from '../../../../shared_imports'; @@ -64,6 +66,10 @@ const stepDefineDefaultValue: DefineStepRule = { filters: [], saved_id: undefined, }, + threshold: { + field: [], + value: '200', + }, timeline: { id: null, title: DEFAULT_TIMELINE_TITLE, @@ -84,6 +90,12 @@ MyLabelButton.defaultProps = { flush: 'right', }; +const RuleTypeEuiFormRow = styled(EuiFormRow).attrs<{ $isVisible: boolean }>(({ $isVisible }) => ({ + style: { + display: $isVisible ? 'flex' : 'none', + }, +}))<{ $isVisible: boolean }>``; + const StepDefineRuleComponent: FC = ({ addPadding = false, defaultValues, @@ -97,7 +109,9 @@ const StepDefineRuleComponent: FC = ({ const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); const [indexModified, setIndexModified] = useState(false); - const [localIsMlRule, setIsMlRule] = useState(false); + const [localRuleType, setLocalRuleType] = useState( + defaultValues?.ruleType || stepDefineDefaultValue.ruleType + ); const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); const [myStepData, setMyStepData] = useState({ ...stepDefineDefaultValue, @@ -156,6 +170,17 @@ const StepDefineRuleComponent: FC = ({ setOpenTimelineSearch(false); }, []); + const ThresholdInputChildren = useCallback( + ({ thresholdField, thresholdValue }) => ( + + ), + [browserFields] + ); + return isReadOnlyView ? ( = ({ isMlAdmin: hasMlAdminPermissions(mlCapabilities), }} /> - + <> = ({ }} /> - - + + <> = ({ }} /> - + + + <> + + {ThresholdInputChildren} + + + = ({ } else if (!deepEqual(index, indicesConfig) && !indexModified) { setIndexModified(true); } + if (myStepData.index !== index) { + setMyStepData((prevValue) => ({ ...prevValue, index })); + } } - if (isMlRule(ruleType) && !localIsMlRule) { - setIsMlRule(true); - clearErrors(); - } else if (!isMlRule(ruleType) && localIsMlRule) { - setIsMlRule(false); + if (ruleType !== localRuleType) { + setLocalRuleType(ruleType); clearErrors(); } - return null; }} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx index 190d4484b156b8..67d795ccf90f00 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx @@ -172,4 +172,36 @@ export const schema: FormSchema = { } ), }, + threshold: { + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.fieldThresholdLabel', + { + defaultMessage: 'Threshold', + } + ), + field: { + type: FIELD_TYPES.COMBO_BOX, + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldThresholdFieldLabel', + { + defaultMessage: 'Field', + } + ), + helpText: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldThresholdFieldHelpText', + { + defaultMessage: 'Select a field to group results by', + } + ), + }, + value: { + type: FIELD_TYPES.NUMBER, + label: i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepAboutRule.fieldThresholdValueLabel', + { + defaultMessage: 'Threshold', + } + ), + }, + }, }; diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/index.tsx new file mode 100644 index 00000000000000..81e771ce4dc5b6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/index.tsx @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useMemo } from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import styled from 'styled-components'; + +import { BrowserFields } from '../../../../common/containers/source'; +import { getCategorizedFieldNames } from '../../../../timelines/components/edit_data_provider/helpers'; +import { FieldHook, Field } from '../../../../shared_imports'; +import { THRESHOLD_FIELD_PLACEHOLDER } from './translations'; + +const FIELD_COMBO_BOX_WIDTH = 410; + +export interface FieldValueThreshold { + field: string[]; + value: string; +} + +interface ThresholdInputProps { + thresholdField: FieldHook; + thresholdValue: FieldHook; + browserFields: BrowserFields; +} + +const OperatorWrapper = styled(EuiFlexItem)` + align-self: center; +`; + +const fieldDescribedByIds = ['detectionEngineStepDefineRuleThresholdField']; +const valueDescribedByIds = ['detectionEngineStepDefineRuleThresholdValue']; + +const ThresholdInputComponent: React.FC = ({ + thresholdField, + thresholdValue, + browserFields, +}: ThresholdInputProps) => { + const fieldEuiFieldProps = useMemo( + () => ({ + fullWidth: true, + singleSelection: { asPlainText: true }, + noSuggestions: false, + options: getCategorizedFieldNames(browserFields), + placeholder: THRESHOLD_FIELD_PLACEHOLDER, + onCreateOption: undefined, + style: { width: `${FIELD_COMBO_BOX_WIDTH}px` }, + }), + [browserFields] + ); + + return ( + + + + + {'>='} + + + + + ); +}; + +export const ThresholdInput = React.memo(ThresholdInputComponent); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/translations.ts b/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/translations.ts new file mode 100644 index 00000000000000..228848ef121300 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/rules/threshold_input/translations.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const THRESHOLD_FIELD_PLACEHOLDER = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepDefineRule.thresholdField.thresholdFieldPlaceholderText', + { + defaultMessage: 'All results', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx index ea5e075811d4b5..e21cbceeaef277 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_config.tsx @@ -19,17 +19,20 @@ export interface UseListsConfigReturn { } export const useListsConfig = (): UseListsConfigReturn => { - const { createIndex, indexExists, loading: indexLoading } = useListsIndex(); + const { createIndex, createIndexError, indexExists, loading: indexLoading } = useListsIndex(); const { canManageIndex, canWriteIndex, loading: privilegesLoading } = useListsPrivileges(); const { lists } = useKibana().services; const enabled = lists != null; const loading = indexLoading || privilegesLoading; const needsIndex = indexExists === false; - const needsConfiguration = !enabled || needsIndex || canWriteIndex === false; + const indexCreationFailed = createIndexError != null; + const needsIndexConfiguration = + needsIndex && (canManageIndex === false || (canManageIndex === true && indexCreationFailed)); + const needsConfiguration = !enabled || canWriteIndex === false || needsIndexConfiguration; useEffect(() => { - if (canManageIndex && needsIndex) { + if (needsIndex && canManageIndex) { createIndex(); } }, [canManageIndex, createIndex, needsIndex]); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx index a9497fd4971c1c..75f12bd07d3ae9 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/lists/use_lists_index.tsx @@ -18,6 +18,8 @@ export interface UseListsIndexState { export interface UseListsIndexReturn extends UseListsIndexState { loading: boolean; createIndex: () => void; + createIndexError: unknown; + createIndexResult: { acknowledged: boolean } | undefined; } export const useListsIndex = (): UseListsIndexReturn => { @@ -96,5 +98,11 @@ export const useListsIndex = (): UseListsIndexReturn => { } }, [createListIndexState.error, toasts]); - return { loading, createIndex, ...state }; + return { + loading, + createIndex, + createIndexError: createListIndexState.error, + createIndexResult: createListIndexState.result, + ...state, + }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts index 3275391f3f074f..f12a5d523bade7 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts @@ -34,6 +34,9 @@ export const getPrePackagedRulesStatus = async ({ rules_installed: 12, rules_not_installed: 0, rules_not_updated: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }); export const createPrepackagedRules = async ({ signal }: BasicFetchProps): Promise => diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts index 2b4b32bce9c7bb..f878b40b99dc3c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/translations.ts @@ -6,10 +6,10 @@ import { i18n } from '@kbn/i18n'; -export const RULE_FETCH_FAILURE = i18n.translate( - 'xpack.securitySolution.containers.detectionEngine.rules', +export const RULE_AND_TIMELINE_FETCH_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.rulesAndTimelines', { - defaultMessage: 'Failed to fetch Rules', + defaultMessage: 'Failed to fetch Rules and Timelines', } ); @@ -20,17 +20,17 @@ export const RULE_ADD_FAILURE = i18n.translate( } ); -export const RULE_PREPACKAGED_FAILURE = i18n.translate( - 'xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleFailDescription', +export const RULE_AND_TIMELINE_PREPACKAGED_FAILURE = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleAndTimelineFailDescription', { - defaultMessage: 'Failed to installed pre-packaged rules from elastic', + defaultMessage: 'Failed to installed pre-packaged rules and timelines from elastic', } ); -export const RULE_PREPACKAGED_SUCCESS = i18n.translate( - 'xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleSuccesDescription', +export const RULE_AND_TIMELINE_PREPACKAGED_SUCCESS = i18n.translate( + 'xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleAndTimelineSuccesDescription', { - defaultMessage: 'Installed pre-packaged rules from elastic', + defaultMessage: 'Installed pre-packaged rules and timelines from elastic', } ); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index c03d19eaf771e8..1f75ff0210bd51 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -16,6 +16,7 @@ import { rule_name_override, severity_mapping, timestamp_override, + threshold, } from '../../../../../common/detection_engine/schemas/common/schemas'; /* eslint-enable @typescript-eslint/camelcase */ import { @@ -65,6 +66,7 @@ export const NewRuleSchema = t.intersection([ saved_id: t.string, tags: t.array(t.string), threat: t.array(t.unknown), + threshold, throttle: t.union([t.string, t.null]), to: t.string, updated_by: t.string, @@ -142,6 +144,7 @@ export const RuleSchema = t.intersection([ saved_id: t.string, status: t.string, status_date: t.string, + threshold, timeline_id: t.string, timeline_title: t.string, timestamp_override, @@ -273,4 +276,7 @@ export interface PrePackagedRulesStatusResponse { rules_installed: number; rules_not_installed: number; rules_not_updated: number; + timelines_installed: number; + timelines_not_installed: number; + timelines_not_updated: number; } diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx index 4d9e283bfb9cc2..9a6ea4f60fdcc9 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx @@ -5,7 +5,7 @@ */ import { renderHook, act } from '@testing-library/react-hooks'; -import { ReturnPrePackagedRules, usePrePackagedRules } from './use_pre_packaged_rules'; +import { ReturnPrePackagedRulesAndTimelines, usePrePackagedRules } from './use_pre_packaged_rules'; import * as api from './api'; jest.mock('./api'); @@ -18,14 +18,15 @@ describe('usePersistRule', () => { test('init', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: null, - hasIndexWrite: null, - isAuthenticated: null, - hasEncryptionKey: null, - isSignalIndexExists: null, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: null, + hasIndexWrite: null, + isAuthenticated: null, + hasEncryptionKey: null, + isSignalIndexExists: null, + }) ); await waitForNextUpdate(); @@ -39,20 +40,24 @@ describe('usePersistRule', () => { rulesInstalled: null, rulesNotInstalled: null, rulesNotUpdated: null, + timelinesInstalled: null, + timelinesNotInstalled: null, + timelinesNotUpdated: null, }); }); }); test('fetch getPrePackagedRulesStatus', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: null, - hasIndexWrite: null, - isAuthenticated: null, - hasEncryptionKey: null, - isSignalIndexExists: null, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: null, + hasIndexWrite: null, + isAuthenticated: null, + hasEncryptionKey: null, + isSignalIndexExists: null, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -66,6 +71,9 @@ describe('usePersistRule', () => { rulesInstalled: 12, rulesNotInstalled: 0, rulesNotUpdated: 0, + timelinesInstalled: 0, + timelinesNotInstalled: 0, + timelinesNotUpdated: 0, }); }); }); @@ -73,14 +81,15 @@ describe('usePersistRule', () => { test('happy path to createPrePackagedRules', async () => { const spyOnCreatePrepackagedRules = jest.spyOn(api, 'createPrepackagedRules'); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: true, - isAuthenticated: true, - hasEncryptionKey: true, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -99,6 +108,9 @@ describe('usePersistRule', () => { rulesInstalled: 12, rulesNotInstalled: 0, rulesNotUpdated: 0, + timelinesInstalled: 0, + timelinesNotInstalled: 0, + timelinesNotUpdated: 0, }); }); }); @@ -109,14 +121,15 @@ describe('usePersistRule', () => { throw new Error('Something went wrong'); }); await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: true, - isAuthenticated: true, - hasEncryptionKey: true, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -131,14 +144,15 @@ describe('usePersistRule', () => { test('can NOT createPrePackagedRules because canUserCrud === false', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: false, - hasIndexWrite: true, - isAuthenticated: true, - hasEncryptionKey: true, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: false, + hasIndexWrite: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -152,14 +166,15 @@ describe('usePersistRule', () => { test('can NOT createPrePackagedRules because hasIndexWrite === false', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: false, - isAuthenticated: true, - hasEncryptionKey: true, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: false, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -173,14 +188,15 @@ describe('usePersistRule', () => { test('can NOT createPrePackagedRules because isAuthenticated === false', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: true, - isAuthenticated: false, - hasEncryptionKey: true, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + isAuthenticated: false, + hasEncryptionKey: true, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -194,14 +210,15 @@ describe('usePersistRule', () => { test('can NOT createPrePackagedRules because hasEncryptionKey === false', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: true, - isAuthenticated: true, - hasEncryptionKey: false, - isSignalIndexExists: true, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + isAuthenticated: true, + hasEncryptionKey: false, + isSignalIndexExists: true, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); @@ -215,14 +232,15 @@ describe('usePersistRule', () => { test('can NOT createPrePackagedRules because isSignalIndexExists === false', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePrePackagedRules({ - canUserCRUD: true, - hasIndexWrite: true, - isAuthenticated: true, - hasEncryptionKey: true, - isSignalIndexExists: false, - }) + const { result, waitForNextUpdate } = renderHook( + () => + usePrePackagedRules({ + canUserCRUD: true, + hasIndexWrite: true, + isAuthenticated: true, + hasEncryptionKey: true, + isSignalIndexExists: false, + }) ); await waitForNextUpdate(); await waitForNextUpdate(); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx index 5f5ee53c29caf9..08c85695e9313f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.tsx @@ -16,7 +16,14 @@ import * as i18n from './translations'; type Func = () => void; export type CreatePreBuiltRules = () => Promise; -export interface ReturnPrePackagedRules { + +interface ReturnPrePackagedTimelines { + timelinesInstalled: number | null; + timelinesNotInstalled: number | null; + timelinesNotUpdated: number | null; +} + +interface ReturnPrePackagedRules { createPrePackagedRules: null | CreatePreBuiltRules; loading: boolean; loadingCreatePrePackagedRules: boolean; @@ -27,6 +34,9 @@ export interface ReturnPrePackagedRules { rulesNotUpdated: number | null; } +export type ReturnPrePackagedRulesAndTimelines = ReturnPrePackagedRules & + ReturnPrePackagedTimelines; + interface UsePrePackagedRuleProps { canUserCRUD: boolean | null; hasIndexWrite: boolean | null; @@ -50,16 +60,19 @@ export const usePrePackagedRules = ({ isAuthenticated, hasEncryptionKey, isSignalIndexExists, -}: UsePrePackagedRuleProps): ReturnPrePackagedRules => { - const [rulesStatus, setRuleStatus] = useState< +}: UsePrePackagedRuleProps): ReturnPrePackagedRulesAndTimelines => { + const [prepackagedDataStatus, setPrepackagedDataStatus] = useState< Pick< - ReturnPrePackagedRules, + ReturnPrePackagedRulesAndTimelines, | 'createPrePackagedRules' | 'refetchPrePackagedRulesStatus' | 'rulesCustomInstalled' | 'rulesInstalled' | 'rulesNotInstalled' | 'rulesNotUpdated' + | 'timelinesInstalled' + | 'timelinesNotInstalled' + | 'timelinesNotUpdated' > >({ createPrePackagedRules: null, @@ -68,7 +81,11 @@ export const usePrePackagedRules = ({ rulesInstalled: null, rulesNotInstalled: null, rulesNotUpdated: null, + timelinesInstalled: null, + timelinesNotInstalled: null, + timelinesNotUpdated: null, }); + const [loadingCreatePrePackagedRules, setLoadingCreatePrePackagedRules] = useState(false); const [loading, setLoading] = useState(true); const [, dispatchToaster] = useStateToaster(); @@ -85,26 +102,33 @@ export const usePrePackagedRules = ({ }); if (isSubscribed) { - setRuleStatus({ + setPrepackagedDataStatus({ createPrePackagedRules: createElasticRules, refetchPrePackagedRulesStatus: fetchPrePackagedRules, rulesCustomInstalled: prePackagedRuleStatusResponse.rules_custom_installed, rulesInstalled: prePackagedRuleStatusResponse.rules_installed, rulesNotInstalled: prePackagedRuleStatusResponse.rules_not_installed, rulesNotUpdated: prePackagedRuleStatusResponse.rules_not_updated, + timelinesInstalled: prePackagedRuleStatusResponse.timelines_installed, + timelinesNotInstalled: prePackagedRuleStatusResponse.timelines_not_installed, + timelinesNotUpdated: prePackagedRuleStatusResponse.timelines_not_updated, }); } } catch (error) { if (isSubscribed) { - setRuleStatus({ + setPrepackagedDataStatus({ createPrePackagedRules: null, refetchPrePackagedRulesStatus: null, rulesCustomInstalled: null, rulesInstalled: null, rulesNotInstalled: null, rulesNotUpdated: null, + timelinesInstalled: null, + timelinesNotInstalled: null, + timelinesNotUpdated: null, }); - errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + + errorToToaster({ title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { @@ -149,15 +173,22 @@ export const usePrePackagedRules = ({ iterationTryOfFetchingPrePackagedCount > 100) ) { setLoadingCreatePrePackagedRules(false); - setRuleStatus({ + setPrepackagedDataStatus({ createPrePackagedRules: createElasticRules, refetchPrePackagedRulesStatus: fetchPrePackagedRules, rulesCustomInstalled: prePackagedRuleStatusResponse.rules_custom_installed, rulesInstalled: prePackagedRuleStatusResponse.rules_installed, rulesNotInstalled: prePackagedRuleStatusResponse.rules_not_installed, rulesNotUpdated: prePackagedRuleStatusResponse.rules_not_updated, + timelinesInstalled: prePackagedRuleStatusResponse.timelines_installed, + timelinesNotInstalled: prePackagedRuleStatusResponse.timelines_not_installed, + timelinesNotUpdated: prePackagedRuleStatusResponse.timelines_not_updated, }); - displaySuccessToast(i18n.RULE_PREPACKAGED_SUCCESS, dispatchToaster); + + displaySuccessToast( + i18n.RULE_AND_TIMELINE_PREPACKAGED_SUCCESS, + dispatchToaster + ); stopTimeOut(); resolve(true); } else { @@ -172,7 +203,11 @@ export const usePrePackagedRules = ({ } catch (error) { if (isSubscribed) { setLoadingCreatePrePackagedRules(false); - errorToToaster({ title: i18n.RULE_PREPACKAGED_FAILURE, error, dispatchToaster }); + errorToToaster({ + title: i18n.RULE_AND_TIMELINE_PREPACKAGED_FAILURE, + error, + dispatchToaster, + }); resolve(false); } } @@ -191,6 +226,6 @@ export const usePrePackagedRules = ({ return { loading, loadingCreatePrePackagedRules, - ...rulesStatus, + ...prepackagedDataStatus, }; }; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx index 3256273fb84253..706c2645a4dddc 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule.tsx @@ -41,7 +41,7 @@ export const useRule = (id: string | undefined): ReturnRule => { } catch (error) { if (isSubscribed) { setRule(null); - errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx index ec1da29de4ba81..0e96f58ee68741 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rule_status.tsx @@ -49,7 +49,7 @@ export const useRuleStatus = (id: string | undefined | null): ReturnRuleStatus = } catch (error) { if (isSubscribed) { setRuleStatus(null); - errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { @@ -106,7 +106,7 @@ export const useRulesStatuses = (rules: Rules): ReturnRulesStatuses => { } catch (error) { if (isSubscribed) { setRuleStatuses([]); - errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE, error, dispatchToaster }); } } if (isSubscribed) { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rules.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rules.tsx index 1a1dbc6e2b3683..3466472ad7276f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rules.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_rules.tsx @@ -67,7 +67,7 @@ export const useRules = ({ } } catch (error) { if (isSubscribed) { - errorToToaster({ title: i18n.RULE_FETCH_FAILURE, error, dispatchToaster }); + errorToToaster({ title: i18n.RULE_AND_TIMELINE_FETCH_FAILURE, error, dispatchToaster }); if (dispatchRulesInReducer != null) { dispatchRulesInReducer([], {}); } diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 2b86abf4255c62..5d84cf53140295 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -153,6 +153,10 @@ export const mockRuleWithEverything = (id: string): Rule => ({ ], }, ], + threshold: { + field: 'host.name', + value: 50, + }, throttle: 'no_actions', timestamp_override: 'event.ingested', note: '# this is some markdown documentation', @@ -213,6 +217,10 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Titled timeline', }, + threshold: { + field: [''], + value: '100', + }, }); export const mockScheduleStepRule = (isNew = false): ScheduleStepRule => ({ diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index 8331346b19ac9b..4bb7196e17db57 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts @@ -51,19 +51,29 @@ export interface RuleFields { queryBar: unknown; index: unknown; ruleType: unknown; + threshold?: unknown; } -type QueryRuleFields = Omit; +type QueryRuleFields = Omit; +type ThresholdRuleFields = Omit; type MlRuleFields = Omit; -const isMlFields = (fields: QueryRuleFields | MlRuleFields): fields is MlRuleFields => - has('anomalyThreshold', fields); +const isMlFields = ( + fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields +): fields is MlRuleFields => has('anomalyThreshold', fields); + +const isThresholdFields = ( + fields: QueryRuleFields | MlRuleFields | ThresholdRuleFields +): fields is ThresholdRuleFields => has('threshold', fields); export const filterRuleFieldsForType = (fields: T, type: RuleType) => { if (isMlRule(type)) { const { index, queryBar, ...mlRuleFields } = fields; return mlRuleFields; + } else if (type === 'threshold') { + const { anomalyThreshold, machineLearningJobId, ...thresholdRuleFields } = fields; + return thresholdRuleFields; } else { - const { anomalyThreshold, machineLearningJobId, ...queryRuleFields } = fields; + const { anomalyThreshold, machineLearningJobId, threshold, ...queryRuleFields } = fields; return queryRuleFields; } }; @@ -85,6 +95,20 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep anomaly_threshold: ruleFields.anomalyThreshold, machine_learning_job_id: ruleFields.machineLearningJobId, } + : isThresholdFields(ruleFields) + ? { + index: ruleFields.index, + filters: ruleFields.queryBar?.filters, + language: ruleFields.queryBar?.query?.language, + query: ruleFields.queryBar?.query?.query as string, + saved_id: ruleFields.queryBar?.saved_id, + ...(ruleType === 'threshold' && { + threshold: { + field: ruleFields.threshold?.field[0] ?? '', + value: parseInt(ruleFields.threshold?.value, 10) ?? 0, + }, + }), + } : { index: ruleFields.index, filters: ruleFields.queryBar?.filters, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index f8969f06c8ef63..590643f8236eea 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -74,6 +74,10 @@ describe('rule helpers', () => { ], saved_id: 'test123', }, + threshold: { + field: ['host.name'], + value: '50', + }, timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Titled timeline', @@ -206,6 +210,10 @@ describe('rule helpers', () => { filters: [], saved_id: "Garrett's IP", }, + threshold: { + field: [], + value: '100', + }, timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Untitled timeline', @@ -235,6 +243,10 @@ describe('rule helpers', () => { filters: [], saved_id: undefined, }, + threshold: { + field: [], + value: '100', + }, timeline: { id: '86aa74d0-2136-11ea-9864-ebc8cc1cb8c2', title: 'Untitled timeline', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index 6a98280076b309..6541b92f575c1c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -84,6 +84,10 @@ export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ id: rule.timeline_id ?? null, title: rule.timeline_title ?? null, }, + threshold: { + field: rule.threshold?.field ? [rule.threshold.field] : [], + value: `${rule.threshold?.value || 100}`, + }, }); export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { @@ -182,6 +186,13 @@ export type PrePackagedRuleStatus = | 'someRuleUninstall' | 'unknown'; +export type PrePackagedTimelineStatus = + | 'timelinesNotInstalled' + | 'timelinesInstalled' + | 'someTimelineUninstall' + | 'timelineNeedUpdate' + | 'unknown'; + export const getPrePackagedRuleStatus = ( rulesInstalled: number | null, rulesNotInstalled: number | null, @@ -221,6 +232,45 @@ export const getPrePackagedRuleStatus = ( } return 'unknown'; }; +export const getPrePackagedTimelineStatus = ( + timelinesInstalled: number | null, + timelinesNotInstalled: number | null, + timelinesNotUpdated: number | null +): PrePackagedTimelineStatus => { + if ( + timelinesNotInstalled != null && + timelinesInstalled === 0 && + timelinesNotInstalled > 0 && + timelinesNotUpdated === 0 + ) { + return 'timelinesNotInstalled'; + } else if ( + timelinesInstalled != null && + timelinesInstalled > 0 && + timelinesNotInstalled === 0 && + timelinesNotUpdated === 0 + ) { + return 'timelinesInstalled'; + } else if ( + timelinesInstalled != null && + timelinesNotInstalled != null && + timelinesInstalled > 0 && + timelinesNotInstalled > 0 && + timelinesNotUpdated === 0 + ) { + return 'someTimelineUninstall'; + } else if ( + timelinesInstalled != null && + timelinesNotInstalled != null && + timelinesNotUpdated != null && + timelinesInstalled > 0 && + timelinesNotInstalled >= 0 && + timelinesNotUpdated > 0 + ) { + return 'timelineNeedUpdate'; + } + return 'unknown'; +}; export const setFieldValue = ( form: FormHook, schema: FormSchema, @@ -244,6 +294,20 @@ export const redirectToDetections = ( hasEncryptionKey === false || needsListsConfiguration; +const getRuleSpecificRuleParamKeys = (ruleType: RuleType) => { + const queryRuleParams = ['index', 'filters', 'language', 'query', 'saved_id']; + + if (isMlRule(ruleType)) { + return ['anomaly_threshold', 'machine_learning_job_id']; + } + + if (ruleType === 'threshold') { + return ['threshold', ...queryRuleParams]; + } + + return queryRuleParams; +}; + export const getActionMessageRuleParams = (ruleType: RuleType): string[] => { const commonRuleParamsKeys = [ 'id', @@ -266,9 +330,7 @@ export const getActionMessageRuleParams = (ruleType: RuleType): string[] => { const ruleParamsKeys = [ ...commonRuleParamsKeys, - ...(isMlRule(ruleType) - ? ['anomaly_threshold', 'machine_learning_job_id'] - : ['index', 'filters', 'language', 'query', 'saved_id']), + ...getRuleSpecificRuleParamKeys(ruleType), ].sort(); return ruleParamsKeys; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index 0fce9e5ea3a44c..c1d8436a7230ed 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -24,7 +24,12 @@ import { ImportDataModal } from '../../../../common/components/import_data_modal import { ReadOnlyCallOut } from '../../../components/rules/read_only_callout'; import { ValueListsModal } from '../../../components/value_lists_management_modal'; import { UpdatePrePackagedRulesCallOut } from '../../../components/rules/pre_packaged_rules/update_callout'; -import { getPrePackagedRuleStatus, redirectToDetections, userHasNoPermissions } from './helpers'; +import { + getPrePackagedRuleStatus, + getPrePackagedTimelineStatus, + redirectToDetections, + userHasNoPermissions, +} from './helpers'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../app/types'; import { LinkButton } from '../../../../common/components/links'; @@ -61,6 +66,9 @@ const RulesPageComponent: React.FC = () => { rulesInstalled, rulesNotInstalled, rulesNotUpdated, + timelinesInstalled, + timelinesNotInstalled, + timelinesNotUpdated, } = usePrePackagedRules({ canUserCRUD, hasIndexWrite, @@ -68,13 +76,19 @@ const RulesPageComponent: React.FC = () => { isAuthenticated, hasEncryptionKey, }); - const { formatUrl } = useFormatUrl(SecurityPageName.detections); const prePackagedRuleStatus = getPrePackagedRuleStatus( rulesInstalled, rulesNotInstalled, rulesNotUpdated ); + const prePackagedTimelineStatus = getPrePackagedTimelineStatus( + timelinesInstalled, + timelinesNotInstalled, + timelinesNotUpdated + ); + const { formatUrl } = useFormatUrl(SecurityPageName.detections); + const handleRefreshRules = useCallback(async () => { if (refreshRulesData.current != null) { refreshRulesData.current(true); @@ -98,6 +112,18 @@ const RulesPageComponent: React.FC = () => { refreshRulesData.current = refreshRule; }, []); + const getMissingRulesOrTimelinesButtonTitle = useCallback( + (missingRules: number, missingTimelines: number) => { + if (missingRules > 0 && missingTimelines === 0) + return i18n.RELOAD_MISSING_PREPACKAGED_RULES(missingRules); + else if (missingRules === 0 && missingTimelines > 0) + return i18n.RELOAD_MISSING_PREPACKAGED_TIMELINES(missingTimelines); + else if (missingRules > 0 && missingTimelines > 0) + return i18n.RELOAD_MISSING_PREPACKAGED_RULES_AND_TIMELINES(missingRules, missingTimelines); + }, + [] + ); + const goToNewRule = useCallback( (ev) => { ev.preventDefault(); @@ -147,7 +173,8 @@ const RulesPageComponent: React.FC = () => { title={i18n.PAGE_TITLE} > - {prePackagedRuleStatus === 'ruleNotInstalled' && ( + {(prePackagedRuleStatus === 'ruleNotInstalled' || + prePackagedTimelineStatus === 'timelinesNotInstalled') && ( { )} - {prePackagedRuleStatus === 'someRuleUninstall' && ( + {(prePackagedRuleStatus === 'someRuleUninstall' || + prePackagedTimelineStatus === 'someTimelineUninstall') && ( { isDisabled={userHasNoPermissions(canUserCRUD) || loading} onClick={handleCreatePrePackagedRules} > - {i18n.RELOAD_MISSING_PREPACKAGED_RULES(rulesNotInstalled ?? 0)} + {getMissingRulesOrTimelinesButtonTitle( + rulesNotInstalled ?? 0, + timelinesNotInstalled ?? 0 + )} )} @@ -206,10 +237,12 @@ const RulesPageComponent: React.FC = () => {
- {prePackagedRuleStatus === 'ruleNeedUpdate' && ( + {(prePackagedRuleStatus === 'ruleNeedUpdate' || + prePackagedTimelineStatus === 'timelineNeedUpdate') && ( )} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 5e6f8ac896e34f..4f292b1bbbab87 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -468,7 +468,7 @@ export const DELETE = i18n.translate( export const LOAD_PREPACKAGED_RULES = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.loadPrePackagedRulesButton', { - defaultMessage: 'Load Elastic prebuilt rules', + defaultMessage: 'Load Elastic prebuilt rules and timeline templates', } ); @@ -482,6 +482,29 @@ export const RELOAD_MISSING_PREPACKAGED_RULES = (missingRules: number) => } ); +export const RELOAD_MISSING_PREPACKAGED_TIMELINES = (missingTimelines: number) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.reloadMissingPrePackagedTimelinesButton', + { + values: { missingTimelines }, + defaultMessage: + 'Install {missingTimelines} Elastic prebuilt {missingTimelines, plural, =1 {timeline} other {timelines}} ', + } + ); + +export const RELOAD_MISSING_PREPACKAGED_RULES_AND_TIMELINES = ( + missingRules: number, + missingTimelines: number +) => + i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.reloadMissingPrePackagedRulesAndTimelinesButton', + { + values: { missingRules, missingTimelines }, + defaultMessage: + 'Install {missingRules} Elastic prebuilt {missingRules, plural, =1 {rule} other {rules}} and {missingTimelines} Elastic prebuilt {missingTimelines, plural, =1 {timeline} other {timelines}} ', + } + ); + export const IMPORT_RULE_BTN_TITLE = i18n.translate( 'xpack.securitySolution.detectionEngine.components.importRuleModal.importRuleTitle', { diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts index f453b5a95994d0..e7daff0947b0d5 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/types.ts @@ -10,6 +10,7 @@ import { Filter } from '../../../../../../../../src/plugins/data/common'; import { FormData, FormHook } from '../../../../shared_imports'; import { FieldValueQueryBar } from '../../../components/rules/query_bar'; import { FieldValueTimeline } from '../../../components/rules/pick_timeline'; +import { FieldValueThreshold } from '../../../components/rules/threshold_input'; import { Author, BuildingBlockType, @@ -99,6 +100,7 @@ export interface DefineStepRule extends StepRuleData { queryBar: FieldValueQueryBar; ruleType: RuleType; timeline: FieldValueTimeline; + threshold: FieldValueThreshold; } export interface ScheduleStepRule extends StepRuleData { @@ -122,6 +124,10 @@ export interface DefineStepRuleJson { saved_id?: string; query?: string; language?: string; + threshold?: { + field: string; + value: number; + }; timeline_id?: string; timeline_title?: string; type: RuleType; diff --git a/x-pack/plugins/security_solution/public/graphql/introspection.json b/x-pack/plugins/security_solution/public/graphql/introspection.json index 20978fa3b063c5..43c478ff120a08 100644 --- a/x-pack/plugins/security_solution/public/graphql/introspection.json +++ b/x-pack/plugins/security_solution/public/graphql/introspection.json @@ -4750,6 +4750,14 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "threshold", + "description": "", + "args": [], + "type": { "kind": "SCALAR", "name": "ToAny", "ofType": null }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "exceptions_list", "description": "", @@ -10593,21 +10601,13 @@ { "name": "pageIndex", "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } - }, + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, "defaultValue": null }, { "name": "pageSize", "description": "", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { "kind": "SCALAR", "name": "Float", "ofType": null } - }, + "type": { "kind": "SCALAR", "name": "Float", "ofType": null }, "defaultValue": null } ], diff --git a/x-pack/plugins/security_solution/public/graphql/types.ts b/x-pack/plugins/security_solution/public/graphql/types.ts index 27aa02038097e5..084d1a63fec75f 100644 --- a/x-pack/plugins/security_solution/public/graphql/types.ts +++ b/x-pack/plugins/security_solution/public/graphql/types.ts @@ -96,9 +96,9 @@ export interface TlsSortField { } export interface PageInfoTimeline { - pageIndex: number; + pageIndex?: Maybe; - pageSize: number; + pageSize?: Maybe; } export interface SortTimeline { @@ -1070,6 +1070,8 @@ export interface RuleField { note?: Maybe; + threshold?: Maybe; + exceptions_list?: Maybe; } @@ -5066,6 +5068,10 @@ export namespace GetTimelineQuery { note: Maybe; + type: Maybe; + + threshold: Maybe; + exceptions_list: Maybe; }; diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts index 59e944d95e04b6..7d16dc251e6fc9 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts @@ -70,10 +70,6 @@ export function ResolverTreeFetcher( const entityIDToFetch = matchingEntities[0].entity_id; result = await context.services.http.get(`/api/endpoint/resolver/${entityIDToFetch}`, { signal: lastRequestAbortController.signal, - query: { - children: 5, - ancestors: 5, - }, }); } catch (error) { // https://developer.mozilla.org/en-US/docs/Web/API/DOMException#exception-AbortError diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index fcd23ff9df4d83..5d4579b427f18f 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -18,6 +18,7 @@ export { FormHook, FormSchema, UseField, + UseMultiFields, useForm, ValidationFunc, VALIDATION_TYPES, diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx index 6d332c79f77cdc..d2ddaae47d1e3a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/index.tsx @@ -124,7 +124,12 @@ export const StatefulOpenTimelineComponent = React.memo( defaultTimelineCount, templateTimelineCount, }); - const { timelineStatus, templateTimelineType, templateTimelineFilter } = useTimelineStatus({ + const { + timelineStatus, + templateTimelineType, + templateTimelineFilter, + installPrepackagedTimelines, + } = useTimelineStatus({ timelineType, customTemplateTimelineCount, elasticTemplateTimelineCount, @@ -287,7 +292,13 @@ export const StatefulOpenTimelineComponent = React.memo( focusInput(); }, []); - useEffect(() => refetch(), [refetch]); + useEffect(() => { + const fetchData = async () => { + await installPrepackagedTimelines(); + refetch(); + }; + fetchData(); + }, [refetch, installPrepackagedTimelines]); return !isModal ? ( void; } => { const [selectedTab, setSelectedTab] = useState( TemplateTimelineType.elastic @@ -101,9 +103,16 @@ export const useTimelineStatus = ({ : null; }, [templateTimelineType, filters, isTemplateFilterEnabled, onFilterClicked]); + const installPrepackagedTimelines = useCallback(async () => { + if (templateTimelineType === TemplateTimelineType.elastic) { + await installPrepackedTimelines(); + } + }, [templateTimelineType]); + return { timelineStatus, templateTimelineType, templateTimelineFilter, + installPrepackagedTimelines, }; }; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx index 125ba23a5c5a53..c9c8250922161c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/actions/index.tsx @@ -96,7 +96,7 @@ export const Actions = React.memo( data-test-subj="event-actions-container" > {showCheckboxes && ( - + {loadingEventIds.includes(eventId) ? ( @@ -117,7 +117,7 @@ export const Actions = React.memo( )} - + {loading ? ( @@ -137,7 +137,7 @@ export const Actions = React.memo( {!isEventViewer && ( <> - + ( - + ( ...acc, icon: [ ...acc.icon, - - + + ( return grouped.contextMenu.length > 0 ? [ ...grouped.icon, - - + + ( : grouped.icon; }, [button, closePopover, id, onClickCb, data, ecsData, timelineActions, isPopoverOpen]); + const handlePinClicked = useCallback( + () => + getPinOnClick({ + allowUnpinning: !eventHasNotes(eventIdToNoteIds[id]), + eventId: id, + onPinEvent, + onUnPinEvent, + isEventPinned, + }), + [eventIdToNoteIds, id, isEventPinned, onPinEvent, onUnPinEvent] + ); + return ( ( loadingEventIds={loadingEventIds} noteIds={eventIdToNoteIds[id] || emptyNotes} onEventToggled={onEventToggled} - onPinClicked={getPinOnClick({ - allowUnpinning: !eventHasNotes(eventIdToNoteIds[id]), - eventId: id, - onPinEvent, - onUnPinEvent, - isEventPinned, - })} + onPinClicked={handlePinClicked} showCheckboxes={showCheckboxes} showNotes={showNotes} toggleShowNotes={toggleShowNotes} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts index 7fa520a2d8df4b..b5c78c458697c9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/search_or_filter/translations.ts @@ -73,7 +73,7 @@ export const FILTER_OR_SEARCH_WITH_KQL = i18n.translate( export const ALL_EVENT = i18n.translate( 'xpack.securitySolution.timeline.searchOrFilter.eventTypeAllEvent', { - defaultMessage: 'All events', + defaultMessage: 'All', } ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx index 78a46e04a69529..7711cb7ba620e0 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/timeline.test.tsx @@ -167,7 +167,7 @@ describe('Timeline', () => { expect(wrapper.find('[data-test-subj="table-pagination"]').exists()).toEqual(false); }); - test('it defaults to showing `All events`', () => { + test('it defaults to showing `All`', () => { const wrapper = mount( @@ -176,9 +176,7 @@ describe('Timeline', () => { ); - expect(wrapper.find('[data-test-subj="pick-event-type"] button').text()).toEqual( - 'All events' - ); + expect(wrapper.find('[data-test-subj="pick-event-type"] button').text()).toEqual('All'); }); it('it shows the timeline footer', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/containers/api.ts b/x-pack/plugins/security_solution/public/timelines/containers/api.ts index 72e1f1d4de32d7..e08d52066ebdcc 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/api.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/api.ts @@ -14,6 +14,8 @@ import { TimelineStatus, TimelineErrorResponseType, TimelineErrorResponse, + ImportTimelineResultSchema, + importTimelineResultSchema, } from '../../../common/types/timeline'; import { TimelineInput, TimelineType } from '../../graphql/types'; import { @@ -21,6 +23,7 @@ import { TIMELINE_DRAFT_URL, TIMELINE_IMPORT_URL, TIMELINE_EXPORT_URL, + TIMELINE_PREPACKAGED_URL, } from '../../../common/constants'; import { KibanaServices } from '../../common/lib/kibana'; @@ -56,6 +59,12 @@ const decodeTimelineErrorResponse = (respTimeline?: TimelineErrorResponse) => fold(throwErrors(createToasterPlainError), identity) ); +const decodePrepackedTimelineResponse = (respTimeline?: ImportTimelineResultSchema) => + pipe( + importTimelineResultSchema.decode(respTimeline), + fold(throwErrors(createToasterPlainError), identity) + ); + const postTimeline = async ({ timeline }: RequestPostTimeline): Promise => { const response = await KibanaServices.get().http.post(TIMELINE_URL, { method: 'POST', @@ -200,3 +209,12 @@ export const cleanDraftTimeline = async ({ return decodeTimelineResponse(response); }; + +export const installPrepackedTimelines = async (): Promise => { + const response = await KibanaServices.get().http.post( + TIMELINE_PREPACKAGED_URL, + {} + ); + + return decodePrepackedTimelineResponse(response); +}; diff --git a/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts b/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts index 2624532b78d4db..6c90b39a8e688c 100644 --- a/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts +++ b/x-pack/plugins/security_solution/public/timelines/containers/index.gql_query.ts @@ -210,6 +210,8 @@ export const timelineQuery = gql` to filters note + type + threshold exceptions_list } } diff --git a/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts index f8afbae840d087..5b093a02b65143 100644 --- a/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/ecs/schema.gql.ts @@ -416,6 +416,7 @@ export const ecsSchema = gql` updated_by: ToStringArray version: ToStringArray note: ToStringArray + threshold: ToAny exceptions_list: ToAny } diff --git a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts index 15e188e281d103..7cbeea67b27509 100644 --- a/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts +++ b/x-pack/plugins/security_solution/server/graphql/timeline/schema.gql.ts @@ -183,8 +183,8 @@ export const timelineSchema = gql` } input PageInfoTimeline { - pageIndex: Float! - pageSize: Float! + pageIndex: Float + pageSize: Float } enum SortFieldTimeline { diff --git a/x-pack/plugins/security_solution/server/graphql/types.ts b/x-pack/plugins/security_solution/server/graphql/types.ts index 6553f709a7fa70..668266cc67c3a9 100644 --- a/x-pack/plugins/security_solution/server/graphql/types.ts +++ b/x-pack/plugins/security_solution/server/graphql/types.ts @@ -98,9 +98,9 @@ export interface TlsSortField { } export interface PageInfoTimeline { - pageIndex: number; + pageIndex?: Maybe; - pageSize: number; + pageSize?: Maybe; } export interface SortTimeline { @@ -1072,6 +1072,8 @@ export interface RuleField { note?: Maybe; + threshold?: Maybe; + exceptions_list?: Maybe; } @@ -4939,6 +4941,8 @@ export namespace RuleFieldResolvers { note?: NoteResolver, TypeParent, TContext>; + threshold?: ThresholdResolver, TypeParent, TContext>; + exceptions_list?: ExceptionsListResolver, TypeParent, TContext>; } @@ -5097,6 +5101,11 @@ export namespace RuleFieldResolvers { Parent = RuleField, TContext = SiemContext > = Resolver; + export type ThresholdResolver< + R = Maybe, + Parent = RuleField, + TContext = SiemContext + > = Resolver; export type ExceptionsListResolver< R = Maybe, Parent = RuleField, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts index 0cec1832dab830..bfaab096a50135 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/index.ts @@ -26,3 +26,9 @@ export const createMockConfig = () => ({ to: 'now', }, }); + +export const mockGetCurrentUser = { + user: { + username: 'mockUser', + }, +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts index 9ca102b4375114..29c56e8ed80b1e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_responses.ts @@ -394,6 +394,7 @@ export const getResult = (): RuleAlertType => ({ ], }, ], + threshold: undefined, timestampOverride: undefined, references: ['http://www.example.com', 'https://ww.example.com'], note: '# Investigative notes', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json index d600bae2746d98..7d80a319e9e520 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json @@ -174,6 +174,16 @@ } } }, + "threshold": { + "properties": { + "field": { + "type": "keyword" + }, + "value": { + "type": "float" + } + } + }, "note": { "type": "text" }, @@ -286,6 +296,9 @@ }, "status": { "type": "keyword" + }, + "threshold_count": { + "type": "float" } } } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts index 945ce5ad85c79a..3ce8d08a57aceb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.test.ts @@ -11,9 +11,11 @@ import { getEmptyIndex, getNonEmptyIndex, } from '../__mocks__/request_responses'; -import { requestContextMock, serverMock } from '../__mocks__'; -import { addPrepackedRulesRoute } from './add_prepackaged_rules_route'; +import { requestContextMock, serverMock, createMockConfig, mockGetCurrentUser } from '../__mocks__'; import { AddPrepackagedRulesSchemaDecoded } from '../../../../../common/detection_engine/schemas/request/add_prepackaged_rules_schema'; +import { SecurityPluginSetup } from '../../../../../../security/server'; +import { installPrepackagedTimelines } from '../../../timeline/routes/utils/install_prepacked_timelines'; +import { addPrepackedRulesRoute } from './add_prepackaged_rules_route'; jest.mock('../../rules/get_prepackaged_rules', () => { return { @@ -51,18 +53,46 @@ jest.mock('../../rules/get_prepackaged_rules', () => { }; }); +jest.mock('../../../timeline/routes/utils/install_prepacked_timelines', () => { + return { + installPrepackagedTimelines: jest.fn().mockResolvedValue({ + success: true, + success_count: 3, + errors: [], + timelines_installed: 3, + timelines_updated: 0, + }), + }; +}); + describe('add_prepackaged_rules_route', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let securitySetup: SecurityPluginSetup; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); - addPrepackedRulesRoute(server.router); + (installPrepackagedTimelines as jest.Mock).mockReset(); + (installPrepackagedTimelines as jest.Mock).mockResolvedValue({ + success: true, + success_count: 0, + timelines_installed: 3, + timelines_updated: 0, + errors: [], + }); + + addPrepackedRulesRoute(server.router, createMockConfig(), securitySetup); }); describe('status codes', () => { @@ -120,6 +150,8 @@ describe('add_prepackaged_rules_route', () => { expect(response.body).toEqual({ rules_installed: 1, rules_updated: 0, + timelines_installed: 3, + timelines_updated: 0, }); }); @@ -131,6 +163,8 @@ describe('add_prepackaged_rules_route', () => { expect(response.body).toEqual({ rules_installed: 0, rules_updated: 1, + timelines_installed: 3, + timelines_updated: 0, }); }); @@ -145,4 +179,96 @@ describe('add_prepackaged_rules_route', () => { expect(response.body).toEqual({ message: 'Test error', status_code: 500 }); }); }); + + test('should install prepackaged timelines', async () => { + (installPrepackagedTimelines as jest.Mock).mockReset(); + (installPrepackagedTimelines as jest.Mock).mockResolvedValue({ + success: false, + success_count: 0, + timelines_installed: 0, + timelines_updated: 0, + errors: [ + { + id: '36429040-b529-11ea-8d8b-21de98be11a6', + error: { + message: 'timeline_id: "36429040-b529-11ea-8d8b-21de98be11a6" already exists', + status_code: 409, + }, + }, + ], + }); + const request = addPrepackagedRulesRequest(); + const response = await server.inject(request, context); + expect(response.body).toEqual({ + rules_installed: 0, + rules_updated: 1, + timelines_installed: 0, + timelines_updated: 0, + }); + }); + + test('should include the result of installing prepackaged timelines - timelines_installed', async () => { + (installPrepackagedTimelines as jest.Mock).mockReset(); + (installPrepackagedTimelines as jest.Mock).mockResolvedValue({ + success: true, + success_count: 1, + timelines_installed: 1, + timelines_updated: 0, + errors: [], + }); + const request = addPrepackagedRulesRequest(); + const response = await server.inject(request, context); + expect(response.body).toEqual({ + rules_installed: 0, + rules_updated: 1, + timelines_installed: 1, + timelines_updated: 0, + }); + }); + + test('should include the result of installing prepackaged timelines - timelines_updated', async () => { + (installPrepackagedTimelines as jest.Mock).mockReset(); + (installPrepackagedTimelines as jest.Mock).mockResolvedValue({ + success: true, + success_count: 1, + timelines_installed: 0, + timelines_updated: 1, + errors: [], + }); + const request = addPrepackagedRulesRequest(); + const response = await server.inject(request, context); + expect(response.body).toEqual({ + rules_installed: 0, + rules_updated: 1, + timelines_installed: 0, + timelines_updated: 1, + }); + }); + + test('should include the result of installing prepackaged timelines - skip the error message', async () => { + (installPrepackagedTimelines as jest.Mock).mockReset(); + (installPrepackagedTimelines as jest.Mock).mockResolvedValue({ + success: false, + success_count: 0, + timelines_installed: 0, + timelines_updated: 0, + errors: [ + { + id: '36429040-b529-11ea-8d8b-21de98be11a6', + error: { + message: 'timeline_id: "36429040-b529-11ea-8d8b-21de98be11a6" already exists', + status_code: 409, + }, + }, + ], + }); + const request = addPrepackagedRulesRequest(); + const response = await server.inject(request, context); + expect(response.body).toEqual({ + rules_installed: 0, + rules_updated: 1, + timelines_installed: 0, + timelines_updated: 0, + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts index 9878521c493226..1226be71f63f5b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/add_prepackaged_rules_route.ts @@ -4,15 +4,22 @@ * you may not use this file except in compliance with the Elastic License. */ +import { IRouter } from '../../../../../../../../src/core/server'; + import { validate } from '../../../../../common/validate'; import { - PrePackagedRulesSchema, - prePackagedRulesSchema, + PrePackagedRulesAndTimelinesSchema, + prePackagedRulesAndTimelinesSchema, } from '../../../../../common/detection_engine/schemas/response/prepackaged_rules_schema'; -import { IRouter } from '../../../../../../../../src/core/server'; +import { importTimelineResultSchema } from '../../../../../common/types/timeline'; import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../../common/constants'; + +import { ConfigType } from '../../../../config'; +import { SetupPlugins } from '../../../../plugin'; +import { buildFrameworkRequest } from '../../../timeline/routes/utils/common'; +import { installPrepackagedTimelines } from '../../../timeline/routes/utils/install_prepacked_timelines'; + import { getIndexExists } from '../../index/get_index_exists'; -import { transformError, buildSiemResponse } from '../utils'; import { getPrepackagedRules } from '../../rules/get_prepackaged_rules'; import { installPrepackagedRules } from '../../rules/install_prepacked_rules'; import { updatePrepackagedRules } from '../../rules/update_prepacked_rules'; @@ -20,7 +27,13 @@ import { getRulesToInstall } from '../../rules/get_rules_to_install'; import { getRulesToUpdate } from '../../rules/get_rules_to_update'; import { getExistingPrepackagedRules } from '../../rules/get_existing_prepackaged_rules'; -export const addPrepackedRulesRoute = (router: IRouter) => { +import { transformError, buildSiemResponse } from '../utils'; + +export const addPrepackedRulesRoute = ( + router: IRouter, + config: ConfigType, + security: SetupPlugins['security'] +) => { router.put( { path: DETECTION_ENGINE_PREPACKAGED_URL, @@ -31,6 +44,7 @@ export const addPrepackedRulesRoute = (router: IRouter) => { }, async (context, _, response) => { const siemResponse = buildSiemResponse(response); + const frameworkRequest = await buildFrameworkRequest(context, security, _); try { const alertsClient = context.alerting?.getAlertsClient(); @@ -41,13 +55,10 @@ export const addPrepackedRulesRoute = (router: IRouter) => { if (!siemClient || !alertsClient) { return siemResponse.error({ statusCode: 404 }); } - const rulesFromFileSystem = getPrepackagedRules(); - const prepackagedRules = await getExistingPrepackagedRules({ alertsClient }); const rulesToInstall = getRulesToInstall(rulesFromFileSystem, prepackagedRules); const rulesToUpdate = getRulesToUpdate(rulesFromFileSystem, prepackagedRules); - const signalsIndex = siemClient.getSignalsIndex(); if (rulesToInstall.length !== 0 || rulesToUpdate.length !== 0) { const signalsIndexExists = await getIndexExists( @@ -61,15 +72,31 @@ export const addPrepackedRulesRoute = (router: IRouter) => { }); } } - await Promise.all(installPrepackagedRules(alertsClient, rulesToInstall, signalsIndex)); + const result = await Promise.all([ + installPrepackagedRules(alertsClient, rulesToInstall, signalsIndex), + installPrepackagedTimelines(config.maxTimelineImportExportSize, frameworkRequest, true), + ]); + const [prepackagedTimelinesResult, timelinesErrors] = validate( + result[1], + importTimelineResultSchema + ); await updatePrepackagedRules(alertsClient, savedObjectsClient, rulesToUpdate, signalsIndex); - const prepackagedRulesOutput: PrePackagedRulesSchema = { + + const prepackagedRulesOutput: PrePackagedRulesAndTimelinesSchema = { rules_installed: rulesToInstall.length, rules_updated: rulesToUpdate.length, + timelines_installed: prepackagedTimelinesResult?.timelines_installed ?? 0, + timelines_updated: prepackagedTimelinesResult?.timelines_updated ?? 0, }; - const [validated, errors] = validate(prepackagedRulesOutput, prePackagedRulesSchema); - if (errors != null) { - return siemResponse.error({ statusCode: 500, body: errors }); + const [validated, genericErrors] = validate( + prepackagedRulesOutput, + prePackagedRulesAndTimelinesSchema + ); + if (genericErrors != null && timelinesErrors != null) { + return siemResponse.error({ + statusCode: 500, + body: [genericErrors, timelinesErrors].filter((msg) => msg != null).join(', '), + }); } else { return response.ok({ body: validated ?? {} }); } diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index 2942413057e375..acd800e54040c3 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -90,6 +90,7 @@ export const createRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => severity_mapping: severityMapping, tags, threat, + threshold, throttle, timestamp_override: timestampOverride, to, @@ -177,6 +178,7 @@ export const createRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => to, type, threat, + threshold, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts index 310a9da56282d0..edad3dd8a4f213 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -44,6 +44,7 @@ export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void if (validationErrors.length) { return siemResponse.error({ statusCode: 400, body: validationErrors }); } + const { actions: actionsRest, anomaly_threshold: anomalyThreshold, @@ -75,6 +76,7 @@ export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void severity_mapping: severityMapping, tags, threat, + threshold, throttle, timestamp_override: timestampOverride, to, @@ -125,6 +127,7 @@ export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void }); } } + const createdRule = await createRules({ alertsClient, anomalyThreshold, @@ -159,6 +162,7 @@ export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void to, type, threat, + threshold, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts index 03059ed5ec5ccf..f8b6f7e3ddcba7 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.test.ts @@ -12,7 +12,8 @@ import { getPrepackagedRulesStatusRequest, getNonEmptyIndex, } from '../__mocks__/request_responses'; -import { requestContextMock, serverMock } from '../__mocks__'; +import { requestContextMock, serverMock, createMockConfig } from '../__mocks__'; +import { SecurityPluginSetup } from '../../../../../../security/server'; jest.mock('../../rules/get_prepackaged_rules', () => { return { @@ -38,17 +39,31 @@ jest.mock('../../rules/get_prepackaged_rules', () => { }); describe('get_prepackaged_rule_status_route', () => { + const mockGetCurrentUser = { + user: { + username: 'mockUser', + }, + }; + let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let securitySetup: SecurityPluginSetup; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; + clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); - getPrepackagedRulesStatusRoute(server.router); + getPrepackagedRulesStatusRoute(server.router, createMockConfig(), securitySetup); }); describe('status codes with actionClient and alertClient', () => { @@ -89,6 +104,9 @@ describe('get_prepackaged_rule_status_route', () => { rules_installed: 0, rules_not_installed: 1, rules_not_updated: 0, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }); }); @@ -103,6 +121,9 @@ describe('get_prepackaged_rule_status_route', () => { rules_installed: 1, rules_not_installed: 0, rules_not_updated: 1, + timelines_installed: 0, + timelines_not_installed: 0, + timelines_not_updated: 0, }); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts index bc199ee132e961..4cd5238ccb1efc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/get_prepackaged_rules_status_route.ts @@ -6,8 +6,8 @@ import { validate } from '../../../../../common/validate'; import { - PrePackagedRulesStatusSchema, - prePackagedRulesStatusSchema, + PrePackagedRulesAndTimelinesStatusSchema, + prePackagedRulesAndTimelinesStatusSchema, } from '../../../../../common/detection_engine/schemas/response/prepackaged_rules_status_schema'; import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../../common/constants'; @@ -17,8 +17,17 @@ import { getRulesToInstall } from '../../rules/get_rules_to_install'; import { getRulesToUpdate } from '../../rules/get_rules_to_update'; import { findRules } from '../../rules/find_rules'; import { getExistingPrepackagedRules } from '../../rules/get_existing_prepackaged_rules'; +import { buildFrameworkRequest } from '../../../timeline/routes/utils/common'; +import { ConfigType } from '../../../../config'; +import { SetupPlugins } from '../../../../plugin'; +import { checkTimelinesStatus } from '../../../timeline/routes/utils/check_timelines_status'; +import { checkTimelineStatusRt } from '../../../timeline/routes/schemas/check_timelines_status_schema'; -export const getPrepackagedRulesStatusRoute = (router: IRouter) => { +export const getPrepackagedRulesStatusRoute = ( + router: IRouter, + config: ConfigType, + security: SetupPlugins['security'] +) => { router.get( { path: `${DETECTION_ENGINE_PREPACKAGED_URL}/_status`, @@ -46,16 +55,31 @@ export const getPrepackagedRulesStatusRoute = (router: IRouter) => { filter: 'alert.attributes.tags:"__internal_immutable:false"', fields: undefined, }); + const frameworkRequest = await buildFrameworkRequest(context, security, request); const prepackagedRules = await getExistingPrepackagedRules({ alertsClient }); + const rulesToInstall = getRulesToInstall(rulesFromFileSystem, prepackagedRules); const rulesToUpdate = getRulesToUpdate(rulesFromFileSystem, prepackagedRules); - const prepackagedRulesStatus: PrePackagedRulesStatusSchema = { + const prepackagedTimelineStatus = await checkTimelinesStatus(frameworkRequest); + const [validatedprepackagedTimelineStatus] = validate( + prepackagedTimelineStatus, + checkTimelineStatusRt + ); + + const prepackagedRulesStatus: PrePackagedRulesAndTimelinesStatusSchema = { rules_custom_installed: customRules.total, rules_installed: prepackagedRules.length, rules_not_installed: rulesToInstall.length, rules_not_updated: rulesToUpdate.length, + timelines_installed: validatedprepackagedTimelineStatus?.prepackagedTimelines.length ?? 0, + timelines_not_installed: + validatedprepackagedTimelineStatus?.timelinesToInstall.length ?? 0, + timelines_not_updated: validatedprepackagedTimelineStatus?.timelinesToUpdate.length ?? 0, }; - const [validated, errors] = validate(prepackagedRulesStatus, prePackagedRulesStatusSchema); + const [validated, errors] = validate( + prepackagedRulesStatus, + prePackagedRulesAndTimelinesStatusSchema + ); if (errors != null) { return siemResponse.error({ statusCode: 500, body: errors }); } else { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts index 43aa1ecd31922f..18eea7c45585fc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -161,6 +161,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP severity_mapping: severityMapping, tags, threat, + threshold, timestamp_override: timestampOverride, to, type, @@ -222,6 +223,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP to, type, threat, + threshold, timestampOverride, references, note, @@ -264,6 +266,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupP to, type, threat, + threshold, references, note, version, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index c3d6f920e47a92..5099cf5de958fe 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -85,6 +85,7 @@ export const patchRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => to, type, threat, + threshold, timestamp_override: timestampOverride, throttle, references, @@ -143,6 +144,7 @@ export const patchRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => to, type, threat, + threshold, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts index eb9624e6412e9c..3b3efd2ed166dc 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -76,6 +76,7 @@ export const patchRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { to, type, threat, + threshold, timestamp_override: timestampOverride, throttle, references, @@ -142,6 +143,7 @@ export const patchRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { to, type, threat, + threshold, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index c1ab1be2dbd0a6..518024387fed31 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -88,6 +88,7 @@ export const updateRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => to, type, threat, + threshold, throttle, timestamp_override: timestampOverride, references, @@ -156,6 +157,7 @@ export const updateRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => to, type, threat, + threshold, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts index 717f388cfc1e9d..299b99c4d37b02 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -78,6 +78,7 @@ export const updateRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { to, type, threat, + threshold, throttle, timestamp_override: timestampOverride, references, @@ -146,6 +147,7 @@ export const updateRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { to, type, threat, + threshold, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts index 9e93dc051a0413..ee83ea91578c5b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/rules/utils.ts @@ -144,6 +144,7 @@ export const transformAlertToRule = ( to: alert.params.to, type: alert.params.type, threat: alert.params.threat ?? [], + threshold: alert.params.threshold, throttle: ruleActions?.ruleThrottle || 'no_actions', timestamp_override: alert.params.timestampOverride, note: alert.params.note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts index a7e24a1ac16096..1117f34b6f8c5a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.mock.ts @@ -39,6 +39,7 @@ export const getCreateRulesOptionsMock = (): CreateRulesOptions => ({ severityMapping: [], tags: [], threat: [], + threshold: undefined, timestampOverride: undefined, to: 'now', type: 'query', @@ -81,6 +82,7 @@ export const getCreateMlRulesOptionsMock = (): CreateRulesOptions => ({ severityMapping: [], tags: [], threat: [], + threshold: undefined, timestampOverride: undefined, to: 'now', type: 'machine_learning', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts index fd9e87e65d10de..ad4038b05dbd3b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/create_rules.ts @@ -42,6 +42,7 @@ export const createRules = async ({ severityMapping, tags, threat, + threshold, timestampOverride, to, type, @@ -84,6 +85,7 @@ export const createRules = async ({ severity, severityMapping, threat, + threshold, timestampOverride, to, type, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts index 8a86a0f103371e..3af0c3f55b485b 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/install_prepacked_rules.ts @@ -47,6 +47,7 @@ export const installPrepackagedRules = ( to, type, threat, + threshold, timestamp_override: timestampOverride, references, note, @@ -92,6 +93,7 @@ export const installPrepackagedRules = ( to, type, threat, + threshold, timestampOverride, references, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts index f3102a5ad2cf37..cfb40056eb85d1 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.mock.ts @@ -143,6 +143,7 @@ export const getPatchRulesOptionsMock = (): PatchRulesOptions => ({ severityMapping: [], tags: [], threat: [], + threshold: undefined, timestampOverride: undefined, to: 'now', type: 'query', @@ -185,6 +186,7 @@ export const getPatchMlRulesOptionsMock = (): PatchRulesOptions => ({ severityMapping: [], tags: [], threat: [], + threshold: undefined, timestampOverride: undefined, to: 'now', type: 'machine_learning', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts index 577d8d426b63d4..e0814647b4c39a 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/patch_rules.ts @@ -42,6 +42,7 @@ export const patchRules = async ({ severityMapping, tags, threat, + threshold, timestampOverride, to, type, @@ -83,6 +84,7 @@ export const patchRules = async ({ severityMapping, tags, threat, + threshold, timestampOverride, to, type, @@ -121,6 +123,7 @@ export const patchRules = async ({ severity, severityMapping, threat, + threshold, timestampOverride, to, type, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md new file mode 100644 index 00000000000000..901dacbfe80cc0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/README.md @@ -0,0 +1,182 @@ + + +### How to on board a new prepackage timelines: + + + +1. [Have the env params set up](https://github.com/elastic/kibana/blob/master/x-pack/plugins/siem/server/lib/detection_engine/README.md) + +2. Create a new timelines template into `x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines` + + ##### 2.a : Create a new template from UI and export it. + + 1. Go to Security Solution app in Kibana + 2. Go to timelines > templates > custom templates (a filter on the right) + 3. Click `Create new timeline template` + 4. Edit your template + 5. Export only **one** timeline template each time and put that in `x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines`. (For potential update requirement in the future, we put one timeline in each file to keep nice and clear) + 6. Rename the file extension to `.json` + 7. Check the chapter of `Fields to hightlight for on boarding a new prepackaged timeline` in this readme and update your template + + + + + ##### 2.b : Create a new template from scratch + Please note that below template is just an example, please replace all your fields with whatever makes sense. Do check `Fields to hightlight for on boarding a new prepackaged timeline` to make sure the template can be created as expected. + + + cd x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines + + + + echo '{"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"signal.rule.description","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"event.action","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.name","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The working directory of the process.","columnHeaderType":"not-filtered","id":"process.working_directory","category":"process","type":"string","searchable":null,"example":"/home/alice"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Absolute path to the process executable.","columnHeaderType":"not-filtered","id":"process.parent.executable","category":"process","type":"string","searchable":null,"example":"/usr/bin/ssh"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.parent.args","category":"process","type":"string","searchable":null,"example":"[\"ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"aggregatable":true,"name":null,"description":"Process id.","columnHeaderType":"not-filtered","id":"process.parent.pid","category":"process","type":"number","searchable":null,"example":"4242"},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"aggregatable":true,"name":null,"description":"Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde","queryMatch":{"displayValue":null,"field":"_id","displayField":null,"value":"590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde","operator":":"},"id":"send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Process Timeline","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1588162404153,"createdBy":"Elastic","updated":1588604767818,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"timelineType":"template","status":"immutable","templateTimelineId":"2c7e0663-5a91-0004-aa15-26bf756d2c40","templateTimelineVersion":1}' > my_new_template.json``` + + #### Note that the json has to be minified. + #### Fields to hightlight for on boarding a new prepackaged timeline: + + - savedObjectId: null + + - version: null + + - templateTimelineId: Specify an unique uuid e.g.: `2c7e0663-5a91-0004-aa15-26bf756d2c40` + + - templateTimelineVersion: just start from `1` + + - timelineType: `template` + + - status: `immutable` + + + +3. ```cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts``` + +4. ```sh ./timelines/regen_prepackage_timelines_index.sh``` + +(this will update `x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson`) + + + +5. Go to `http://localhost:5601/app/security#/detections/rules` and click on `Install Elastic prebuild rules` + +or run + +``` +cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts + +sh ./timelines/add_prepackaged_timelines.sh + +``` + + + +6. Check in UI or run the script below to see if prepackaged timelines on-boarded correctly. + +``` + +sh ./timelines/find_timeline_by_filter.sh immutable template elastic + +``` + + + +### How to update an existing prepackage timeline: + +1. ```cd x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines``` + +2. Open the json file you wish to update, and remember to bump the `templateTimelineVersion` + +3. Go to ```cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts```, run ```sh ./timelines/regen_prepackage_timelines_index.sh``` + +4. Go to `http://localhost:5601/app/security#/detections/rules` and click on `Install Elastic prebuild rules` + +or run + +``` + +sh ./timelines/add_prepackaged_timelines.sh + +``` + + + +5. Check in UI or run the script below to see if the prepackaged timeline updated correctly. + +``` + +sh ./timelines/find_timeline_by_filter.sh immutable template elastic + +``` + + + + +### How to install prepackaged timelines: + +1. ```cd x-pack/plugins/siem/server/lib/detection_engine/scripts``` + +2. ```sh ./timelines/add_prepackaged_timelines.sh``` + +3. ```sh ./timelines/find_timeline_by_filter.sh immutable template elastic``` + + + +### Get timeline by id: + +``` + +cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts + +sh ./timelines/get_timeline_by_id.sh {id} + +``` + + + + +### Get timeline by templateTimelineId: + +``` + +cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts + +sh ./timelines/get_timeline_by_template_timeline_id.sh {template_timeline_id} + +``` + + + + +### Get all custom timelines: + +``` + +cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts + +sh ./timelines/get_all_timelines.sh + +``` + + + + +### Delete all timelines: + +``` + +cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts + +sh ./timelines/delete_all_timelines.sh + +``` + + + +### Delete timeline by timeline id: + +``` + +cd x-pack/plugins/security_solution/server/lib/detection_engine/scripts + +./timelines/delete_all_alerts.sh {timeline_id} + +``` diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/endpoint.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/endpoint.json new file mode 100644 index 00000000000000..711050e1f136a8 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/endpoint.json @@ -0,0 +1 @@ +{"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"signal.rule.description","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"event.action","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.name","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The working directory of the process.","columnHeaderType":"not-filtered","id":"process.working_directory","category":"process","type":"string","searchable":null,"example":"/home/alice"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Absolute path to the process executable.","columnHeaderType":"not-filtered","id":"process.parent.executable","category":"process","type":"string","searchable":null,"example":"/usr/bin/ssh"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.parent.args","category":"process","type":"string","searchable":null,"example":"[\"ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"aggregatable":true,"name":null,"description":"Process id.","columnHeaderType":"not-filtered","id":"process.parent.pid","category":"process","type":"number","searchable":null,"example":"4242"},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"aggregatable":true,"name":null,"description":"Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"{process.name}","queryMatch":{"displayValue":"{agent.type}","field":"agent.type","displayField":"agent.type","value":"{agent.type}","operator":":*"},"id":"timeline-1-8622010a-61fb-490d-b162-beac9c36a853","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"","queryMatch":{"displayValue":"endpoint","field":"agent.type","displayField":"agent.type","value":"endpoint","operator":":"},"id":"timeline-1-4685da24-35c1-43f3-892d-1f926dbf5568","type":"default","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Endpoint Timeline","timelineType":"template","templateTimelineVersion":1,"templateTimelineId":"db366523-f1c6-4c1f-8731-6ce5ed9e5717","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735857110,"createdBy":"Elastic","updated":1594736314036,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson new file mode 100644 index 00000000000000..7c074242c39d10 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/index.ndjson @@ -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. + */ + +// Auto generated file from scripts/regen_prepackage_timelines_index.sh +// Do not hand edit. Run that script to regenerate package information instead + +{"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"signal.rule.description","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"event.action","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.name","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The working directory of the process.","columnHeaderType":"not-filtered","id":"process.working_directory","category":"process","type":"string","searchable":null,"example":"/home/alice"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Absolute path to the process executable.","columnHeaderType":"not-filtered","id":"process.parent.executable","category":"process","type":"string","searchable":null,"example":"/usr/bin/ssh"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.parent.args","category":"process","type":"string","searchable":null,"example":"[\"ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"aggregatable":true,"name":null,"description":"Process id.","columnHeaderType":"not-filtered","id":"process.parent.pid","category":"process","type":"number","searchable":null,"example":"4242"},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"aggregatable":true,"name":null,"description":"Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"{process.name}","queryMatch":{"displayValue":"{agent.type}","field":"agent.type","displayField":"agent.type","value":"{agent.type}","operator":":*"},"id":"timeline-1-8622010a-61fb-490d-b162-beac9c36a853","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"","queryMatch":{"displayValue":"endpoint","field":"agent.type","displayField":"agent.type","value":"endpoint","operator":":"},"id":"timeline-1-4685da24-35c1-43f3-892d-1f926dbf5568","type":"default","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Endpoint Timeline","timelineType":"template","templateTimelineVersion":1,"templateTimelineId":"db366523-f1c6-4c1f-8731-6ce5ed9e5717","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735857110,"createdBy":"Elastic","updated":1594736314036,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} +{"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"signal.rule.description","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","searchable":null,"example":"user-password-change"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"IP address of the source (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Port of the source.","columnHeaderType":"not-filtered","id":"source.port","category":"source","type":"number","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"IP address of the destination (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"destination.port","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"host.name","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"{source.ip}","queryMatch":{"displayValue":null,"field":"source.ip","displayField":null,"value":"{source.ip}","operator":":*"},"id":"timeline-1-ec778f01-1802-40f0-9dfb-ed8de1f656cb","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{destination.ip}","queryMatch":{"displayValue":null,"field":"destination.ip","displayField":null,"value":"{destination.ip}","operator":":*"},"id":"timeline-1-e37e37c5-a6e7-4338-af30-47bfbc3c0e1e","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Network Timeline","timelineType":"template","templateTimelineVersion":1,"templateTimelineId":"91832785-286d-4ebe-b884-1a208d111a70","dateRange":{"start":1588255858373,"end":1588256218373},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735573866,"createdBy":"Elastic","updated":1594736099397,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} +{"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"signal.rule.description","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"event.action","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.name","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The working directory of the process.","columnHeaderType":"not-filtered","id":"process.working_directory","category":"process","type":"string","searchable":null,"example":"/home/alice"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Absolute path to the process executable.","columnHeaderType":"not-filtered","id":"process.parent.executable","category":"process","type":"string","searchable":null,"example":"/usr/bin/ssh"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.parent.args","category":"process","type":"string","searchable":null,"example":"[\"ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"aggregatable":true,"name":null,"description":"Process id.","columnHeaderType":"not-filtered","id":"process.parent.pid","category":"process","type":"number","searchable":null,"example":"4242"},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"aggregatable":true,"name":null,"description":"Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"{process.name}","queryMatch":{"displayValue":null,"field":"process.name","displayField":null,"value":"{process.name}","operator":":"},"id":"timeline-1-8622010a-61fb-490d-b162-beac9c36a853","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{event.type}","queryMatch":{"displayValue":null,"field":"event.type","displayField":null,"value":"{event.type}","operator":":*"},"id":"timeline-1-4685da24-35c1-43f3-892d-1f926dbf5568","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Process Timeline","timelineType":"template","templateTimelineVersion":1,"templateTimelineId":"76e52245-7519-4251-91ab-262fb1a1728c","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735629389,"createdBy":"Elastic","updated":1594736083598,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/network.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/network.json new file mode 100644 index 00000000000000..1634476b4e99de --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/network.json @@ -0,0 +1 @@ +{"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"signal.rule.description","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","searchable":null,"example":"user-password-change"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"IP address of the source (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"source.ip","category":"source","type":"ip","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Port of the source.","columnHeaderType":"not-filtered","id":"source.port","category":"source","type":"number","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"IP address of the destination (IPv4 or IPv6).","columnHeaderType":"not-filtered","id":"destination.ip","category":"destination","type":"ip","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"destination.port","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"host.name","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"{source.ip}","queryMatch":{"displayValue":null,"field":"source.ip","displayField":null,"value":"{source.ip}","operator":":*"},"id":"timeline-1-ec778f01-1802-40f0-9dfb-ed8de1f656cb","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{destination.ip}","queryMatch":{"displayValue":null,"field":"destination.ip","displayField":null,"value":"{destination.ip}","operator":":*"},"id":"timeline-1-e37e37c5-a6e7-4338-af30-47bfbc3c0e1e","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Network Timeline","timelineType":"template","templateTimelineVersion":1,"templateTimelineId":"91832785-286d-4ebe-b884-1a208d111a70","dateRange":{"start":1588255858373,"end":1588256218373},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735573866,"createdBy":"Elastic","updated":1594736099397,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/process.json b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/process.json new file mode 100644 index 00000000000000..767f38133f263b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/prepackaged_timelines/process.json @@ -0,0 +1 @@ +{"savedObjectId":null,"version":null,"columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"signal.rule.description","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"event.action","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.name","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The working directory of the process.","columnHeaderType":"not-filtered","id":"process.working_directory","category":"process","type":"string","searchable":null,"example":"/home/alice"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.args","category":"process","type":"string","searchable":null,"example":"[\"/usr/bin/ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"process.pid","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"Absolute path to the process executable.","columnHeaderType":"not-filtered","id":"process.parent.executable","category":"process","type":"string","searchable":null,"example":"/usr/bin/ssh"},{"indexes":null,"aggregatable":true,"name":null,"description":"Array of process arguments.\n\nMay be filtered to protect sensitive information.","columnHeaderType":"not-filtered","id":"process.parent.args","category":"process","type":"string","searchable":null,"example":"[\"ssh\",\"-l\",\"user\",\"10.0.0.16\"]"},{"indexes":null,"aggregatable":true,"name":null,"description":"Process id.","columnHeaderType":"not-filtered","id":"process.parent.pid","category":"process","type":"number","searchable":null,"example":"4242"},{"indexes":null,"aggregatable":true,"name":null,"description":"Short name or login of the user.","columnHeaderType":"not-filtered","id":"user.name","category":"user","type":"string","searchable":null,"example":"albert"},{"indexes":null,"aggregatable":true,"name":null,"description":"Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string","searchable":null}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"{process.name}","queryMatch":{"displayValue":null,"field":"process.name","displayField":null,"value":"{process.name}","operator":":"},"id":"timeline-1-8622010a-61fb-490d-b162-beac9c36a853","type":"template","enabled":true},{"excluded":false,"and":[],"kqlQuery":"","name":"{event.type}","queryMatch":{"displayValue":null,"field":"event.type","displayField":null,"value":"{event.type}","operator":":*"},"id":"timeline-1-4685da24-35c1-43f3-892d-1f926dbf5568","type":"template","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Process Timeline","timelineType":"template","templateTimelineVersion":1,"templateTimelineId":"76e52245-7519-4251-91ab-262fb1a1728c","dateRange":{"start":1588161020848,"end":1588162280848},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1594735629389,"createdBy":"Elastic","updated":1594736083598,"updatedBy":"Elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"status":"immutable"} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts index 7b793ffbdb3629..b845990fd94ef9 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/types.ts @@ -59,6 +59,7 @@ import { TagsOrUndefined, ToOrUndefined, ThreatOrUndefined, + ThresholdOrUndefined, TypeOrUndefined, ReferencesOrUndefined, PerPageOrUndefined, @@ -204,6 +205,7 @@ export interface CreateRulesOptions { severityMapping: SeverityMapping; tags: Tags; threat: Threat; + threshold: ThresholdOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: To; type: Type; @@ -247,6 +249,7 @@ export interface UpdateRulesOptions { severityMapping: SeverityMapping; tags: Tags; threat: Threat; + threshold: ThresholdOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: To; type: Type; @@ -288,6 +291,7 @@ export interface PatchRulesOptions { severityMapping: SeverityMappingOrUndefined; tags: TagsOrUndefined; threat: ThreatOrUndefined; + threshold: ThresholdOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: ToOrUndefined; type: TypeOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts index 6466cc596d8915..bf97784e8d917e 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -45,6 +45,7 @@ export const updatePrepackagedRules = async ( to, type, threat, + threshold, timestamp_override: timestampOverride, references, version, @@ -93,6 +94,7 @@ export const updatePrepackagedRules = async ( to, type, threat, + threshold, references, version, note, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts index fdc0a61274e759..650b59fb85bc03 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.mock.ts @@ -41,6 +41,7 @@ export const getUpdateRulesOptionsMock = (): UpdateRulesOptions => ({ severityMapping: [], tags: [], threat: [], + threshold: undefined, timestampOverride: undefined, to: 'now', type: 'query', @@ -84,6 +85,7 @@ export const getUpdateMlRulesOptionsMock = (): UpdateRulesOptions => ({ severityMapping: [], tags: [], threat: [], + threshold: undefined, timestampOverride: undefined, to: 'now', type: 'machine_learning', diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts index 669b70aca4c9de..494a4e221d8629 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/update_rules.ts @@ -43,6 +43,7 @@ export const updateRules = async ({ severityMapping, tags, threat, + threshold, timestampOverride, to, type, @@ -85,6 +86,7 @@ export const updateRules = async ({ severityMapping, tags, threat, + threshold, timestampOverride, to, type, @@ -129,6 +131,7 @@ export const updateRules = async ({ severity, severityMapping, threat, + threshold, timestampOverride, to, type, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts index aa0512678b073c..17505a44782618 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.test.ts @@ -53,6 +53,7 @@ describe('utils', () => { severityMapping: undefined, tags: undefined, threat: undefined, + threshold: undefined, to: undefined, timestampOverride: undefined, type: undefined, @@ -94,6 +95,7 @@ describe('utils', () => { severityMapping: undefined, tags: undefined, threat: undefined, + threshold: undefined, to: undefined, timestampOverride: undefined, type: undefined, @@ -135,6 +137,7 @@ describe('utils', () => { severityMapping: undefined, tags: undefined, threat: undefined, + threshold: undefined, to: undefined, timestampOverride: undefined, type: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts index 861d02a8203e6b..49c02f92ff3361 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/rules/utils.ts @@ -29,6 +29,7 @@ import { TagsOrUndefined, ToOrUndefined, ThreatOrUndefined, + ThresholdOrUndefined, TypeOrUndefined, ReferencesOrUndefined, AuthorOrUndefined, @@ -82,6 +83,7 @@ export interface UpdateProperties { severityMapping: SeverityMappingOrUndefined; tags: TagsOrUndefined; threat: ThreatOrUndefined; + threshold: ThresholdOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: ToOrUndefined; type: TypeOrUndefined; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/export_timelines_to_file.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/export_timelines_to_file.sh new file mode 100644 index 00000000000000..92799e575043bb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/export_timelines_to_file.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +FILENAME=${1:-test_timeline.ndjson} + +# Example export to the file named test_timeline.ndjson +# ./export_timelines_to_file.sh + +# Example export to the file named my_test_timeline.ndjson +# ./export_timelines_to_file.sh my_test_timeline.ndjson +curl -s -k -OJ \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/timeline/_export?file_name=${FILENAME}" diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/regen_prepackge_rules_index.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/regen_prepackage_rules_index.sh similarity index 96% rename from x-pack/plugins/security_solution/server/lib/detection_engine/scripts/regen_prepackge_rules_index.sh rename to x-pack/plugins/security_solution/server/lib/detection_engine/scripts/regen_prepackage_rules_index.sh index 3bcf158703c7d7..984d12bb740f12 100755 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/regen_prepackge_rules_index.sh +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/regen_prepackage_rules_index.sh @@ -30,4 +30,4 @@ for f in ../rules/prepackaged_rules/*.json ; do RULE_NUMBER=$[$RULE_NUMBER +1] done -echo "];" >> ${PREPACKAGED_RULES_INDEX} \ No newline at end of file +echo "];" >> ${PREPACKAGED_RULES_INDEX} diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/add_prepackaged_timelines.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/add_prepackaged_timelines.sh new file mode 100755 index 00000000000000..5214552ea082a3 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/add_prepackaged_timelines.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Uses a defaults if no argument is specified +TIMELINES=${1:-../../rules/prepackaged_timelines/index.ndjson} + +# Example to import and overwrite everything from ../rules/prepackaged_timelines/index.ndjson +# ./timelines/add_prepackaged_timelines.sh +curl -s -k \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/timeline/_prepackaged" \ + | jq . diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/delete_all_timelines.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/delete_all_timelines.sh new file mode 100755 index 00000000000000..b0cda7476da0c0 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/delete_all_timelines.sh @@ -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. +# + +set -e +./check_env_variables.sh + +# Example: ./timelines/delete_all_timelines.sh +curl -s -k \ + -H "Content-Type: application/json" \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST ${ELASTICSEARCH_URL}/${KIBANA_INDEX}*/_delete_by_query \ + --data '{ + "query" : { + "bool": { + "minimum_should_match": 1, + "should": [ + {"exists" :{ "field": "siem-ui-timeline" }}, + {"exists" :{ "field": "siem-ui-timeline-note" }}, + {"exists" :{ "field": "siem-ui-timeline-pinned-event" }} + ] + } + } +}' \ +| jq . diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/delete_timeline_by_timeline_id.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/delete_timeline_by_timeline_id.sh new file mode 100755 index 00000000000000..b1e9fbdd938416 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/delete_timeline_by_timeline_id.sh @@ -0,0 +1,21 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + + +# Example: ./timelines/delete_timeline_by_id.sh {timeline_id} + +curl -s -k \ + -H "Content-Type: application/json" \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/solutions/security/graphql" \ + -d '{"operationName":"DeleteTimelineMutation","variables":{"id":["'$1'"]},"query":"mutation DeleteTimelineMutation($id: [ID!]!) {\n deleteTimeline(id: $id)\n}\n"}' + diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/find_timeline_by_filter.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/find_timeline_by_filter.sh new file mode 100755 index 00000000000000..c267b4d9f36d5e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/find_timeline_by_filter.sh @@ -0,0 +1,34 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Uses a default if no argument is specified +STATUS=${1:-active} +TIMELINE_TYPE=${2:-default} +TEMPLATE_TIMELINE_TYPE=${3:-custom} + +# Example get all timelines: +# ./timelines/find_timeline_by_filter.sh active + +# Example get all prepackaged timeline templates: +# ./timelines/find_timeline_by_filter.sh immutable template elastic + +# Example get all custom timeline templates: +# ./timelines/find_timeline_by_filter.sh active template custom + +curl -s -k \ + -H "Content-Type: application/json" \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/solutions/security/graphql" \ + -d '{"operationName":"GetAllTimeline","variables":{"onlyUserFavorite":false,"pageInfo":{"pageIndex":1,"pageSize":10},"search":"","sort":{"sortField":"updated","sortOrder":"desc"},"status":"'$STATUS'","timelineType":"'$TIMELINE_TYPE'","templateTimelineType":"'$TEMPLATE_TIMELINE_TYPE'"},"query":"query GetAllTimeline($pageInfo: PageInfoTimeline!, $search: String, $sort: SortTimeline, $onlyUserFavorite: Boolean, $timelineType: TimelineType, $templateTimelineType: TemplateTimelineType, $status: TimelineStatus) {\n getAllTimeline(pageInfo: $pageInfo, search: $search, sort: $sort, onlyUserFavorite: $onlyUserFavorite, timelineType: $timelineType, templateTimelineType: $templateTimelineType, status: $status) {\n totalCount\n defaultTimelineCount\n templateTimelineCount\n elasticTemplateTimelineCount\n customTemplateTimelineCount\n favoriteCount\n timeline {\n savedObjectId\n description\n favorite {\n fullName\n userName\n favoriteDate\n __typename\n }\n eventIdToNoteIds {\n eventId\n note\n timelineId\n noteId\n created\n createdBy\n timelineVersion\n updated\n updatedBy\n version\n __typename\n }\n notes {\n eventId\n note\n timelineId\n timelineVersion\n noteId\n created\n createdBy\n updated\n updatedBy\n version\n __typename\n }\n noteIds\n pinnedEventIds\n status\n title\n timelineType\n templateTimelineId\n templateTimelineVersion\n created\n createdBy\n updated\n updatedBy\n version\n __typename\n }\n __typename\n }\n}\n"}' \ + | jq . + + diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/get_all_timelines.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/get_all_timelines.sh new file mode 100755 index 00000000000000..f58632c7cbbe31 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/get_all_timelines.sh @@ -0,0 +1,37 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Example: ./timelines/get_all_timelines.sh +curl -s -k \ + -H "Content-Type: application/json" \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X POST "${KIBANA_URL}${SPACE_URL}/api/solutions/security/graphql" \ + -d '{ + "operationName": "GetAllTimeline", + "variables": { + "onlyUserFavorite": false, + "pageInfo": { + "pageIndex": null, + "pageSize": null + }, + "search": "", + "sort": { + "sortField": "updated", + "sortOrder": "desc" + }, + "status": "active", + "timelineType": null, + "templateTimelineType": null + }, + "query": "query GetAllTimeline($pageInfo: PageInfoTimeline!, $search: String, $sort: SortTimeline, $onlyUserFavorite: Boolean, $timelineType: TimelineType, $templateTimelineType: TemplateTimelineType, $status: TimelineStatus) {\n getAllTimeline(pageInfo: $pageInfo, search: $search, sort: $sort, onlyUserFavorite: $onlyUserFavorite, timelineType: $timelineType, templateTimelineType: $templateTimelineType, status: $status) {\n totalCount\n defaultTimelineCount\n templateTimelineCount\n elasticTemplateTimelineCount\n customTemplateTimelineCount\n favoriteCount\n timeline {\n savedObjectId\n description\n favorite {\n fullName\n userName\n favoriteDate\n __typename\n }\n eventIdToNoteIds {\n eventId\n note\n timelineId\n noteId\n created\n createdBy\n timelineVersion\n updated\n updatedBy\n version\n __typename\n }\n notes {\n eventId\n note\n timelineId\n timelineVersion\n noteId\n created\n createdBy\n updated\n updatedBy\n version\n __typename\n }\n noteIds\n pinnedEventIds\n status\n title\n timelineType\n templateTimelineId\n templateTimelineVersion\n created\n createdBy\n updated\n updatedBy\n version\n __typename\n }\n __typename\n }\n}\n" +}' | jq . + diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/get_timeline_by_id.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/get_timeline_by_id.sh new file mode 100755 index 00000000000000..0c0694c0591f9c --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/get_timeline_by_id.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Example: ./timelines/get_timeline_by_id.sh {timeline_id} + +curl -s -k \ + -H "Content-Type: application/json" \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/timeline?id=$1" \ + | jq . diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/get_timeline_by_template_timeline_id.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/get_timeline_by_template_timeline_id.sh new file mode 100755 index 00000000000000..36862b519130b5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/get_timeline_by_template_timeline_id.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +# +# Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one +# or more contributor license agreements. Licensed under the Elastic License; +# you may not use this file except in compliance with the Elastic License. +# + +set -e +./check_env_variables.sh + +# Example: ./timelines/get_timeline_by_template_timeline_id.sh {template_timeline_id} + +curl -s -k \ + -H "Content-Type: application/json" \ + -H 'kbn-xsrf: 123' \ + -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ + -X GET "${KIBANA_URL}${SPACE_URL}/api/timeline?template_timeline_id=$1" \ + | jq . diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/regen_prepackage_timelines_index.sh b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/regen_prepackage_timelines_index.sh new file mode 100755 index 00000000000000..19c53a3c00b6d7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/scripts/timelines/regen_prepackage_timelines_index.sh @@ -0,0 +1,25 @@ +#!/bin/sh + +set -e +./check_env_variables.sh + +# Regenerates the index.ts that contains all of the timelines that are read in from json +PREPACKAGED_TIMELINES_INDEX=../rules/prepackaged_timelines/index.ndjson + +# Clear existing content +echo "" > ${PREPACKAGED_TIMELINES_INDEX} + +echo "/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +// Auto generated file from scripts/regen_prepackage_timelines_index.sh +// Do not hand edit. Run that script to regenerate package information instead +" > ${PREPACKAGED_TIMELINES_INDEX} + +for f in ../rules/prepackaged_timelines/*.json ; do + echo "converting $f" + sed ':a;N;$!ba;s/\n/ /g' $f >> ${PREPACKAGED_TIMELINES_INDEX} +done diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts index 7492422968062b..17e05109b9a879 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/__mocks__/es_results.ts @@ -46,6 +46,7 @@ export const sampleRuleAlertParams = ( machineLearningJobId: undefined, filters: undefined, savedId: undefined, + threshold: undefined, timelineId: undefined, timelineTitle: undefined, timestampOverride: undefined, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts index b368c8fe360542..452ba958876d69 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.test.ts @@ -291,4 +291,158 @@ describe('create_signals', () => { }, }); }); + test('if aggregations is not provided it should not be included', () => { + const query = buildEventsSearchQuery({ + index: ['auditbeat-*'], + from: 'now-5m', + to: 'today', + filter: {}, + size: 100, + searchAfterSortId: undefined, + }); + expect(query).toEqual({ + allowNoIndices: true, + index: ['auditbeat-*'], + size: 100, + ignoreUnavailable: true, + body: { + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + bool: { + should: [ + { + range: { + '@timestamp': { + gte: 'now-5m', + }, + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + range: { + '@timestamp': { + lte: 'today', + }, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + + sort: [ + { + '@timestamp': { + order: 'asc', + }, + }, + ], + }, + }); + }); + + test('if aggregations is provided it should be included', () => { + const query = buildEventsSearchQuery({ + aggregations: { + tags: { + terms: { + field: 'tag', + }, + }, + }, + index: ['auditbeat-*'], + from: 'now-5m', + to: 'today', + filter: {}, + size: 100, + searchAfterSortId: undefined, + }); + expect(query).toEqual({ + allowNoIndices: true, + index: ['auditbeat-*'], + size: 100, + ignoreUnavailable: true, + body: { + query: { + bool: { + filter: [ + {}, + { + bool: { + filter: [ + { + bool: { + should: [ + { + range: { + '@timestamp': { + gte: 'now-5m', + }, + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + range: { + '@timestamp': { + lte: 'today', + }, + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + { + match_all: {}, + }, + ], + }, + }, + aggregations: { + tags: { + terms: { + field: 'tag', + }, + }, + }, + sort: [ + { + '@timestamp': { + order: 'asc', + }, + }, + ], + }, + }); + }); }); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts index c75dddf896fd17..dcf3a90364a401 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_events_query.ts @@ -5,6 +5,7 @@ */ interface BuildEventsSearchQuery { + aggregations?: unknown; index: string[]; from: string; to: string; @@ -14,6 +15,7 @@ interface BuildEventsSearchQuery { } export const buildEventsSearchQuery = ({ + aggregations, index, from, to, @@ -74,6 +76,7 @@ export const buildEventsSearchQuery = ({ ], }, }, + ...(aggregations ? { aggregations } : {}), sort: [ { '@timestamp': { @@ -83,6 +86,7 @@ export const buildEventsSearchQuery = ({ ], }, }; + if (searchAfterSortId) { return { ...searchQuery, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts index fc8b26450c8522..9e118f77a73e79 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_rule.ts @@ -83,5 +83,6 @@ export const buildRule = ({ exceptions_list: ruleParams.exceptionsList ?? [], machine_learning_job_id: ruleParams.machineLearningJobId, anomaly_threshold: ruleParams.anomalyThreshold, + threshold: ruleParams.threshold, }); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts index 77a63c63ff97ad..e7098c015c1654 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/build_signal.ts @@ -46,7 +46,7 @@ export const buildSignal = (doc: SignalSourceHit, rule: Partial): S const ruleWithoutInternalTags = removeInternalTagsFromRule(rule); const parent = buildAncestor(doc, rule); const ancestors = buildAncestorsSignal(doc, rule); - const signal: Signal = { + let signal: Signal = { parent, ancestors, original_time: doc._source['@timestamp'], @@ -54,7 +54,11 @@ export const buildSignal = (doc: SignalSourceHit, rule: Partial): S rule: ruleWithoutInternalTags, }; if (doc._source.event != null) { - return { ...signal, original_event: doc._source.event }; + signal = { ...signal, original_event: doc._source.event }; + } + if (doc._source.threshold_count != null) { + signal = { ...signal, threshold_count: doc._source.threshold_count }; + delete doc._source.threshold_count; } return signal; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts new file mode 100644 index 00000000000000..744e2b0c06efeb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.test.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getThresholdSignalQueryFields } from './bulk_create_threshold_signals'; + +describe('getThresholdSignalQueryFields', () => { + it('should return proper fields for match_phrase filters', () => { + const mockFilters = { + bool: { + must: [], + filter: [ + { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { + 'event.module': 'traefik', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { + 'event.dataset': 'traefik.access', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + match_phrase: { + 'traefik.access.entryPointName': 'web-secure', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + ], + }, + }, + { + match_phrase: { + 'url.domain': 'kibana.siem.estc.dev', + }, + }, + ], + should: [], + must_not: [], + }, + }; + + expect(getThresholdSignalQueryFields(mockFilters)).toEqual({ + 'event.module': 'traefik', + 'event.dataset': 'traefik.access', + 'traefik.access.entryPointName': 'web-secure', + 'url.domain': 'kibana.siem.estc.dev', + }); + }); + + it('should return proper fields object for nested match filters', () => { + const filters = { + bool: { + must: [], + filter: [ + { + bool: { + filter: [ + { + bool: { + should: [ + { + match_phrase: { + 'event.module': 'traefik', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + bool: { + should: [ + { + match: { + 'event.dataset': 'traefik.access', + }, + }, + ], + minimum_should_match: 1, + }, + }, + ], + }, + }, + ], + should: [], + must_not: [], + }, + }; + + expect(getThresholdSignalQueryFields(filters)).toEqual({ + 'event.module': 'traefik', + 'event.dataset': 'traefik.access', + }); + }); + + it('should return proper object for simple match filters', () => { + const filters = { + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + match: { + 'event.module': 'traefik', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + match_phrase: { + 'event.dataset': 'traefik.access', + }, + }, + ], + should: [], + must_not: [], + }, + }; + + expect(getThresholdSignalQueryFields(filters)).toEqual({ + 'event.module': 'traefik', + 'event.dataset': 'traefik.access', + }); + }); + + it('should return proper object for simple match_phrase filters', () => { + const filters = { + bool: { + must: [], + filter: [ + { + bool: { + should: [ + { + match_phrase: { + 'event.module': 'traefik', + }, + }, + ], + minimum_should_match: 1, + }, + }, + { + match_phrase: { + 'event.dataset': 'traefik.access', + }, + }, + ], + should: [], + must_not: [], + }, + }; + + expect(getThresholdSignalQueryFields(filters)).toEqual({ + 'event.module': 'traefik', + 'event.dataset': 'traefik.access', + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts new file mode 100644 index 00000000000000..ef9fbe485b92f9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/bulk_create_threshold_signals.ts @@ -0,0 +1,199 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuidv5 from 'uuid/v5'; +import { reduce, get, isEmpty } from 'lodash/fp'; +import set from 'set-value'; + +import { Threshold } from '../../../../common/detection_engine/schemas/common/schemas'; +import { Logger } from '../../../../../../../src/core/server'; +import { AlertServices } from '../../../../../alerts/server'; +import { RuleAlertAction } from '../../../../common/detection_engine/types'; +import { RuleTypeParams, RefreshTypes } from '../types'; +import { singleBulkCreate, SingleBulkCreateResponse } from './single_bulk_create'; +import { SignalSearchResponse } from './types'; + +// used to generate constant Threshold Signals ID when run with the same params +const NAMESPACE_ID = '0684ec03-7201-4ee0-8ee0-3a3f6b2479b2'; + +interface BulkCreateThresholdSignalsParams { + actions: RuleAlertAction[]; + someResult: SignalSearchResponse; + ruleParams: RuleTypeParams; + services: AlertServices; + inputIndexPattern: string[]; + logger: Logger; + id: string; + filter: unknown; + signalsIndex: string; + name: string; + createdAt: string; + createdBy: string; + updatedAt: string; + updatedBy: string; + interval: string; + enabled: boolean; + refresh: RefreshTypes; + tags: string[]; + throttle: string; + startedAt: Date; +} + +interface FilterObject { + bool?: { + filter?: FilterObject | FilterObject[]; + should?: Array>>; + }; +} + +const getNestedQueryFilters = (filtersObj: FilterObject): Record => { + if (Array.isArray(filtersObj.bool?.filter)) { + return reduce( + (acc, filterItem) => { + const nestedFilter = getNestedQueryFilters(filterItem); + + if (nestedFilter) { + return { ...acc, ...nestedFilter }; + } + + return acc; + }, + {}, + filtersObj.bool?.filter + ); + } else { + return ( + (filtersObj.bool?.should && + filtersObj.bool?.should[0] && + (filtersObj.bool.should[0].match || filtersObj.bool.should[0].match_phrase)) ?? + {} + ); + } +}; + +export const getThresholdSignalQueryFields = (filter: unknown) => { + const filters = get('bool.filter', filter); + + return reduce( + (acc, item) => { + if (item.match_phrase) { + return { ...acc, ...item.match_phrase }; + } + + if (item.bool.should && (item.bool.should[0].match || item.bool.should[0].match_phrase)) { + return { ...acc, ...(item.bool.should[0].match || item.bool.should[0].match_phrase) }; + } + + if (item.bool?.filter) { + return { ...acc, ...getNestedQueryFilters(item) }; + } + + return acc; + }, + {}, + filters + ); +}; + +const getTransformedHits = ( + results: SignalSearchResponse, + inputIndex: string, + startedAt: Date, + threshold: Threshold, + ruleId: string, + signalQueryFields: Record +) => { + if (isEmpty(threshold.field)) { + const totalResults = + typeof results.hits.total === 'number' ? results.hits.total : results.hits.total.value; + + if (totalResults < threshold.value) { + return []; + } + + const source = { + '@timestamp': new Date().toISOString(), + threshold_count: totalResults, + ...signalQueryFields, + }; + + return [ + { + _index: inputIndex, + _id: uuidv5(`${ruleId}${startedAt}${threshold.field}`, NAMESPACE_ID), + _source: source, + }, + ]; + } + + if (!results.aggregations?.threshold) { + return []; + } + + return results.aggregations.threshold.buckets.map( + ({ key, doc_count }: { key: string; doc_count: number }) => { + const source = { + '@timestamp': new Date().toISOString(), + threshold_count: doc_count, + ...signalQueryFields, + }; + + set(source, threshold.field, key); + + return { + _index: inputIndex, + _id: uuidv5(`${ruleId}${startedAt}${threshold.field}${key}`, NAMESPACE_ID), + _source: source, + }; + } + ); +}; + +export const transformThresholdResultsToEcs = ( + results: SignalSearchResponse, + inputIndex: string, + startedAt: Date, + filter: unknown, + threshold: Threshold, + ruleId: string +): SignalSearchResponse => { + const signalQueryFields = getThresholdSignalQueryFields(filter); + const transformedHits = getTransformedHits( + results, + inputIndex, + startedAt, + threshold, + ruleId, + signalQueryFields + ); + const thresholdResults = { + ...results, + hits: { + ...results.hits, + hits: transformedHits, + }, + }; + + set(thresholdResults, 'results.hits.total', transformedHits.length); + + return thresholdResults; +}; + +export const bulkCreateThresholdSignals = async ( + params: BulkCreateThresholdSignalsParams +): Promise => { + const thresholdResults = params.someResult; + const ecsResults = transformThresholdResultsToEcs( + thresholdResults, + params.inputIndexPattern.join(','), + params.startedAt, + params.filter, + params.ruleParams.threshold!, + params.ruleParams.ruleId + ); + + return singleBulkCreate({ ...params, filteredEvents: ecsResults }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_threshold_signals.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_threshold_signals.ts new file mode 100644 index 00000000000000..a9a199f210da0f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/find_threshold_signals.ts @@ -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 { isEmpty } from 'lodash/fp'; + +import { Threshold } from '../../../../common/detection_engine/schemas/common/schemas'; +import { singleSearchAfter } from './single_search_after'; + +import { AlertServices } from '../../../../../alerts/server'; +import { Logger } from '../../../../../../../src/core/server'; +import { SignalSearchResponse } from './types'; + +interface FindThresholdSignalsParams { + from: string; + to: string; + inputIndexPattern: string[]; + services: AlertServices; + logger: Logger; + filter: unknown; + threshold: Threshold; +} + +export const findThresholdSignals = async ({ + from, + to, + inputIndexPattern, + services, + logger, + filter, + threshold, +}: FindThresholdSignalsParams): Promise<{ + searchResult: SignalSearchResponse; + searchDuration: string; +}> => { + const aggregations = + threshold && !isEmpty(threshold.field) + ? { + threshold: { + terms: { + field: threshold.field, + min_doc_count: threshold.value, + }, + }, + } + : {}; + + return singleSearchAfter({ + aggregations, + searchAfterSortId: undefined, + index: inputIndexPattern, + from, + to, + services, + logger, + filter, + pageSize: 0, + }); +}; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts index 4bd9de734f4480..67dc1d50eefcdb 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/get_filter.ts @@ -49,49 +49,62 @@ export const getFilter = async ({ query, lists, }: GetFilterArgs): Promise => { + const queryFilter = () => { + if (query != null && language != null && index != null) { + return getQueryFilter(query, language, filters || [], index, lists); + } else { + throw new BadRequestError('query, filters, and index parameter should be defined'); + } + }; + + const savedQueryFilter = async () => { + if (savedId != null && index != null) { + try { + // try to get the saved object first + const savedObject = await services.savedObjectsClient.get( + 'query', + savedId + ); + return getQueryFilter( + savedObject.attributes.query.query, + savedObject.attributes.query.language, + savedObject.attributes.filters, + index, + lists + ); + } catch (err) { + // saved object does not exist, so try and fall back if the user pushed + // any additional language, query, filters, etc... + if (query != null && language != null && index != null) { + return getQueryFilter(query, language, filters || [], index, lists); + } else { + // user did not give any additional fall back mechanism for generating a rule + // rethrow error for activity monitoring + throw err; + } + } + } else { + throw new BadRequestError('savedId parameter should be defined'); + } + }; + switch (type) { + case 'threshold': { + return savedId != null ? savedQueryFilter() : queryFilter(); + } case 'query': { - if (query != null && language != null && index != null) { - return getQueryFilter(query, language, filters || [], index, lists); - } else { - throw new BadRequestError('query, filters, and index parameter should be defined'); - } + return queryFilter(); } case 'saved_query': { - if (savedId != null && index != null) { - try { - // try to get the saved object first - const savedObject = await services.savedObjectsClient.get( - 'query', - savedId - ); - return getQueryFilter( - savedObject.attributes.query.query, - savedObject.attributes.query.language, - savedObject.attributes.filters, - index, - lists - ); - } catch (err) { - // saved object does not exist, so try and fall back if the user pushed - // any additional language, query, filters, etc... - if (query != null && language != null && index != null) { - return getQueryFilter(query, language, filters || [], index, lists); - } else { - // user did not give any additional fall back mechanism for generating a rule - // rethrow error for activity monitoring - throw err; - } - } - } else { - throw new BadRequestError('savedId parameter should be defined'); - } + return savedQueryFilter(); } case 'machine_learning': { throw new BadRequestError( 'Unsupported Rule of type "machine_learning" supplied to getFilter' ); } + default: { + return assertUnreachable(type); + } } - return assertUnreachable(type); }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts index 2583cf2c8da912..d08ca90f3e3534 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_params_schema.ts @@ -37,6 +37,9 @@ const signalSchema = schema.object({ severity: schema.string(), severityMapping: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), threat: schema.nullable(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), + threshold: schema.maybe( + schema.object({ field: schema.nullable(schema.string()), value: schema.number() }) + ), timestampOverride: schema.nullable(schema.string()), to: schema.string(), type: schema.string(), diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts index 49945134e378bb..49efc30b9704d4 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -26,7 +26,9 @@ import { getGapBetweenRuns, parseScheduleDates, getListsClient, getExceptions } import { signalParamsSchema } from './signal_params_schema'; import { siemRuleActionGroups } from './siem_rule_action_groups'; import { findMlSignals } from './find_ml_signals'; +import { findThresholdSignals } from './find_threshold_signals'; import { bulkCreateMlSignals } from './bulk_create_ml_signals'; +import { bulkCreateThresholdSignals } from './bulk_create_threshold_signals'; import { scheduleNotificationActions, NotificationRuleTypeParams, @@ -58,6 +60,7 @@ export const signalRulesAlertType = ({ producer: SERVER_APP_ID, async executor({ previousStartedAt, + startedAt, alertId, services, params, @@ -78,6 +81,7 @@ export const signalRulesAlertType = ({ savedId, query, to, + threshold, type, exceptionsList, } = params; @@ -224,6 +228,60 @@ export const signalRulesAlertType = ({ if (bulkCreateDuration) { result.bulkCreateTimes.push(bulkCreateDuration); } + } else if (type === 'threshold' && threshold) { + const inputIndex = await getInputIndex(services, version, index); + const esFilter = await getFilter({ + type, + filters, + language, + query, + savedId, + services, + index: inputIndex, + lists: exceptionItems ?? [], + }); + + const { searchResult: thresholdResults } = await findThresholdSignals({ + inputIndexPattern: inputIndex, + from, + to, + services, + logger, + filter: esFilter, + threshold, + }); + + const { + success, + bulkCreateDuration, + createdItemsCount, + } = await bulkCreateThresholdSignals({ + actions, + throttle, + someResult: thresholdResults, + ruleParams: params, + filter: esFilter, + services, + logger, + id: alertId, + inputIndexPattern: inputIndex, + signalsIndex: outputIndex, + startedAt, + name, + createdBy, + createdAt, + updatedBy, + updatedAt, + interval, + enabled, + refresh, + tags, + }); + result.success = success; + result.createdSignalsCount = createdItemsCount; + if (bulkCreateDuration) { + result.bulkCreateTimes.push(bulkCreateDuration); + } } else { const inputIndex = await getInputIndex(services, version, index); const esFilter = await getFilter({ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts index 409f374d7df1e6..daea277f143682 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/single_search_after.ts @@ -12,6 +12,7 @@ import { buildEventsSearchQuery } from './build_events_query'; import { makeFloatString } from './utils'; interface SingleSearchAfterParams { + aggregations?: unknown; searchAfterSortId: string | undefined; index: string[]; from: string; @@ -24,6 +25,7 @@ interface SingleSearchAfterParams { // utilize search_after for paging results into bulk. export const singleSearchAfter = async ({ + aggregations, searchAfterSortId, index, from, @@ -38,6 +40,7 @@ export const singleSearchAfter = async ({ }> => { try { const searchAfterQuery = buildEventsSearchQuery({ + aggregations, index, from, to, diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts index 082211df28320d..5d6bafc5a6d09f 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/signals/types.ts @@ -121,6 +121,7 @@ export interface Signal { original_time: string; original_event?: SearchTypes; status: Status; + threshold_count?: SearchTypes; } export interface SignalHit { diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts index 365222d62d3224..4b4f5147c9a42c 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/types.ts @@ -9,6 +9,7 @@ import { Description, NoteOrUndefined, ThreatOrUndefined, + ThresholdOrUndefined, FalsePositives, From, Immutable, @@ -71,6 +72,7 @@ export interface RuleTypeParams { severity: Severity; severityMapping: SeverityMappingOrUndefined; threat: ThreatOrUndefined; + threshold: ThresholdOrUndefined; timestampOverride: TimestampOverrideOrUndefined; to: To; type: RuleType; diff --git a/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts b/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts index 17ed6d20db29eb..19b16bd4bc6d24 100644 --- a/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts +++ b/x-pack/plugins/security_solution/server/lib/ecs_fields/index.ts @@ -322,6 +322,7 @@ export const signalFieldsMap: Readonly> = { 'signal.rule.updated_by': 'signal.rule.updated_by', 'signal.rule.version': 'signal.rule.version', 'signal.rule.note': 'signal.rule.note', + 'signal.rule.threshold': 'signal.rule.threshold', 'signal.rule.exceptions_list': 'signal.rule.exceptions_list', }; diff --git a/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts b/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts index e9b692e4731aab..73e9ae58244c17 100644 --- a/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts +++ b/x-pack/plugins/security_solution/server/lib/machine_learning/mocks.ts @@ -16,6 +16,8 @@ const createMockMlSystemProvider = () => export const mlServicesMock = { create: () => (({ + modulesProvider: jest.fn(), + jobServiceProvider: jest.fn(), mlSystemProvider: createMockMlSystemProvider(), mlClient: createMockClient(), } as unknown) as jest.Mocked), diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts index adfdf831f22cf4..2afe3197d6d645 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/import_timelines.ts @@ -162,7 +162,7 @@ export const mockUniqueParsedTemplateTimelineObjects = [ ]; export const mockParsedTemplateTimelineObjects = [ - { ...mockParsedObjects[0], ...mockGetTemplateTimelineValue }, + { ...mockParsedObjects[0], ...mockGetTemplateTimelineValue, templateTimelineVersion: 2 }, ]; export const mockGetDraftTimelineValue = { @@ -252,3 +252,949 @@ export const mockCreatedTemplateTimeline = { ...mockCreatedTimeline, ...mockGetTemplateTimelineValue, }; + +export const mockCheckTimelinesStatusBeforeInstallResult = { + timelinesToInstall: [ + { + savedObjectId: null, + version: null, + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'signal.rule.description', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.', + columnHeaderType: 'not-filtered', + id: 'event.action', + category: 'event', + type: 'string', + searchable: null, + example: 'user-password-change', + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'endgame.data.rule_name', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'rule.reference', + searchable: null, + }, + { + aggregatable: true, + description: + 'Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.', + columnHeaderType: 'not-filtered', + id: 'host.name', + category: 'host', + type: 'string', + }, + { + aggregatable: true, + description: 'Operating system name, without the version.', + columnHeaderType: 'not-filtered', + id: 'host.os.name', + category: 'host', + type: 'string', + example: 'Mac OS X', + }, + ], + dataProviders: [ + { + excluded: false, + and: [], + kqlQuery: '', + name: '3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + queryMatch: { + displayValue: null, + field: '_id', + displayField: null, + value: '3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + operator: ':', + }, + id: + 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + enabled: true, + }, + ], + description: '', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { + kind: 'kuery', + expression: '', + }, + serializedQuery: '', + }, + }, + title: 'Generic Endpoint Timeline', + dateRange: { + start: 1588257731065, + end: 1588258391065, + }, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + created: 1588258576517, + createdBy: 'Elastic', + updated: 1588261039030, + updatedBy: 'Elastic', + eventNotes: [], + globalNotes: [], + pinnedEventIds: [], + timelineType: 'template', + status: 'immutable', + templateTimelineId: '2c7e0663-5a91-0001-aa15-26bf756d2c39', + templateTimelineVersion: 1, + }, + { + savedObjectId: null, + version: null, + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'signal.rule.description', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.', + columnHeaderType: 'not-filtered', + id: 'event.action', + category: 'event', + type: 'string', + searchable: null, + example: 'user-password-change', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.', + columnHeaderType: 'not-filtered', + id: 'process.args', + category: 'process', + type: 'string', + searchable: null, + example: '["/usr/bin/ssh","-l","user","10.0.0.16"]', + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'process.pid', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'IP address of the source (IPv4 or IPv6).', + columnHeaderType: 'not-filtered', + id: 'source.ip', + category: 'source', + type: 'ip', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'Port of the source.', + columnHeaderType: 'not-filtered', + id: 'source.port', + category: 'source', + type: 'number', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'IP address of the destination (IPv4 or IPv6).', + columnHeaderType: 'not-filtered', + id: 'destination.ip', + category: 'destination', + type: 'ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'destination.port', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'Short name or login of the user.', + columnHeaderType: 'not-filtered', + id: 'user.name', + category: 'user', + type: 'string', + searchable: null, + example: 'albert', + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'host.name', + searchable: null, + }, + ], + dataProviders: [ + { + excluded: false, + and: [], + kqlQuery: '', + name: '30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d', + queryMatch: { + displayValue: null, + field: '_id', + displayField: null, + value: '30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d', + operator: ':', + }, + id: + 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d', + enabled: true, + }, + ], + description: '', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { + kind: 'kuery', + expression: '', + }, + serializedQuery: '', + }, + }, + title: 'Generic Network Timeline', + dateRange: { + start: 1588255858373, + end: 1588256218373, + }, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + created: 1588256264265, + createdBy: 'Elastic', + updated: 1588256629234, + updatedBy: 'Elastic', + eventNotes: [], + globalNotes: [], + pinnedEventIds: [], + timelineType: 'template', + status: 'immutable', + templateTimelineId: '2c7e0663-5a91-0002-aa15-26bf756d2c39', + templateTimelineVersion: 1, + }, + { + savedObjectId: null, + version: null, + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'signal.rule.description', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.action', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'process.name', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'The working directory of the process.', + columnHeaderType: 'not-filtered', + id: 'process.working_directory', + category: 'process', + type: 'string', + searchable: null, + example: '/home/alice', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.', + columnHeaderType: 'not-filtered', + id: 'process.args', + category: 'process', + type: 'string', + searchable: null, + example: '["/usr/bin/ssh","-l","user","10.0.0.16"]', + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'process.pid', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'Absolute path to the process executable.', + columnHeaderType: 'not-filtered', + id: 'process.parent.executable', + category: 'process', + type: 'string', + searchable: null, + example: '/usr/bin/ssh', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'Array of process arguments.\n\nMay be filtered to protect sensitive information.', + columnHeaderType: 'not-filtered', + id: 'process.parent.args', + category: 'process', + type: 'string', + searchable: null, + example: '["ssh","-l","user","10.0.0.16"]', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'Process id.', + columnHeaderType: 'not-filtered', + id: 'process.parent.pid', + category: 'process', + type: 'number', + searchable: null, + example: '4242', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'Short name or login of the user.', + columnHeaderType: 'not-filtered', + id: 'user.name', + category: 'user', + type: 'string', + searchable: null, + example: 'albert', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.', + columnHeaderType: 'not-filtered', + id: 'host.name', + category: 'host', + type: 'string', + searchable: null, + }, + ], + dataProviders: [ + { + excluded: false, + and: [], + kqlQuery: '', + name: '590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde', + queryMatch: { + displayValue: null, + field: '_id', + displayField: null, + value: '590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde', + operator: ':', + }, + id: + 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde', + enabled: true, + }, + ], + description: '', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { + kind: 'kuery', + expression: '', + }, + serializedQuery: '', + }, + }, + title: 'Generic Process Timeline', + dateRange: { + start: 1588161020848, + end: 1588162280848, + }, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + created: 1588162404153, + createdBy: 'Elastic', + updated: 1588604767818, + updatedBy: 'Elastic', + eventNotes: [], + globalNotes: [], + pinnedEventIds: [], + timelineType: 'template', + status: 'immutable', + templateTimelineId: '2c7e0663-5a91-0003-aa15-26bf756d2c39', + templateTimelineVersion: 1, + }, + ], + timelinesToUpdate: [], + prepackagedTimelines: [], +}; + +export const mockCheckTimelinesStatusAfterInstallResult = { + timelinesToInstall: [], + timelinesToUpdate: [], + prepackagedTimelines: [ + { + savedObjectId: '4dc6b080-c4f5-11ea-90f7-5913f6a19d5c', + version: 'WzQxNywxXQ==', + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'signal.rule.description', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.', + columnHeaderType: 'not-filtered', + id: 'event.action', + category: 'event', + type: 'string', + searchable: null, + example: 'user-password-change', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.', + columnHeaderType: 'not-filtered', + id: 'process.args', + category: 'process', + type: 'string', + searchable: null, + example: '["/usr/bin/ssh","-l","user","10.0.0.16"]', + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'process.pid', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'IP address of the source (IPv4 or IPv6).', + columnHeaderType: 'not-filtered', + id: 'source.ip', + category: 'source', + type: 'ip', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'Port of the source.', + columnHeaderType: 'not-filtered', + id: 'source.port', + category: 'source', + type: 'number', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'IP address of the destination (IPv4 or IPv6).', + columnHeaderType: 'not-filtered', + id: 'destination.ip', + category: 'destination', + type: 'ip', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'destination.port', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'Short name or login of the user.', + columnHeaderType: 'not-filtered', + id: 'user.name', + category: 'user', + type: 'string', + searchable: null, + example: 'albert', + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'host.name', + searchable: null, + }, + ], + dataProviders: [ + { + excluded: false, + and: [], + kqlQuery: '', + name: '30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d', + queryMatch: { + displayValue: null, + field: '_id', + displayField: null, + value: '30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d', + operator: ':', + }, + id: + 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-30d47c8a1b179ae435058e5b23b96118125a451fe58efd77be288f00456ff77d', + enabled: true, + }, + ], + description: '', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { + kind: 'kuery', + expression: '', + }, + serializedQuery: '', + }, + }, + title: 'Generic Network Timeline', + dateRange: { + start: 1588255858373, + end: 1588256218373, + }, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + timelineType: 'template', + status: 'immutable', + templateTimelineId: '2c7e0663-5a91-0002-aa15-26bf756d2c39', + templateTimelineVersion: 1, + created: 1594636843808, + createdBy: 'Elastic', + updated: 1594636843808, + updatedBy: 'Elastic', + excludedRowRendererIds: [], + favorite: [], + eventIdToNoteIds: [], + noteIds: [], + notes: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + }, + { + savedObjectId: '4dc79ae0-c4f5-11ea-90f7-5913f6a19d5c', + version: 'WzQxOCwxXQ==', + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'signal.rule.description', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'event.action', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'process.name', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'The working directory of the process.', + columnHeaderType: 'not-filtered', + id: 'process.working_directory', + category: 'process', + type: 'string', + searchable: null, + example: '/home/alice', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'Array of process arguments, starting with the absolute path to\nthe executable.\n\nMay be filtered to protect sensitive information.', + columnHeaderType: 'not-filtered', + id: 'process.args', + category: 'process', + type: 'string', + searchable: null, + example: '["/usr/bin/ssh","-l","user","10.0.0.16"]', + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'process.pid', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'Absolute path to the process executable.', + columnHeaderType: 'not-filtered', + id: 'process.parent.executable', + category: 'process', + type: 'string', + searchable: null, + example: '/usr/bin/ssh', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'Array of process arguments.\n\nMay be filtered to protect sensitive information.', + columnHeaderType: 'not-filtered', + id: 'process.parent.args', + category: 'process', + type: 'string', + searchable: null, + example: '["ssh","-l","user","10.0.0.16"]', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'Process id.', + columnHeaderType: 'not-filtered', + id: 'process.parent.pid', + category: 'process', + type: 'number', + searchable: null, + example: '4242', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: 'Short name or login of the user.', + columnHeaderType: 'not-filtered', + id: 'user.name', + category: 'user', + type: 'string', + searchable: null, + example: 'albert', + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.', + columnHeaderType: 'not-filtered', + id: 'host.name', + category: 'host', + type: 'string', + searchable: null, + }, + ], + dataProviders: [ + { + excluded: false, + and: [], + kqlQuery: '', + name: '590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde', + queryMatch: { + displayValue: null, + field: '_id', + displayField: null, + value: '590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde', + operator: ':', + }, + id: + 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-590eb946a7fdbacaa587ed0f6b1a16f5ad3d659ec47ef35ad0826c47af133bde', + enabled: true, + }, + ], + description: '', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { + kind: 'kuery', + expression: '', + }, + serializedQuery: '', + }, + }, + title: 'Generic Process Timeline', + dateRange: { + start: 1588161020848, + end: 1588162280848, + }, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + timelineType: 'template', + status: 'immutable', + templateTimelineId: '2c7e0663-5a91-0003-aa15-26bf756d2c39', + templateTimelineVersion: 1, + created: 1594636843813, + createdBy: 'Elastic', + updated: 1594636843813, + updatedBy: 'Elastic', + excludedRowRendererIds: [], + favorite: [], + eventIdToNoteIds: [], + noteIds: [], + notes: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + }, + { + savedObjectId: '4dc66260-c4f5-11ea-90f7-5913f6a19d5c', + version: 'WzQxNiwxXQ==', + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'signal.rule.description', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.', + columnHeaderType: 'not-filtered', + id: 'event.action', + category: 'event', + type: 'string', + searchable: null, + example: 'user-password-change', + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'endgame.data.rule_name', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'rule.reference', + searchable: null, + }, + { + aggregatable: true, + description: + 'Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.', + columnHeaderType: 'not-filtered', + id: 'host.name', + category: 'host', + type: 'string', + }, + { + aggregatable: true, + description: 'Operating system name, without the version.', + columnHeaderType: 'not-filtered', + id: 'host.os.name', + category: 'host', + type: 'string', + example: 'Mac OS X', + }, + ], + dataProviders: [ + { + excluded: false, + and: [], + kqlQuery: '', + name: '3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + queryMatch: { + displayValue: null, + field: '_id', + displayField: null, + value: '3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + operator: ':', + }, + id: + 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + enabled: true, + }, + ], + description: '', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { + filterQuery: { + kuery: { + kind: 'kuery', + expression: '', + }, + serializedQuery: '', + }, + }, + title: 'Generic Endpoint Timeline', + dateRange: { + start: 1588257731065, + end: 1588258391065, + }, + savedQueryId: null, + sort: { + columnId: '@timestamp', + sortDirection: 'desc', + }, + timelineType: 'template', + status: 'immutable', + templateTimelineId: '2c7e0663-5a91-0001-aa15-26bf756d2c39', + templateTimelineVersion: 1, + created: 1594636843807, + createdBy: 'Elastic', + updated: 1594636843807, + updatedBy: 'Elastic', + excludedRowRendererIds: [], + favorite: [], + eventIdToNoteIds: [], + noteIds: [], + notes: [], + pinnedEventIds: [], + pinnedEventsSaveObject: [], + }, + ], +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/prepackaged_timelines.ndjson b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/prepackaged_timelines.ndjson new file mode 100644 index 00000000000000..f7113a4ac395ed --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/prepackaged_timelines.ndjson @@ -0,0 +1 @@ +{"savedObjectId":"mocked-timeline-id-1","version":"WzExNzEyLDFd","columns":[{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"@timestamp","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"signal.rule.description","searchable":null},{"indexes":null,"aggregatable":true,"name":null,"description":"The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.","columnHeaderType":"not-filtered","id":"event.action","category":"event","type":"string","searchable":null,"example":"user-password-change"},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"endgame.data.rule_name","searchable":null},{"indexes":null,"name":null,"columnHeaderType":"not-filtered","id":"rule.reference","searchable":null},{"aggregatable":true,"description":"Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.","columnHeaderType":"not-filtered","id":"host.name","category":"host","type":"string"},{"aggregatable":true,"description":"Operating system name, without the version.","columnHeaderType":"not-filtered","id":"host.os.name","category":"host","type":"string","example":"Mac OS X"}],"dataProviders":[{"excluded":false,"and":[],"kqlQuery":"","name":"3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509","queryMatch":{"displayValue":null,"field":"_id","displayField":null,"value":"3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509","operator":":"},"id":"send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509","enabled":true}],"description":"","eventType":"all","filters":[],"kqlMode":"filter","kqlQuery":{"filterQuery":{"kuery":{"kind":"kuery","expression":""},"serializedQuery":""}},"title":"Generic Endpoint Timeline","dateRange":{"start":1588257731065,"end":1588258391065},"savedQueryId":null,"sort":{"columnId":"@timestamp","sortDirection":"desc"},"created":1588258576517,"createdBy":"elastic","updated":1588261039030,"updatedBy":"elastic","eventNotes":[],"globalNotes":[],"pinnedEventIds":[],"timelineType":"template"} diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts index 9afe5ad533324f..a314d5fb36c6df 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/__mocks__/request_responses.ts @@ -11,6 +11,7 @@ import { TIMELINE_EXPORT_URL, TIMELINE_IMPORT_URL, TIMELINE_URL, + TIMELINE_PREPACKAGED_URL, } from '../../../../../common/constants'; import { SavedTimeline, TimelineType, TimelineStatus } from '../../../../../common/types/timeline'; @@ -18,6 +19,7 @@ import { requestMock } from '../../../detection_engine/routes/__mocks__'; import { updateTimelineSchema } from '../schemas/update_timelines_schema'; import { createTimelineSchema } from '../schemas/create_timelines_schema'; +import { GetTimelineByIdSchemaQuery } from '../schemas/get_timeline_by_id_schema'; const readable = new stream.Readable(); export const getExportTimelinesRequest = () => @@ -173,6 +175,19 @@ export const cleanDraftTimelinesRequest = (timelineType: TimelineType) => }, }); +export const getTimelineByIdRequest = (query: GetTimelineByIdSchemaQuery) => + requestMock.create({ + method: 'get', + path: TIMELINE_URL, + query, + }); + +export const installPrepackedTimelinesRequest = () => + requestMock.create({ + method: 'post', + path: TIMELINE_PREPACKAGED_URL, + }); + export const mockTimelinesSavedObjects = () => ({ saved_objects: [ { diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/export_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/export_timelines_route.test.ts index c66bf7b192c620..a6f0ce232fa7ba 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/export_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/export_timelines_route.test.ts @@ -22,6 +22,9 @@ import { TIMELINE_EXPORT_URL } from '../../../../common/constants'; import { convertSavedObjectToSavedNote } from '../../note/saved_object'; import { convertSavedObjectToSavedPinnedEvent } from '../../pinned_event/saved_object'; import { convertSavedObjectToSavedTimeline } from '../convert_saved_object_to_savedtimeline'; +import { mockGetCurrentUser } from './__mocks__/import_timelines'; +import { SecurityPluginSetup } from '../../../../../../plugins/security/server'; + jest.mock('../convert_saved_object_to_savedtimeline', () => { return { convertSavedObjectToSavedTimeline: jest.fn(), @@ -31,22 +34,30 @@ jest.mock('../convert_saved_object_to_savedtimeline', () => { jest.mock('../../note/saved_object', () => { return { convertSavedObjectToSavedNote: jest.fn(), + getNotesByTimelineId: jest.fn().mockReturnValue([]), }; }); jest.mock('../../pinned_event/saved_object', () => { return { convertSavedObjectToSavedPinnedEvent: jest.fn(), + getAllPinnedEventsByTimelineId: jest.fn().mockReturnValue([]), }; }); describe('export timelines', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let securitySetup: SecurityPluginSetup; beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); - + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; clients.savedObjectsClient.bulkGet.mockResolvedValue(mockTimelinesSavedObjects()); ((convertSavedObjectToSavedTimeline as unknown) as jest.Mock).mockReturnValue(mockTimelines()); @@ -54,7 +65,7 @@ describe('export timelines', () => { ((convertSavedObjectToSavedPinnedEvent as unknown) as jest.Mock).mockReturnValue( mockPinnedEvents() ); - exportTimelinesRoute(server.router, createMockConfig()); + exportTimelinesRoute(server.router, createMockConfig(), securitySetup); }); describe('status codes', () => { @@ -85,7 +96,7 @@ describe('export timelines', () => { const result = server.validate(request); expect(result.badRequest.mock.calls[0][0]).toEqual( - 'Invalid value "undefined" supplied to "ids"' + 'Invalid value "undefined" supplied to "file_name"' ); }); @@ -93,12 +104,13 @@ describe('export timelines', () => { const request = requestMock.create({ method: 'get', path: TIMELINE_EXPORT_URL, - body: { id: 'someId' }, + query: { file_name: 'test.ndjson' }, + body: { ids: 'someId' }, }); const result = server.validate(request); - expect(result.badRequest.mock.calls[1][0]).toEqual( - 'Invalid value "undefined" supplied to "file_name"' + expect(result.badRequest.mock.calls[0][0]).toEqual( + 'Invalid value "someId" supplied to "ids",Invalid value "someId" supplied to "ids",Invalid value "{"ids":"someId"}" supplied to "(Partial<{ ids: (Array | null) }> | null)"' ); }); }); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/export_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/export_timelines_route.ts index 1d16cd92610693..89e38753ac9263 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/export_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/export_timelines_route.ts @@ -15,8 +15,14 @@ import { exportTimelinesRequestBodySchema, } from './schemas/export_timelines_schema'; import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; +import { buildFrameworkRequest } from './utils/common'; +import { SetupPlugins } from '../../../plugin'; -export const exportTimelinesRoute = (router: IRouter, config: ConfigType) => { +export const exportTimelinesRoute = ( + router: IRouter, + config: ConfigType, + security: SetupPlugins['security'] +) => { router.post( { path: TIMELINE_EXPORT_URL, @@ -31,7 +37,8 @@ export const exportTimelinesRoute = (router: IRouter, config: ConfigType) => { async (context, request, response) => { try { const siemResponse = buildSiemResponse(response); - const savedObjectsClient = context.core.savedObjects.client; + const frameworkRequest = await buildFrameworkRequest(context, security, request); + const exportSizeLimit = config.maxTimelineImportExportSize; if (request.body?.ids != null && request.body.ids.length > exportSizeLimit) { @@ -42,8 +49,8 @@ export const exportTimelinesRoute = (router: IRouter, config: ConfigType) => { } const responseBody = await getExportTimelineByObjectIds({ - client: savedObjectsClient, - ids: request.body.ids, + frameworkRequest, + ids: request.body?.ids, }); return response.ok({ diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/get_timeline_by_id_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_timeline_by_id_route.test.ts new file mode 100644 index 00000000000000..30528f8563ab8e --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_timeline_by_id_route.test.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SecurityPluginSetup } from '../../../../../../plugins/security/server'; + +import { + serverMock, + requestContextMock, + createMockConfig, +} from '../../detection_engine/routes/__mocks__'; + +import { mockGetCurrentUser } from './__mocks__/import_timelines'; +import { getTimelineByIdRequest } from './__mocks__/request_responses'; + +import { getTimeline, getTemplateTimeline } from './utils/create_timelines'; +import { getTimelineByIdRoute } from './get_timeline_by_id_route'; + +jest.mock('./utils/create_timelines', () => ({ + getTimeline: jest.fn(), + getTemplateTimeline: jest.fn(), +})); + +describe('get timeline by id', () => { + let server: ReturnType; + let securitySetup: SecurityPluginSetup; + let { context } = requestContextMock.createTools(); + + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + + server = serverMock.create(); + context = requestContextMock.createTools().context; + + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; + + getTimelineByIdRoute(server.router, createMockConfig(), securitySetup); + }); + + test('should call getTemplateTimeline if templateTimelineId is given', async () => { + const templateTimelineId = '123'; + await server.inject( + getTimelineByIdRequest({ template_timeline_id: templateTimelineId }), + context + ); + + expect((getTemplateTimeline as jest.Mock).mock.calls[0][1]).toEqual(templateTimelineId); + }); + + test('should call getTimeline if id is given', async () => { + const id = '456'; + + await server.inject(getTimelineByIdRequest({ id }), context); + + expect((getTimeline as jest.Mock).mock.calls[0][1]).toEqual(id); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/get_timeline_by_id_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_timeline_by_id_route.ts new file mode 100644 index 00000000000000..c4957b9d4b9e26 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/get_timeline_by_id_route.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 { IRouter } from '../../../../../../../src/core/server'; + +import { TIMELINE_URL } from '../../../../common/constants'; + +import { ConfigType } from '../../..'; +import { SetupPlugins } from '../../../plugin'; +import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; + +import { buildSiemResponse, transformError } from '../../detection_engine/routes/utils'; + +import { buildFrameworkRequest } from './utils/common'; +import { getTimelineByIdSchemaQuery } from './schemas/get_timeline_by_id_schema'; +import { getTimeline, getTemplateTimeline } from './utils/create_timelines'; + +export const getTimelineByIdRoute = ( + router: IRouter, + config: ConfigType, + security: SetupPlugins['security'] +) => { + router.get( + { + path: `${TIMELINE_URL}`, + validate: { query: buildRouteValidation(getTimelineByIdSchemaQuery) }, + options: { + tags: ['access:securitySolution'], + }, + }, + async (context, request, response) => { + try { + const frameworkRequest = await buildFrameworkRequest(context, security, request); + const { template_timeline_id: templateTimelineId, id } = request.query; + let res = null; + if (templateTimelineId != null) { + res = await getTemplateTimeline(frameworkRequest, templateTimelineId); + } else if (id != null) { + res = await getTimeline(frameworkRequest, id); + } + + return response.ok({ body: res ?? {} }); + } catch (err) { + const error = transformError(err); + const siemResponse = buildSiemResponse(response); + + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts index 248bf358064c02..fe5993cb0161de 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.test.ts @@ -83,10 +83,8 @@ describe('import timelines', () => { }; }); - jest.doMock('./utils/import_timelines', () => { - const originalModule = jest.requireActual('./utils/import_timelines'); + jest.doMock('./utils/get_timelines_from_stream', () => { return { - ...originalModule, getTupleDuplicateErrorsAndUniqueTimeline: mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue( [mockDuplicateIdErrors, mockUniqueParsedObjects] ), @@ -173,6 +171,8 @@ describe('import timelines', () => { expect(response.body).toEqual({ success: false, success_count: 0, + timelines_installed: 0, + timelines_updated: 0, errors: [ { id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', @@ -295,6 +295,8 @@ describe('import timelines', () => { expect(response.body).toEqual({ success: false, success_count: 0, + timelines_installed: 0, + timelines_updated: 0, errors: [ { id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', @@ -322,6 +324,8 @@ describe('import timelines', () => { expect(response.body).toEqual({ success: false, success_count: 0, + timelines_installed: 0, + timelines_updated: 0, errors: [ { id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', @@ -349,6 +353,8 @@ describe('import timelines', () => { expect(response.body).toEqual({ success: false, success_count: 0, + timelines_installed: 0, + timelines_updated: 0, errors: [ { id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', @@ -458,10 +464,8 @@ describe('import timeline templates', () => { }; }); - jest.doMock('./utils/import_timelines', () => { - const originalModule = jest.requireActual('./utils/import_timelines'); + jest.doMock('./utils/get_timelines_from_stream', () => { return { - ...originalModule, getTupleDuplicateErrorsAndUniqueTimeline: mockGetTupleDuplicateErrorsAndUniqueTimeline.mockReturnValue( [mockDuplicateIdErrors, mockUniqueParsedTemplateTimelineObjects] ), @@ -719,6 +723,8 @@ describe('import timeline templates', () => { expect(response.body).toEqual({ success: false, success_count: 0, + timelines_installed: 0, + timelines_updated: 0, errors: [ { id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', @@ -746,6 +752,8 @@ describe('import timeline templates', () => { expect(response.body).toEqual({ success: false, success_count: 0, + timelines_installed: 0, + timelines_updated: 0, errors: [ { id: '79deb4c0-6bc1-11ea-a90b-f5341fb7a189', diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts index 56e4e81b4214b6..c93983e499fb54 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/import_timelines_route.ts @@ -5,48 +5,19 @@ */ import { extname } from 'path'; -import { chunk, omit } from 'lodash/fp'; -import uuid from 'uuid'; -import { createPromiseFromStreams } from '../../../../../../../src/legacy/utils'; import { IRouter } from '../../../../../../../src/core/server'; import { TIMELINE_IMPORT_URL } from '../../../../common/constants'; -import { validate } from '../../../../common/validate'; import { SetupPlugins } from '../../../plugin'; import { ConfigType } from '../../../config'; import { buildRouteValidation } from '../../../utils/build_validation/route_validation'; -import { importRulesSchema } from '../../../../common/detection_engine/schemas/response/import_rules_schema'; -import { - buildSiemResponse, - createBulkErrorObject, - BulkError, - transformError, -} from '../../detection_engine/routes/utils'; - -import { createTimelinesStreamFromNdJson } from '../create_timelines_stream_from_ndjson'; +import { buildSiemResponse, transformError } from '../../detection_engine/routes/utils'; +import { importTimelines } from './utils/import_timelines'; import { ImportTimelinesPayloadSchemaRt } from './schemas/import_timelines_schema'; -import { - buildFrameworkRequest, - CompareTimelinesStatus, - TimelineStatusActions, -} from './utils/common'; -import { - getTupleDuplicateErrorsAndUniqueTimeline, - isBulkError, - isImportRegular, - ImportTimelineResponse, - ImportTimelinesSchema, - PromiseFromStreams, - timelineSavedObjectOmittedFields, -} from './utils/import_timelines'; -import { createTimelines } from './utils/create_timelines'; -import { TimelineStatus } from '../../../../common/types/timeline'; - -const CHUNK_PARSED_OBJECT_SIZE = 10; -const DEFAULT_IMPORT_ERROR = `Something has gone wrong. We didn't handle something properly. To help us fix this, please upload your file to https://discuss.elastic.co/c/security/siem.`; +import { buildFrameworkRequest } from './utils/common'; export const importTimelinesRoute = ( router: IRouter, @@ -75,9 +46,8 @@ export const importTimelinesRoute = ( return siemResponse.error({ statusCode: 404 }); } - const { file } = request.body; + const { file, isImmutable } = request.body; const { filename } = file.hapi; - const fileExtension = extname(filename).toLowerCase(); if (fileExtension !== '.ndjson') { @@ -87,193 +57,16 @@ export const importTimelinesRoute = ( }); } - const objectLimit = config.maxTimelineImportExportSize; + const frameworkRequest = await buildFrameworkRequest(context, security, request); - const readStream = createTimelinesStreamFromNdJson(objectLimit); - const parsedObjects = await createPromiseFromStreams([ + const res = await importTimelines( file, - ...readStream, - ]); - const [duplicateIdErrors, uniqueParsedObjects] = getTupleDuplicateErrorsAndUniqueTimeline( - parsedObjects, - false + config.maxTimelineImportExportSize, + frameworkRequest, + isImmutable === 'true' ); - const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects); - let importTimelineResponse: ImportTimelineResponse[] = []; - - const frameworkRequest = await buildFrameworkRequest(context, security, request); - - while (chunkParseObjects.length) { - const batchParseObjects = chunkParseObjects.shift() ?? []; - const newImportTimelineResponse = await Promise.all( - batchParseObjects.reduce>>( - (accum, parsedTimeline) => { - const importsWorkerPromise = new Promise( - async (resolve, reject) => { - if (parsedTimeline instanceof Error) { - // If the JSON object had a validation or parse error then we return - // early with the error and an (unknown) for the ruleId - resolve( - createBulkErrorObject({ - statusCode: 400, - message: parsedTimeline.message, - }) - ); - - return null; - } - - const { - savedObjectId, - pinnedEventIds, - globalNotes, - eventNotes, - status, - templateTimelineId, - templateTimelineVersion, - title, - timelineType, - version, - } = parsedTimeline; - const parsedTimelineObject = omit( - timelineSavedObjectOmittedFields, - parsedTimeline - ); - let newTimeline = null; - try { - const compareTimelinesStatus = new CompareTimelinesStatus({ - status, - timelineType, - title, - timelineInput: { - id: savedObjectId, - version, - }, - templateTimelineInput: { - id: templateTimelineId, - version: templateTimelineVersion, - }, - frameworkRequest, - }); - await compareTimelinesStatus.init(); - const isTemplateTimeline = compareTimelinesStatus.isHandlingTemplateTimeline; - if (compareTimelinesStatus.isCreatableViaImport) { - // create timeline / timeline template - newTimeline = await createTimelines({ - frameworkRequest, - timeline: { - ...parsedTimelineObject, - status: - status === TimelineStatus.draft - ? TimelineStatus.active - : status ?? TimelineStatus.active, - templateTimelineVersion: isTemplateTimeline - ? templateTimelineVersion - : null, - templateTimelineId: isTemplateTimeline - ? templateTimelineId ?? uuid.v4() - : null, - }, - pinnedEventIds: isTemplateTimeline ? null : pinnedEventIds, - notes: isTemplateTimeline ? globalNotes : [...globalNotes, ...eventNotes], - }); - - resolve({ - timeline_id: newTimeline.timeline.savedObjectId, - status_code: 200, - }); - } - - if (!compareTimelinesStatus.isHandlingTemplateTimeline) { - const errorMessage = compareTimelinesStatus.checkIsFailureCases( - TimelineStatusActions.createViaImport - ); - const message = errorMessage?.body ?? DEFAULT_IMPORT_ERROR; - - resolve( - createBulkErrorObject({ - id: savedObjectId ?? 'unknown', - statusCode: 409, - message, - }) - ); - } else { - if (compareTimelinesStatus.isUpdatableViaImport) { - // update timeline template - newTimeline = await createTimelines({ - frameworkRequest, - timeline: parsedTimelineObject, - timelineSavedObjectId: compareTimelinesStatus.timelineId, - timelineVersion: compareTimelinesStatus.timelineVersion, - notes: globalNotes, - existingNoteIds: compareTimelinesStatus.timelineInput.data?.noteIds, - }); - - resolve({ - timeline_id: newTimeline.timeline.savedObjectId, - status_code: 200, - }); - } else { - const errorMessage = compareTimelinesStatus.checkIsFailureCases( - TimelineStatusActions.updateViaImport - ); - - const message = errorMessage?.body ?? DEFAULT_IMPORT_ERROR; - - resolve( - createBulkErrorObject({ - id: savedObjectId ?? 'unknown', - statusCode: 409, - message, - }) - ); - } - } - } catch (err) { - resolve( - createBulkErrorObject({ - id: savedObjectId ?? 'unknown', - statusCode: 400, - message: err.message, - }) - ); - } - } - ); - return [...accum, importsWorkerPromise]; - }, - [] - ) - ); - importTimelineResponse = [ - ...duplicateIdErrors, - ...importTimelineResponse, - ...newImportTimelineResponse, - ]; - } - - const errorsResp = importTimelineResponse.filter((resp) => { - return isBulkError(resp); - }) as BulkError[]; - const successes = importTimelineResponse.filter((resp) => { - if (isImportRegular(resp)) { - return resp.status_code === 200; - } else { - return false; - } - }); - const importTimelines: ImportTimelinesSchema = { - success: errorsResp.length === 0, - success_count: successes.length, - errors: errorsResp, - }; - const [validated, errors] = validate(importTimelines, importRulesSchema); - - if (errors != null) { - return siemResponse.error({ statusCode: 500, body: errors }); - } else { - return response.ok({ body: validated ?? {} }); - } + if (typeof res !== 'string') return response.ok({ body: res ?? {} }); + else throw res; } catch (err) { const error = transformError(err); const siemResponse = buildSiemResponse(response); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/install_prepacked_timelines_route.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/install_prepacked_timelines_route.test.ts new file mode 100644 index 00000000000000..1fd2d40b02819b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/install_prepacked_timelines_route.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SecurityPluginSetup } from '../../../../../../plugins/security/server'; + +import { + serverMock, + requestContextMock, + createMockConfig, +} from '../../detection_engine/routes/__mocks__'; + +import { + mockGetCurrentUser, + mockCheckTimelinesStatusBeforeInstallResult, + mockCheckTimelinesStatusAfterInstallResult, +} from './__mocks__/import_timelines'; +import { installPrepackedTimelinesRequest } from './__mocks__/request_responses'; + +import { installPrepackagedTimelines } from './utils/install_prepacked_timelines'; +import { checkTimelinesStatus } from './utils/check_timelines_status'; + +import { installPrepackedTimelinesRoute } from './install_prepacked_timelines_route'; + +jest.mock('./utils/install_prepacked_timelines', () => ({ + installPrepackagedTimelines: jest.fn(), +})); + +jest.mock('./utils/check_timelines_status', () => ({ + checkTimelinesStatus: jest.fn(), +})); + +describe('installPrepackagedTimelines', () => { + let server: ReturnType; + let securitySetup: SecurityPluginSetup; + let { context } = requestContextMock.createTools(); + + beforeEach(() => { + jest.resetModules(); + jest.resetAllMocks(); + + server = serverMock.create(); + context = requestContextMock.createTools().context; + + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; + + installPrepackedTimelinesRoute(server.router, createMockConfig(), securitySetup); + }); + + test('should call installPrepackagedTimelines ', async () => { + (checkTimelinesStatus as jest.Mock).mockReturnValue( + mockCheckTimelinesStatusBeforeInstallResult + ); + + await server.inject(installPrepackedTimelinesRequest(), context); + + expect(installPrepackagedTimelines).toHaveBeenCalled(); + }); + + test('should return installPrepackagedTimelines result ', async () => { + (checkTimelinesStatus as jest.Mock).mockReturnValue( + mockCheckTimelinesStatusBeforeInstallResult + ); + (installPrepackagedTimelines as jest.Mock).mockReturnValue({ + errors: [], + success: true, + success_count: 3, + timelines_installed: 3, + timelines_updated: 0, + }); + + const result = await server.inject(installPrepackedTimelinesRequest(), context); + + expect(result.body).toEqual({ + errors: [], + success: true, + success_count: 3, + timelines_installed: 3, + timelines_updated: 0, + }); + }); + + test('should not call installPrepackagedTimelines if it has nothing to install or update', async () => { + (checkTimelinesStatus as jest.Mock).mockReturnValue(mockCheckTimelinesStatusAfterInstallResult); + + await server.inject(installPrepackedTimelinesRequest(), context); + + expect(installPrepackagedTimelines).not.toHaveBeenCalled(); + }); + + test('should return success if it has nothing to install or update', async () => { + (checkTimelinesStatus as jest.Mock).mockReturnValue(mockCheckTimelinesStatusAfterInstallResult); + + const result = await server.inject(installPrepackedTimelinesRequest(), context); + + expect(result.body).toEqual({ + errors: [], + success: true, + success_count: 0, + timelines_installed: 0, + timelines_updated: 0, + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/install_prepacked_timelines_route.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/install_prepacked_timelines_route.ts new file mode 100644 index 00000000000000..aba05054abfe23 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/install_prepacked_timelines_route.ts @@ -0,0 +1,90 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter } from '../../../../../../../src/core/server'; + +import { TIMELINE_PREPACKAGED_URL } from '../../../../common/constants'; + +import { SetupPlugins } from '../../../plugin'; +import { ConfigType } from '../../../config'; +import { validate } from '../../../../common/validate'; + +import { buildSiemResponse, transformError } from '../../detection_engine/routes/utils'; + +import { installPrepackagedTimelines } from './utils/install_prepacked_timelines'; + +import { checkTimelinesStatus } from './utils/check_timelines_status'; + +import { checkTimelineStatusRt } from './schemas/check_timelines_status_schema'; +import { buildFrameworkRequest } from './utils/common'; + +export const installPrepackedTimelinesRoute = ( + router: IRouter, + config: ConfigType, + security: SetupPlugins['security'] +) => { + router.post( + { + path: `${TIMELINE_PREPACKAGED_URL}`, + validate: {}, + options: { + tags: ['access:securitySolution'], + body: { + maxBytes: config.maxTimelineImportPayloadBytes, + output: 'stream', + }, + }, + }, + async (context, request, response) => { + try { + const frameworkRequest = await buildFrameworkRequest(context, security, request); + const prepackagedTimelineStatus = await checkTimelinesStatus(frameworkRequest); + const [validatedprepackagedTimelineStatus, prepackagedTimelineStatusError] = validate( + prepackagedTimelineStatus, + checkTimelineStatusRt + ); + + if (prepackagedTimelineStatusError != null) { + throw prepackagedTimelineStatusError; + } + + const timelinesToInstalled = + validatedprepackagedTimelineStatus?.timelinesToInstall.length ?? 0; + const timelinesNotUpdated = + validatedprepackagedTimelineStatus?.timelinesToUpdate.length ?? 0; + let res = null; + + if (timelinesToInstalled > 0 || timelinesNotUpdated > 0) { + res = await installPrepackagedTimelines( + config.maxTimelineImportExportSize, + frameworkRequest, + true + ); + } + if (res instanceof Error) { + throw res; + } else { + return response.ok({ + body: res ?? { + success: true, + success_count: 0, + timelines_installed: 0, + timelines_updated: 0, + errors: [], + }, + }); + } + } catch (err) { + const error = transformError(err); + const siemResponse = buildSiemResponse(response); + return siemResponse.error({ + body: error.message, + statusCode: error.statusCode, + }); + } + } + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/check_timelines_status_schema.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/check_timelines_status_schema.ts new file mode 100644 index 00000000000000..f21ce5689a03b9 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/check_timelines_status_schema.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'; +import { TimelineSavedToReturnObjectRuntimeType } from '../../../../../common/types/timeline'; + +import { ImportTimelinesSchemaRt } from './import_timelines_schema'; +import { unionWithNullType } from '../../../../../common/utility_types'; + +export const checkTimelineStatusRt = rt.type({ + timelinesToInstall: rt.array(unionWithNullType(ImportTimelinesSchemaRt)), + timelinesToUpdate: rt.array(unionWithNullType(ImportTimelinesSchemaRt)), + prepackagedTimelines: rt.array(unionWithNullType(TimelineSavedToReturnObjectRuntimeType)), +}); + +export type CheckTimelineStatusRt = rt.TypeOf; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/export_timelines_schema.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/export_timelines_schema.ts index 9264f1e3e50474..ce8eb93bdbdbd4 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/export_timelines_schema.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/export_timelines_schema.ts @@ -5,11 +5,14 @@ */ import * as rt from 'io-ts'; +import { unionWithNullType } from '../../../../../common/utility_types'; export const exportTimelinesQuerySchema = rt.type({ file_name: rt.string, }); -export const exportTimelinesRequestBodySchema = rt.type({ - ids: rt.array(rt.string), -}); +export const exportTimelinesRequestBodySchema = unionWithNullType( + rt.partial({ + ids: unionWithNullType(rt.array(rt.string)), + }) +); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/get_timeline_by_id_schema.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/get_timeline_by_id_schema.ts new file mode 100644 index 00000000000000..2c6098bc75500f --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/get_timeline_by_id_schema.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'; + +export const getTimelineByIdSchemaQuery = rt.partial({ + template_timeline_id: rt.string, + id: rt.string, +}); + +export type GetTimelineByIdSchemaQuery = rt.TypeOf; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/import_timelines_schema.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/import_timelines_schema.ts index 9045f2b3f35d2d..afce9d6cdcb24e 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/import_timelines_schema.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/schemas/import_timelines_schema.ts @@ -26,6 +26,8 @@ export const ImportTimelinesSchemaRt = rt.intersection([ }), ]); +export type ImportTimelinesSchema = rt.TypeOf; + const ReadableRt = new rt.Type( 'ReadableRt', (u): u is Readable => u instanceof Readable, @@ -36,11 +38,17 @@ const ReadableRt = new rt.Type( }), (a) => a ); -export const ImportTimelinesPayloadSchemaRt = rt.type({ - file: rt.intersection([ - ReadableRt, - rt.type({ - hapi: rt.type({ filename: rt.string }), - }), - ]), -}); + +const booleanInString = rt.union([rt.literal('true'), rt.literal('false')]); + +export const ImportTimelinesPayloadSchemaRt = rt.intersection([ + rt.type({ + file: rt.intersection([ + ReadableRt, + rt.type({ + hapi: rt.type({ filename: rt.string }), + }), + ]), + }), + rt.partial({ isImmutable: booleanInString }), +]); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.ts new file mode 100644 index 00000000000000..2ce2c37d4fa314 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/check_timelines_status.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 path, { join, resolve } from 'path'; + +import { TimelineSavedObject } from '../../../../../common/types/timeline'; + +import { FrameworkRequest } from '../../../framework'; + +import { getExistingPrepackagedTimelines } from '../../saved_object'; + +import { CheckTimelineStatusRt } from '../schemas/check_timelines_status_schema'; + +import { loadData, getReadables } from './common'; +import { getTimelinesToInstall } from './get_timelines_to_install'; +import { getTimelinesToUpdate } from './get_timelines_to_update'; + +export const checkTimelinesStatus = async ( + frameworkRequest: FrameworkRequest, + filePath?: string, + fileName?: string +): Promise => { + let readStream; + let timeline: { + totalCount: number; + timeline: TimelineSavedObject[]; + }; + const dir = resolve( + join(__dirname, filePath ?? '../../../detection_engine/rules/prepackaged_timelines') + ); + const file = fileName ?? 'index.ndjson'; + const dataPath = path.join(dir, file); + + try { + readStream = await getReadables(dataPath); + timeline = await getExistingPrepackagedTimelines(frameworkRequest, false); + } catch (err) { + return { + timelinesToInstall: [], + timelinesToUpdate: [], + prepackagedTimelines: [], + }; + } + + return loadData<'utf-8', CheckTimelineStatusRt>( + readStream, + (timelinesFromFileSystem: T) => { + if (Array.isArray(timelinesFromFileSystem)) { + const parsedTimelinesFromFileSystem = timelinesFromFileSystem.map((t: string) => + JSON.parse(t) + ); + const prepackagedTimelines = timeline.timeline ?? []; + const timelinesToInstall = getTimelinesToInstall( + parsedTimelinesFromFileSystem, + prepackagedTimelines + ); + const timelinesToUpdate = getTimelinesToUpdate( + parsedTimelinesFromFileSystem, + prepackagedTimelines + ); + + return Promise.resolve({ + timelinesToInstall, + timelinesToUpdate, + prepackagedTimelines, + }); + } else { + return Promise.reject(new Error('load timeline error')); + } + }, + 'utf-8' + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts index d086b11df8aa53..fc25f1a48194e2 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/common.ts @@ -4,9 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ import { set } from '@elastic/safer-lodash-set/fp'; +import readline from 'readline'; +import fs from 'fs'; +import { Readable } from 'stream'; import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { createListStream } from '../../../../../../../../src/legacy/utils'; + import { SetupPlugins } from '../../../../plugin'; import { FrameworkRequest } from '../../../framework'; @@ -30,6 +35,103 @@ export const buildFrameworkRequest = async ( ); }; +export const getReadables = (dataPath: string): Promise => + new Promise((resolved, reject) => { + const contents: string[] = []; + const readable = fs.createReadStream(dataPath, { encoding: 'utf-8' }); + + readable.on('data', (stream) => { + contents.push(stream); + }); + + readable.on('end', () => { + const streams = createListStream(contents); + resolved(streams); + }); + + readable.on('error', (err) => { + reject(err); + }); + }); + +export const loadData = ( + readStream: Readable, + bulkInsert: (docs: V) => Promise, + encoding?: T, + maxTimelineImportExportSize?: number | null +): Promise => { + return new Promise((resolved, reject) => { + let docs: string[] = []; + let isPaused: boolean = false; + + const lineStream = readline.createInterface({ input: readStream }); + const onClose = async () => { + if (docs.length > 0) { + try { + let bulkInsertResult; + if (typeof encoding === 'string' && encoding === 'utf-8') { + bulkInsertResult = await bulkInsert(docs); + } else { + const docstmp = createListStream(docs.join('\n')); + bulkInsertResult = await bulkInsert(docstmp); + } + resolved(bulkInsertResult); + } catch (err) { + reject(err); + return; + } + } + reject(new Error('No data provided')); + }; + + const closeWithError = (err: Error) => { + lineStream.removeListener('close', onClose); + lineStream.close(); + reject(err); + }; + + lineStream.on('close', onClose); + + lineStream.on('line', async (line) => { + if (line.length === 0 || line.charAt(0) === '/' || line.charAt(0) === ' ') { + return; + } + + docs.push(line); + + if ( + maxTimelineImportExportSize != null && + docs.length >= maxTimelineImportExportSize && + !isPaused + ) { + lineStream.pause(); + + const docstmp = createListStream(docs.join('\n')); + docs = []; + + try { + if (typeof encoding === 'string' && encoding === 'utf-8') { + await bulkInsert(docs); + } else { + await bulkInsert(docstmp); + } + lineStream.resume(); + } catch (err) { + closeWithError(err); + } + } + }); + + lineStream.on('pause', async () => { + isPaused = true; + }); + + lineStream.on('resume', async () => { + isPaused = false; + }); + }); +}; + export enum TimelineStatusActions { create = 'create', createViaImport = 'createViaImport', diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts index 67965469e1a9f7..cdedffbbd94589 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/create_timelines.ts @@ -16,10 +16,18 @@ import { NoteResult, ResponseTimeline } from '../../../../graphql/types'; export const saveTimelines = ( frameworkRequest: FrameworkRequest, timeline: SavedTimeline, - timelineSavedObjectId: string | null = null, - timelineVersion: string | null = null -): Promise => - timelineLib.persistTimeline(frameworkRequest, timelineSavedObjectId, timelineVersion, timeline); + timelineSavedObjectId?: string | null, + timelineVersion?: string | null, + isImmutable?: boolean +): Promise => { + return timelineLib.persistTimeline( + frameworkRequest, + timelineSavedObjectId ?? null, + timelineVersion ?? null, + timeline, + isImmutable + ); +}; export const savePinnedEvents = ( frameworkRequest: FrameworkRequest, @@ -70,6 +78,7 @@ interface CreateTimelineProps { pinnedEventIds?: string[] | null; notes?: NoteResult[]; existingNoteIds?: string[]; + isImmutable?: boolean; } export const createTimelines = async ({ @@ -80,12 +89,14 @@ export const createTimelines = async ({ pinnedEventIds = null, notes = [], existingNoteIds = [], + isImmutable, }: CreateTimelineProps): Promise => { const responseTimeline = await saveTimelines( frameworkRequest, timeline, timelineSavedObjectId, - timelineVersion + timelineVersion, + isImmutable ); const newTimelineSavedObjectId = responseTimeline.timeline.savedObjectId; const newTimelineVersion = responseTimeline.timeline.version; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts index f4b97ac3510ccc..6f194c3b8538ee 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/export_timelines.ts @@ -6,17 +6,9 @@ import { omit } from 'lodash/fp'; -import { - SavedObjectsClient, - SavedObjectsFindOptions, - SavedObjectsFindResponse, -} from '../../../../../../../../src/core/server'; - import { ExportedTimelines, - ExportTimelineSavedObjectsClient, ExportedNotes, - TimelineSavedObject, ExportTimelineNotFoundError, } from '../../../../../common/types/timeline'; import { NoteSavedObject } from '../../../../../common/types/timeline/note'; @@ -24,71 +16,11 @@ import { PinnedEventSavedObject } from '../../../../../common/types/timeline/pin import { transformDataToNdjson } from '../../../../utils/read_stream/create_stream_from_ndjson'; -import { convertSavedObjectToSavedPinnedEvent } from '../../../pinned_event/saved_object'; -import { convertSavedObjectToSavedNote } from '../../../note/saved_object'; -import { pinnedEventSavedObjectType } from '../../../pinned_event/saved_object_mappings'; -import { noteSavedObjectType } from '../../../note/saved_object_mappings'; - -import { timelineSavedObjectType } from '../../saved_object_mappings'; -import { convertSavedObjectToSavedTimeline } from '../../convert_saved_object_to_savedtimeline'; - -export type TimelineSavedObjectsClient = Pick< - SavedObjectsClient, - | 'get' - | 'errors' - | 'create' - | 'bulkCreate' - | 'delete' - | 'find' - | 'bulkGet' - | 'update' - | 'bulkUpdate' ->; - -const getAllSavedPinnedEvents = ( - pinnedEventsSavedObjects: SavedObjectsFindResponse -): PinnedEventSavedObject[] => { - return pinnedEventsSavedObjects != null - ? (pinnedEventsSavedObjects?.saved_objects ?? []).map((savedObject) => - convertSavedObjectToSavedPinnedEvent(savedObject) - ) - : []; -}; - -const getPinnedEventsByTimelineId = ( - savedObjectsClient: ExportTimelineSavedObjectsClient, - timelineId: string -): Promise> => { - const options: SavedObjectsFindOptions = { - type: pinnedEventSavedObjectType, - search: timelineId, - searchFields: ['timelineId'], - }; - return savedObjectsClient.find(options); -}; +import { FrameworkRequest } from '../../../framework'; +import * as noteLib from '../../../note/saved_object'; +import * as pinnedEventLib from '../../../pinned_event/saved_object'; -const getAllSavedNote = ( - noteSavedObjects: SavedObjectsFindResponse -): NoteSavedObject[] => { - return noteSavedObjects != null - ? noteSavedObjects.saved_objects.map((savedObject) => - convertSavedObjectToSavedNote(savedObject) - ) - : []; -}; - -const getNotesByTimelineId = ( - savedObjectsClient: ExportTimelineSavedObjectsClient, - timelineId: string -): Promise> => { - const options: SavedObjectsFindOptions = { - type: noteSavedObjectType, - search: timelineId, - searchFields: ['timelineId'], - }; - - return savedObjectsClient.find(options); -}; +import { getTimelines } from '../../saved_object'; const getGlobalEventNotesByTimelineId = (currentNotes: NoteSavedObject[]): ExportedNotes => { const initialNotes: ExportedNotes = { @@ -119,64 +51,30 @@ const getPinnedEventsIdsByTimelineId = ( return currentPinnedEvents.map((event) => event.eventId) ?? []; }; -const getTimelines = async ( - savedObjectsClient: ExportTimelineSavedObjectsClient, - timelineIds: string[] -) => { - const savedObjects = await Promise.resolve( - savedObjectsClient.bulkGet( - timelineIds.reduce( - (acc, timelineId) => [...acc, { id: timelineId, type: timelineSavedObjectType }], - [] as Array<{ id: string; type: string }> - ) - ) - ); - - const timelineObjects: { - timelines: TimelineSavedObject[]; - errors: ExportTimelineNotFoundError[]; - } = savedObjects.saved_objects.reduce( - (acc, savedObject) => { - return savedObject.error == null - ? { - errors: acc.errors, - timelines: [...acc.timelines, convertSavedObjectToSavedTimeline(savedObject)], - } - : { errors: [...acc.errors, savedObject.error], timelines: acc.timelines }; - }, - { - timelines: [] as TimelineSavedObject[], - errors: [] as ExportTimelineNotFoundError[], - } - ); - - return timelineObjects; -}; - const getTimelinesFromObjects = async ( - savedObjectsClient: ExportTimelineSavedObjectsClient, - ids: string[] + request: FrameworkRequest, + ids?: string[] | null ): Promise> => { - const { timelines, errors } = await getTimelines(savedObjectsClient, ids); + const { timelines, errors } = await getTimelines(request, ids); + const exportedIds = timelines.map((t) => t.savedObjectId); - const [notes, pinnedEventIds] = await Promise.all([ - Promise.all(ids.map((timelineId) => getNotesByTimelineId(savedObjectsClient, timelineId))), + const [notes, pinnedEvents] = await Promise.all([ + Promise.all(exportedIds.map((timelineId) => noteLib.getNotesByTimelineId(request, timelineId))), Promise.all( - ids.map((timelineId) => getPinnedEventsByTimelineId(savedObjectsClient, timelineId)) + exportedIds.map((timelineId) => + pinnedEventLib.getAllPinnedEventsByTimelineId(request, timelineId) + ) ), ]); - const myNotes = notes.reduce( - (acc, note) => [...acc, ...getAllSavedNote(note)], - [] - ); + const myNotes = notes.reduce((acc, note) => [...acc, ...note], []); - const myPinnedEventIds = pinnedEventIds.reduce( - (acc, pinnedEventId) => [...acc, ...getAllSavedPinnedEvents(pinnedEventId)], + const myPinnedEventIds = pinnedEvents.reduce( + (acc, pinnedEventId) => [...acc, ...pinnedEventId], [] ); - const myResponse = ids.reduce((acc, timelineId) => { + const myResponse = exportedIds.reduce((acc, timelineId) => { const myTimeline = timelines.find((t) => t.savedObjectId === timelineId); if (myTimeline != null) { const timelineNotes = myNotes.filter((n) => n.timelineId === timelineId); @@ -198,12 +96,12 @@ const getTimelinesFromObjects = async ( }; export const getExportTimelineByObjectIds = async ({ - client, + frameworkRequest, ids, }: { - client: ExportTimelineSavedObjectsClient; - ids: string[]; + frameworkRequest: FrameworkRequest; + ids?: string[] | null; }) => { - const timeline = await getTimelinesFromObjects(client, ids); + const timeline = await getTimelinesFromObjects(frameworkRequest, ids); return transformDataToNdjson(timeline); }; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines.ts new file mode 100644 index 00000000000000..1dac773ad6fde4 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines.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 { FrameworkRequest } from '../../../framework'; +import { getTimelines as getSelectedTimelines } from '../../saved_object'; +import { TimelineSavedObject } from '../../../../../common/types/timeline'; + +export const getTimelines = async ( + frameworkRequest: FrameworkRequest, + ids: string[] +): Promise<{ timeline: TimelineSavedObject[] | null; error: string | null }> => { + try { + const timelines = await getSelectedTimelines(frameworkRequest, ids); + const existingTimelineIds = timelines.timelines.map((timeline) => timeline.savedObjectId); + const errorMsg = timelines.errors.reduce( + (acc, curr) => (acc ? `${acc}, ${curr.message}` : curr.message), + '' + ); + if (existingTimelineIds.length > 0) { + const message = existingTimelineIds.join(', '); + return { + timeline: timelines.timelines, + error: errorMsg ? `${message} found, ${errorMsg}` : null, + }; + } else { + return { timeline: null, error: errorMsg }; + } + } catch (e) { + return e.message; + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines_from_stream.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines_from_stream.ts new file mode 100644 index 00000000000000..85e35d055767eb --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines_from_stream.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import uuid from 'uuid'; +import { createBulkErrorObject, BulkError } from '../../../detection_engine/routes/utils'; +import { PromiseFromStreams } from './import_timelines'; + +export const getTupleDuplicateErrorsAndUniqueTimeline = ( + timelines: PromiseFromStreams[], + isOverwrite: boolean +): [BulkError[], PromiseFromStreams[]] => { + const { errors, timelinesAcc } = timelines.reduce( + (acc, parsedTimeline) => { + if (parsedTimeline instanceof Error) { + acc.timelinesAcc.set(uuid.v4(), parsedTimeline); + } else { + const { savedObjectId } = parsedTimeline; + if (savedObjectId != null) { + if (acc.timelinesAcc.has(savedObjectId) && !isOverwrite) { + acc.errors.set( + uuid.v4(), + createBulkErrorObject({ + id: savedObjectId, + statusCode: 400, + message: `More than one timeline with savedObjectId: "${savedObjectId}" found`, + }) + ); + } + acc.timelinesAcc.set(savedObjectId, parsedTimeline); + } else { + acc.timelinesAcc.set(uuid.v4(), parsedTimeline); + } + } + + return acc; + }, // using map (preserves ordering) + { + errors: new Map(), + timelinesAcc: new Map(), + } + ); + + return [Array.from(errors.values()), Array.from(timelinesAcc.values())]; +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines_to_install.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines_to_install.ts new file mode 100644 index 00000000000000..096ff48a82176b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines_to_install.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ImportTimelinesSchema } from '../schemas/import_timelines_schema'; +import { TimelineSavedObject } from '../../../../../common/types/timeline'; + +export const getTimelinesToInstall = ( + timelinesFromFileSystem: ImportTimelinesSchema[], + installedTimelines: TimelineSavedObject[] +): ImportTimelinesSchema[] => { + return timelinesFromFileSystem.filter( + (timeline) => + !installedTimelines.some( + (installedTimeline) => installedTimeline.templateTimelineId === timeline.templateTimelineId + ) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines_to_update.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines_to_update.ts new file mode 100644 index 00000000000000..51ede7feee83a6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/get_timelines_to_update.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ImportTimelinesSchema } from '../schemas/import_timelines_schema'; +import { TimelineSavedObject } from '../../../../../common/types/timeline'; + +export const getTimelinesToUpdate = ( + timelinesFromFileSystem: ImportTimelinesSchema[], + installedTimelines: TimelineSavedObject[] +): ImportTimelinesSchema[] => { + return timelinesFromFileSystem.filter((timeline) => + installedTimelines.some((installedTimeline) => { + return ( + timeline.templateTimelineId === installedTimeline.templateTimelineId && + timeline.templateTimelineVersion! > installedTimeline.templateTimelineVersion! + ); + }) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts index 3f46b9ba91dc47..996dc5823691d6 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/import_timelines.ts @@ -4,18 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ +import { has, chunk, omit } from 'lodash/fp'; +import { Readable } from 'stream'; import uuid from 'uuid'; -import { has } from 'lodash/fp'; -import { createBulkErrorObject, BulkError } from '../../../detection_engine/routes/utils'; -import { SavedTimeline } from '../../../../../common/types/timeline'; + +import { + TimelineStatus, + SavedTimeline, + ImportTimelineResultSchema, + importTimelineResultSchema, +} from '../../../../../common/types/timeline'; +import { validate } from '../../../../../common/validate'; import { NoteResult } from '../../../../graphql/types'; import { HapiReadableStream } from '../../../detection_engine/rules/types'; +import { createBulkErrorObject, BulkError } from '../../../detection_engine/routes/utils'; +import { createTimelines } from './create_timelines'; +import { FrameworkRequest } from '../../../framework'; +import { createTimelinesStreamFromNdJson } from '../../create_timelines_stream_from_ndjson'; +import { createPromiseFromStreams } from '../../../../../../../../src/legacy/utils'; -export interface ImportTimelinesSchema { - success: boolean; - success_count: number; - errors: BulkError[]; -} +import { getTupleDuplicateErrorsAndUniqueTimeline } from './get_timelines_from_stream'; +import { CompareTimelinesStatus } from './compare_timelines_status'; +import { TimelineStatusActions } from './common'; export type ImportedTimeline = SavedTimeline & { savedObjectId: string | null; @@ -25,56 +35,20 @@ export type ImportedTimeline = SavedTimeline & { eventNotes: NoteResult[]; }; +export type PromiseFromStreams = ImportedTimeline; + interface ImportRegular { timeline_id: string; status_code: number; message?: string; + action: TimelineStatusActions.createViaImport | TimelineStatusActions.updateViaImport; } export type ImportTimelineResponse = ImportRegular | BulkError; -export type PromiseFromStreams = ImportedTimeline; export interface ImportTimelinesRequestParams { body: { file: HapiReadableStream }; } -export const getTupleDuplicateErrorsAndUniqueTimeline = ( - timelines: PromiseFromStreams[], - isOverwrite: boolean -): [BulkError[], PromiseFromStreams[]] => { - const { errors, timelinesAcc } = timelines.reduce( - (acc, parsedTimeline) => { - if (parsedTimeline instanceof Error) { - acc.timelinesAcc.set(uuid.v4(), parsedTimeline); - } else { - const { savedObjectId } = parsedTimeline; - if (savedObjectId != null) { - if (acc.timelinesAcc.has(savedObjectId) && !isOverwrite) { - acc.errors.set( - uuid.v4(), - createBulkErrorObject({ - id: savedObjectId, - statusCode: 400, - message: `More than one timeline with savedObjectId: "${savedObjectId}" found`, - }) - ); - } - acc.timelinesAcc.set(savedObjectId, parsedTimeline); - } else { - acc.timelinesAcc.set(uuid.v4(), parsedTimeline); - } - } - - return acc; - }, // using map (preserves ordering) - { - errors: new Map(), - timelinesAcc: new Map(), - } - ); - - return [Array.from(errors.values()), Array.from(timelinesAcc.values())]; -}; - export const isImportRegular = ( importTimelineResponse: ImportTimelineResponse ): importTimelineResponse is ImportRegular => { @@ -102,3 +76,205 @@ export const timelineSavedObjectOmittedFields = [ 'updatedBy', 'version', ]; + +const CHUNK_PARSED_OBJECT_SIZE = 10; +const DEFAULT_IMPORT_ERROR = `Something has gone wrong. We didn't handle something properly. To help us fix this, please upload your file to https://discuss.elastic.co/c/security/siem.`; + +export const importTimelines = async ( + file: Readable, + maxTimelineImportExportSize: number, + frameworkRequest: FrameworkRequest, + isImmutable?: boolean +): Promise => { + const readStream = createTimelinesStreamFromNdJson(maxTimelineImportExportSize); + const parsedObjects = await createPromiseFromStreams([file, ...readStream]); + + const [duplicateIdErrors, uniqueParsedObjects] = getTupleDuplicateErrorsAndUniqueTimeline( + parsedObjects, + false + ); + + const chunkParseObjects = chunk(CHUNK_PARSED_OBJECT_SIZE, uniqueParsedObjects); + let importTimelineResponse: ImportTimelineResponse[] = []; + + while (chunkParseObjects.length) { + const batchParseObjects = chunkParseObjects.shift() ?? []; + const newImportTimelineResponse = await Promise.all( + batchParseObjects.reduce>>((accum, parsedTimeline) => { + const importsWorkerPromise = new Promise( + async (resolve, reject) => { + if (parsedTimeline instanceof Error) { + // If the JSON object had a validation or parse error then we return + // early with the error and an (unknown) for the ruleId + resolve( + createBulkErrorObject({ + statusCode: 400, + message: parsedTimeline.message, + }) + ); + + return null; + } + + const { + savedObjectId, + pinnedEventIds, + globalNotes, + eventNotes, + status, + templateTimelineId, + templateTimelineVersion, + title, + timelineType, + version, + } = parsedTimeline; + const parsedTimelineObject = omit(timelineSavedObjectOmittedFields, parsedTimeline); + let newTimeline = null; + try { + const compareTimelinesStatus = new CompareTimelinesStatus({ + status, + timelineType, + title, + timelineInput: { + id: savedObjectId, + version, + }, + templateTimelineInput: { + id: templateTimelineId, + version: templateTimelineVersion, + }, + frameworkRequest, + }); + await compareTimelinesStatus.init(); + const isTemplateTimeline = compareTimelinesStatus.isHandlingTemplateTimeline; + if (compareTimelinesStatus.isCreatableViaImport) { + // create timeline / timeline template + newTimeline = await createTimelines({ + frameworkRequest, + timeline: { + ...parsedTimelineObject, + status: + status === TimelineStatus.draft + ? TimelineStatus.active + : status ?? TimelineStatus.active, + templateTimelineVersion: isTemplateTimeline ? templateTimelineVersion : null, + templateTimelineId: isTemplateTimeline ? templateTimelineId ?? uuid.v4() : null, + }, + pinnedEventIds: isTemplateTimeline ? null : pinnedEventIds, + notes: isTemplateTimeline ? globalNotes : [...globalNotes, ...eventNotes], + isImmutable, + }); + + resolve({ + timeline_id: newTimeline.timeline.savedObjectId, + status_code: 200, + action: TimelineStatusActions.createViaImport, + }); + } + + if (!compareTimelinesStatus.isHandlingTemplateTimeline) { + const errorMessage = compareTimelinesStatus.checkIsFailureCases( + TimelineStatusActions.createViaImport + ); + const message = errorMessage?.body ?? DEFAULT_IMPORT_ERROR; + + resolve( + createBulkErrorObject({ + id: savedObjectId ?? 'unknown', + statusCode: 409, + message, + }) + ); + } else { + if (compareTimelinesStatus.isUpdatableViaImport) { + // update timeline template + newTimeline = await createTimelines({ + frameworkRequest, + timeline: parsedTimelineObject, + timelineSavedObjectId: compareTimelinesStatus.timelineId, + timelineVersion: compareTimelinesStatus.timelineVersion, + notes: globalNotes, + existingNoteIds: compareTimelinesStatus.timelineInput.data?.noteIds, + isImmutable, + }); + + resolve({ + timeline_id: newTimeline.timeline.savedObjectId, + status_code: 200, + action: TimelineStatusActions.updateViaImport, + }); + } else { + const errorMessage = compareTimelinesStatus.checkIsFailureCases( + TimelineStatusActions.updateViaImport + ); + + const message = errorMessage?.body ?? DEFAULT_IMPORT_ERROR; + + resolve( + createBulkErrorObject({ + id: + savedObjectId ?? + (templateTimelineId + ? `(template_timeline_id) ${templateTimelineId}` + : 'unknown'), + statusCode: 409, + message, + }) + ); + } + } + } catch (err) { + resolve( + createBulkErrorObject({ + id: + savedObjectId ?? + (templateTimelineId + ? `(template_timeline_id) ${templateTimelineId}` + : 'unknown'), + statusCode: 400, + message: err.message, + }) + ); + } + } + ); + return [...accum, importsWorkerPromise]; + }, []) + ); + importTimelineResponse = [ + ...duplicateIdErrors, + ...importTimelineResponse, + ...newImportTimelineResponse, + ]; + } + + const errorsResp = importTimelineResponse.filter((resp) => { + return isBulkError(resp); + }) as BulkError[]; + const successes = importTimelineResponse.filter((resp) => { + if (isImportRegular(resp)) { + return resp.status_code === 200; + } else { + return false; + } + }); + const timelinesInstalled = importTimelineResponse.filter( + (resp) => isImportRegular(resp) && resp.action === 'createViaImport' + ); + const timelinesUpdated = importTimelineResponse.filter( + (resp) => isImportRegular(resp) && resp.action === 'updateViaImport' + ); + const importTimelinesRes: ImportTimelineResultSchema = { + success: errorsResp.length === 0, + success_count: successes.length, + errors: errorsResp, + timelines_installed: timelinesInstalled.length ?? 0, + timelines_updated: timelinesUpdated.length ?? 0, + }; + const [validated, errors] = validate(importTimelinesRes, importTimelineResultSchema); + if (errors != null || validated == null) { + return new Error(errors || 'Import timeline error'); + } else { + return validated; + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts new file mode 100644 index 00000000000000..66f16db01a508b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.test.ts @@ -0,0 +1,257 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { join, resolve } from 'path'; + +import { createPromiseFromStreams } from '../../../../../../../../src/legacy/utils/streams'; +import { SecurityPluginSetup } from '../../../../../../security/server'; + +import { FrameworkRequest } from '../../../framework'; +import { + createMockConfig, + requestContextMock, + mockGetCurrentUser, +} from '../../../detection_engine/routes/__mocks__'; +import { + addPrepackagedRulesRequest, + getNonEmptyIndex, + getFindResultWithSingleHit, +} from '../../../detection_engine/routes/__mocks__/request_responses'; + +import * as lib from './install_prepacked_timelines'; +import { importTimelines } from './import_timelines'; +import { buildFrameworkRequest } from './common'; +import { ImportTimelineResultSchema } from '../../../../../common/types/timeline'; + +jest.mock('./import_timelines'); + +describe('installPrepackagedTimelines', () => { + let securitySetup: SecurityPluginSetup; + let frameworkRequest: FrameworkRequest; + const spyInstallPrepackagedTimelines = jest.spyOn(lib, 'installPrepackagedTimelines'); + + const { clients, context } = requestContextMock.createTools(); + const config = createMockConfig(); + const mockFilePath = '../__mocks__'; + const mockFileName = 'prepackaged_timelines.ndjson'; + + beforeEach(async () => { + securitySetup = ({ + authc: { + getCurrentUser: jest.fn().mockReturnValue(mockGetCurrentUser), + }, + authz: {}, + } as unknown) as SecurityPluginSetup; + + clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); + clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); + + jest.doMock('./install_prepacked_timelines', () => { + return { + ...lib, + installPrepackagedTimelines: spyInstallPrepackagedTimelines, + }; + }); + + const request = addPrepackagedRulesRequest(); + frameworkRequest = await buildFrameworkRequest(context, securitySetup, request); + }); + + afterEach(() => { + spyInstallPrepackagedTimelines.mockClear(); + }); + + afterAll(() => { + jest.resetModules(); + }); + + test('should call importTimelines', async () => { + await lib.installPrepackagedTimelines( + config.maxTimelineImportExportSize, + frameworkRequest, + true, + mockFilePath, + mockFileName + ); + + expect(importTimelines).toHaveBeenCalled(); + }); + + test('should call importTimelines with Readables', async () => { + const dir = resolve(join(__dirname, mockFilePath)); + const file = mockFileName; + await lib.installPrepackagedTimelines( + config.maxTimelineImportExportSize, + frameworkRequest, + true, + dir, + file + ); + const args = await createPromiseFromStreams([(importTimelines as jest.Mock).mock.calls[0][0]]); + const expected = JSON.stringify({ + savedObjectId: 'mocked-timeline-id-1', + version: 'WzExNzEyLDFd', + columns: [ + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: '@timestamp', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'signal.rule.description', + searchable: null, + }, + { + indexes: null, + aggregatable: true, + name: null, + description: + 'The action captured by the event.\n\nThis describes the information in the event. It is more specific than `event.category`.\nExamples are `group-add`, `process-started`, `file-created`. The value is\nnormally defined by the implementer.', + columnHeaderType: 'not-filtered', + id: 'event.action', + category: 'event', + type: 'string', + searchable: null, + example: 'user-password-change', + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'endgame.data.rule_name', + searchable: null, + }, + { + indexes: null, + name: null, + columnHeaderType: 'not-filtered', + id: 'rule.reference', + searchable: null, + }, + { + aggregatable: true, + description: + 'Name of the host.\n\nIt can contain what `hostname` returns on Unix systems, the fully qualified\ndomain name, or a name specified by the user. The sender decides which value\nto use.', + columnHeaderType: 'not-filtered', + id: 'host.name', + category: 'host', + type: 'string', + }, + { + aggregatable: true, + description: 'Operating system name, without the version.', + columnHeaderType: 'not-filtered', + id: 'host.os.name', + category: 'host', + type: 'string', + example: 'Mac OS X', + }, + ], + dataProviders: [ + { + excluded: false, + and: [], + kqlQuery: '', + name: '3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + queryMatch: { + displayValue: null, + field: '_id', + displayField: null, + value: '3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + operator: ':', + }, + id: + 'send-signal-to-timeline-action-default-draggable-event-details-value-formatted-field-value-timeline-1-signal-id-3c322ed995865f642c1a269d54cbd177bd4b0e6efcf15a589f4f8582efbe7509', + enabled: true, + }, + ], + description: '', + eventType: 'all', + filters: [], + kqlMode: 'filter', + kqlQuery: { filterQuery: { kuery: { kind: 'kuery', expression: '' }, serializedQuery: '' } }, + title: 'Generic Endpoint Timeline', + dateRange: { start: 1588257731065, end: 1588258391065 }, + savedQueryId: null, + sort: { columnId: '@timestamp', sortDirection: 'desc' }, + created: 1588258576517, + createdBy: 'elastic', + updated: 1588261039030, + updatedBy: 'elastic', + eventNotes: [], + globalNotes: [], + pinnedEventIds: [], + timelineType: 'template', + }); + expect(args).toEqual(expected); + }); + + test('should call importTimelines with maxTimelineImportExportSize', async () => { + const dir = resolve(join(__dirname, mockFilePath)); + const file = mockFileName; + await lib.installPrepackagedTimelines( + config.maxTimelineImportExportSize, + frameworkRequest, + true, + dir, + file + ); + + expect((importTimelines as jest.Mock).mock.calls[0][1]).toEqual( + config.maxTimelineImportExportSize + ); + }); + + test('should call importTimelines with frameworkRequest', async () => { + const dir = resolve(join(__dirname, mockFilePath)); + const file = mockFileName; + await lib.installPrepackagedTimelines( + config.maxTimelineImportExportSize, + frameworkRequest, + true, + dir, + file + ); + + expect(JSON.stringify((importTimelines as jest.Mock).mock.calls[0][2])).toEqual( + JSON.stringify(frameworkRequest) + ); + }); + + test('should call importTimelines with immutable', async () => { + const dir = resolve(join(__dirname, mockFilePath)); + const file = mockFileName; + await lib.installPrepackagedTimelines( + config.maxTimelineImportExportSize, + frameworkRequest, + true, + dir, + file + ); + + expect((importTimelines as jest.Mock).mock.calls[0][3]).toEqual(true); + }); + + test('should handle errors from getReadables', async () => { + const result = await lib.installPrepackagedTimelines( + config.maxTimelineImportExportSize, + frameworkRequest, + true, + mockFilePath, + 'prepackaged_timeline.ndjson' + ); + + expect( + (result as ImportTimelineResultSchema).errors[0].error.message.includes( + 'read prepackaged timelines error:' + ) + ).toBeTruthy(); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.ts b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.ts new file mode 100644 index 00000000000000..eb83a463eabbf7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/timeline/routes/utils/install_prepacked_timelines.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import path, { join, resolve } from 'path'; +import { Readable } from 'stream'; + +import { ImportTimelineResultSchema } from '../../../../../common/types/timeline'; + +import { FrameworkRequest } from '../../../framework'; + +import { importTimelines } from './import_timelines'; + +import { loadData, getReadables } from './common'; + +export const installPrepackagedTimelines = async ( + maxTimelineImportExportSize: number, + frameworkRequest: FrameworkRequest, + isImmutable: boolean, + filePath?: string, + fileName?: string +): Promise => { + let readStream; + const dir = resolve( + join(__dirname, filePath ?? '../../../detection_engine/rules/prepackaged_timelines') + ); + const file = fileName ?? 'index.ndjson'; + const dataPath = path.join(dir, file); + try { + readStream = await getReadables(dataPath); + } catch (err) { + return { + success: false, + success_count: 0, + timelines_installed: 0, + timelines_updated: 0, + errors: [ + { + error: { message: `read prepackaged timelines error: ${err.message}`, status_code: 500 }, + }, + ], + }; + } + + return loadData(readStream, (docs: T) => + docs instanceof Readable + ? importTimelines(docs, maxTimelineImportExportSize, frameworkRequest, isImmutable) + : Promise.reject(new Error(`read prepackaged timelines error`)) + ); +}; diff --git a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts index f4dbd2db3329c6..82a2a866a71ff0 100644 --- a/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts +++ b/x-pack/plugins/security_solution/server/lib/timeline/saved_object.ts @@ -14,6 +14,7 @@ import { SavedTimeline, TimelineSavedObject, TimelineTypeLiteralWithNull, + ExportTimelineNotFoundError, TimelineStatusLiteralWithNull, TemplateTimelineTypeLiteralWithNull, TemplateTimelineType, @@ -35,6 +36,7 @@ import { convertSavedObjectToSavedTimeline } from './convert_saved_object_to_sav import { pickSavedTimeline } from './pick_saved_timeline'; import { timelineSavedObjectType } from './saved_object_mappings'; import { draftTimelineDefaults } from './default_timeline'; +import { AuthenticatedUser } from '../../../../security/server'; interface ResponseTimelines { timeline: TimelineSavedObject[]; @@ -81,7 +83,7 @@ export interface Timeline { timelineId: string | null, version: string | null, timeline: SavedTimeline, - timelineType?: TimelineTypeLiteralWithNull + isImmutable?: boolean ) => Promise; deleteTimeline: (request: FrameworkRequest, timelineIds: string[]) => Promise; @@ -160,6 +162,33 @@ const getTimelineTypeFilter = ( return filters.filter((f) => f != null).join(' and '); }; +export const getExistingPrepackagedTimelines = async ( + request: FrameworkRequest, + countsOnly?: boolean, + pageInfo?: PageInfoTimeline | null +): Promise<{ + totalCount: number; + timeline: TimelineSavedObject[]; +}> => { + const queryPageInfo = countsOnly + ? { + perPage: 1, + page: 1, + } + : pageInfo ?? {}; + const elasticTemplateTimelineOptions = { + type: timelineSavedObjectType, + ...queryPageInfo, + filter: getTimelineTypeFilter( + TimelineType.template, + TemplateTimelineType.elastic, + TimelineStatus.immutable + ), + }; + + return getAllSavedTimeline(request, elasticTemplateTimelineOptions); +}; + export const getAllTimeline = async ( request: FrameworkRequest, onlyUserFavorite: boolean | null, @@ -172,8 +201,8 @@ export const getAllTimeline = async ( ): Promise => { const options: SavedObjectsFindOptions = { type: timelineSavedObjectType, - perPage: pageInfo != null ? pageInfo.pageSize : undefined, - page: pageInfo != null ? pageInfo.pageIndex : undefined, + perPage: pageInfo?.pageSize ?? undefined, + page: pageInfo?.pageIndex ?? undefined, search: search != null ? search : undefined, searchFields: onlyUserFavorite ? ['title', 'description', 'favorite.keySearch'] @@ -197,17 +226,6 @@ export const getAllTimeline = async ( filter: getTimelineTypeFilter(TimelineType.template, null, null), }; - const elasticTemplateTimelineOptions = { - type: timelineSavedObjectType, - perPage: 1, - page: 1, - filter: getTimelineTypeFilter( - TimelineType.template, - TemplateTimelineType.elastic, - TimelineStatus.immutable - ), - }; - const customTemplateTimelineOptions = { type: timelineSavedObjectType, perPage: 1, @@ -231,7 +249,7 @@ export const getAllTimeline = async ( getAllSavedTimeline(request, options), getAllSavedTimeline(request, timelineOptions), getAllSavedTimeline(request, templateTimelineOptions), - getAllSavedTimeline(request, elasticTemplateTimelineOptions), + getExistingPrepackagedTimelines(request, true), getAllSavedTimeline(request, customTemplateTimelineOptions), getAllSavedTimeline(request, favoriteTimelineOptions), ]); @@ -336,16 +354,18 @@ export const persistTimeline = async ( request: FrameworkRequest, timelineId: string | null, version: string | null, - timeline: SavedTimeline + timeline: SavedTimeline, + isImmutable?: boolean ): Promise => { const savedObjectsClient = request.context.core.savedObjects.client; + const userInfo = isImmutable ? ({ username: 'Elastic' } as AuthenticatedUser) : request.user; try { if (timelineId == null) { // Create new timeline const newTimeline = convertSavedObjectToSavedTimeline( await savedObjectsClient.create( timelineSavedObjectType, - pickSavedTimeline(timelineId, timeline, request.user) + pickSavedTimeline(timelineId, timeline, userInfo) ) ); return { @@ -358,7 +378,7 @@ export const persistTimeline = async ( await savedObjectsClient.update( timelineSavedObjectType, timelineId, - pickSavedTimeline(timelineId, timeline, request.user), + pickSavedTimeline(timelineId, timeline, userInfo), { version: version || undefined, } @@ -537,3 +557,50 @@ export const timelineWithReduxProperties = ( pinnedEventIds: pinnedEvents.map((e) => e.eventId), pinnedEventsSaveObject: pinnedEvents, }); + +export const getTimelines = async (request: FrameworkRequest, timelineIds?: string[] | null) => { + const savedObjectsClient = request.context.core.savedObjects.client; + let exportedIds = timelineIds; + if (timelineIds == null || timelineIds.length === 0) { + const { timeline: savedAllTimelines } = await getAllTimeline( + request, + false, + null, + null, + null, + TimelineStatus.active, + null, + null + ); + exportedIds = savedAllTimelines.map((t) => t.savedObjectId); + } + + const savedObjects = await Promise.resolve( + savedObjectsClient.bulkGet( + exportedIds?.reduce( + (acc, timelineId) => [...acc, { id: timelineId, type: timelineSavedObjectType }], + [] as Array<{ id: string; type: string }> + ) + ) + ); + + const timelineObjects: { + timelines: TimelineSavedObject[]; + errors: ExportTimelineNotFoundError[]; + } = savedObjects.saved_objects.reduce( + (acc, savedObject) => { + return savedObject.error == null + ? { + errors: acc.errors, + timelines: [...acc.timelines, convertSavedObjectToSavedTimeline(savedObject)], + } + : { errors: [...acc.errors, savedObject.error], timelines: acc.timelines }; + }, + { + timelines: [] as TimelineSavedObject[], + errors: [] as ExportTimelineNotFoundError[], + } + ); + + return timelineObjects; +}; diff --git a/x-pack/plugins/security_solution/server/routes/index.ts b/x-pack/plugins/security_solution/server/routes/index.ts index 54d7dcccba8158..37a97c03ad3328 100644 --- a/x-pack/plugins/security_solution/server/routes/index.ts +++ b/x-pack/plugins/security_solution/server/routes/index.ts @@ -36,6 +36,8 @@ import { getDraftTimelinesRoute } from '../lib/timeline/routes/get_draft_timelin import { cleanDraftTimelinesRoute } from '../lib/timeline/routes/clean_draft_timelines_route'; import { SetupPlugins } from '../plugin'; import { ConfigType } from '../config'; +import { installPrepackedTimelinesRoute } from '../lib/timeline/routes/install_prepacked_timelines_route'; +import { getTimelineByIdRoute } from '../lib/timeline/routes/get_timeline_by_id_route'; export const initRoutes = ( router: IRouter, @@ -53,8 +55,8 @@ export const initRoutes = ( deleteRulesRoute(router); findRulesRoute(router); - addPrepackedRulesRoute(router); - getPrepackagedRulesStatusRoute(router); + addPrepackedRulesRoute(router, config, security); + getPrepackagedRulesStatusRoute(router, config, security); createRulesBulkRoute(router, ml); updateRulesBulkRoute(router, ml); patchRulesBulkRoute(router, ml); @@ -66,10 +68,13 @@ export const initRoutes = ( exportRulesRoute(router, config); importTimelinesRoute(router, config, security); - exportTimelinesRoute(router, config); + exportTimelinesRoute(router, config, security); getDraftTimelinesRoute(router, config, security); + getTimelineByIdRoute(router, config, security); cleanDraftTimelinesRoute(router, config, security); + installPrepackedTimelinesRoute(router, config, security); + findRulesStatusesRoute(router); // Detection Engine Signals routes that have the REST endpoints of /api/detection_engine/signals diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts index 0fc23f90a0ebf6..69ae53a14227d6 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts @@ -6,8 +6,6 @@ import { LegacyAPICaller } from '../../../../../../src/core/server'; import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; -import { jobServiceProvider } from '../../../../ml/server/models/job_service'; -import { DataRecognizer } from '../../../../ml/server/models/data_recognizer'; import { mlServicesMock } from '../../lib/machine_learning/mocks'; import { getMockJobSummaryResponse, @@ -16,9 +14,6 @@ import { } from './detections.mocks'; import { fetchDetectionsUsage } from './index'; -jest.mock('../../../../ml/server/models/job_service'); -jest.mock('../../../../ml/server/models/data_recognizer'); - describe('Detections Usage', () => { describe('fetchDetectionsUsage()', () => { let callClusterMock: jest.Mocked; @@ -79,12 +74,12 @@ describe('Detections Usage', () => { it('tallies jobs data given jobs results', async () => { const mockJobSummary = jest.fn().mockResolvedValue(getMockJobSummaryResponse()); const mockListModules = jest.fn().mockResolvedValue(getMockListModulesResponse()); - (jobServiceProvider as jest.Mock).mockImplementation(() => ({ - jobsSummary: mockJobSummary, - })); - (DataRecognizer as jest.Mock).mockImplementation(() => ({ + mlMock.modulesProvider.mockReturnValue(({ listModules: mockListModules, - })); + } as unknown) as ReturnType); + mlMock.jobServiceProvider.mockReturnValue({ + jobsSummary: mockJobSummary, + }); const result = await fetchDetectionsUsage('', callClusterMock, mlMock); diff --git a/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts index bad8ef235c6d6c..e9d4f3aa426f4a 100644 --- a/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts @@ -5,13 +5,12 @@ */ import { SearchParams } from 'elasticsearch'; -import { ILegacyScopedClusterClient, KibanaRequest } from 'kibana/server'; -import { LegacyAPICaller, SavedObjectsClient } from '../../../../../../src/core/server'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { jobServiceProvider } from '../../../../ml/server/models/job_service'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { DataRecognizer } from '../../../../ml/server/models/data_recognizer'; +import { + LegacyAPICaller, + SavedObjectsClient, + KibanaRequest, +} from '../../../../../../src/core/server'; import { MlPluginSetup } from '../../../../ml/server'; import { SIGNALS_ID, INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; import { DetectionRulesUsage, MlJobsUsage } from './index'; @@ -164,25 +163,20 @@ export const getRulesUsage = async ( export const getMlJobsUsage = async (ml: MlPluginSetup | undefined): Promise => { let jobsUsage: MlJobsUsage = initialMlJobsUsage; - // Fake objects to be passed to ML functions. - // TODO - These ML functions should come from ML's setup contract - // and not be imported directly. - const fakeScopedClusterClient = { - callAsCurrentUser: ml?.mlClient.callAsInternalUser, - callAsInternalUser: ml?.mlClient.callAsInternalUser, - } as ILegacyScopedClusterClient; - const fakeSavedObjectsClient = {} as SavedObjectsClient; - const fakeRequest = {} as KibanaRequest; - if (ml) { try { - const modules = await new DataRecognizer( - fakeScopedClusterClient, - fakeSavedObjectsClient, - fakeRequest - ).listModules(); + const fakeRequest = { headers: {}, params: 'DummyKibanaRequest' } as KibanaRequest; + const fakeSOClient = {} as SavedObjectsClient; + const internalMlClient = { + callAsCurrentUser: ml?.mlClient.callAsInternalUser, + callAsInternalUser: ml?.mlClient.callAsInternalUser, + }; + + const modules = await ml + .modulesProvider(internalMlClient, fakeRequest, fakeSOClient) + .listModules(); const moduleJobs = modules.flatMap((module) => module.jobs); - const jobs = await jobServiceProvider(fakeScopedClusterClient).jobsSummary(['siem']); + const jobs = await ml.jobServiceProvider(internalMlClient, fakeRequest).jobsSummary(['siem']); jobsUsage = jobs.reduce((usage, job) => { const isElastic = moduleJobs.some((moduleJob) => moduleJob.id === job.id); diff --git a/x-pack/plugins/snapshot_restore/public/plugin.ts b/x-pack/plugins/snapshot_restore/public/plugin.ts index ee27886948a540..b864e707086524 100644 --- a/x-pack/plugins/snapshot_restore/public/plugin.ts +++ b/x-pack/plugins/snapshot_restore/public/plugin.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, PluginInitializerContext } from 'src/core/public'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { PLUGIN } from '../common/constants'; import { ClientConfigType } from './types'; @@ -40,7 +40,7 @@ export class SnapshotRestoreUIPlugin { textService.setup(i18n); httpService.setup(http); - management.sections.getSection(ManagementSectionId.Data).registerApp({ + management.sections.section.data.registerApp({ id: PLUGIN.id, title: i18n.translate('xpack.snapshotRestore.appTitle', { defaultMessage: 'Snapshot and Restore', diff --git a/x-pack/plugins/spaces/kibana.json b/x-pack/plugins/spaces/kibana.json index 0698535cc15fd7..4443b6d8a685bd 100644 --- a/x-pack/plugins/spaces/kibana.json +++ b/x-pack/plugins/spaces/kibana.json @@ -18,7 +18,6 @@ "requiredBundles": [ "kibanaReact", "savedObjectsManagement", - "management", "home" ] } diff --git a/x-pack/plugins/spaces/public/management/management_service.test.ts b/x-pack/plugins/spaces/public/management/management_service.test.ts index eb543d44ecb4b6..444ccf43d3d1f7 100644 --- a/x-pack/plugins/spaces/public/management/management_service.test.ts +++ b/x-pack/plugins/spaces/public/management/management_service.test.ts @@ -18,22 +18,19 @@ describe('ManagementService', () => { const mockKibanaSection = ({ registerApp: jest.fn(), } as unknown) as ManagementSection; + const managementMockSetup = managementPluginMock.createSetupContract(); + managementMockSetup.sections.section.kibana = mockKibanaSection; const deps = { - management: managementPluginMock.createSetupContract(), + management: managementMockSetup, getStartServices: coreMock.createSetup().getStartServices as CoreSetup< PluginsStart >['getStartServices'], spacesManager: spacesManagerMock.create(), }; - deps.management.sections.getSection.mockReturnValue(mockKibanaSection); - const service = new ManagementService(); service.setup(deps); - expect(deps.management.sections.getSection).toHaveBeenCalledTimes(1); - expect(deps.management.sections.getSection).toHaveBeenCalledWith('kibana'); - expect(mockKibanaSection.registerApp).toHaveBeenCalledTimes(1); expect(mockKibanaSection.registerApp).toHaveBeenCalledWith({ id: 'spaces', @@ -63,20 +60,17 @@ describe('ManagementService', () => { const mockKibanaSection = ({ registerApp: jest.fn().mockReturnValue(mockSpacesManagementPage), } as unknown) as ManagementSection; + const managementMockSetup = managementPluginMock.createSetupContract(); + managementMockSetup.sections.section.kibana = mockKibanaSection; const deps = { - management: managementPluginMock.createSetupContract(), + management: managementMockSetup, getStartServices: coreMock.createSetup().getStartServices as CoreSetup< PluginsStart >['getStartServices'], spacesManager: spacesManagerMock.create(), }; - deps.management.sections.getSection.mockImplementation((id) => { - if (id === 'kibana') return mockKibanaSection; - throw new Error(`unexpected getSection call: ${id}`); - }); - const service = new ManagementService(); service.setup(deps); diff --git a/x-pack/plugins/spaces/public/management/management_service.tsx b/x-pack/plugins/spaces/public/management/management_service.tsx index 4d5a1b32b31a37..11853e5f1abdd3 100644 --- a/x-pack/plugins/spaces/public/management/management_service.tsx +++ b/x-pack/plugins/spaces/public/management/management_service.tsx @@ -5,11 +5,7 @@ */ import { StartServicesAccessor } from 'src/core/public'; -import { - ManagementSetup, - ManagementApp, - ManagementSectionId, -} from '../../../../../src/plugins/management/public'; +import { ManagementSetup, ManagementApp } from '../../../../../src/plugins/management/public'; import { SecurityLicense } from '../../../security/public'; import { SpacesManager } from '../spaces_manager'; import { PluginsStart } from '../plugin'; @@ -26,11 +22,9 @@ export class ManagementService { private registeredSpacesManagementApp?: ManagementApp; public setup({ getStartServices, management, spacesManager, securityLicense }: SetupDeps) { - this.registeredSpacesManagementApp = management.sections - .getSection(ManagementSectionId.Kibana) - .registerApp( - spacesManagementApp.create({ getStartServices, spacesManager, securityLicense }) - ); + this.registeredSpacesManagementApp = management.sections.section.kibana.registerApp( + spacesManagementApp.create({ getStartServices, spacesManager, securityLicense }) + ); } public stop() { diff --git a/x-pack/plugins/spaces/public/plugin.test.ts b/x-pack/plugins/spaces/public/plugin.test.ts index 4a49cf20d3a4a3..d8eecb9c7e606c 100644 --- a/x-pack/plugins/spaces/public/plugin.test.ts +++ b/x-pack/plugins/spaces/public/plugin.test.ts @@ -7,8 +7,10 @@ import { coreMock } from 'src/core/public/mocks'; import { SpacesPlugin } from './plugin'; import { homePluginMock } from '../../../../src/plugins/home/public/mocks'; -import { ManagementSection, ManagementSectionId } from '../../../../src/plugins/management/public'; -import { managementPluginMock } from '../../../../src/plugins/management/public/mocks'; +import { + managementPluginMock, + createManagementSectionMock, +} from '../../../../src/plugins/management/public/mocks'; import { advancedSettingsMock } from '../../../../src/plugins/advanced_settings/public/mocks'; import { featuresPluginMock } from '../../features/public/mocks'; @@ -32,19 +34,13 @@ describe('Spaces plugin', () => { it('should register the management and feature catalogue sections when the management and home plugins are both available', () => { const coreSetup = coreMock.createSetup(); - - const kibanaSection = new ManagementSection({ - id: ManagementSectionId.Kibana, - title: 'Mock Kibana Section', - order: 1, - }); - - const registerAppSpy = jest.spyOn(kibanaSection, 'registerApp'); - const home = homePluginMock.createSetupContract(); const management = managementPluginMock.createSetupContract(); - management.sections.getSection.mockReturnValue(kibanaSection); + const mockSection = createManagementSectionMock(); + mockSection.registerApp = jest.fn(); + + management.sections.section.kibana = mockSection; const plugin = new SpacesPlugin(); plugin.setup(coreSetup, { @@ -52,7 +48,9 @@ describe('Spaces plugin', () => { home, }); - expect(registerAppSpy).toHaveBeenCalledWith(expect.objectContaining({ id: 'spaces' })); + expect(mockSection.registerApp).toHaveBeenCalledWith( + expect.objectContaining({ id: 'spaces' }) + ); expect(home.featureCatalogue.register).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/x-pack/plugins/transform/public/plugin.ts b/x-pack/plugins/transform/public/plugin.ts index 27d1ad29f51d0b..74256a478e7324 100644 --- a/x-pack/plugins/transform/public/plugin.ts +++ b/x-pack/plugins/transform/public/plugin.ts @@ -8,7 +8,7 @@ import { i18n as kbnI18n } from '@kbn/i18n'; import { CoreSetup } from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { HomePublicPluginSetup } from 'src/plugins/home/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { registerFeature } from './register_feature'; export interface PluginsDependencies { @@ -22,7 +22,7 @@ export class TransformUiPlugin { const { management, home } = pluginsSetup; // Register management section - const esSection = management.sections.getSection(ManagementSectionId.Data); + const esSection = management.sections.section.data; esSection.registerApp({ id: 'transform', title: kbnI18n.translate('xpack.transform.appTitle', { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 2787acb5390b99..2a8365a8bc5c90 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4153,14 +4153,6 @@ "xpack.apm.customLink.buttom.create.title": "作成", "xpack.apm.customLink.buttom.manage": "カスタムリンクを管理", "xpack.apm.customLink.empty": "カスタムリンクが見つかりません。独自のカスタムリンク、たとえば特定のダッシュボードまたは外部リンクへのリンクをセットアップします。", - "xpack.apm.datePicker.last15MinutesLabel": "過去 15 分間", - "xpack.apm.datePicker.last1HourLabel": "過去 1 時間", - "xpack.apm.datePicker.last1YearLabel": "過去 1 年間", - "xpack.apm.datePicker.last24HoursLabel": "過去 24 時間", - "xpack.apm.datePicker.last30DaysLabel": "過去 30 日間", - "xpack.apm.datePicker.last30MinutesLabel": "過去 30 分間", - "xpack.apm.datePicker.last7DaysLabel": "過去 7 日間", - "xpack.apm.datePicker.last90DaysLabel": "過去 90 日間", "xpack.apm.emptyMessage.noDataFoundDescription": "別の時間範囲を試すか検索フィルターをリセットしてください。", "xpack.apm.emptyMessage.noDataFoundLabel": "データが見つかりません。", "xpack.apm.error.prompt.body": "詳細はブラウザの開発者コンソールをご確認ください。", @@ -6185,8 +6177,8 @@ "xpack.graph.bar.pickSourceLabel": "データソースを選択", "xpack.graph.bar.pickSourceTooltip": "グラフの関係性を開始するデータソースを選択します。", "xpack.graph.bar.searchFieldPlaceholder": "データを検索してグラフに追加", - "xpack.graph.blacklist.noEntriesDescription": "ブロックされた用語がありません。頂点を選択して、右側のコントロールパネルの {stopSign} をクリックしてブロックします。ブロックされた用語に一致するドキュメントは今後表示されず、関係性が非表示になります。", - "xpack.graph.blacklist.removeButtonAriaLabel": "削除", + "xpack.graph.blocklist.noEntriesDescription": "ブロックされた用語がありません。頂点を選択して、右側のコントロールパネルの {stopSign} をクリックしてブロックします。ブロックされた用語に一致するドキュメントは今後表示されず、関係性が非表示になります。", + "xpack.graph.blocklist.removeButtonAriaLabel": "削除", "xpack.graph.clearWorkspace.confirmButtonLabel": "データソースを変更", "xpack.graph.clearWorkspace.confirmText": "データソースを変更すると、現在のフィールドと頂点がリセットされます。", "xpack.graph.clearWorkspace.modalTitle": "保存されていない変更", @@ -6322,9 +6314,9 @@ "xpack.graph.settings.advancedSettings.timeoutInputLabel": "タイムアウト (ms)", "xpack.graph.settings.advancedSettings.timeoutUnit": "ms", "xpack.graph.settings.advancedSettingsTitle": "高度な設定", - "xpack.graph.settings.blacklist.blacklistHelpText": "これらの用語は現在ワークスペースに再度表示されないようブラックリストに登録されています", - "xpack.graph.settings.blacklist.clearButtonLabel": "消去", - "xpack.graph.settings.blacklistTitle": "ブラックリスト", + "xpack.graph.settings.blocklist.blocklistHelpText": "これらの用語は現在ワークスペースに再度表示されないようブラックリストに登録されています", + "xpack.graph.settings.blocklist.clearButtonLabel": "消去", + "xpack.graph.settings.blocklistTitle": "ブラックリスト", "xpack.graph.settings.closeLabel": "閉じる", "xpack.graph.settings.drillDowns.cancelButtonLabel": "キャンセル", "xpack.graph.settings.drillDowns.defaultUrlTemplateTitle": "生ドキュメント", @@ -9605,7 +9597,6 @@ "xpack.ml.dataframe.analyticsList.createDataFrameAnalyticsButton": "分析ジョブの作成", "xpack.ml.dataframe.analyticsList.deleteActionDisabledToolTipContent": "削除するにはデータフレーム分析を停止してください。", "xpack.ml.dataframe.analyticsList.deleteActionName": "削除", - "xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage": "データフレーム分析{analyticsId}の削除中にエラーが発生しました。{error}", "xpack.ml.dataframe.analyticsList.deleteAnalyticsSuccessMessage": "データフレーム分析 {analyticsId} の削除リクエストが受け付けられました。", "xpack.ml.dataframe.analyticsList.deleteModalBody": "この分析ジョブを削除してよろしいですか?この分析ジョブのデスティネーションインデックスとオプションのKibanaインデックスパターンは削除されません。", "xpack.ml.dataframe.analyticsList.deleteModalCancelButton": "キャンセル", @@ -9629,7 +9620,6 @@ "xpack.ml.dataframe.analyticsList.showDetailsColumn.screenReaderDescription": "このカラムには各ジョブの詳細を示すクリック可能なコントロールが含まれます", "xpack.ml.dataframe.analyticsList.sourceIndex": "ソースインデックス", "xpack.ml.dataframe.analyticsList.startActionName": "開始", - "xpack.ml.dataframe.analyticsList.startAnalyticsErrorMessage": "データフレーム分析{analyticsId}の開始中にエラーが発生しました。{error}", "xpack.ml.dataframe.analyticsList.startAnalyticsSuccessMessage": "データフレーム分析 {analyticsId} の開始リクエストが受け付けられました。", "xpack.ml.dataframe.analyticsList.startModalBody": "データフレーム分析ジョブは、クラスターの検索とインデックスによる負荷を増やします。過剰な負荷が生じた場合は分析ジョブを停止してください。この分析ジョブを開始してよろしいですか?", "xpack.ml.dataframe.analyticsList.startModalCancelButton": "キャンセル", @@ -9697,7 +9687,6 @@ "xpack.ml.datavisualizerBreadcrumbLabel": "データビジュアライザー", "xpack.ml.dataVisualizerPageLabel": "データビジュアライザー", "xpack.ml.dfAnalyticsList.analyticsDetails.messagesPane.errorMessage": "メッセージを読み込めませんでした", - "xpack.ml.explorer.annotationsTitle": "注釈", "xpack.ml.explorer.anomaliesTitle": "異常", "xpack.ml.explorer.anomalyTimelineTitle": "異常のタイムライン", "xpack.ml.explorer.charts.detectorLabel": "「{fieldName}」で分割された {detectorLabel}{br} Y 軸イベントの分布", @@ -10006,11 +9995,9 @@ "xpack.ml.jobService.couldNotStartDatafeedErrorMessage": "{jobId} のデータフィードを開始できませんでした", "xpack.ml.jobService.couldNotStopDatafeedErrorMessage": "{jobId} のデータフィードを停止できませんでした", "xpack.ml.jobService.couldNotUpdateDatafeedErrorMessage": "データフィードを更新できませんでした: {datafeedId}", - "xpack.ml.jobService.couldNotUpdateJobErrorMessage": "ジョブを更新できませんでした: {jobId}", "xpack.ml.jobService.datafeedsListCouldNotBeRetrievedErrorMessage": "データフィードリストを取得できませんでした", "xpack.ml.jobService.failedJobsLabel": "失敗したジョブ", "xpack.ml.jobService.jobsListCouldNotBeRetrievedErrorMessage": "ジョブリストを取得できませんでした", - "xpack.ml.jobService.jobValidationErrorMessage": "ジョブ検証エラー: {errorMessage}", "xpack.ml.jobService.openJobsLabel": "ジョブを開く", "xpack.ml.jobService.requestMayHaveTimedOutErrorMessage": "リクエストがタイムアウトし、まだバックグラウンドで実行中の可能性があります。", "xpack.ml.jobService.totalJobsLabel": "合計ジョブ数", @@ -10810,7 +10797,6 @@ "xpack.ml.timeSeriesExplorer.annotationFlyout.noAnnotationTextError": "注釈テキストを入力してください", "xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel": "更新", "xpack.ml.timeSeriesExplorer.annotationsLabel": "注釈", - "xpack.ml.timeSeriesExplorer.annotationsTitle": "注釈", "xpack.ml.timeSeriesExplorer.anomaliesTitle": "異常", "xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText": "、初めのジョブを自動選択します", "xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage": "リクエストされた‘{invalidIdsCount, plural, one {ジョブ} other {件のジョブ}} {invalidIds} をこのダッシュボードで表示できません", @@ -13622,9 +13608,6 @@ "xpack.securitySolution.containers.case.reopenedCases": "{totalCases, plural, =1 {\"{caseTitle}\"} other {{totalCases}件のケース}}を再オープンしました", "xpack.securitySolution.containers.case.updatedCase": "\"{caseTitle}\"を更新しました", "xpack.securitySolution.containers.detectionEngine.addRuleFailDescription": "ルールを追加できませんでした", - "xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleFailDescription": "Elasticから事前にパッケージ化されているルールをインストールすることができませんでした", - "xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleSuccesDescription": "Elasticから事前にパッケージ化されているルールをインストールしました", - "xpack.securitySolution.containers.detectionEngine.rules": "ルールを取得できませんでした", "xpack.securitySolution.containers.detectionEngine.tagFetchFailDescription": "タグを取得できませんでした", "xpack.securitySolution.containers.errors.dataFetchFailureTitle": "データの取得に失敗", "xpack.securitySolution.containers.errors.networkFailureTitle": "ネットワーク障害", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b42c4dbb285690..42240203a2eaf1 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4157,14 +4157,6 @@ "xpack.apm.customLink.buttom.create.title": "创建", "xpack.apm.customLink.buttom.manage": "管理定制链接", "xpack.apm.customLink.empty": "未找到定制链接。设置自己的定制链接,如特定仪表板的链接或外部链接。", - "xpack.apm.datePicker.last15MinutesLabel": "过去 15 分钟", - "xpack.apm.datePicker.last1HourLabel": "过去 1 小时", - "xpack.apm.datePicker.last1YearLabel": "过去 1 年", - "xpack.apm.datePicker.last24HoursLabel": "过去 24 小时", - "xpack.apm.datePicker.last30DaysLabel": "过去 30 天", - "xpack.apm.datePicker.last30MinutesLabel": "过去 30 分钟", - "xpack.apm.datePicker.last7DaysLabel": "过去 7 天", - "xpack.apm.datePicker.last90DaysLabel": "过去 90 天", "xpack.apm.emptyMessage.noDataFoundDescription": "尝试其他时间范围或重置搜索筛选。", "xpack.apm.emptyMessage.noDataFoundLabel": "未找到任何数据", "xpack.apm.error.prompt.body": "有关详情,请查看您的浏览器开发者控制台。", @@ -6189,8 +6181,8 @@ "xpack.graph.bar.pickSourceLabel": "选择数据源", "xpack.graph.bar.pickSourceTooltip": "选择数据源以开始绘制关系图。", "xpack.graph.bar.searchFieldPlaceholder": "搜索数据并将其添加到图表", - "xpack.graph.blacklist.noEntriesDescription": "您没有任何已阻止字词。选择顶点并单击右侧控制面板上的 {stopSign} 以阻止它们。匹配已阻止字词的文档将不再被浏览,与它们的关系将隐藏。", - "xpack.graph.blacklist.removeButtonAriaLabel": "删除", + "xpack.graph.blocklist.noEntriesDescription": "您没有任何已阻止字词。选择顶点并单击右侧控制面板上的 {stopSign} 以阻止它们。匹配已阻止字词的文档将不再被浏览,与它们的关系将隐藏。", + "xpack.graph.blocklist.removeButtonAriaLabel": "删除", "xpack.graph.clearWorkspace.confirmButtonLabel": "更改数据源", "xpack.graph.clearWorkspace.confirmText": "如果更改数据源,您当前的字段和顶点将会重置。", "xpack.graph.clearWorkspace.modalTitle": "未保存更改", @@ -6326,9 +6318,9 @@ "xpack.graph.settings.advancedSettings.timeoutInputLabel": "超时 (ms)", "xpack.graph.settings.advancedSettings.timeoutUnit": "ms", "xpack.graph.settings.advancedSettingsTitle": "高级设置", - "xpack.graph.settings.blacklist.blacklistHelpText": "这些字词当前已列入黑名单,不允许重新显示在工作空间中。", - "xpack.graph.settings.blacklist.clearButtonLabel": "清除", - "xpack.graph.settings.blacklistTitle": "黑名单", + "xpack.graph.settings.blocklist.blocklistHelpText": "这些字词当前已列入黑名单,不允许重新显示在工作空间中。", + "xpack.graph.settings.blocklist.clearButtonLabel": "清除", + "xpack.graph.settings.blocklistTitle": "黑名单", "xpack.graph.settings.closeLabel": "关闭", "xpack.graph.settings.drillDowns.cancelButtonLabel": "取消", "xpack.graph.settings.drillDowns.defaultUrlTemplateTitle": "原始文档", @@ -9610,7 +9602,6 @@ "xpack.ml.dataframe.analyticsList.createDataFrameAnalyticsButton": "创建分析作业", "xpack.ml.dataframe.analyticsList.deleteActionDisabledToolTipContent": "停止数据帧分析,才能将其删除。", "xpack.ml.dataframe.analyticsList.deleteActionName": "删除", - "xpack.ml.dataframe.analyticsList.deleteAnalyticsErrorMessage": "删除数据帧分析 {analyticsId} 时发生错误:{error}", "xpack.ml.dataframe.analyticsList.deleteAnalyticsSuccessMessage": "数据帧分析 {analyticsId} 删除请求已确认。", "xpack.ml.dataframe.analyticsList.deleteModalBody": "是否确定要删除此分析作业?分析作业的目标索引和可选 Kibana 索引模式将不会删除。", "xpack.ml.dataframe.analyticsList.deleteModalCancelButton": "取消", @@ -9634,7 +9625,6 @@ "xpack.ml.dataframe.analyticsList.showDetailsColumn.screenReaderDescription": "此列包含可单击控件,用于显示每个作业的更多详情", "xpack.ml.dataframe.analyticsList.sourceIndex": "源索引", "xpack.ml.dataframe.analyticsList.startActionName": "开始", - "xpack.ml.dataframe.analyticsList.startAnalyticsErrorMessage": "启动数据帧分析 {analyticsId} 时发生错误:{error}", "xpack.ml.dataframe.analyticsList.startAnalyticsSuccessMessage": "数据帧分析 {analyticsId} 启动请求已确认。", "xpack.ml.dataframe.analyticsList.startModalBody": "数据帧分析作业将增加集群的搜索和索引负荷。如果负荷超载,请停止分析作业。是否确定要启动此分析作业?", "xpack.ml.dataframe.analyticsList.startModalCancelButton": "取消", @@ -9702,7 +9692,6 @@ "xpack.ml.datavisualizerBreadcrumbLabel": "数据可视化工具", "xpack.ml.dataVisualizerPageLabel": "数据可视化工具", "xpack.ml.dfAnalyticsList.analyticsDetails.messagesPane.errorMessage": "无法加载消息", - "xpack.ml.explorer.annotationsTitle": "注释", "xpack.ml.explorer.anomaliesTitle": "异常", "xpack.ml.explorer.anomalyTimelineTitle": "异常时间线", "xpack.ml.explorer.charts.detectorLabel": "{detectorLabel}{br}y 轴事件分布按 “{fieldName}” 分割", @@ -10011,11 +10000,9 @@ "xpack.ml.jobService.couldNotStartDatafeedErrorMessage": "无法开始 {jobId} 的数据馈送", "xpack.ml.jobService.couldNotStopDatafeedErrorMessage": "无法停止 {jobId} 的数据馈送", "xpack.ml.jobService.couldNotUpdateDatafeedErrorMessage": "无法更新数据馈送:{datafeedId}", - "xpack.ml.jobService.couldNotUpdateJobErrorMessage": "无法更新作业:{jobId}", "xpack.ml.jobService.datafeedsListCouldNotBeRetrievedErrorMessage": "无法检索数据馈送列表", "xpack.ml.jobService.failedJobsLabel": "失败的作业", "xpack.ml.jobService.jobsListCouldNotBeRetrievedErrorMessage": "无法检索作业列表", - "xpack.ml.jobService.jobValidationErrorMessage": "作业验证错误:{errorMessage}", "xpack.ml.jobService.openJobsLabel": "打开的作业", "xpack.ml.jobService.requestMayHaveTimedOutErrorMessage": "请求可能已超时,并可能仍在后台运行。", "xpack.ml.jobService.totalJobsLabel": "总计作业数", @@ -10815,7 +10802,6 @@ "xpack.ml.timeSeriesExplorer.annotationFlyout.noAnnotationTextError": "输入注释文本", "xpack.ml.timeSeriesExplorer.annotationFlyout.updateButtonLabel": "更新", "xpack.ml.timeSeriesExplorer.annotationsLabel": "注释", - "xpack.ml.timeSeriesExplorer.annotationsTitle": "注释", "xpack.ml.timeSeriesExplorer.anomaliesTitle": "异常", "xpack.ml.timeSeriesExplorer.autoSelectingFirstJobText": ",自动选择第一个作业", "xpack.ml.timeSeriesExplorer.canNotViewRequestedJobsWarningMessage": "您无法在此仪表板中查看请求的 {invalidIdsCount, plural, one {作业} other {作业}} {invalidIds}", @@ -13628,9 +13614,6 @@ "xpack.securitySolution.containers.case.reopenedCases": "已重新打开 {totalCases, plural, =1 {\"{caseTitle}\"} other {{totalCases} 个案例}}", "xpack.securitySolution.containers.case.updatedCase": "已更新“{caseTitle}”", "xpack.securitySolution.containers.detectionEngine.addRuleFailDescription": "无法添加规则", - "xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleFailDescription": "无法安装 elastic 的预打包规则", - "xpack.securitySolution.containers.detectionEngine.createPrePackagedRuleSuccesDescription": "已安装 elastic 的预打包规则", - "xpack.securitySolution.containers.detectionEngine.rules": "无法提取规则", "xpack.securitySolution.containers.detectionEngine.tagFetchFailDescription": "无法提取标记", "xpack.securitySolution.containers.errors.dataFetchFailureTitle": "数据提取失败", "xpack.securitySolution.containers.errors.networkFailureTitle": "网络故障", diff --git a/x-pack/plugins/triggers_actions_ui/public/plugin.ts b/x-pack/plugins/triggers_actions_ui/public/plugin.ts index db93e48ab36922..af4d2784cfa672 100644 --- a/x-pack/plugins/triggers_actions_ui/public/plugin.ts +++ b/x-pack/plugins/triggers_actions_ui/public/plugin.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreStart, PluginInitializerContext, Plugin as CorePlugin } from 'src/core/public'; +import { + CoreStart, + CoreSetup, + PluginInitializerContext, + Plugin as CorePlugin, +} from 'src/core/public'; import { i18n } from '@kbn/i18n'; import { registerBuiltInActionTypes } from './application/components/builtin_action_types'; @@ -12,7 +17,11 @@ import { registerBuiltInAlertTypes } from './application/components/builtin_aler import { hasShowActionsCapability, hasShowAlertsCapability } from './application/lib/capabilities'; import { ActionTypeModel, AlertTypeModel } from './types'; import { TypeRegistry } from './application/type_registry'; -import { ManagementStart, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { + ManagementSetup, + ManagementAppMountParams, + ManagementApp, +} from '../../../../src/plugins/management/public'; import { boot } from './application/boot'; import { ChartsPluginStart } from '../../../../src/plugins/charts/public'; import { PluginStartContract as AlertingStart } from '../../alerts/public'; @@ -28,10 +37,13 @@ export interface TriggersAndActionsUIPublicPluginStart { alertTypeRegistry: TypeRegistry; } +interface PluginsSetup { + management: ManagementSetup; +} + interface PluginsStart { data: DataPublicPluginStart; charts: ChartsPluginStart; - management: ManagementStart; alerts?: AlertingStart; navigateToApp: CoreStart['application']['navigateToApp']; } @@ -41,6 +53,7 @@ export class Plugin CorePlugin { private actionTypeRegistry: TypeRegistry; private alertTypeRegistry: TypeRegistry; + private managementApp?: ManagementApp; constructor(initializerContext: PluginInitializerContext) { const actionTypeRegistry = new TypeRegistry(); @@ -50,7 +63,45 @@ export class Plugin this.alertTypeRegistry = alertTypeRegistry; } - public setup(): TriggersAndActionsUIPublicPluginSetup { + public setup(core: CoreSetup, plugins: PluginsSetup): TriggersAndActionsUIPublicPluginSetup { + const actionTypeRegistry = this.actionTypeRegistry; + const alertTypeRegistry = this.alertTypeRegistry; + + this.managementApp = plugins.management.sections.section.insightsAndAlerting.registerApp({ + id: 'triggersActions', + title: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', { + defaultMessage: 'Alerts and Actions', + }), + order: 0, + async mount(params: ManagementAppMountParams) { + const [coreStart, pluginsStart] = (await core.getStartServices()) as [ + CoreStart, + PluginsStart, + unknown + ]; + boot({ + dataPlugin: pluginsStart.data, + charts: pluginsStart.charts, + alerts: pluginsStart.alerts, + element: params.element, + toastNotifications: coreStart.notifications.toasts, + http: coreStart.http, + uiSettings: coreStart.uiSettings, + docLinks: coreStart.docLinks, + chrome: coreStart.chrome, + savedObjects: coreStart.savedObjects.client, + I18nContext: coreStart.i18n.Context, + capabilities: coreStart.application.capabilities, + navigateToApp: coreStart.application.navigateToApp, + setBreadcrumbs: params.setBreadcrumbs, + history: params.history, + actionTypeRegistry, + alertTypeRegistry, + }); + return () => {}; + }, + }); + registerBuiltInActionTypes({ actionTypeRegistry: this.actionTypeRegistry, }); @@ -65,43 +116,18 @@ export class Plugin }; } - public start(core: CoreStart, plugins: PluginsStart): TriggersAndActionsUIPublicPluginStart { + public start(core: CoreStart): TriggersAndActionsUIPublicPluginStart { const { capabilities } = core.application; const canShowActions = hasShowActionsCapability(capabilities); const canShowAlerts = hasShowAlertsCapability(capabilities); + const managementApp = this.managementApp as ManagementApp; // Don't register routes when user doesn't have access to the application if (canShowActions || canShowAlerts) { - plugins.management.sections.getSection(ManagementSectionId.InsightsAndAlerting).registerApp({ - id: 'triggersActions', - title: i18n.translate('xpack.triggersActionsUI.managementSection.displayName', { - defaultMessage: 'Alerts and Actions', - }), - order: 0, - mount: (params) => { - boot({ - dataPlugin: plugins.data, - charts: plugins.charts, - alerts: plugins.alerts, - element: params.element, - toastNotifications: core.notifications.toasts, - http: core.http, - uiSettings: core.uiSettings, - docLinks: core.docLinks, - chrome: core.chrome, - savedObjects: core.savedObjects.client, - I18nContext: core.i18n.Context, - capabilities: core.application.capabilities, - navigateToApp: core.application.navigateToApp, - setBreadcrumbs: params.setBreadcrumbs, - history: params.history, - actionTypeRegistry: this.actionTypeRegistry, - alertTypeRegistry: this.alertTypeRegistry, - }); - return () => {}; - }, - }); + managementApp.enable(); + } else { + managementApp.disable(); } return { actionTypeRegistry: this.actionTypeRegistry, diff --git a/x-pack/plugins/upgrade_assistant/kibana.json b/x-pack/plugins/upgrade_assistant/kibana.json index 31109dd963ab45..273036a653aeb8 100644 --- a/x-pack/plugins/upgrade_assistant/kibana.json +++ b/x-pack/plugins/upgrade_assistant/kibana.json @@ -5,6 +5,5 @@ "ui": true, "configPath": ["xpack", "upgrade_assistant"], "requiredPlugins": ["management", "licensing"], - "optionalPlugins": ["cloud", "usageCollection"], - "requiredBundles": ["management"] + "optionalPlugins": ["cloud", "usageCollection"] } diff --git a/x-pack/plugins/upgrade_assistant/public/plugin.ts b/x-pack/plugins/upgrade_assistant/public/plugin.ts index be00a030d5a27c..01c1a6a4659d55 100644 --- a/x-pack/plugins/upgrade_assistant/public/plugin.ts +++ b/x-pack/plugins/upgrade_assistant/public/plugin.ts @@ -7,7 +7,7 @@ import { i18n } from '@kbn/i18n'; import { Plugin, CoreSetup, PluginInitializerContext } from 'src/core/public'; import { CloudSetup } from '../../cloud/public'; -import { ManagementSetup, ManagementSectionId } from '../../../../src/plugins/management/public'; +import { ManagementSetup } from '../../../../src/plugins/management/public'; import { NEXT_MAJOR_VERSION } from '../common/version'; import { Config } from '../common/config'; @@ -24,7 +24,7 @@ export class UpgradeAssistantUIPlugin implements Plugin { if (!enabled) { return; } - const appRegistrar = management.sections.getSection(ManagementSectionId.Stack); + const appRegistrar = management.sections.section.stack; const isCloudEnabled = Boolean(cloud?.isCloudEnabled); appRegistrar.registerApp({ diff --git a/x-pack/plugins/uptime/common/constants/alerts.ts b/x-pack/plugins/uptime/common/constants/alerts.ts index a259fc0a3eb818..61a7a02bf8b30d 100644 --- a/x-pack/plugins/uptime/common/constants/alerts.ts +++ b/x-pack/plugins/uptime/common/constants/alerts.ts @@ -20,9 +20,14 @@ export const ACTION_GROUP_DEFINITIONS: ActionGroupDefinitions = { id: 'xpack.uptime.alerts.actionGroups.tls', name: 'Uptime TLS Alert', }, + DURATION_ANOMALY: { + id: 'xpack.uptime.alerts.actionGroups.durationAnomaly', + name: 'Uptime Duration Anomaly', + }, }; export const CLIENT_ALERT_TYPES = { MONITOR_STATUS: 'xpack.uptime.alerts.monitorStatus', TLS: 'xpack.uptime.alerts.tls', + DURATION_ANOMALY: 'xpack.uptime.alerts.durationAnomaly', }; diff --git a/x-pack/plugins/uptime/common/constants/rest_api.ts b/x-pack/plugins/uptime/common/constants/rest_api.ts index 169d175f02d3bb..f3f06f776260dc 100644 --- a/x-pack/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/plugins/uptime/common/constants/rest_api.ts @@ -24,4 +24,6 @@ export enum API_URLS { ML_DELETE_JOB = `/api/ml/jobs/delete_jobs`, ML_CAPABILITIES = '/api/ml/ml_capabilities', ML_ANOMALIES_RESULT = `/api/ml/results/anomalies_table_data`, + ALERT = '/api/alerts/alert/', + ALERTS_FIND = '/api/alerts/_find', } diff --git a/x-pack/plugins/uptime/public/state/api/__tests__/ml_anomaly.test.ts b/x-pack/plugins/uptime/common/lib/__tests__/ml.test.ts similarity index 95% rename from x-pack/plugins/uptime/public/state/api/__tests__/ml_anomaly.test.ts rename to x-pack/plugins/uptime/common/lib/__tests__/ml.test.ts index 838e5b8246b4b5..122755638db7fd 100644 --- a/x-pack/plugins/uptime/public/state/api/__tests__/ml_anomaly.test.ts +++ b/x-pack/plugins/uptime/common/lib/__tests__/ml.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getMLJobId } from '../ml_anomaly'; +import { getMLJobId } from '../ml'; describe('ML Anomaly API', () => { it('it generates a lowercase job id', async () => { diff --git a/x-pack/plugins/uptime/common/lib/index.ts b/x-pack/plugins/uptime/common/lib/index.ts index 2daec0adf87e4e..33fe5b80d469b4 100644 --- a/x-pack/plugins/uptime/common/lib/index.ts +++ b/x-pack/plugins/uptime/common/lib/index.ts @@ -6,3 +6,5 @@ export * from './combine_filters_and_user_search'; export * from './stringify_kueries'; + +export { getMLJobId } from './ml'; diff --git a/x-pack/plugins/uptime/common/lib/ml.ts b/x-pack/plugins/uptime/common/lib/ml.ts new file mode 100644 index 00000000000000..8be7c472fa5b90 --- /dev/null +++ b/x-pack/plugins/uptime/common/lib/ml.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 { ML_JOB_ID } from '../constants'; + +export const getJobPrefix = (monitorId: string) => { + // ML App doesn't support upper case characters in job name + // Also Spaces and the characters / ? , " < > | * are not allowed + // so we will replace all special chars with _ + + const prefix = monitorId.replace(/[^A-Z0-9]+/gi, '_').toLowerCase(); + + // ML Job ID can't be greater than 64 length, so will be substring it, and hope + // At such big length, there is minimum chance of having duplicate monitor id + // Subtracting ML_JOB_ID constant as well + const postfix = '_' + ML_JOB_ID; + + if ((prefix + postfix).length > 64) { + return prefix.substring(0, 64 - postfix.length) + '_'; + } + return prefix + '_'; +}; + +export const getMLJobId = (monitorId: string) => `${getJobPrefix(monitorId)}${ML_JOB_ID}`; diff --git a/x-pack/plugins/uptime/kibana.json b/x-pack/plugins/uptime/kibana.json index a057e546e4414a..f2b028e323ff67 100644 --- a/x-pack/plugins/uptime/kibana.json +++ b/x-pack/plugins/uptime/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack", "uptime"], "id": "uptime", "kibanaVersion": "kibana", - "optionalPlugins": ["capabilities", "data", "home", "observability"], + "optionalPlugins": ["capabilities", "data", "home", "observability", "ml"], "requiredPlugins": [ "alerts", "embeddable", diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx index 30038b030be563..841c577a4014b8 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/__tests__/ml_manage_job.test.tsx @@ -11,8 +11,8 @@ import { renderWithRouter, shallowWithRouter } from '../../../../lib'; describe('Manage ML Job', () => { it('shallow renders without errors', () => { - const spy = jest.spyOn(redux, 'useSelector'); - spy.mockReturnValue(true); + jest.spyOn(redux, 'useSelector').mockReturnValue(true); + jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn()); const wrapper = shallowWithRouter( @@ -21,8 +21,8 @@ describe('Manage ML Job', () => { }); it('renders without errors', () => { - const spy = jest.spyOn(redux, 'useSelector'); - spy.mockReturnValue(true); + jest.spyOn(redux, 'useDispatch').mockReturnValue(jest.fn()); + jest.spyOn(redux, 'useSelector').mockReturnValue(true); const wrapper = renderWithRouter( diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx new file mode 100644 index 00000000000000..cd5e509e3ad88b --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ml/confirm_alert_delete.tsx @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import React from 'react'; +import { EuiOverlayMask, EuiConfirmModal } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import * as labels from './translations'; + +interface Props { + onConfirm: () => void; + onCancel: () => void; +} + +export const ConfirmAlertDeletion: React.FC = ({ onConfirm, onCancel }) => { + return ( + + +

+ +

+
+
+ ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx index 248ea179ccd2b3..5c3674761af84c 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/manage_ml_job.tsx @@ -7,7 +7,8 @@ import React, { useContext, useState } from 'react'; import { EuiButtonEmpty, EuiContextMenu, EuiIcon, EuiPopover } from '@elastic/eui'; -import { useSelector } from 'react-redux'; +import { useSelector, useDispatch } from 'react-redux'; +import { CLIENT_ALERT_TYPES } from '../../../../common/constants'; import { canDeleteMLJobSelector, hasMLJobSelector, @@ -18,6 +19,10 @@ import * as labels from './translations'; import { getMLJobLinkHref } from './ml_job_link'; import { useGetUrlParams } from '../../../hooks'; import { useMonitorId } from '../../../hooks'; +import { setAlertFlyoutType, setAlertFlyoutVisible } from '../../../state/actions'; +import { useAnomalyAlert } from './use_anomaly_alert'; +import { ConfirmAlertDeletion } from './confirm_alert_delete'; +import { deleteAlertAction } from '../../../state/actions/alerts'; interface Props { hasMLJob: boolean; @@ -40,6 +45,15 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro const monitorId = useMonitorId(); + const dispatch = useDispatch(); + + const anomalyAlert = useAnomalyAlert(); + + const [isConfirmAlertDeleteOpen, setIsConfirmAlertDeleteOpen] = useState(false); + + const deleteAnomalyAlert = () => + dispatch(deleteAlertAction.get({ alertId: anomalyAlert?.id as string })); + const button = ( , + onClick: () => { + if (anomalyAlert) { + setIsConfirmAlertDeleteOpen(true); + } else { + dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY)); + dispatch(setAlertFlyoutVisible(true)); + } + }, + }, { name: labels.DISABLE_ANOMALY_DETECTION, 'data-test-subj': 'uptimeDeleteMLJobBtn', @@ -82,12 +111,29 @@ export const ManageMLJobComponent = ({ hasMLJob, onEnableJob, onJobDelete }: Pro ]; return ( - setIsPopOverOpen(false)}> - - + <> + setIsPopOverOpen(false)} + > + + + {isConfirmAlertDeleteOpen && ( + { + deleteAnomalyAlert(); + setIsConfirmAlertDeleteOpen(false); + }} + onCancel={() => { + setIsConfirmAlertDeleteOpen(false); + }} + /> + )} + ); }; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx index e4bb3d0ac9e174..84634f328621fb 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_flyout_container.tsx @@ -13,59 +13,61 @@ import { isMLJobCreatingSelector, selectDynamicSettings, } from '../../../state/selectors'; -import { createMLJobAction, getExistingMLJobAction } from '../../../state/actions'; +import { + createMLJobAction, + getExistingMLJobAction, + setAlertFlyoutType, + setAlertFlyoutVisible, +} from '../../../state/actions'; import { MLJobLink } from './ml_job_link'; import * as labels from './translations'; -import { - useKibana, - KibanaReactNotifications, -} from '../../../../../../../src/plugins/kibana_react/public'; import { MLFlyoutView } from './ml_flyout'; -import { ML_JOB_ID } from '../../../../common/constants'; +import { CLIENT_ALERT_TYPES, ML_JOB_ID } from '../../../../common/constants'; import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; import { useGetUrlParams } from '../../../hooks'; import { getDynamicSettings } from '../../../state/actions/dynamic_settings'; import { useMonitorId } from '../../../hooks'; +import { kibanaService } from '../../../state/kibana_service'; +import { toMountPoint } from '../../../../../../../src/plugins/kibana_react/public'; interface Props { onClose: () => void; } const showMLJobNotification = ( - notifications: KibanaReactNotifications, monitorId: string, basePath: string, range: { to: string; from: string }, success: boolean, - message = '' + error?: Error ) => { if (success) { - notifications.toasts.success({ - title: ( -

{labels.JOB_CREATED_SUCCESS_TITLE}

- ), - body: ( -

- {labels.JOB_CREATED_SUCCESS_MESSAGE} - - {labels.VIEW_JOB} - -

- ), - toastLifeTimeMs: 10000, - }); + kibanaService.toasts.addSuccess( + { + title: toMountPoint( +

{labels.JOB_CREATED_SUCCESS_TITLE}

+ ), + text: toMountPoint( +

+ {labels.JOB_CREATED_SUCCESS_MESSAGE} + + {labels.VIEW_JOB} + +

+ ), + }, + { toastLifeTimeMs: 10000 } + ); } else { - notifications.toasts.danger({ - title:

{labels.JOB_CREATION_FAILED}

, - body: message ??

{labels.JOB_CREATION_FAILED_MESSAGE}

, + kibanaService.toasts.addError(error!, { + title: labels.JOB_CREATION_FAILED, + toastMessage: labels.JOB_CREATION_FAILED_MESSAGE, toastLifeTimeMs: 10000, }); } }; export const MachineLearningFlyout: React.FC = ({ onClose }) => { - const { notifications } = useKibana(); - const dispatch = useDispatch(); const { data: hasMLJob, error } = useSelector(hasNewMLJobSelector); const isMLJobCreating = useSelector(isMLJobCreatingSelector); @@ -100,7 +102,6 @@ export const MachineLearningFlyout: React.FC = ({ onClose }) => { if (isCreatingJob && !isMLJobCreating) { if (hasMLJob) { showMLJobNotification( - notifications, monitorId as string, basePath, { to: dateRangeEnd, from: dateRangeStart }, @@ -112,31 +113,22 @@ export const MachineLearningFlyout: React.FC = ({ onClose }) => { loadMLJob(ML_JOB_ID); refreshApp(); + dispatch(setAlertFlyoutType(CLIENT_ALERT_TYPES.DURATION_ANOMALY)); + dispatch(setAlertFlyoutVisible(true)); } else { showMLJobNotification( - notifications, monitorId as string, basePath, { to: dateRangeEnd, from: dateRangeStart }, false, - error?.message || error?.body?.message + error as Error ); } setIsCreatingJob(false); onClose(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - hasMLJob, - notifications, - onClose, - isCreatingJob, - error, - isMLJobCreating, - monitorId, - dispatch, - basePath, - ]); + }, [hasMLJob, onClose, isCreatingJob, error, isMLJobCreating, monitorId, dispatch, basePath]); useEffect(() => { if (hasExistingMLJob && !isMLJobCreating && !hasMLJob && heartbeatIndices) { diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx index 1de19dda3b88f4..aa67c7ba1c2f9b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_integeration.tsx @@ -16,12 +16,12 @@ import { import { deleteMLJobAction, getExistingMLJobAction, resetMLState } from '../../../state/actions'; import { ConfirmJobDeletion } from './confirm_delete'; import { UptimeRefreshContext } from '../../../contexts'; -import { getMLJobId } from '../../../state/api/ml_anomaly'; import * as labels from './translations'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { ManageMLJobComponent } from './manage_ml_job'; import { JobStat } from '../../../../../../plugins/ml/public'; import { useMonitorId } from '../../../hooks'; +import { getMLJobId } from '../../../../common/lib'; export const MLIntegrationComponent = () => { const [isMlFlyoutOpen, setIsMlFlyoutOpen] = useState(false); diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx index 4b6f7e3ba061d4..adc05695b4379a 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/ml_job_link.tsx @@ -8,7 +8,7 @@ import React from 'react'; import url from 'url'; import { EuiButtonEmpty } from '@elastic/eui'; import rison, { RisonValue } from 'rison-node'; -import { getMLJobId } from '../../../state/api/ml_anomaly'; +import { getMLJobId } from '../../../../common/lib'; interface Props { monitorId: string; diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx b/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx index bcc3fca770652b..90ebdf10a73f55 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ml/translations.tsx @@ -89,6 +89,20 @@ export const DISABLE_ANOMALY_DETECTION = i18n.translate( } ); +export const ENABLE_ANOMALY_ALERT = i18n.translate( + 'xpack.uptime.ml.enableAnomalyDetectionPanel.enableAnomalyAlert', + { + defaultMessage: 'Enable anomaly alert', + } +); + +export const DISABLE_ANOMALY_ALERT = i18n.translate( + 'xpack.uptime.ml.enableAnomalyDetectionPanel.disableAnomalyAlert', + { + defaultMessage: 'Disable anomaly alert', + } +); + export const MANAGE_ANOMALY_DETECTION = i18n.translate( 'xpack.uptime.ml.enableAnomalyDetectionPanel.manageAnomalyDetectionTitle', { diff --git a/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts b/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts new file mode 100644 index 00000000000000..d204cdf10012a5 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/ml/use_anomaly_alert.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useContext, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { getExistingAlertAction } from '../../../state/actions/alerts'; +import { alertSelector, selectAlertFlyoutVisibility } from '../../../state/selectors'; +import { UptimeRefreshContext } from '../../../contexts'; +import { useMonitorId } from '../../../hooks'; + +export const useAnomalyAlert = () => { + const { lastRefresh } = useContext(UptimeRefreshContext); + + const dispatch = useDispatch(); + + const monitorId = useMonitorId(); + + const { data: anomalyAlert } = useSelector(alertSelector); + + const alertFlyoutVisible = useSelector(selectAlertFlyoutVisibility); + + useEffect(() => { + dispatch(getExistingAlertAction.get({ monitorId })); + }, [monitorId, lastRefresh, dispatch, alertFlyoutVisible]); + + return anomalyAlert; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx index df8ceed76b7968..29edb69f4674be 100644 --- a/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/monitor_duration/monitor_duration_container.tsx @@ -19,10 +19,10 @@ import { selectDurationLines, } from '../../../state/selectors'; import { UptimeRefreshContext } from '../../../contexts'; -import { getMLJobId } from '../../../state/api/ml_anomaly'; import { JobStat } from '../../../../../ml/public'; import { MonitorDurationComponent } from './monitor_duration'; import { MonitorIdParam } from '../../../../common/types'; +import { getMLJobId } from '../../../../common/lib'; export const MonitorDuration: React.FC = ({ monitorId }) => { const { diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx index 0ae8c3a93da949..b5ef240e67dbfc 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/alert_expression_popover.tsx @@ -14,8 +14,8 @@ interface AlertExpressionPopoverProps { 'data-test-subj': string; isEnabled?: boolean; id: string; + value: string | JSX.Element; isInvalid?: boolean; - value: string; } const getColor = ( diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx new file mode 100644 index 00000000000000..4b84012575ae90 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/anomaly_alert.tsx @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + EuiExpression, + EuiFlexItem, + EuiFlexGroup, + EuiSpacer, + EuiHealth, + EuiText, +} from '@elastic/eui'; +import { useSelector } from 'react-redux'; +import React, { useEffect, useState } from 'react'; +import { AnomalyTranslations } from './translations'; +import { AlertExpressionPopover } from '../alert_expression_popover'; +import { DEFAULT_SEVERITY, SelectSeverity } from './select_severity'; +import { monitorIdSelector } from '../../../../state/selectors'; +import { getSeverityColor, getSeverityType } from '../../../../../../ml/public'; + +interface Props { + alertParams: { [key: string]: any }; + setAlertParams: (key: string, value: any) => void; +} + +// eslint-disable-next-line import/no-default-export +export default function AnomalyAlertComponent({ setAlertParams, alertParams }: Props) { + const [severity, setSeverity] = useState(DEFAULT_SEVERITY); + + const monitorIdStore = useSelector(monitorIdSelector); + + const monitorId = monitorIdStore || alertParams?.monitorId; + + useEffect(() => { + setAlertParams('monitorId', monitorId); + }, [monitorId, setAlertParams]); + + useEffect(() => { + setAlertParams('severity', severity.val); + }, [severity, setAlertParams]); + + return ( + <> + + + + +
{monitorId}
+ + } + /> +
+ + + } + data-test-subj={'uptimeAnomalySeverity'} + description={AnomalyTranslations.hasAnomalyWithSeverity} + id="severity" + value={ + + {getSeverityType(severity.val)} + + } + isEnabled={true} + /> + +
+ + + ); +} diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx new file mode 100644 index 00000000000000..0932d0c6eca8d5 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/select_severity.tsx @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, FC, useState, useEffect } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; + +import { EuiHealth, EuiSpacer, EuiSuperSelect, EuiText } from '@elastic/eui'; +import { getSeverityColor } from '../../../../../../ml/public'; + +const warningLabel = i18n.translate('xpack.uptime.controls.selectSeverity.warningLabel', { + defaultMessage: 'warning', +}); +const minorLabel = i18n.translate('xpack.uptime.controls.selectSeverity.minorLabel', { + defaultMessage: 'minor', +}); +const majorLabel = i18n.translate('xpack.uptime.controls.selectSeverity.majorLabel', { + defaultMessage: 'major', +}); +const criticalLabel = i18n.translate('xpack.uptime.controls.selectSeverity.criticalLabel', { + defaultMessage: 'critical', +}); + +const optionsMap = { + [warningLabel]: 0, + [minorLabel]: 25, + [majorLabel]: 50, + [criticalLabel]: 75, +}; + +interface TableSeverity { + val: number; + display: string; + color: string; +} + +export const SEVERITY_OPTIONS: TableSeverity[] = [ + { + val: 0, + display: warningLabel, + color: getSeverityColor(0), + }, + { + val: 25, + display: minorLabel, + color: getSeverityColor(25), + }, + { + val: 50, + display: majorLabel, + color: getSeverityColor(50), + }, + { + val: 75, + display: criticalLabel, + color: getSeverityColor(75), + }, +]; + +function optionValueToThreshold(value: number) { + // Get corresponding threshold object with required display and val properties from the specified value. + let threshold = SEVERITY_OPTIONS.find((opt) => opt.val === value); + + // Default to warning if supplied value doesn't map to one of the options. + if (threshold === undefined) { + threshold = SEVERITY_OPTIONS[0]; + } + + return threshold; +} + +export const DEFAULT_SEVERITY = SEVERITY_OPTIONS[3]; + +const getSeverityOptions = () => + SEVERITY_OPTIONS.map(({ color, display, val }) => ({ + 'data-test-subj': `alertAnomaly${display}`, + value: display, + inputDisplay: ( + + + {display} + + + ), + dropdownDisplay: ( + + + {display} + + + +

+ +

+
+
+ ), + })); + +interface Props { + onChange: (sev: TableSeverity) => void; + value: TableSeverity; +} + +export const SelectSeverity: FC = ({ onChange, value }) => { + const [severity, setSeverity] = useState(DEFAULT_SEVERITY); + + const onSeverityChange = (valueDisplay: string) => { + const option = optionValueToThreshold(optionsMap[valueDisplay]); + setSeverity(option); + onChange(option); + }; + + useEffect(() => { + setSeverity(value); + }, [value]); + + return ( + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts new file mode 100644 index 00000000000000..5fd37609f86bf7 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/alerts/anomaly_alert/translations.ts @@ -0,0 +1,26 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const AnomalyTranslations = { + criteriaAriaLabel: i18n.translate('xpack.uptime.alerts.anomaly.criteriaExpression.ariaLabel', { + defaultMessage: 'An expression displaying the criteria for a selected monitor.', + }), + whenMonitor: i18n.translate('xpack.uptime.alerts.anomaly.criteriaExpression.description', { + defaultMessage: 'When monitor', + }), + scoreAriaLabel: i18n.translate('xpack.uptime.alerts.anomaly.scoreExpression.ariaLabel', { + defaultMessage: 'An expression displaying the criteria for an anomaly alert threshold.', + }), + hasAnomalyWithSeverity: i18n.translate( + 'xpack.uptime.alerts.anomaly.scoreExpression.description', + { + defaultMessage: 'has anomaly with severity', + description: 'An expression displaying the criteria for an anomaly alert threshold.', + } + ), +}; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx b/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx new file mode 100644 index 00000000000000..f0eb3054615826 --- /dev/null +++ b/x-pack/plugins/uptime/public/lib/alert_types/duration_anomaly.tsx @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { Provider as ReduxProvider } from 'react-redux'; +import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; +import { CLIENT_ALERT_TYPES } from '../../../common/constants'; +import { DurationAnomalyTranslations } from './translations'; +import { AlertTypeInitializer } from '.'; +import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; +import { store } from '../../state'; + +const { name, defaultActionMessage } = DurationAnomalyTranslations; +const AnomalyAlertExpression = React.lazy(() => + import('../../components/overview/alerts/anomaly_alert/anomaly_alert') +); +export const initDurationAnomalyAlertType: AlertTypeInitializer = ({ + core, + plugins, +}): AlertTypeModel => ({ + id: CLIENT_ALERT_TYPES.DURATION_ANOMALY, + iconClass: 'uptimeApp', + alertParamsExpression: (params: any) => ( + + + + + + ), + name, + validate: () => ({ errors: {} }), + defaultActionMessage, + requiresAppContext: false, +}); diff --git a/x-pack/plugins/uptime/public/lib/alert_types/index.ts b/x-pack/plugins/uptime/public/lib/alert_types/index.ts index f2f72311d22620..5eb693c6bd5c35 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/index.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/index.ts @@ -9,6 +9,7 @@ import { AlertTypeModel } from '../../../../triggers_actions_ui/public'; import { initMonitorStatusAlertType } from './monitor_status'; import { initTlsAlertType } from './tls'; import { ClientPluginsStart } from '../../apps/plugin'; +import { initDurationAnomalyAlertType } from './duration_anomaly'; export type AlertTypeInitializer = (dependenies: { core: CoreStart; @@ -18,4 +19,5 @@ export type AlertTypeInitializer = (dependenies: { export const alertTypeInitializers: AlertTypeInitializer[] = [ initMonitorStatusAlertType, initTlsAlertType, + initDurationAnomalyAlertType, ]; diff --git a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts index 11fa70bc56f4a1..9232dd590ad5e5 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/translations.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/translations.ts @@ -26,7 +26,7 @@ export const TlsTranslations = { {expiringConditionalOpen} Expiring cert count: {expiringCount} Expiring Certificates: {expiringCommonNameAndDate} -{expiringConditionalClose} +{expiringConditionalClose} {agingConditionalOpen} Aging cert count: {agingCount} @@ -49,3 +49,23 @@ Aging Certificates: {agingCommonNameAndDate} defaultMessage: 'Uptime TLS', }), }; + +export const DurationAnomalyTranslations = { + defaultActionMessage: i18n.translate('xpack.uptime.alerts.durationAnomaly.defaultActionMessage', { + defaultMessage: `Abnormal ({severity} level) response time detected on {monitor} with url {monitorUrl} at {anomalyStartTimestamp}. Anomaly severity score is {severityScore}. +Response times as high as {slowestAnomalyResponse} have been detected from location {observerLocation}. Expected response time is {expectedResponseTime}.`, + values: { + severity: '{{state.severity}}', + anomalyStartTimestamp: '{{state.anomalyStartTimestamp}}', + monitor: '{{state.monitor}}', + monitorUrl: '{{{state.monitorUrl}}}', + slowestAnomalyResponse: '{{state.slowestAnomalyResponse}}', + expectedResponseTime: '{{state.expectedResponseTime}}', + severityScore: '{{state.severityScore}}', + observerLocation: '{{state.observerLocation}}', + }, + }), + name: i18n.translate('xpack.uptime.alerts.durationAnomaly.clientName', { + defaultMessage: 'Uptime Duration Anomaly', + }), +}; diff --git a/x-pack/plugins/uptime/public/pages/monitor.tsx b/x-pack/plugins/uptime/public/pages/monitor.tsx index ab7cf5b2cb3e23..f7012fc5119e9f 100644 --- a/x-pack/plugins/uptime/public/pages/monitor.tsx +++ b/x-pack/plugins/uptime/public/pages/monitor.tsx @@ -16,6 +16,7 @@ import { MonitorCharts } from '../components/monitor'; import { MonitorStatusDetails, PingList } from '../components/monitor'; import { getDynamicSettings } from '../state/actions/dynamic_settings'; import { Ping } from '../../common/runtime_types/ping'; +import { setSelectedMonitorId } from '../state/actions'; const isAutogeneratedId = (id: string) => { const autoGeneratedId = /^auto-(icmp|http|tcp)-OX[A-F0-9]{16}-[a-f0-9]{16}/; @@ -43,6 +44,10 @@ export const MonitorPage: React.FC = () => { const monitorId = useMonitorId(); + useEffect(() => { + dispatch(setSelectedMonitorId(monitorId)); + }, [monitorId, dispatch]); + const selectedMonitor = useSelector(monitorStatusSelector); useTrackPageview({ app: 'uptime', path: 'monitor' }); diff --git a/x-pack/plugins/uptime/public/state/actions/alerts.ts b/x-pack/plugins/uptime/public/state/actions/alerts.ts new file mode 100644 index 00000000000000..a650a9ba8d08b0 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/actions/alerts.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 { createAsyncAction } from './utils'; +import { MonitorIdParam } from './types'; +import { Alert } from '../../../../triggers_actions_ui/public'; + +export const getExistingAlertAction = createAsyncAction( + 'GET EXISTING ALERTS' +); + +export const deleteAlertAction = createAsyncAction<{ alertId: string }, any>('DELETE ALERTS'); diff --git a/x-pack/plugins/uptime/public/state/actions/ui.ts b/x-pack/plugins/uptime/public/state/actions/ui.ts index 04ad6c2fa0bf3c..9387506e4e7b50 100644 --- a/x-pack/plugins/uptime/public/state/actions/ui.ts +++ b/x-pack/plugins/uptime/public/state/actions/ui.ts @@ -25,3 +25,5 @@ export const setSearchTextAction = createAction('SET SEARCH'); export const toggleIntegrationsPopover = createAction( 'TOGGLE INTEGRATION POPOVER STATE' ); + +export const setSelectedMonitorId = createAction('SET MONITOR ID'); diff --git a/x-pack/plugins/uptime/public/state/api/alerts.ts b/x-pack/plugins/uptime/public/state/api/alerts.ts new file mode 100644 index 00000000000000..526abd6b303e57 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/api/alerts.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 { apiService } from './utils'; +import { API_URLS } from '../../../common/constants'; +import { MonitorIdParam } from '../actions/types'; +import { Alert } from '../../../../triggers_actions_ui/public'; + +export const fetchAlertRecords = async ({ monitorId }: MonitorIdParam): Promise => { + const data = { + page: 1, + per_page: 500, + filter: 'alert.attributes.alertTypeId:(xpack.uptime.alerts.durationAnomaly)', + default_search_operator: 'AND', + sort_field: 'name.keyword', + sort_order: 'asc', + }; + const alerts = await apiService.get(API_URLS.ALERTS_FIND, data); + return alerts.data.find((alert: Alert) => alert.params.monitorId === monitorId); +}; + +export const disableAnomalyAlert = async ({ alertId }: { alertId: string }) => { + return await apiService.delete(API_URLS.ALERT + alertId); +}; diff --git a/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts b/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts index 5ec7a6262db664..1d25f35e8f38ab 100644 --- a/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts +++ b/x-pack/plugins/uptime/public/state/api/ml_anomaly.ts @@ -7,38 +7,19 @@ import moment from 'moment'; import { apiService } from './utils'; import { AnomalyRecords, AnomalyRecordsParams } from '../actions'; -import { API_URLS, ML_JOB_ID, ML_MODULE_ID } from '../../../common/constants'; +import { API_URLS, ML_MODULE_ID } from '../../../common/constants'; import { - MlCapabilitiesResponse, DataRecognizerConfigResponse, JobExistResult, + MlCapabilitiesResponse, } from '../../../../../plugins/ml/public'; import { CreateMLJobSuccess, DeleteJobResults, - MonitorIdParam, HeartbeatIndicesParam, + MonitorIdParam, } from '../actions/types'; - -const getJobPrefix = (monitorId: string) => { - // ML App doesn't support upper case characters in job name - // Also Spaces and the characters / ? , " < > | * are not allowed - // so we will replace all special chars with _ - - const prefix = monitorId.replace(/[^A-Z0-9]+/gi, '_').toLowerCase(); - - // ML Job ID can't be greater than 64 length, so will be substring it, and hope - // At such big length, there is minimum chance of having duplicate monitor id - // Subtracting ML_JOB_ID constant as well - const postfix = '_' + ML_JOB_ID; - - if ((prefix + postfix).length > 64) { - return prefix.substring(0, 64 - postfix.length) + '_'; - } - return prefix + '_'; -}; - -export const getMLJobId = (monitorId: string) => `${getJobPrefix(monitorId)}${ML_JOB_ID}`; +import { getJobPrefix, getMLJobId } from '../../../common/lib/ml'; export const getMLCapabilities = async (): Promise => { return await apiService.get(API_URLS.ML_CAPABILITIES); diff --git a/x-pack/plugins/uptime/public/state/effects/alerts.ts b/x-pack/plugins/uptime/public/state/effects/alerts.ts new file mode 100644 index 00000000000000..5f71b0bea7b2c1 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/effects/alerts.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Action } from 'redux-actions'; +import { call, put, takeLatest, select } from 'redux-saga/effects'; +import { fetchEffectFactory } from './fetch_effect'; +import { deleteAlertAction, getExistingAlertAction } from '../actions/alerts'; +import { disableAnomalyAlert, fetchAlertRecords } from '../api/alerts'; +import { kibanaService } from '../kibana_service'; +import { monitorIdSelector } from '../selectors'; + +export function* fetchAlertsEffect() { + yield takeLatest( + getExistingAlertAction.get, + fetchEffectFactory( + fetchAlertRecords, + getExistingAlertAction.success, + getExistingAlertAction.fail + ) + ); + + yield takeLatest(String(deleteAlertAction.get), function* (action: Action<{ alertId: string }>) { + try { + const response = yield call(disableAnomalyAlert, action.payload); + yield put(deleteAlertAction.success(response)); + kibanaService.core.notifications.toasts.addSuccess('Alert successfully deleted!'); + const monitorId = yield select(monitorIdSelector); + yield put(getExistingAlertAction.get({ monitorId })); + } catch (err) { + kibanaService.core.notifications.toasts.addError(err, { + title: 'Alert cannot be deleted', + }); + yield put(deleteAlertAction.fail(err)); + } + }); +} diff --git a/x-pack/plugins/uptime/public/state/effects/index.ts b/x-pack/plugins/uptime/public/state/effects/index.ts index 211067c840d54b..b13ba7f1a9107f 100644 --- a/x-pack/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/plugins/uptime/public/state/effects/index.ts @@ -17,6 +17,7 @@ import { fetchMonitorDurationEffect } from './monitor_duration'; import { fetchMLJobEffect } from './ml_anomaly'; import { fetchIndexStatusEffect } from './index_status'; import { fetchCertificatesEffect } from '../certificates/certificates'; +import { fetchAlertsEffect } from './alerts'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); @@ -33,4 +34,5 @@ export function* rootEffect() { yield fork(fetchMonitorDurationEffect); yield fork(fetchIndexStatusEffect); yield fork(fetchCertificatesEffect); + yield fork(fetchAlertsEffect); } diff --git a/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts b/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts index a6a376b546ab82..00f8a388c689f8 100644 --- a/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts +++ b/x-pack/plugins/uptime/public/state/effects/ml_anomaly.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { takeLatest } from 'redux-saga/effects'; +import { Action } from 'redux-actions'; +import { call, put, select, takeLatest } from 'redux-saga/effects'; import { getMLCapabilitiesAction, getExistingMLJobAction, @@ -20,6 +21,9 @@ import { deleteMLJob, getMLCapabilities, } from '../api/ml_anomaly'; +import { deleteAlertAction } from '../actions/alerts'; +import { alertSelector } from '../selectors'; +import { MonitorIdParam } from '../actions/types'; export function* fetchMLJobEffect() { yield takeLatest( @@ -38,10 +42,22 @@ export function* fetchMLJobEffect() { getAnomalyRecordsAction.fail ) ); - yield takeLatest( - deleteMLJobAction.get, - fetchEffectFactory(deleteMLJob, deleteMLJobAction.success, deleteMLJobAction.fail) - ); + + yield takeLatest(String(deleteMLJobAction.get), function* (action: Action) { + try { + const response = yield call(deleteMLJob, action.payload); + yield put(deleteMLJobAction.success(response)); + + // let's delete alert as well if it's there + const { data: anomalyAlert } = yield select(alertSelector); + if (anomalyAlert) { + yield put(deleteAlertAction.get({ alertId: anomalyAlert.id as string })); + } + } catch (err) { + yield put(deleteMLJobAction.fail(err)); + } + }); + yield takeLatest( getMLCapabilitiesAction.get, fetchEffectFactory( diff --git a/x-pack/plugins/uptime/public/state/kibana_service.ts b/x-pack/plugins/uptime/public/state/kibana_service.ts index 4fd2d446daa171..f1eb3af9da6679 100644 --- a/x-pack/plugins/uptime/public/state/kibana_service.ts +++ b/x-pack/plugins/uptime/public/state/kibana_service.ts @@ -20,6 +20,10 @@ class KibanaService { apiService.http = this._core.http; } + public get toasts() { + return this._core.notifications.toasts; + } + private constructor() {} static getInstance(): KibanaService { diff --git a/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap b/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap index c11b146101d355..040fbf7f4fe0a7 100644 --- a/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap +++ b/x-pack/plugins/uptime/public/state/reducers/__tests__/__snapshots__/ui.test.ts.snap @@ -9,6 +9,7 @@ Object { "id": "popover-2", "open": true, }, + "monitorId": "test", "searchText": "", } `; @@ -19,6 +20,7 @@ Object { "basePath": "yyz", "esKuery": "", "integrationsPopoverOpen": null, + "monitorId": "test", "searchText": "", } `; diff --git a/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts b/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts index 4683c654270dbc..c265cd9fc7ecdb 100644 --- a/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts +++ b/x-pack/plugins/uptime/public/state/reducers/__tests__/ui.test.ts @@ -24,6 +24,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -43,6 +44,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -59,6 +61,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -68,6 +71,7 @@ describe('ui reducer', () => { "basePath": "", "esKuery": "", "integrationsPopoverOpen": null, + "monitorId": "test", "searchText": "", } `); @@ -83,6 +87,7 @@ describe('ui reducer', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: 'test', }, action ) @@ -92,6 +97,7 @@ describe('ui reducer', () => { "basePath": "", "esKuery": "", "integrationsPopoverOpen": null, + "monitorId": "test", "searchText": "lorem ipsum", } `); diff --git a/x-pack/plugins/uptime/public/state/reducers/alerts.ts b/x-pack/plugins/uptime/public/state/reducers/alerts.ts new file mode 100644 index 00000000000000..a2cd844e249640 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/reducers/alerts.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 { handleActions } from 'redux-actions'; +import { getAsyncInitialState, handleAsyncAction } from './utils'; +import { AsyncInitialState } from './types'; +import { deleteAlertAction, getExistingAlertAction } from '../actions/alerts'; +import { Alert } from '../../../../triggers_actions_ui/public'; + +export interface AlertsState { + alert: AsyncInitialState; + alertDeletion: AsyncInitialState; +} + +const initialState: AlertsState = { + alert: getAsyncInitialState(), + alertDeletion: getAsyncInitialState(), +}; + +export const alertsReducer = handleActions( + { + ...handleAsyncAction('alert', getExistingAlertAction), + ...handleAsyncAction('alertDeletion', deleteAlertAction), + }, + initialState +); diff --git a/x-pack/plugins/uptime/public/state/reducers/index.ts b/x-pack/plugins/uptime/public/state/reducers/index.ts index c05c740ab8ebf3..01baf7cf07c929 100644 --- a/x-pack/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/plugins/uptime/public/state/reducers/index.ts @@ -20,6 +20,7 @@ import { indexStatusReducer } from './index_status'; import { mlJobsReducer } from './ml_anomaly'; import { certificatesReducer } from '../certificates/certificates'; import { selectedFiltersReducer } from './selected_filters'; +import { alertsReducer } from './alerts'; export const rootReducer = combineReducers({ monitor: monitorReducer, @@ -37,4 +38,5 @@ export const rootReducer = combineReducers({ indexStatus: indexStatusReducer, certificates: certificatesReducer, selectedFilters: selectedFiltersReducer, + alerts: alertsReducer, }); diff --git a/x-pack/plugins/uptime/public/state/reducers/ui.ts b/x-pack/plugins/uptime/public/state/reducers/ui.ts index 3cf4ae9c0bbf28..568234a3a83cd7 100644 --- a/x-pack/plugins/uptime/public/state/reducers/ui.ts +++ b/x-pack/plugins/uptime/public/state/reducers/ui.ts @@ -14,6 +14,7 @@ import { setAlertFlyoutType, setAlertFlyoutVisible, setSearchTextAction, + setSelectedMonitorId, } from '../actions'; export interface UiState { @@ -23,6 +24,7 @@ export interface UiState { esKuery: string; searchText: string; integrationsPopoverOpen: PopoverState | null; + monitorId: string; } const initialState: UiState = { @@ -31,6 +33,7 @@ const initialState: UiState = { esKuery: '', searchText: '', integrationsPopoverOpen: null, + monitorId: '', }; export const uiReducer = handleActions( @@ -64,6 +67,10 @@ export const uiReducer = handleActions( ...state, searchText: action.payload, }), + [String(setSelectedMonitorId)]: (state, action: Action) => ({ + ...state, + monitorId: action.payload, + }), }, initialState ); diff --git a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts index b1885ddeeba3f6..de8615c7016a73 100644 --- a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -45,6 +45,7 @@ describe('state selectors', () => { esKuery: '', integrationsPopoverOpen: null, searchText: '', + monitorId: '', }, monitorStatus: { status: null, @@ -108,6 +109,10 @@ describe('state selectors', () => { }, }, selectedFilters: null, + alerts: { + alertDeletion: { data: null, loading: false }, + alert: { data: null, loading: false }, + }, }; it('selects base path from state', () => { diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts index 4c2b671203f0ad..bf6c9b3666a6a7 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -59,6 +59,8 @@ export const hasNewMLJobSelector = ({ ml }: AppState) => ml.createJob; export const isMLJobCreatingSelector = ({ ml }: AppState) => ml.createJob.loading; export const isMLJobDeletingSelector = ({ ml }: AppState) => ml.deleteJob.loading; +export const isAnomalyAlertDeletingSelector = ({ alerts }: AppState) => + alerts.alertDeletion.loading; export const isMLJobDeletedSelector = ({ ml }: AppState) => ml.deleteJob; @@ -88,3 +90,7 @@ export const esKuerySelector = ({ ui: { esKuery } }: AppState) => esKuery; export const searchTextSelector = ({ ui: { searchText } }: AppState) => searchText; export const selectedFiltersSelector = ({ selectedFilters }: AppState) => selectedFilters; + +export const monitorIdSelector = ({ ui: { monitorId } }: AppState) => monitorId; + +export const alertSelector = ({ alerts }: AppState) => alerts.alert; diff --git a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts index 2e732f59e4f307..75d9c8aa959b1e 100644 --- a/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts +++ b/x-pack/plugins/uptime/server/lib/adapters/framework/adapter_types.ts @@ -14,6 +14,7 @@ import { import { UMKibanaRoute } from '../../../rest_api'; import { PluginSetupContract } from '../../../../../features/server'; import { DynamicSettings } from '../../../../common/runtime_types'; +import { MlPluginSetup as MlSetup } from '../../../../../ml/server'; export type APICaller = ( endpoint: string, @@ -39,6 +40,7 @@ export interface UptimeCorePlugins { alerts: any; elasticsearch: any; usageCollection: UsageCollectionSetup; + ml: MlSetup; } export interface UMBackendFrameworkAdapter { diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts index d85752768b47bb..a38132d0f7a83b 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -17,7 +17,7 @@ import { GetMonitorStatusResult } from '../../requests'; import { AlertType } from '../../../../../alerts/server'; import { IRouter } from 'kibana/server'; import { UMServerLibs } from '../../lib'; -import { UptimeCoreSetup } from '../../adapters'; +import { UptimeCorePlugins, UptimeCoreSetup } from '../../adapters'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; import { alertsMock, AlertServicesMock } from '../../../../../alerts/server/mocks'; @@ -33,9 +33,10 @@ const bootstrapDependencies = (customRequests?: any) => { // these server/libs parameters don't have any functionality, which is fine // because we aren't testing them here const server: UptimeCoreSetup = { router }; + const plugins: UptimeCorePlugins = {} as any; const libs: UMServerLibs = { requests: {} } as UMServerLibs; libs.requests = { ...libs.requests, ...customRequests }; - return { server, libs }; + return { server, libs, plugins }; }; /** @@ -82,8 +83,8 @@ describe('status check alert', () => { expect.assertions(4); const mockGetter = jest.fn(); mockGetter.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); - const alert = statusCheckAlertFactory(server, libs); + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs, plugins); // @ts-ignore the executor can return `void`, but ours never does const state: Record = await alert.executor(mockOptions()); @@ -128,8 +129,8 @@ describe('status check alert', () => { status: 'down', }, ]); - const { server, libs } = bootstrapDependencies({ getMonitorStatus: mockGetter }); - const alert = statusCheckAlertFactory(server, libs); + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions(); const alertServices: AlertServicesMock = options.services; // @ts-ignore the executor can return `void`, but ours never does @@ -213,11 +214,11 @@ describe('status check alert', () => { status: 'down', }, ]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 4, timerange: { from: 'now-14h', to: 'now' }, @@ -286,11 +287,11 @@ describe('status check alert', () => { status: 'down', }, ]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 3, timerangeUnit: 'm', @@ -371,11 +372,11 @@ describe('status check alert', () => { toISOStringSpy.mockImplementation(() => 'search test'); const mockGetter = jest.fn(); mockGetter.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getIndexPattern: jest.fn(), getMonitorStatus: mockGetter, }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ numTimes: 20, timerangeCount: 30, @@ -467,12 +468,12 @@ describe('status check alert', () => { availabilityRatio: 0.909245845760545, }, ]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorAvailability: mockAvailability, getMonitorStatus: mockGetter, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ availability: { range: 35, @@ -559,11 +560,11 @@ describe('status check alert', () => { mockGetter.mockReturnValue([]); const mockAvailability = jest.fn(); mockAvailability.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorAvailability: mockAvailability, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ availability: { range: 23, @@ -600,11 +601,11 @@ describe('status check alert', () => { mockGetter.mockReturnValue([]); const mockAvailability = jest.fn(); mockAvailability.mockReturnValue([]); - const { server, libs } = bootstrapDependencies({ + const { server, libs, plugins } = bootstrapDependencies({ getMonitorAvailability: mockAvailability, getIndexPattern: jest.fn(), }); - const alert = statusCheckAlertFactory(server, libs); + const alert = statusCheckAlertFactory(server, libs, plugins); const options = mockOptions({ availability: { range: 23, @@ -748,8 +749,8 @@ describe('status check alert', () => { let alert: AlertType; beforeEach(() => { - const { server, libs } = bootstrapDependencies(); - alert = statusCheckAlertFactory(server, libs); + const { server, libs, plugins } = bootstrapDependencies(); + alert = statusCheckAlertFactory(server, libs, plugins); }); it('creates an alert with expected params', () => { diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts new file mode 100644 index 00000000000000..7dd357e99b83df --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.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 moment from 'moment'; +import { schema } from '@kbn/config-schema'; +import { ILegacyScopedClusterClient } from 'kibana/server'; +import { updateState } from './common'; +import { ACTION_GROUP_DEFINITIONS } from '../../../common/constants'; +import { commonStateTranslations, durationAnomalyTranslations } from './translations'; +import { AnomaliesTableRecord } from '../../../../ml/common/types/anomalies'; +import { getSeverityType } from '../../../../ml/common/util/anomaly_utils'; +import { getLatestMonitor } from '../requests'; +import { savedObjectsAdapter } from '../saved_objects'; +import { UptimeCorePlugins } from '../adapters/framework'; +import { UptimeAlertTypeFactory } from './types'; +import { Ping } from '../../../common/runtime_types/ping'; +import { getMLJobId } from '../../../common/lib'; + +const { DURATION_ANOMALY } = ACTION_GROUP_DEFINITIONS; + +export const getAnomalySummary = (anomaly: AnomaliesTableRecord, monitorInfo: Ping) => { + return { + severity: getSeverityType(anomaly.severity), + severityScore: Math.round(anomaly.severity), + anomalyStartTimestamp: moment(anomaly.source.timestamp).toISOString(), + monitor: anomaly.source['monitor.id'], + monitorUrl: monitorInfo.url?.full, + slowestAnomalyResponse: Math.round(anomaly.actualSort / 1000) + ' ms', + expectedResponseTime: Math.round(anomaly.typicalSort / 1000) + ' ms', + observerLocation: anomaly.entityValue, + }; +}; + +const getAnomalies = async ( + plugins: UptimeCorePlugins, + mlClusterClient: ILegacyScopedClusterClient, + params: Record, + lastCheckedAt: string +) => { + const { getAnomaliesTableData } = plugins.ml.resultsServiceProvider(mlClusterClient, { + params: 'DummyKibanaRequest', + } as any); + + return await getAnomaliesTableData( + [getMLJobId(params.monitorId)], + [], + [], + 'auto', + params.severity, + moment(lastCheckedAt).valueOf(), + moment().valueOf(), + Intl.DateTimeFormat().resolvedOptions().timeZone, + 500, + 10, + undefined + ); +}; + +export const durationAnomalyAlertFactory: UptimeAlertTypeFactory = (_server, _libs, plugins) => ({ + id: 'xpack.uptime.alerts.durationAnomaly', + name: durationAnomalyTranslations.alertFactoryName, + validate: { + params: schema.object({ + monitorId: schema.string(), + severity: schema.number(), + }), + }, + defaultActionGroupId: DURATION_ANOMALY.id, + actionGroups: [ + { + id: DURATION_ANOMALY.id, + name: DURATION_ANOMALY.name, + }, + ], + actionVariables: { + context: [], + state: [...durationAnomalyTranslations.actionVariables, ...commonStateTranslations], + }, + producer: 'uptime', + async executor(options) { + const { + services: { + alertInstanceFactory, + callCluster, + savedObjectsClient, + getLegacyScopedClusterClient, + }, + state, + params, + } = options; + + const { anomalies } = + (await getAnomalies( + plugins, + getLegacyScopedClusterClient(plugins.ml.mlClient), + params, + state.lastCheckedAt + )) ?? {}; + + const foundAnomalies = anomalies?.length > 0; + + if (foundAnomalies) { + const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( + savedObjectsClient + ); + const monitorInfo = await getLatestMonitor({ + dynamicSettings, + callES: callCluster, + dateStart: 'now-15m', + dateEnd: 'now', + monitorId: params.monitorId, + }); + anomalies.forEach((anomaly, index) => { + const alertInstance = alertInstanceFactory(DURATION_ANOMALY.id + index); + const summary = getAnomalySummary(anomaly, monitorInfo); + alertInstance.replaceState({ + ...updateState(state, false), + ...summary, + }); + alertInstance.scheduleActions(DURATION_ANOMALY.id); + }); + } + + return updateState(state, foundAnomalies); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/index.ts b/x-pack/plugins/uptime/server/lib/alerts/index.ts index 661df39ece6286..c8d3037f98aeba 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/index.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/index.ts @@ -7,8 +7,10 @@ import { UptimeAlertTypeFactory } from './types'; import { statusCheckAlertFactory } from './status_check'; import { tlsAlertFactory } from './tls'; +import { durationAnomalyAlertFactory } from './duration_anomaly'; export const uptimeAlertTypeFactories: UptimeAlertTypeFactory[] = [ statusCheckAlertFactory, tlsAlertFactory, + durationAnomalyAlertFactory, ]; diff --git a/x-pack/plugins/uptime/server/lib/alerts/translations.ts b/x-pack/plugins/uptime/server/lib/alerts/translations.ts index e41930aad5af0b..50eedcd4fa69e4 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/translations.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/translations.ts @@ -148,3 +148,93 @@ export const tlsTranslations = { }, }), }; + +export const durationAnomalyTranslations = { + alertFactoryName: i18n.translate('xpack.uptime.alerts.durationAnomaly', { + defaultMessage: 'Uptime Duration Anomaly', + }), + actionVariables: [ + { + name: 'severity', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.severity', + { + defaultMessage: 'The severity of the anomaly.', + } + ), + }, + { + name: 'anomalyStartTimestamp', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.anomalyStartTimestamp', + { + defaultMessage: 'ISO8601 timestamp of the start of the anomaly.', + } + ), + }, + { + name: 'monitor', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.monitor', + { + defaultMessage: + 'A human friendly rendering of name or ID, preferring name (e.g. My Monitor)', + } + ), + }, + { + name: 'monitorId', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.monitorId', + { + defaultMessage: 'ID of the monitor.', + } + ), + }, + { + name: 'monitorUrl', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.monitorUrl', + { + defaultMessage: 'URL of the monitor.', + } + ), + }, + { + name: 'slowestAnomalyResponse', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.slowestAnomalyResponse', + { + defaultMessage: 'Slowest response time during anomaly bucket with unit (ms, s) attached.', + } + ), + }, + { + name: 'expectedResponseTime', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.expectedResponseTime', + { + defaultMessage: 'Expected response time', + } + ), + }, + { + name: 'severityScore', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.severityScore', + { + defaultMessage: 'Anomaly severity score', + } + ), + }, + { + name: 'observerLocation', + description: i18n.translate( + 'xpack.uptime.alerts.durationAnomaly.actionVariables.state.observerLocation', + { + defaultMessage: 'Observer location from which heartbeat check is performed.', + } + ), + }, + ], +}; diff --git a/x-pack/plugins/uptime/server/lib/alerts/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts index a321cc124ac22e..172930bc3dd3bd 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/types.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/types.ts @@ -5,7 +5,11 @@ */ import { AlertType } from '../../../../alerts/server'; -import { UptimeCoreSetup } from '../adapters'; +import { UptimeCorePlugins, UptimeCoreSetup } from '../adapters'; import { UMServerLibs } from '../lib'; -export type UptimeAlertTypeFactory = (server: UptimeCoreSetup, libs: UMServerLibs) => AlertType; +export type UptimeAlertTypeFactory = ( + server: UptimeCoreSetup, + libs: UMServerLibs, + plugins: UptimeCorePlugins +) => AlertType; diff --git a/x-pack/plugins/uptime/server/uptime_server.ts b/x-pack/plugins/uptime/server/uptime_server.ts index fb90dfe2be6c53..afad5896ae64b4 100644 --- a/x-pack/plugins/uptime/server/uptime_server.ts +++ b/x-pack/plugins/uptime/server/uptime_server.ts @@ -19,6 +19,6 @@ export const initUptimeServer = ( ); uptimeAlertTypeFactories.forEach((alertTypeFactory) => - plugins.alerts.registerType(alertTypeFactory(server, libs)) + plugins.alerts.registerType(alertTypeFactory(server, libs, plugins)) ); }; diff --git a/x-pack/plugins/watcher/public/plugin.ts b/x-pack/plugins/watcher/public/plugin.ts index 3d907ac0dff3ad..6b66c341497b7a 100644 --- a/x-pack/plugins/watcher/public/plugin.ts +++ b/x-pack/plugins/watcher/public/plugin.ts @@ -7,7 +7,6 @@ import { i18n } from '@kbn/i18n'; import { CoreSetup, Plugin, CoreStart } from 'kibana/public'; import { first, map, skip } from 'rxjs/operators'; -import { ManagementSectionId } from '../../../../src/plugins/management/public'; import { FeatureCatalogueCategory } from '../../../../src/plugins/home/public'; import { LicenseStatus } from '../common/types/license_status'; @@ -29,7 +28,7 @@ export class WatcherUIPlugin implements Plugin { { notifications, http, uiSettings, getStartServices }: CoreSetup, { licensing, management, data, home, charts }: Dependencies ) { - const esSection = management.sections.getSection(ManagementSectionId.InsightsAndAlerting); + const esSection = management.sections.section.insightsAndAlerting; const watcherESApp = esSection.registerApp({ id: 'watcher', diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts index 04564fc00ec2f9..242f906d0d197c 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/add_prepackaged_rules.ts @@ -8,7 +8,12 @@ import expect from '@kbn/expect'; import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex } from '../../utils'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteAllTimelines, + deleteSignalsIndex, +} from '../../utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -40,6 +45,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); await deleteAllAlerts(es); + await deleteAllTimelines(es); }); it('should contain two output keys of rules_installed and rules_updated', async () => { @@ -49,7 +55,12 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(200); - expect(Object.keys(body)).to.eql(['rules_installed', 'rules_updated']); + expect(Object.keys(body)).to.eql([ + 'rules_installed', + 'rules_updated', + 'timelines_installed', + 'timelines_updated', + ]); }); it('should create the prepackaged rules and return a count greater than zero', async () => { diff --git a/x-pack/test/detection_engine_api_integration/basic/tests/get_prepackaged_rules_status.ts b/x-pack/test/detection_engine_api_integration/basic/tests/get_prepackaged_rules_status.ts index 8af67f818ea918..1bbfce42d2baad 100644 --- a/x-pack/test/detection_engine_api_integration/basic/tests/get_prepackaged_rules_status.ts +++ b/x-pack/test/detection_engine_api_integration/basic/tests/get_prepackaged_rules_status.ts @@ -16,6 +16,7 @@ import { deleteAllAlerts, deleteSignalsIndex, getSimpleRule, + deleteAllTimelines, } from '../../utils'; // eslint-disable-next-line import/no-default-export @@ -32,9 +33,10 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); await deleteAllAlerts(es); + await deleteAllTimelines(es); }); - it('should return expected JSON keys of the pre-packaged rules status', async () => { + it('should return expected JSON keys of the pre-packaged rules and pre-packaged timelines status', async () => { const { body } = await supertest .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) .set('kbn-xsrf', 'true') @@ -46,6 +48,9 @@ export default ({ getService }: FtrProviderContext): void => { 'rules_installed', 'rules_not_installed', 'rules_not_updated', + 'timelines_installed', + 'timelines_not_installed', + 'timelines_not_updated', ]); }); @@ -58,6 +63,15 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.rules_not_installed).to.be.greaterThan(0); }); + it('should return that timelines_not_installed are greater than zero', async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.timelines_not_installed).to.be.greaterThan(0); + }); + it('should return that rules_custom_installed, rules_installed, and rules_not_updated are zero', async () => { const { body } = await supertest .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) @@ -69,6 +83,16 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.rules_not_updated).to.eql(0); }); + it('should return that timelines_installed, and timelines_not_updated are zero', async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.timelines_installed).to.eql(0); + expect(body.timelines_not_updated).to.eql(0); + }); + it('should show that one custom rule is installed when a custom rule is added', async () => { await supertest .post(DETECTION_ENGINE_RULES_URL) @@ -84,9 +108,11 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.rules_custom_installed).to.eql(1); expect(body.rules_installed).to.eql(0); expect(body.rules_not_updated).to.eql(0); + expect(body.timelines_installed).to.eql(0); + expect(body.timelines_not_updated).to.eql(0); }); - it('should show rules are installed when adding pre-packaged rules', async () => { + it('should show rules and timelines are installed when adding pre-packaged rules', async () => { await supertest .put(DETECTION_ENGINE_PREPACKAGED_URL) .set('kbn-xsrf', 'true') @@ -99,6 +125,7 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(200); expect(body.rules_installed).to.be.greaterThan(0); + expect(body.timelines_installed).to.be.greaterThan(0); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts index 04564fc00ec2f9..242f906d0d197c 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/add_prepackaged_rules.ts @@ -8,7 +8,12 @@ import expect from '@kbn/expect'; import { DETECTION_ENGINE_PREPACKAGED_URL } from '../../../../plugins/security_solution/common/constants'; import { FtrProviderContext } from '../../common/ftr_provider_context'; -import { createSignalsIndex, deleteAllAlerts, deleteSignalsIndex } from '../../utils'; +import { + createSignalsIndex, + deleteAllAlerts, + deleteAllTimelines, + deleteSignalsIndex, +} from '../../utils'; // eslint-disable-next-line import/no-default-export export default ({ getService }: FtrProviderContext): void => { @@ -40,6 +45,7 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); await deleteAllAlerts(es); + await deleteAllTimelines(es); }); it('should contain two output keys of rules_installed and rules_updated', async () => { @@ -49,7 +55,12 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(200); - expect(Object.keys(body)).to.eql(['rules_installed', 'rules_updated']); + expect(Object.keys(body)).to.eql([ + 'rules_installed', + 'rules_updated', + 'timelines_installed', + 'timelines_updated', + ]); }); it('should create the prepackaged rules and return a count greater than zero', async () => { diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts index 8af67f818ea918..1bbfce42d2baad 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/get_prepackaged_rules_status.ts @@ -16,6 +16,7 @@ import { deleteAllAlerts, deleteSignalsIndex, getSimpleRule, + deleteAllTimelines, } from '../../utils'; // eslint-disable-next-line import/no-default-export @@ -32,9 +33,10 @@ export default ({ getService }: FtrProviderContext): void => { afterEach(async () => { await deleteSignalsIndex(supertest); await deleteAllAlerts(es); + await deleteAllTimelines(es); }); - it('should return expected JSON keys of the pre-packaged rules status', async () => { + it('should return expected JSON keys of the pre-packaged rules and pre-packaged timelines status', async () => { const { body } = await supertest .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) .set('kbn-xsrf', 'true') @@ -46,6 +48,9 @@ export default ({ getService }: FtrProviderContext): void => { 'rules_installed', 'rules_not_installed', 'rules_not_updated', + 'timelines_installed', + 'timelines_not_installed', + 'timelines_not_updated', ]); }); @@ -58,6 +63,15 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.rules_not_installed).to.be.greaterThan(0); }); + it('should return that timelines_not_installed are greater than zero', async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.timelines_not_installed).to.be.greaterThan(0); + }); + it('should return that rules_custom_installed, rules_installed, and rules_not_updated are zero', async () => { const { body } = await supertest .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) @@ -69,6 +83,16 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.rules_not_updated).to.eql(0); }); + it('should return that timelines_installed, and timelines_not_updated are zero', async () => { + const { body } = await supertest + .get(`${DETECTION_ENGINE_PREPACKAGED_URL}/_status`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.timelines_installed).to.eql(0); + expect(body.timelines_not_updated).to.eql(0); + }); + it('should show that one custom rule is installed when a custom rule is added', async () => { await supertest .post(DETECTION_ENGINE_RULES_URL) @@ -84,9 +108,11 @@ export default ({ getService }: FtrProviderContext): void => { expect(body.rules_custom_installed).to.eql(1); expect(body.rules_installed).to.eql(0); expect(body.rules_not_updated).to.eql(0); + expect(body.timelines_installed).to.eql(0); + expect(body.timelines_not_updated).to.eql(0); }); - it('should show rules are installed when adding pre-packaged rules', async () => { + it('should show rules and timelines are installed when adding pre-packaged rules', async () => { await supertest .put(DETECTION_ENGINE_PREPACKAGED_URL) .set('kbn-xsrf', 'true') @@ -99,6 +125,7 @@ export default ({ getService }: FtrProviderContext): void => { .send() .expect(200); expect(body.rules_installed).to.be.greaterThan(0); + expect(body.timelines_installed).to.be.greaterThan(0); }); }); }); diff --git a/x-pack/test/detection_engine_api_integration/utils.ts b/x-pack/test/detection_engine_api_integration/utils.ts index 4b980536d2cd10..102a1577a7eaf1 100644 --- a/x-pack/test/detection_engine_api_integration/utils.ts +++ b/x-pack/test/detection_engine_api_integration/utils.ts @@ -259,6 +259,20 @@ export const deleteAllAlerts = async (es: Client, retryCount = 20): Promise => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:siem-ui-timeline', + wait_for_completion: true, + refresh: true, + body: {}, + }); +}; + /** * Remove all rules statuses from the .kibana index * This will retry 20 times before giving up and hopefully still not interfere with other tests diff --git a/x-pack/test/functional/services/uptime/ml_anomaly.ts b/x-pack/test/functional/services/uptime/ml_anomaly.ts index a5f138b7a5716c..ac9f6ab2b3d147 100644 --- a/x-pack/test/functional/services/uptime/ml_anomaly.ts +++ b/x-pack/test/functional/services/uptime/ml_anomaly.ts @@ -20,12 +20,18 @@ export function UptimeMLAnomalyProvider({ getService }: FtrProviderContext) { }, async openMLManageMenu() { + await this.cancelAlertFlyout(); return retry.tryForTime(30000, async () => { await testSubjects.click('uptimeManageMLJobBtn'); await testSubjects.existOrFail('uptimeManageMLContextMenu'); }); }, + async cancelAlertFlyout() { + if (await testSubjects.exists('euiFlyoutCloseButton')) + await testSubjects.click('euiFlyoutCloseButton', 60 * 1000); + }, + async alreadyHasJob() { return await testSubjects.exists('uptimeManageMLJobBtn'); }, @@ -55,5 +61,19 @@ export function UptimeMLAnomalyProvider({ getService }: FtrProviderContext) { async hasNoLicenseInfo() { return await testSubjects.missingOrFail('uptimeMLLicenseInfo', { timeout: 1000 }); }, + + async openAlertFlyout() { + return await testSubjects.click('uptimeEnableAnomalyAlertBtn'); + }, + + async disableAnomalyAlertIsVisible() { + return await testSubjects.exists('uptimeDisableAnomalyAlertBtn'); + }, + + async changeAlertThreshold(level: string) { + await testSubjects.click('uptimeAnomalySeverity'); + await testSubjects.click('anomalySeveritySelect'); + await testSubjects.click(`alertAnomaly${level}`); + }, }; } diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts new file mode 100644 index 00000000000000..03343bff642c3c --- /dev/null +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/anomaly_alert.ts @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * 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) => { + describe('uptime anomaly alert', () => { + const pageObjects = getPageObjects(['common', 'uptime']); + const supertest = getService('supertest'); + const retry = getService('retry'); + + const monitorId = '0000-intermittent'; + + const uptime = getService('uptime'); + + const DEFAULT_DATE_START = 'Sep 10, 2019 @ 12:40:08.078'; + const DEFAULT_DATE_END = 'Sep 11, 2019 @ 19:40:08.078'; + let alerts: any; + const alertId = 'uptime-anomaly-alert'; + + before(async () => { + alerts = getService('uptime').alerts; + + await uptime.navigation.goToUptime(); + + await uptime.navigation.loadDataAndGoToMonitorPage( + DEFAULT_DATE_START, + DEFAULT_DATE_END, + monitorId + ); + }); + + it('can delete existing job', async () => { + if (await uptime.ml.alreadyHasJob()) { + await uptime.ml.openMLManageMenu(); + await uptime.ml.deleteMLJob(); + await uptime.navigation.refreshApp(); + } + }); + + it('can open ml flyout', async () => { + await uptime.ml.openMLFlyout(); + }); + + it('has permission to create job', async () => { + expect(uptime.ml.canCreateJob()).to.eql(true); + expect(uptime.ml.hasNoLicenseInfo()).to.eql(false); + }); + + it('can create job successfully', async () => { + await uptime.ml.createMLJob(); + await pageObjects.common.closeToast(); + await uptime.ml.cancelAlertFlyout(); + }); + + it('can open ML Manage Menu', async () => { + await uptime.ml.openMLManageMenu(); + }); + + it('can open anomaly alert flyout', async () => { + await uptime.ml.openAlertFlyout(); + }); + + it('can set alert name', async () => { + await alerts.setAlertName(alertId); + }); + + it('can set alert tags', async () => { + await alerts.setAlertTags(['uptime', 'anomaly-alert']); + }); + + it('can change anomaly alert threshold', async () => { + await uptime.ml.changeAlertThreshold('major'); + }); + + it('can save alert', async () => { + await alerts.clickSaveAlertButton(); + await pageObjects.common.closeToast(); + }); + + it('has created a valid alert with expected parameters', async () => { + let alert: any; + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get(`/api/alerts/_find?search=${alertId}`); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === alertId + ); + expect(alertsFromThisTest).to.have.length(1); + alert = alertsFromThisTest[0]; + }); + + // Ensure the parameters and other stateful data + // on the alert match up with the values we provided + // for our test helper to input into the flyout. + const { actions, alertTypeId, consumer, id, params, tags } = alert; + try { + expect(actions).to.eql([]); + expect(alertTypeId).to.eql('xpack.uptime.alerts.durationAnomaly'); + expect(consumer).to.eql('uptime'); + expect(tags).to.eql(['uptime', 'anomaly-alert']); + expect(params.monitorId).to.eql(monitorId); + expect(params.severity).to.eql(50); + } finally { + await supertest.delete(`/api/alerts/alert/${id}`).set('kbn-xsrf', 'true').expect(204); + } + }); + + it('change button to disable anomaly alert', async () => { + await uptime.ml.openMLManageMenu(); + expect(uptime.ml.disableAnomalyAlertIsVisible()).to.eql(true); + }); + + it('can delete job successfully', async () => { + await uptime.ml.deleteMLJob(); + }); + + it('verifies that alert is also deleted', async () => { + await retry.tryForTime(15000, async () => { + const apiResponse = await supertest.get(`/api/alerts/_find?search=${alertId}`); + const alertsFromThisTest = apiResponse.body.data.filter( + ({ name }: { name: string }) => name === alertId + ); + expect(alertsFromThisTest).to.have.length(0); + }); + }); + }); +}; diff --git a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts index ce91a2a26ce919..3016bd6d68f958 100644 --- a/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts +++ b/x-pack/test/functional_with_es_ssl/apps/uptime/index.ts @@ -22,6 +22,7 @@ export default ({ getService, loadTestFile }: FtrProviderContext) => { after(async () => await esArchiver.unload(ARCHIVE)); loadTestFile(require.resolve('./alert_flyout')); + loadTestFile(require.resolve('./anomaly_alert')); }); }); }; diff --git a/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts b/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts index 64e8aa16955a54..74aaf48d156742 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/epm/list.ts @@ -29,7 +29,7 @@ export default function ({ getService }: FtrProviderContext) { return response.body; }; const listResponse = await fetchPackageList(); - expect(listResponse.response.length).to.be(12); + expect(listResponse.response.length).to.be(5); } else { warnAndSkipTest(this, log); }