diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b4563dd1f9a9c0..bcb47744758497 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -63,6 +63,13 @@ /src/plugins/apm_oss/ @elastic/apm-ui /src/apm.js @watson @vigneshshanmugam +# Client Side Monitoring (lives in APM directories but owned by Uptime) +/x-pack/plugins/apm/e2e/cypress/support/step_definitions/rum @elastic/uptime +/x-pack/plugins/apm/public/application/csmApp.tsx @elastic/uptime +/x-pack/plugins/apm/public/components/app/RumDashboard @elastic/uptime +/x-pack/plugins/apm/server/lib/rum_client @elastic/uptime +/x-pack/plugins/apm/server/routes/rum_client.ts @elastic/uptime + # Beats /x-pack/legacy/plugins/beats_management/ @elastic/beats diff --git a/docs/management/advanced-options.asciidoc b/docs/management/advanced-options.asciidoc index 9f13c152b4cbec..a64a0330ae43f4 100644 --- a/docs/management/advanced-options.asciidoc +++ b/docs/management/advanced-options.asciidoc @@ -150,6 +150,12 @@ working on big documents. ==== Machine learning [horizontal] +`ml:anomalyDetection:results:enableTimeDefaults`:: Use the default time filter +in the *Single Metric Viewer* and *Anomaly Explorer*. If this setting is +disabled, the results for the full time range are shown. +`ml:anomalyDetection:results:timeDefaults`:: Sets the default time filter for +viewing {anomaly-job} results. This setting must contain `from` and `to` values (see {ref}/common-options.html#date-math[accepted formats]). It is ignored +unless `ml:anomalyDetection:results:enableTimeDefaults` is enabled. `ml:fileDataVisualizerMaxFileSize`:: Sets the file size limit when importing data in the {data-viz}. The default value is `100MB`. The highest supported value for this setting is `1GB`. diff --git a/docs/settings/alert-action-settings.asciidoc b/docs/settings/alert-action-settings.asciidoc index 88858c36643ec8..13c1d20552fa15 100644 --- a/docs/settings/alert-action-settings.asciidoc +++ b/docs/settings/alert-action-settings.asciidoc @@ -37,10 +37,10 @@ You can configure the following settings in the `kibana.yml` file. [cols="2*<"] |=== -| `xpack.actions.whitelistedHosts` {ess-icon} - | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly whitelisted. An empty list `[]` can be used to block built-in actions from making any external connections. + +| `xpack.actions.allowedHosts` {ess-icon} + | A list of hostnames that {kib} is allowed to connect to when built-in actions are triggered. It defaults to `[*]`, allowing any host, but keep in mind the potential for SSRF attacks when hosts are not explicitly added to the allowed hosts. An empty list `[]` can be used to block built-in actions from making any external connections. + + - Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically whitelisted. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are whitelisted as well. + Note that hosts associated with built-in actions, such as Slack and PagerDuty, are not automatically added to allowed hosts. If you are not using the default `[*]` setting, you must ensure that the corresponding endpoints are added to the allowed hosts as well. | `xpack.actions.enabledActionTypes` {ess-icon} | A list of action types that are enabled. It defaults to `[*]`, enabling all types. The names for built-in {kib} action types are prefixed with a `.` and include: `.server-log`, `.slack`, `.email`, `.index`, `.pagerduty`, and `.webhook`. An empty list `[]` will disable all action types. + diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/user/alerting/action-types/email.asciidoc index f6a02b9038c02b..83e7edc5a016a6 100644 --- a/docs/user/alerting/action-types/email.asciidoc +++ b/docs/user/alerting/action-types/email.asciidoc @@ -12,7 +12,7 @@ Email connectors have the following configuration properties: Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. Sender:: The from address for all emails sent with this connector, specified in `user@host-name` format. -Host:: Host name of the service provider. If you are using the <> setting, make sure this hostname is whitelisted. +Host:: Host name of the service provider. If you are using the <> setting, make sure this hostname is added to the allowed hosts. Port:: The port to connect to on the service provider. Secure:: If true the connection will use TLS when connecting to the service provider. See https://nodemailer.com/smtp/#tls-options[nodemailer TLS documentation] for more information. Username:: username for 'login' type authentication. diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/user/alerting/action-types/pagerduty.asciidoc index 5fd85a10452655..2c9add5233c913 100644 --- a/docs/user/alerting/action-types/pagerduty.asciidoc +++ b/docs/user/alerting/action-types/pagerduty.asciidoc @@ -132,7 +132,7 @@ This is an irreversible action and impacts all alerts that use this connector. PagerDuty connectors have the following configuration properties: Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -API URL:: An optional PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue`. If you are using the <> setting, make sure the hostname is whitelisted. +API URL:: An optional PagerDuty event URL. Defaults to `https://events.pagerduty.com/v2/enqueue`. If you are using the <> setting, make sure the hostname is added to the allowed hosts. Integration Key:: A 32 character PagerDuty Integration Key for an integration on a service, also referred to as the routing key. [float] diff --git a/docs/user/alerting/action-types/slack.asciidoc b/docs/user/alerting/action-types/slack.asciidoc index 99bf73c0f5597e..a1fe7a2521b22a 100644 --- a/docs/user/alerting/action-types/slack.asciidoc +++ b/docs/user/alerting/action-types/slack.asciidoc @@ -11,7 +11,7 @@ The Slack action type uses https://api.slack.com/incoming-webhooks[Slack Incomin Slack connectors have the following configuration properties: Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messaging/webhooks#getting_started[Slack Incoming Webhooks] for instructions on generating this URL. If you are using the <> setting, make sure the hostname is whitelisted. +Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messaging/webhooks#getting_started[Slack Incoming Webhooks] for instructions on generating this URL. If you are using the <> setting, make sure the hostname is added to the allowed hosts. [float] [[Preconfigured-slack-configuration]] diff --git a/docs/user/alerting/action-types/webhook.asciidoc b/docs/user/alerting/action-types/webhook.asciidoc index c91c24430e982c..659c3afad6bd1c 100644 --- a/docs/user/alerting/action-types/webhook.asciidoc +++ b/docs/user/alerting/action-types/webhook.asciidoc @@ -11,7 +11,7 @@ The Webhook action type uses https://github.com/axios/axios[axios] to send a POS Webhook connectors have the following configuration properties: Name:: The name of the connector. The name is used to identify a connector in the management UI connector listing, or in the connector list when configuring an action. -URL:: The request URL. If you are using the <> setting, make sure the hostname is whitelisted. +URL:: The request URL. If you are using the <> setting, make sure the hostname is added to the allowed hosts. Method:: HTTP request method, either `post`(default) or `put`. Headers:: A set of key-value pairs sent as headers with the request User:: An optional username. If set, HTTP basic authentication is used. Currently only basic authentication is supported. diff --git a/docs/visualize/images/lens_drag_drop.gif b/docs/visualize/images/lens_drag_drop.gif index ca62115e7ea3a8..1f8580d462702c 100644 Binary files a/docs/visualize/images/lens_drag_drop.gif and b/docs/visualize/images/lens_drag_drop.gif differ diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index b033fe86cd1c70..48b81c27e8b8dc 100644 --- a/examples/embeddable_examples/public/book/book_embeddable.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable.tsx @@ -71,11 +71,7 @@ export class BookEmbeddable constructor( initialInput: BookEmbeddableInput, - private attributeService: AttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput - >, + private attributeService: AttributeService, { parent, }: { @@ -99,18 +95,21 @@ export class BookEmbeddable }); } - inputIsRefType = (input: BookEmbeddableInput): input is BookByReferenceInput => { + readonly inputIsRefType = (input: BookEmbeddableInput): input is BookByReferenceInput => { return this.attributeService.inputIsRefType(input); }; - getInputAsValueType = async (): Promise => { + readonly getInputAsValueType = async (): Promise => { const input = this.attributeService.getExplicitInputFromEmbeddable(this); return this.attributeService.getInputAsValueType(input); }; - getInputAsRefType = async (): Promise => { + readonly getInputAsRefType = async (): Promise => { const input = this.attributeService.getExplicitInputFromEmbeddable(this); - return this.attributeService.getInputAsRefType(input, { showSaveModal: true }); + return this.attributeService.getInputAsRefType(input, { + showSaveModal: true, + saveModalTitle: this.getTitle(), + }); }; public render(node: HTMLElement) { diff --git a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx index 4c144c3843c470..292261ee16c59e 100644 --- a/examples/embeddable_examples/public/book/book_embeddable_factory.tsx +++ b/examples/embeddable_examples/public/book/book_embeddable_factory.tsx @@ -31,8 +31,6 @@ import { BOOK_EMBEDDABLE, BookEmbeddableInput, BookEmbeddableOutput, - BookByValueInput, - BookByReferenceInput, } from './book_embeddable'; import { CreateEditBookComponent } from './create_edit_book_component'; import { OverlayStart } from '../../../../src/core/public'; @@ -66,11 +64,7 @@ export class BookEmbeddableFactoryDefinition getIconForSavedObject: () => 'pencil', }; - private attributeService?: AttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput - >; + private attributeService?: AttributeService; constructor(private getStartServices: () => Promise) {} @@ -126,9 +120,7 @@ export class BookEmbeddableFactoryDefinition private async getAttributeService() { if (!this.attributeService) { this.attributeService = await (await this.getStartServices()).getAttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput + BookSavedObjectAttributes >(this.type); } return this.attributeService!; diff --git a/examples/embeddable_examples/public/book/edit_book_action.tsx b/examples/embeddable_examples/public/book/edit_book_action.tsx index 5b14dc85b1fc71..3541ace1e5e7e6 100644 --- a/examples/embeddable_examples/public/book/edit_book_action.tsx +++ b/examples/embeddable_examples/public/book/edit_book_action.tsx @@ -57,13 +57,13 @@ export const createEditBookAction = (getStartServices: () => Promise { const { openModal, getAttributeService } = await getStartServices(); - const attributeService = getAttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput - >(BOOK_SAVED_OBJECT); + const attributeService = getAttributeService(BOOK_SAVED_OBJECT); const onSave = async (attributes: BookSavedObjectAttributes, useRefType: boolean) => { - const newInput = await attributeService.wrapAttributes(attributes, useRefType, embeddable); + const newInput = await attributeService.wrapAttributes( + attributes, + useRefType, + attributeService.getExplicitInputFromEmbeddable(embeddable) + ); if (!useRefType && (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId) { // Set the saved object ID to null so that update input will remove the existing savedObjectId... (newInput as BookByValueInput & { savedObjectId: unknown }).savedObjectId = null; diff --git a/packages/elastic-eslint-config-kibana/typescript.js b/packages/elastic-eslint-config-kibana/typescript.js index 18b11eb62beef6..d3e80b7448151f 100644 --- a/packages/elastic-eslint-config-kibana/typescript.js +++ b/packages/elastic-eslint-config-kibana/typescript.js @@ -223,7 +223,8 @@ module.exports = { 'no-undef-init': 'error', 'no-unsafe-finally': 'error', 'no-unsanitized/property': 'error', - 'no-unused-expressions': 'error', + 'no-unused-expressions': 'off', + '@typescript-eslint/no-unused-expressions': 'error', 'no-unused-labels': 'error', 'no-var': 'error', 'object-shorthand': 'error', diff --git a/src/core/tsconfig.json b/src/core/tsconfig.json deleted file mode 100644 index 1a9e6253bff705..00000000000000 --- a/src/core/tsconfig.json +++ /dev/null @@ -1,23 +0,0 @@ -// { -// "extends": "../../tsconfig.base.json", -// "compilerOptions": { -// // "composite": true, -// "outDir": "./target", -// "emitDeclarationOnly": true, -// "declaration": true, -// "declarationMap": true -// }, -// "include": [ -// "public", -// "server", -// "types", -// "test_helpers", -// "utils", -// "index.ts", -// "../../kibana.d.ts", -// "../../typings/**/*" -// ], -// "references": [ -// { "path": "../test_utils" } -// ] -// } diff --git a/src/dev/build/tasks/create_archives_task.ts b/src/dev/build/tasks/create_archives_task.ts index 0083881e9f7487..a05e383394ecf2 100644 --- a/src/dev/build/tasks/create_archives_task.ts +++ b/src/dev/build/tasks/create_archives_task.ts @@ -92,8 +92,8 @@ export const CreateArchives: Task = { }); metrics.push({ - group: `${build.isOss() ? 'oss ' : ''}distributable file count`, - id: 'total', + group: 'distributable file count', + id: build.isOss() ? 'oss' : 'default', value: fileCount, }); } diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index 0d20fdee07df51..212b54be9ae046 100644 --- a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx +++ b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx @@ -24,9 +24,18 @@ import { EuiCheckboxGroupIdToSelectedMap } from '@elastic/eui/src/components/for import React, { useState, ReactElement } from 'react'; import ReactDOM from 'react-dom'; import angular from 'angular'; +import deepEqual from 'fast-deep-equal'; import { Observable, pipe, Subscription, merge } from 'rxjs'; -import { filter, map, debounceTime, mapTo, startWith, switchMap } from 'rxjs/operators'; +import { + filter, + map, + debounceTime, + mapTo, + startWith, + switchMap, + distinctUntilChanged, +} from 'rxjs/operators'; import { History } from 'history'; import { SavedObjectSaveOpts } from 'src/plugins/saved_objects/public'; import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; @@ -279,6 +288,12 @@ export class DashboardAppController { const updateIndexPatternsOperator = pipe( filter((container: DashboardContainer) => !!container && !isErrorEmbeddable(container)), map(getDashboardIndexPatterns), + distinctUntilChanged((a, b) => + deepEqual( + a.map((ip) => ip.id), + b.map((ip) => ip.id) + ) + ), // using switchMap for previous task cancellation switchMap((panelIndexPatterns: IndexPattern[]) => { return new Observable((observer) => { @@ -405,17 +420,29 @@ export class DashboardAppController { ) : null; }; - outputSubscription = new Subscription(); - outputSubscription.add( - dashboardContainer - .getOutput$() - .pipe( - mapTo(dashboardContainer), - startWith(dashboardContainer), // to trigger initial index pattern update - updateIndexPatternsOperator + outputSubscription = merge( + // output of dashboard container itself + dashboardContainer.getOutput$(), + // plus output of dashboard container children, + // children may change, so make sure we subscribe/unsubscribe with switchMap + dashboardContainer.getOutput$().pipe( + map(() => dashboardContainer!.getChildIds()), + distinctUntilChanged(deepEqual), + switchMap((newChildIds: string[]) => + merge( + ...newChildIds.map((childId) => + dashboardContainer!.getChild(childId).getOutput$() + ) + ) ) - .subscribe() - ); + ) + ) + .pipe( + mapTo(dashboardContainer), + startWith(dashboardContainer), // to trigger initial index pattern update + updateIndexPatternsOperator + ) + .subscribe(); inputSubscription = dashboardContainer.getInput$().subscribe(() => { let dirty = false; diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts b/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts new file mode 100644 index 00000000000000..06f380ca3862b6 --- /dev/null +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.test.ts @@ -0,0 +1,193 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ATTRIBUTE_SERVICE_KEY } from './attribute_service'; +import { mockAttributeService } from './attribute_service_mock'; +import { coreMock } from '../../../../core/public/mocks'; + +interface TestAttributes { + title: string; + testAttr1?: string; + testAttr2?: { array: unknown[]; testAttr3: string }; +} + +interface TestByValueInput { + id: string; + [ATTRIBUTE_SERVICE_KEY]: TestAttributes; +} + +describe('attributeService', () => { + const defaultTestType = 'defaultTestType'; + let attributes: TestAttributes; + let byValueInput: TestByValueInput; + let byReferenceInput: { id: string; savedObjectId: string }; + + beforeEach(() => { + attributes = { + title: 'ultra title', + testAttr1: 'neat first attribute', + testAttr2: { array: [1, 2, 3], testAttr3: 'super attribute' }, + }; + byValueInput = { + id: '456', + attributes, + }; + byReferenceInput = { + id: '456', + savedObjectId: '123', + }; + }); + + describe('determining input type', () => { + const defaultAttributeService = mockAttributeService(defaultTestType); + const customAttributeService = mockAttributeService( + defaultTestType + ); + + it('can determine input type given default types', () => { + expect( + defaultAttributeService.inputIsRefType({ id: '456', savedObjectId: '123' }) + ).toBeTruthy(); + expect( + defaultAttributeService.inputIsRefType({ + id: '456', + attributes: { title: 'wow I am by value' }, + }) + ).toBeFalsy(); + }); + it('can determine input type given custom types', () => { + expect( + customAttributeService.inputIsRefType({ id: '456', savedObjectId: '123' }) + ).toBeTruthy(); + expect( + customAttributeService.inputIsRefType({ + id: '456', + [ATTRIBUTE_SERVICE_KEY]: { title: 'wow I am by value' }, + }) + ).toBeFalsy(); + }); + }); + + describe('unwrapping attributes', () => { + it('can unwrap all default attributes when given reference type input', async () => { + const core = coreMock.createStart(); + core.savedObjects.client.get = jest.fn().mockResolvedValueOnce({ + attributes, + }); + const attributeService = mockAttributeService( + defaultTestType, + undefined, + core + ); + expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual(attributes); + }); + + it('returns attributes when when given value type input', async () => { + const attributeService = mockAttributeService(defaultTestType); + expect(await attributeService.unwrapAttributes(byValueInput)).toEqual(attributes); + }); + + it('runs attributes through a custom unwrap method', async () => { + const core = coreMock.createStart(); + core.savedObjects.client.get = jest.fn().mockResolvedValueOnce({ + attributes, + }); + const attributeService = mockAttributeService( + defaultTestType, + { + customUnwrapMethod: (savedObject) => ({ + ...savedObject.attributes, + testAttr2: { array: [1, 2, 3, 4, 5], testAttr3: 'kibanana' }, + }), + }, + core + ); + expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual({ + ...attributes, + testAttr2: { array: [1, 2, 3, 4, 5], testAttr3: 'kibanana' }, + }); + }); + }); + + describe('wrapping attributes', () => { + it('returns given attributes when use ref type is false', async () => { + const attributeService = mockAttributeService(defaultTestType); + expect(await attributeService.wrapAttributes(attributes, false)).toEqual({ attributes }); + }); + + it('updates existing saved object with new attributes when given id', async () => { + const core = coreMock.createStart(); + const attributeService = mockAttributeService( + defaultTestType, + undefined, + core + ); + expect(await attributeService.wrapAttributes(attributes, true, byReferenceInput)).toEqual( + byReferenceInput + ); + expect(core.savedObjects.client.update).toHaveBeenCalledWith( + defaultTestType, + '123', + attributes + ); + }); + + it('creates new saved object with attributes when given no id', async () => { + const core = coreMock.createStart(); + core.savedObjects.client.create = jest.fn().mockResolvedValueOnce({ + id: '678', + }); + const attributeService = mockAttributeService( + defaultTestType, + undefined, + core + ); + expect(await attributeService.wrapAttributes(attributes, true)).toEqual({ + savedObjectId: '678', + }); + expect(core.savedObjects.client.create).toHaveBeenCalledWith(defaultTestType, attributes); + }); + + it('uses custom save method when given an id', async () => { + const customSaveMethod = jest.fn().mockReturnValue({ id: '123' }); + const attributeService = mockAttributeService(defaultTestType, { + customSaveMethod, + }); + expect(await attributeService.wrapAttributes(attributes, true, byReferenceInput)).toEqual( + byReferenceInput + ); + expect(customSaveMethod).toHaveBeenCalledWith( + defaultTestType, + attributes, + byReferenceInput.savedObjectId + ); + }); + + it('uses custom save method given no id', async () => { + const customSaveMethod = jest.fn().mockReturnValue({ id: '678' }); + const attributeService = mockAttributeService(defaultTestType, { + customSaveMethod, + }); + expect(await attributeService.wrapAttributes(attributes, true)).toEqual({ + savedObjectId: '678', + }); + expect(customSaveMethod).toHaveBeenCalledWith(defaultTestType, attributes, undefined); + }); + }); +}); diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx index fe5f6a0c8e2bd4..a36363d22d87da 100644 --- a/src/plugins/dashboard/public/attribute_service/attribute_service.tsx +++ b/src/plugins/dashboard/public/attribute_service/attribute_service.tsx @@ -19,11 +19,16 @@ import React from 'react'; import { i18n } from '@kbn/i18n'; +import { get } from 'lodash'; import { EmbeddableInput, SavedObjectEmbeddableInput, isSavedObjectEmbeddableInput, IEmbeddable, + Container, + EmbeddableStart, + EmbeddableFactory, + EmbeddableFactoryNotFoundError, } from '../embeddable_plugin'; import { SavedObjectsClientContract, @@ -34,17 +39,10 @@ import { } from '../../../../core/public'; import { SavedObjectSaveModal, - showSaveModal, OnSaveProps, SaveResult, checkForDuplicateTitle, } from '../../../saved_objects/public'; -import { - EmbeddableStart, - EmbeddableFactory, - EmbeddableFactoryNotFoundError, - Container, -} from '../../../embeddable/public'; /** * The attribute service is a shared, generic service that embeddables can use to provide the functionality @@ -52,26 +50,46 @@ import { * can also be used as a higher level wrapper to transform an embeddable input shape that references a saved object * into an embeddable input shape that contains that saved object's attributes by value. */ +export const ATTRIBUTE_SERVICE_KEY = 'attributes'; + +export interface AttributeServiceOptions { + customSaveMethod?: ( + type: string, + attributes: A, + savedObjectId?: string + ) => Promise<{ id: string }>; + customUnwrapMethod?: (savedObject: SimpleSavedObject) => A; +} + export class AttributeService< SavedObjectAttributes extends { title: string }, - ValType extends EmbeddableInput & { attributes: SavedObjectAttributes }, - RefType extends SavedObjectEmbeddableInput + ValType extends EmbeddableInput & { + [ATTRIBUTE_SERVICE_KEY]: SavedObjectAttributes; + } = EmbeddableInput & { [ATTRIBUTE_SERVICE_KEY]: SavedObjectAttributes }, + RefType extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput > { - private embeddableFactory: EmbeddableFactory; + private embeddableFactory?: EmbeddableFactory; constructor( private type: string, + private showSaveModal: ( + saveModal: React.ReactElement, + I18nContext: I18nStart['Context'] + ) => void, private savedObjectsClient: SavedObjectsClientContract, private overlays: OverlayStart, private i18nContext: I18nStart['Context'], private toasts: NotificationsStart['toasts'], - getEmbeddableFactory: EmbeddableStart['getEmbeddableFactory'] + getEmbeddableFactory?: EmbeddableStart['getEmbeddableFactory'], + private options?: AttributeServiceOptions ) { - const factory = getEmbeddableFactory(this.type); - if (!factory) { - throw new EmbeddableFactoryNotFoundError(this.type); + if (getEmbeddableFactory) { + const factory = getEmbeddableFactory(this.type); + if (!factory) { + throw new EmbeddableFactoryNotFoundError(this.type); + } + this.embeddableFactory = factory; } - this.embeddableFactory = factory; } public async unwrapAttributes(input: RefType | ValType): Promise { @@ -79,43 +97,54 @@ export class AttributeService< const savedObject: SimpleSavedObject = await this.savedObjectsClient.get< SavedObjectAttributes >(this.type, input.savedObjectId); - return savedObject.attributes; + return this.options?.customUnwrapMethod + ? this.options?.customUnwrapMethod(savedObject) + : { ...savedObject.attributes }; } - return input.attributes; + return input[ATTRIBUTE_SERVICE_KEY]; } public async wrapAttributes( newAttributes: SavedObjectAttributes, useRefType: boolean, - embeddable?: IEmbeddable + input?: ValType | RefType ): Promise> { + const originalInput = input ? input : {}; const savedObjectId = - embeddable && isSavedObjectEmbeddableInput(embeddable.getInput()) - ? (embeddable.getInput() as SavedObjectEmbeddableInput).savedObjectId + input && this.inputIsRefType(input) + ? (input as SavedObjectEmbeddableInput).savedObjectId : undefined; if (!useRefType) { - return { attributes: newAttributes } as ValType; - } else { - try { - if (savedObjectId) { - await this.savedObjectsClient.update(this.type, savedObjectId, newAttributes); - return { savedObjectId } as RefType; - } else { - const savedItem = await this.savedObjectsClient.create(this.type, newAttributes); - return { savedObjectId: savedItem.id } as RefType; - } - } catch (error) { - this.toasts.addDanger({ - title: i18n.translate('dashboard.attributeService.saveToLibraryError', { - defaultMessage: `Panel was not saved to the library. Error: {errorMessage}`, - values: { - errorMessage: error.message, - }, - }), - 'data-test-subj': 'saveDashboardFailure', - }); - return Promise.reject({ error }); + return { [ATTRIBUTE_SERVICE_KEY]: newAttributes } as ValType; + } + try { + if (this.options?.customSaveMethod) { + const savedItem = await this.options.customSaveMethod( + this.type, + newAttributes, + savedObjectId + ); + return { ...originalInput, savedObjectId: savedItem.id } as RefType; + } + + if (savedObjectId) { + await this.savedObjectsClient.update(this.type, savedObjectId, newAttributes); + return { ...originalInput, savedObjectId } as RefType; } + + const savedItem = await this.savedObjectsClient.create(this.type, newAttributes); + return { ...originalInput, savedObjectId: savedItem.id } as RefType; + } catch (error) { + this.toasts.addDanger({ + title: i18n.translate('dashboard.attributeService.saveToLibraryError', { + defaultMessage: `Panel was not saved to the library. Error: {errorMessage}`, + values: { + errorMessage: error.message, + }, + }), + 'data-test-subj': 'saveDashboardFailure', + }); + return Promise.reject({ error }); } } @@ -146,7 +175,7 @@ export class AttributeService< getInputAsRefType = async ( input: ValType | RefType, - saveOptions?: { showSaveModal: boolean } | { title: string } + saveOptions?: { showSaveModal: boolean; saveModalTitle?: string } | { title: string } ): Promise => { if (this.inputIsRefType(input)) { return input; @@ -159,7 +188,7 @@ export class AttributeService< copyOnSave: false, lastSavedTitle: '', getEsType: () => this.type, - getDisplayName: this.embeddableFactory.getDisplayName, + getDisplayName: this.embeddableFactory?.getDisplayName || (() => this.type), }, props.isTitleDuplicateConfirmed, props.onTitleDuplicate, @@ -169,7 +198,7 @@ export class AttributeService< } ); try { - const newAttributes = { ...input.attributes }; + const newAttributes = { ...input[ATTRIBUTE_SERVICE_KEY] }; newAttributes.title = props.newTitle; const wrappedInput = (await this.wrapAttributes(newAttributes, true)) as RefType; resolve(wrappedInput); @@ -181,11 +210,11 @@ export class AttributeService< }; if (saveOptions && (saveOptions as { showSaveModal: boolean }).showSaveModal) { - showSaveModal( + this.showSaveModal( reject()} - title={input.attributes.title} + title={get(saveOptions, 'saveModalTitle', input[ATTRIBUTE_SERVICE_KEY].title)} showCopyOnSave={false} objectType={this.type} showDescription={false} diff --git a/src/plugins/dashboard/public/attribute_service/attribute_service_mock.tsx b/src/plugins/dashboard/public/attribute_service/attribute_service_mock.tsx new file mode 100644 index 00000000000000..321a53361fc7aa --- /dev/null +++ b/src/plugins/dashboard/public/attribute_service/attribute_service_mock.tsx @@ -0,0 +1,49 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EmbeddableInput, SavedObjectEmbeddableInput } from '../embeddable_plugin'; +import { coreMock } from '../../../../core/public/mocks'; +import { AttributeServiceOptions } from './attribute_service'; +import { CoreStart } from '../../../../core/public'; +import { AttributeService, ATTRIBUTE_SERVICE_KEY } from '..'; + +export const mockAttributeService = < + A extends { title: string }, + V extends EmbeddableInput & { [ATTRIBUTE_SERVICE_KEY]: A } = EmbeddableInput & { + [ATTRIBUTE_SERVICE_KEY]: A; + }, + R extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput +>( + type: string, + options?: AttributeServiceOptions, + customCore?: jest.Mocked +): AttributeService => { + const core = customCore ? customCore : coreMock.createStart(); + const service = new AttributeService( + type, + jest.fn(), + core.savedObjects.client, + core.overlays, + core.i18n.Context, + core.notifications.toasts, + jest.fn().mockReturnValue(() => ({ getDisplayName: () => type })), + options + ); + return service; +}; diff --git a/src/plugins/dashboard/public/index.ts b/src/plugins/dashboard/public/index.ts index 8a9954cc77a2e5..e22d1f038a4560 100644 --- a/src/plugins/dashboard/public/index.ts +++ b/src/plugins/dashboard/public/index.ts @@ -40,7 +40,7 @@ export { export { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; export { SavedObjectDashboard } from './saved_dashboards'; export { SavedDashboardPanel } from './types'; -export { AttributeService } from './attribute_service/attribute_service'; +export { AttributeService, ATTRIBUTE_SERVICE_KEY } from './attribute_service/attribute_service'; export function plugin(initializerContext: PluginInitializerContext) { return new DashboardPlugin(initializerContext); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index 3df52f4e7a2054..0ce6f9489ea022 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -52,6 +52,7 @@ import { getSavedObjectFinder, SavedObjectLoader, SavedObjectsStart, + showSaveModal, } from '../../saved_objects/public'; import { ExitFullScreenButton as ExitFullScreenButtonUi, @@ -102,6 +103,10 @@ import { addEmbeddableToDashboardUrl } from './url_utils/url_helper'; import { PlaceholderEmbeddableFactory } from './application/embeddable/placeholder'; import { UrlGeneratorState } from '../../share/public'; import { AttributeService } from '.'; +import { + AttributeServiceOptions, + ATTRIBUTE_SERVICE_KEY, +} from './attribute_service/attribute_service'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -150,10 +155,13 @@ export interface DashboardStart { DashboardContainerByValueRenderer: ReturnType; getAttributeService: < A extends { title: string }, - V extends EmbeddableInput & { attributes: A }, - R extends SavedObjectEmbeddableInput + V extends EmbeddableInput & { [ATTRIBUTE_SERVICE_KEY]: A } = EmbeddableInput & { + [ATTRIBUTE_SERVICE_KEY]: A; + }, + R extends SavedObjectEmbeddableInput = SavedObjectEmbeddableInput >( - type: string + type: string, + options?: AttributeServiceOptions ) => AttributeService; } @@ -465,14 +473,16 @@ export class DashboardPlugin DashboardContainerByValueRenderer: createDashboardContainerByValueRenderer({ factory: dashboardContainerFactory, }), - getAttributeService: (type: string) => + getAttributeService: (type: string, options) => new AttributeService( type, + showSaveModal, core.savedObjects.client, core.overlays, core.i18n.Context, core.notifications.toasts, - embeddable.getEmbeddableFactory + embeddable.getEmbeddableFactory, + options ), }; } diff --git a/test/functional/apps/visualize/_tsvb_markdown.ts b/test/functional/apps/visualize/_tsvb_markdown.ts index ba60aa83d92daf..4f12a45cf5f6b3 100644 --- a/test/functional/apps/visualize/_tsvb_markdown.ts +++ b/test/functional/apps/visualize/_tsvb_markdown.ts @@ -30,10 +30,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await visualBuilder.markdownSwitchSubTab('markdown'); const rerenderedTable = await visualBuilder.getMarkdownTableVariables(); rerenderedTable.forEach((row) => { - // eslint-disable-next-line no-unused-expressions - variableName === 'label' - ? expect(row.key).to.include.string(checkedValue) - : expect(row.key).to.not.include.string(checkedValue); + if (variableName === 'label') { + expect(row.key).to.include.string(checkedValue); + } else { + expect(row.key).to.not.include.string(checkedValue); + } }); } @@ -107,10 +108,11 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { table.forEach((row, index) => { // exception: last index for variable is always: {{count.label}} - // eslint-disable-next-line no-unused-expressions - index === table.length - 1 - ? expect(row.key).to.not.include.string(VARIABLE) - : expect(row.key).to.include.string(VARIABLE); + if (index === table.length - 1) { + expect(row.key).to.not.include.string(VARIABLE); + } else { + expect(row.key).to.include.string(VARIABLE); + } }); await cleanupMarkdownData(VARIABLE, VARIABLE); diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx new file mode 100644 index 00000000000000..bc9df71c534ef5 --- /dev/null +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/TransactionList.stories.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { storiesOf } from '@storybook/react'; +import React from 'react'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { TransactionGroup } from '../../../../../server/lib/transaction_groups/fetcher'; +import { TransactionList } from './'; + +storiesOf('app/TransactionOverview/TransactionList', module).add( + 'Single Row', + () => { + const items: TransactionGroup[] = [ + { + name: + 'GET /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all', + sample: { + container: { + id: + 'xa802046074071c9c828e8db3b7ef92ea0484d9fe783b9c518f65a7b45dfdd2c', + }, + agent: { + name: 'java', + ephemeral_id: 'x787d6b7-3241-4b55-ba49-0c96bc9857d1', + version: '1.17.0', + }, + process: { + pid: 28, + title: '/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java', + }, + processor: { + name: 'transaction', + event: 'transaction', + }, + labels: { + path: + '/api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all', + status_code: '200', + request_method: 'GET', + request_id: 'x273dc2477e021979125e0ec67e8d778', + }, + observer: { + hostname: 'x840922c967b', + name: 'instance-000000000x', + id: 'xb384baf-c16a-415a-928a-a10635a04b81', + ephemeral_id: 'x9227f0e-848d-423e-a65a-5fdee321f4a9', + type: 'apm-server', + version: '7.8.1', + version_major: 7, + }, + trace: { + id: 'x998d7e5db84aa8341b358a264a78984', + }, + '@timestamp': '2020-08-26T14:40:31.472Z', + ecs: { + version: '1.5.0', + }, + service: { + node: { + name: + 'xa802046074071c9c828e8db3b7ef92ea0484d9fe783b9c518f65a7b45dfdd2c', + }, + environment: 'qa', + framework: { + name: 'API', + }, + name: 'adminconsole', + runtime: { + name: 'Java', + version: '1.8.0_265', + }, + language: { + name: 'Java', + version: '1.8.0_265', + }, + version: 'ms-44.1-BC_1', + }, + host: { + hostname: 'xa8020460740', + os: { + platform: 'Linux', + }, + ip: '3.83.239.24', + name: 'xa8020460740', + architecture: 'amd64', + }, + transaction: { + duration: { + us: 8260617, + }, + result: 'HTTP 2xx', + name: + 'GET /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all', + span_count: { + dropped: 0, + started: 8, + }, + id: 'xaa3cae6fd4f7023', + type: 'request', + sampled: true, + }, + timestamp: { + us: 1598452831472001, + }, + }, + p95: 11974156, + averageResponseTime: 8087434.558974359, + transactionsPerMinute: 0.40625, + impact: 100, + impactRelative: 100, + }, + ]; + + return ; + } +); diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/index.tsx similarity index 85% rename from x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx rename to x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/index.tsx index 2b1c1b8e8c11c8..d8c6d7d28fa9f0 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/List/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionList/index.tsx @@ -19,9 +19,16 @@ import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; import { EmptyMessage } from '../../../shared/EmptyMessage'; import { TransactionDetailLink } from '../../../shared/Links/apm/TransactionDetailLink'; +// Truncate both the link and the child span (the tooltip anchor.) The link so +// it doesn't overflow, and the anchor so we get the ellipsis. const TransactionNameLink = styled(TransactionDetailLink)` - ${truncate('100%')}; font-family: ${fontFamilyCode}; + white-space: nowrap; + ${truncate('100%')}; + + > span { + ${truncate('100%')}; + } `; interface Props { @@ -41,20 +48,20 @@ export function TransactionList({ items, isLoading }: Props) { sortable: true, render: (_, { sample }: TransactionGroup) => { return ( - - - {sample.transaction.name || NOT_AVAILABLE_LABEL} - - + <>{sample.transaction.name || NOT_AVAILABLE_LABEL} + + ); }, }, diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx index 81d8a6f8073753..5999988abe848f 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -33,7 +33,7 @@ import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { TransactionTypeFilter } from '../../shared/LocalUIFilters/TransactionTypeFilter'; -import { TransactionList } from './List'; +import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; function getRedirectLocation({ @@ -62,6 +62,7 @@ function getRedirectLocation({ export function TransactionOverview() { const location = useLocation(); const { urlParams } = useUrlParams(); + const { serviceName, transactionType } = urlParams; // TODO: fetching of transaction types should perhaps be lifted since it is needed in several places. Context? diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/ApmHeader.stories.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/ApmHeader.stories.tsx new file mode 100644 index 00000000000000..c9b7c774098404 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/ApmHeader/ApmHeader.stories.tsx @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiTitle } from '@elastic/eui'; +import { storiesOf } from '@storybook/react'; +import React from 'react'; +import { MockApmPluginContextWrapper } from '../../../context/ApmPluginContext/MockApmPluginContext'; +import { ApmHeader } from './'; + +storiesOf('shared/ApmHeader', module) + .addDecorator((storyFn) => { + return ( + {storyFn()} + ); + }) + .add('Example', () => { + return ( + + +

+ GET + /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all +

+
+
+ ); + }); diff --git a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx index 4ffd4228018163..9f67ba99103ec4 100644 --- a/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/ApmHeader/index.tsx @@ -6,15 +6,25 @@ import { EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; import React, { ReactNode } from 'react'; -import { KueryBar } from '../KueryBar'; +import styled from 'styled-components'; import { DatePicker } from '../DatePicker'; import { EnvironmentFilter } from '../EnvironmentFilter'; +import { KueryBar } from '../KueryBar'; + +// Header titles with long, unbroken words, like you would see for a long URL in +// a transaction name, with the default `work-break`, don't break, and that ends +// up pushing the date picker off of the screen. Setting `break-all` here lets +// it wrap even if it has a long, unbroken work. The wrapped result is not great +// looking, since it wraps, but it doesn't push any controls off of the screen. +const ChildrenContainerFlexItem = styled(EuiFlexItem)` + word-break: break-all; +`; export function ApmHeader({ children }: { children: ReactNode }) { return ( <> - {children} + {children} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx b/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx index da9adbb8dfeade..08165418653621 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionDurationAlertTrigger/index.stories.tsx @@ -19,7 +19,7 @@ import { MockUrlParamsContextProvider } from '../../../context/UrlParamsContext/ // our current storybook setup has core-js-related problems when trying to import // it. // storiesOf('app/TransactionDurationAlertTrigger', module).add('example', -// eslint-disable-next-line no-unused-expressions +// eslint-disable-next-line @typescript-eslint/no-unused-expressions () => { const params = { threshold: 1500, diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts index d0ba31f42c536b..5c1e1839d9c535 100644 --- a/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts +++ b/x-pack/plugins/apm/server/lib/transaction_groups/fetcher.ts @@ -185,10 +185,12 @@ export async function transactionGroupsFetcher( } export interface TransactionGroup { - key: Record | string; + name?: string; + key?: Record | string; averageResponseTime: number | null | undefined; transactionsPerMinute: number; p95: number | null | undefined; impact: number; + impactRelative?: number; sample: Transaction; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/host.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/host.ts index 51c09e59d9b686..1869a4fc1bef5c 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/host.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/host.ts @@ -5,5 +5,11 @@ */ export interface Host { + architecture?: string; hostname?: string; + name?: string; + ip?: string; + os?: { + platform?: string; + }; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts index 0815b7cd88163d..823d12cbd80950 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/observer.ts @@ -5,7 +5,11 @@ */ export interface Observer { + ephemeral_id?: string; + hostname?: string; + id?: string; name?: string; + type?: string; version: string; version_major: number; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/process.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/process.ts index 63e1faa3821634..898ef04ed6a008 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/process.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/process.ts @@ -5,8 +5,8 @@ */ export interface Process { - args: string[]; + args?: string[]; pid: number; - ppid: number; - title: string; + ppid?: number; + title?: string; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts b/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts index 3ef852ebf6dd69..00795d69e13b64 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/fields/service.ts @@ -9,7 +9,10 @@ export interface Service { environment?: string; framework?: { name: string; - version: string; + version?: string; + }; + node?: { + name?: string; }; runtime?: { name: string; @@ -19,4 +22,5 @@ export interface Service { name: string; version?: string; }; + version?: string; } diff --git a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts index b8ebb4cf8da51d..cdfe4183c96f57 100644 --- a/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts +++ b/x-pack/plugins/apm/typings/es_schemas/raw/transaction_raw.ts @@ -54,6 +54,7 @@ export interface TransactionRaw extends APMBaseDoc { // Shared by errors and transactions container?: Container; + ecs?: { version?: string }; host?: Host; http?: Http; kubernetes?: Kubernetes; diff --git a/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts b/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts index 1e542dec06a724..4d98825f36b507 100644 --- a/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts +++ b/x-pack/plugins/apm/typings/es_schemas/ui/fields/agent.ts @@ -19,6 +19,7 @@ export type AgentName = | 'ruby'; export interface Agent { + ephemeral_id?: string; name: AgentName; version: string; } diff --git a/x-pack/plugins/index_management/server/lib/fetch_indices.ts b/x-pack/plugins/index_management/server/lib/fetch_indices.ts index 6fc983dde6afd9..e9eaec3e22428c 100644 --- a/x-pack/plugins/index_management/server/lib/fetch_indices.ts +++ b/x-pack/plugins/index_management/server/lib/fetch_indices.ts @@ -45,7 +45,9 @@ async function fetchIndicesCall( // This call retrieves alias and settings (incl. hidden status) information about indices const indices: GetIndicesResponse = await callAsCurrentUser('transport.request', { method: 'GET', - path: `/${indexNamesString}`, + // transport.request doesn't do any URI encoding, unlike other JS client APIs. This enables + // working with Logstash indices with names like %{[@metadata][beat]}-%{[@metadata][version]}. + path: `/${encodeURIComponent(indexNamesString)}`, query: { expand_wildcards: 'hidden,all', }, diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx index 5077bccdc1ca2d..c1d4fc8b8d3c87 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_row.tsx @@ -85,7 +85,7 @@ export const LogEntryRow = memo( ]); const handleOpenViewLogInContext = useCallback(() => { - openViewLogInContext?.(logEntry); // eslint-disable-line no-unused-expressions + openViewLogInContext?.(logEntry); trackMetric({ metric: 'view_in_context__stream' }); }, [openViewLogInContext, logEntry, trackMetric]); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx index 028dd0d3a1a7bf..740fc8b7bafcdb 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_categories/page_results_content.tsx @@ -73,7 +73,6 @@ export const LogEntryCategoriesResultsContent: React.FunctionComponent { - // eslint-disable-next-line no-unused-expressions services.notifications?.toasts.addError(error, { title: loadDataErrorTitle, }); diff --git a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx index de72ac5c5a574e..b33eaf7e77bc3b 100644 --- a/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx +++ b/x-pack/plugins/infra/public/pages/logs/log_entry_rate/page_results_content.tsx @@ -103,7 +103,6 @@ export const LogEntryRateResultsContent: React.FunctionComponent = () => { }), }; - // eslint-disable-next-line no-unused-expressions navigateToApp?.('logs', { path: `/stream?${stringify(params)}` }); }, [queryTimeRange, navigateToApp] diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx index 23425297f34208..52750529684b0c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/append.tsx @@ -28,13 +28,13 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Value', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.appendForm.valueFieldHelpText', { - defaultMessage: 'The value to be appended by this processor.', + defaultMessage: 'Values to append.', }), validations: [ { validator: emptyField( i18n.translate('xpack.ingestPipelines.pipelineEditor.appendForm.valueRequiredError', { - defaultMessage: 'A value to set is required.', + defaultMessage: 'A value is required.', }) ), }, @@ -47,7 +47,7 @@ export const Append: FunctionComponent = () => { <> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/bytes.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/bytes.tsx index a76e1a6f3ce9a4..6633f9e5de94bc 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/bytes.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/bytes.tsx @@ -17,7 +17,10 @@ export const Bytes: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/circle.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/circle.tsx index 599d2fdbfd413d..70df18acfd0a9f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/circle.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/circle.tsx @@ -5,7 +5,9 @@ */ import React, { FunctionComponent } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; +import { EuiCode } from '@elastic/eui'; import { FIELD_TYPES, @@ -34,12 +36,15 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Error distance', } ), - helpText: i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.circleForm.errorDistanceHelpText', - { - defaultMessage: - 'The difference between the resulting inscribed distance from center to side and the circle’s radius (measured in meters for geo_shape, unit-less for shape).', - } + helpText: () => ( + {'geo_shape'}, + shape: {'shape'}, + }} + /> ), validations: [ { @@ -66,7 +71,7 @@ const fieldsConfig: FieldsConfig = { }), helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.circleForm.shapeTypeFieldHelpText', - { defaultMessage: 'Which field mapping type is to be used.' } + { defaultMessage: 'Field mapping type to use when processing the output polygon.' } ), validations: [ { @@ -86,7 +91,7 @@ export const Circle: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/common_processor_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/common_processor_fields.tsx index e1048d627f66cd..1777cac2a5615c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/common_processor_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/common_processor_fields.tsx @@ -40,7 +40,7 @@ const ifConfig: FieldConfig = { defaultMessage: 'Condition (optional)', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.ifFieldHelpText', { - defaultMessage: 'Conditionally execute this processor.', + defaultMessage: 'Conditionally run this processor.', }), type: FIELD_TYPES.TEXT, }; @@ -50,7 +50,7 @@ const tagConfig: FieldConfig = { defaultMessage: 'Tag (optional)', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.commonFields.tagFieldHelpText', { - defaultMessage: 'An identifier for this processor. Useful for debugging and metrics.', + defaultMessage: 'Identifier for the processor. Useful for debugging and metrics.', }), type: FIELD_TYPES.TEXT, }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/convert.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/convert.tsx index 2bf642dd9b5187..7284bd6c627519 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/convert.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/convert.tsx @@ -30,7 +30,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Type', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.convertForm.typeFieldHelpText', { - defaultMessage: 'The type to convert the existing value to.', + defaultMessage: 'Field data type for the output.', }), validations: [ { @@ -50,7 +50,7 @@ export const Convert: FunctionComponent = () => { @@ -115,14 +115,7 @@ export const Convert: FunctionComponent = () => { path="fields.type" /> - + diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/csv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/csv.tsx index 835177dd861d56..471efaa56dea0f 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/csv.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/csv.tsx @@ -36,7 +36,7 @@ const isStringLengthOne: ValidationFunc = ({ value }) => { message: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.convertForm.separatorLengthError', { - defaultMessage: 'A separator value must be 1 character.', + defaultMessage: 'Must be a single character.', } ), } @@ -52,7 +52,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Target fields', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.csvForm.targetFieldsHelpText', { - defaultMessage: 'The array of fields to assign extracted values to.', + defaultMessage: 'Output fields. Extracted values are mapped to these fields.', }), validations: [ { @@ -83,7 +83,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {','} }} /> ), @@ -102,7 +102,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'"'} }} /> ), @@ -115,7 +115,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Trim', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.csvForm.trimFieldHelpText', { - defaultMessage: 'Trim whitespaces in unquoted fields', + defaultMessage: 'Remove whitespaces in unquoted CSV data.', }), }, empty_value: { @@ -127,7 +127,7 @@ const fieldsConfig: FieldsConfig = { 'xpack.ingestPipelines.pipelineEditor.convertForm.emptyValueFieldHelpText', { defaultMessage: - 'Value used to fill empty fields, empty fields will be skipped if this is not provided.', + 'Used to fill empty fields. If no value is provided, empty fields are skipped.', } ), }, @@ -138,7 +138,7 @@ export const CSV: FunctionComponent = () => { <> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date.tsx index 7e3f8e0d7cd701..8d6d88d2b06623 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date.tsx @@ -33,7 +33,7 @@ const fieldsConfig: FieldsConfig = { }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.dateForm.formatsFieldHelpText', { defaultMessage: - 'An array of the expected date formats. Can be a java time pattern or one of the following formats: ISO8601, UNIX, UNIX_MS, or TAI64N.', + 'Expected date formats. Provided formats are applied sequentially. Accepts a Java time pattern, ISO8601, UNIX, UNIX_MS, or TAI64N formats.', }), validations: [ { @@ -59,7 +59,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'UTC'} }} /> ), @@ -73,7 +73,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'ENGLISH'} }} /> ), @@ -89,7 +89,7 @@ export const DateProcessor: FunctionComponent = () => { @@ -99,7 +99,7 @@ export const DateProcessor: FunctionComponent = () => { helpText={ {'@timestamp'}, }} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx index 8cbc064c1c90ce..73fa54429734f0 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/date_index_name.tsx @@ -36,7 +36,8 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.dateRoundingFieldHelpText', { - defaultMessage: 'How to round the date when formatting the date into the index name.', + defaultMessage: + 'Time period used to round the date when formatting the date into the index name.', } ), validations: [ @@ -64,7 +65,7 @@ const fieldsConfig: FieldsConfig = { ), helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.indexNamePrefixFieldHelpText', - { defaultMessage: 'A prefix of the index name to be prepended before the printed date.' } + { defaultMessage: 'Prefix to add before the printed date in the index name.' } ), }, index_name_format: { @@ -79,7 +80,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'yyyy-MM-dd'} }} /> ), @@ -99,7 +100,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {"yyyy-MM-dd'T'HH:mm:ss.SSSXX"} }} /> ), @@ -116,7 +117,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'UTC'} }} /> ), @@ -133,7 +134,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( {'ENGLISH'} }} /> ), @@ -149,7 +150,7 @@ export const DateIndexName: FunctionComponent = () => { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dissect.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dissect.tsx index 344855304e4fd7..51bc54c5b372c6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dissect.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dissect.tsx @@ -5,7 +5,7 @@ */ import React, { FunctionComponent } from 'react'; -import { EuiCode } from '@elastic/eui'; +import { EuiCode, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { TextEditor } from '../field_components'; @@ -16,6 +16,7 @@ import { fieldValidators, UseField, Field, + useKibana, } from '../../../../../../shared_imports'; import { FieldNameField } from './common_fields/field_name_field'; @@ -24,55 +25,79 @@ import { EDITOR_PX_HEIGHT } from './shared'; const { emptyField } = fieldValidators; -const fieldsConfig: Record = { - /* Required field config */ - pattern: { - type: FIELD_TYPES.TEXT, - label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldLabel', { - defaultMessage: 'Pattern', - }), - helpText: i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldHelpText', - { - defaultMessage: 'The pattern to apply to the field.', - } - ), - validations: [ - { - validator: emptyField( - i18n.translate('xpack.ingestPipelines.pipelineEditor.dissectForm.patternRequiredError', { - defaultMessage: 'A pattern value is required.', - }) - ), - }, - ], - }, - /* Optional field config */ - append_separator: { - type: FIELD_TYPES.TEXT, - label: i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.dissectForm.appendSeparatorparaotrFieldLabel', - { - defaultMessage: 'Append separator (optional)', - } - ), - helpText: ( - {'""'} }} - /> - ), - }, +const getFieldsConfig = (esDocUrl: string): Record => { + return { + /* Required field config */ + pattern: { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldLabel', { + defaultMessage: 'Pattern', + }), + helpText: ( + + {i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldHelpText.dissectProcessorLink', + { + defaultMessage: 'key modifier', + } + )} + + ), + }} + /> + ), + validations: [ + { + validator: emptyField( + i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.dissectForm.patternRequiredError', + { + defaultMessage: 'A pattern value is required.', + } + ) + ), + }, + ], + }, + /* Optional field config */ + append_separator: { + type: FIELD_TYPES.TEXT, + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.dissectForm.appendSeparatorparaotrFieldLabel', + { + defaultMessage: 'Append separator (optional)', + } + ), + helpText: ( + {'""'} }} + /> + ), + }, + }; }; export const Dissect: FunctionComponent = () => { + const { services } = useKibana(); + const fieldsConfig = getFieldsConfig(services.documentation.getEsDocsBasePath()); + return ( <> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dot_expander.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dot_expander.tsx index 4e50c61ac930c3..4f2aa2915fdeca 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dot_expander.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/dot_expander.tsx @@ -18,7 +18,8 @@ const fieldsConfig: Record = { defaultMessage: 'Path', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.dotExpanderForm.pathHelpText', { - defaultMessage: 'Only required if the field to expand is part another object field.', + defaultMessage: + 'Output field. Only required if the field to expand is part another object field.', }), }, }; @@ -29,7 +30,7 @@ export const DotExpander: FunctionComponent = () => { ( + {'my-index-yyyy-MM-dd'} }} + /> + ), }, dissect: { FieldsComponent: Dissect, @@ -112,6 +140,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.dissect', { defaultMessage: 'Dissect', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.dissect', { + defaultMessage: 'Uses dissect patterns to extract matches from a field.', + }), }, dot_expander: { FieldsComponent: DotExpander, @@ -119,6 +150,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.dotExpander', { defaultMessage: 'Dot expander', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.dotExpander', { + defaultMessage: + 'Expands a field containing dot notation into an object field. The object field is then accessible by other processors in the pipeline.', + }), }, drop: { FieldsComponent: Drop, @@ -126,6 +161,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.drop', { defaultMessage: 'Drop', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.drop', { + defaultMessage: + 'Drops documents without returning an error. Used to only index documents that meet specified conditions.', + }), }, enrich: { FieldsComponent: Enrich, diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 194f12cf9291b6..0db456e0760ec3 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -205,10 +205,10 @@ describe('Datatable Visualization', () => { }, frame, }).groups - ).toHaveLength(1); + ).toHaveLength(2); }); - it('allows all kinds of operations', () => { + it('allows only bucket operations one category', () => { const datasource = createMockDatasource('test'); const frame = mockFrame(); frame.datasourceLayers = { first: datasource.publicAPIMock }; @@ -232,6 +232,40 @@ describe('Datatable Visualization', () => { expect(filterOperations({ ...baseOperation, dataType: 'boolean' })).toEqual(true); expect(filterOperations({ ...baseOperation, dataType: 'other' as DataType })).toEqual(true); expect(filterOperations({ ...baseOperation, dataType: 'date', isBucketed: false })).toEqual( + false + ); + expect(filterOperations({ ...baseOperation, dataType: 'number', isBucketed: false })).toEqual( + false + ); + }); + + it('allows only metric operations in one category', () => { + const datasource = createMockDatasource('test'); + const frame = mockFrame(); + frame.datasourceLayers = { first: datasource.publicAPIMock }; + + const filterOperations = datatableVisualization.getConfiguration({ + layerId: 'first', + state: { + layers: [{ layerId: 'first', columns: [] }], + }, + frame, + }).groups[1].filterOperations; + + const baseOperation: Operation = { + dataType: 'string', + isBucketed: true, + label: '', + }; + expect(filterOperations({ ...baseOperation })).toEqual(false); + expect(filterOperations({ ...baseOperation, dataType: 'number' })).toEqual(false); + expect(filterOperations({ ...baseOperation, dataType: 'date' })).toEqual(false); + expect(filterOperations({ ...baseOperation, dataType: 'boolean' })).toEqual(false); + expect(filterOperations({ ...baseOperation, dataType: 'other' as DataType })).toEqual(false); + expect(filterOperations({ ...baseOperation, dataType: 'date', isBucketed: false })).toEqual( + true + ); + expect(filterOperations({ ...baseOperation, dataType: 'number', isBucketed: false })).toEqual( true ); }); @@ -248,7 +282,7 @@ describe('Datatable Visualization', () => { layerId: 'a', state: { layers: [layer] }, frame, - }).groups[0].accessors + }).groups[1].accessors ).toEqual(['c', 'b']); }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 5aff4e14b17f2b..836ffcb15cfa10 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -143,15 +143,29 @@ export const datatableVisualization: Visualization groups: [ { groupId: 'columns', - groupLabel: i18n.translate('xpack.lens.datatable.columns', { - defaultMessage: 'Columns', + groupLabel: i18n.translate('xpack.lens.datatable.breakdown', { + defaultMessage: 'Break down by', }), layerId: state.layers[0].layerId, - accessors: sortedColumns, + accessors: sortedColumns.filter((c) => datasource.getOperationForColumnId(c)?.isBucketed), supportsMoreColumns: true, - filterOperations: () => true, + filterOperations: (op) => op.isBucketed, dataTestSubj: 'lnsDatatable_column', }, + { + groupId: 'metrics', + groupLabel: i18n.translate('xpack.lens.datatable.metrics', { + defaultMessage: 'Metrics', + }), + layerId: state.layers[0].layerId, + accessors: sortedColumns.filter( + (c) => !datasource.getOperationForColumnId(c)?.isBucketed + ), + supportsMoreColumns: true, + filterOperations: (op) => !op.isBucketed, + required: true, + dataTestSubj: 'lnsDatatable_metrics', + }, ], }; }, diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index d18a2db614f550..3581151dd5f768 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -9,6 +9,20 @@ exports[`DragDrop droppable is reflected in the className 1`] = ` `; +exports[`DragDrop items that have droppable=false get special styling when another item is dragged 1`] = ` +
+ Hello! +
+`; + exports[`DragDrop renders if nothing is being dragged 1`] = `
{ const value = {}; const component = mount( - + Hello! @@ -127,4 +127,63 @@ describe('DragDrop', () => { expect(component).toMatchSnapshot(); }); + + test('items that have droppable=false get special styling when another item is dragged', () => { + const component = mount( + {}}> + + Ignored + + {}} droppable={false}> + Hello! + + + ); + + expect(component.find('[data-test-subj="lnsDragDrop"]').at(1)).toMatchSnapshot(); + }); + + test('additional styles are reflected in the className until drop', () => { + let dragging: string | undefined; + const getAdditionalClasses = jest.fn().mockReturnValue('additional'); + const component = mount( + { + dragging = 'hello'; + }} + > + + Ignored + + {}} + droppable + getAdditionalClassesOnEnter={getAdditionalClasses} + > + Hello! + + + ); + + const dataTransfer = { + setData: jest.fn(), + getData: jest.fn(), + }; + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + jest.runAllTimers(); + + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); + expect(component.find('.additional')).toHaveLength(1); + + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); + expect(component.find('.additional')).toHaveLength(0); + + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop'); + expect(component.find('.additional')).toHaveLength(0); + }); }); diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 5a0fc3b3839f72..85bdd24bd4f804 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -49,6 +49,11 @@ interface BaseProps { */ droppable?: boolean; + /** + * Additional class names to apply when another element is over the drop target + */ + getAdditionalClassesOnEnter?: () => string; + /** * The optional test subject associated with this DOM element. */ @@ -97,6 +102,12 @@ export const DragDrop = (props: Props) => { {...props} dragging={droppable ? dragging : undefined} isDragging={!!(draggable && value === dragging)} + isNotDroppable={ + // If the configuration has provided a droppable flag, but this particular item is not + // droppable, then it should be less prominent. Ignores items that are both + // draggable and drop targets + droppable === false && Boolean(dragging) && value !== dragging + } setDragging={setDragging} /> ); @@ -107,9 +118,13 @@ const DragDropInner = React.memo(function DragDropInner( dragging: unknown; setDragging: (dragging: unknown) => void; isDragging: boolean; + isNotDroppable: boolean; } ) { - const [state, setState] = useState({ isActive: false }); + const [state, setState] = useState({ + isActive: false, + dragEnterClassNames: '', + }); const { className, onDrop, @@ -120,13 +135,20 @@ const DragDropInner = React.memo(function DragDropInner( dragging, setDragging, isDragging, + isNotDroppable, } = props; - const classes = classNames('lnsDragDrop', className, { - 'lnsDragDrop-isDropTarget': droppable, - 'lnsDragDrop-isActiveDropTarget': droppable && state.isActive, - 'lnsDragDrop-isDragging': isDragging, - }); + const classes = classNames( + 'lnsDragDrop', + className, + { + 'lnsDragDrop-isDropTarget': droppable, + 'lnsDragDrop-isActiveDropTarget': droppable && state.isActive, + 'lnsDragDrop-isDragging': isDragging, + 'lnsDragDrop-isNotDroppable': isNotDroppable, + }, + state.dragEnterClassNames + ); const dragStart = (e: DroppableEvent) => { // Setting stopPropgagation causes Chrome failures, so @@ -159,19 +181,25 @@ const DragDropInner = React.memo(function DragDropInner( // An optimization to prevent a bunch of React churn. if (!state.isActive) { - setState({ ...state, isActive: true }); + setState({ + ...state, + isActive: true, + dragEnterClassNames: props.getAdditionalClassesOnEnter + ? props.getAdditionalClassesOnEnter() + : '', + }); } }; const dragLeave = () => { - setState({ ...state, isActive: false }); + setState({ ...state, isActive: false, dragEnterClassNames: '' }); }; const drop = (e: DroppableEvent) => { e.preventDefault(); e.stopPropagation(); - setState({ ...state, isActive: false }); + setState({ ...state, isActive: false, dragEnterClassNames: '' }); setDragging(undefined); if (onDrop && droppable) { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss index 4e13fd95d19618..62bc6d7ed7cc8a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss @@ -27,6 +27,14 @@ overflow: hidden; } +.lnsLayerPanel__dimension-isHidden { + opacity: 0; +} + +.lnsLayerPanel__dimension-isReplacing { + text-decoration: line-through; +} + .lnsLayerPanel__triggerLink { padding: $euiSizeS; width: 100%; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index b3ad03b71770c2..85dbee6de524f8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -12,6 +12,7 @@ import { createMockDatasource, DatasourceMock, } from '../../mocks'; +import { ChildDragDropProvider } from '../../../drag_drop'; import { EuiFormRow, EuiPopover } from '@elastic/eui'; import { mount } from 'enzyme'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; @@ -272,6 +273,7 @@ describe('LayerPanel', () => { expect(component.find(EuiPopover).prop('isOpen')).toBe(true); }); + it('should close the popover when the active visualization changes', () => { /** * The ID generation system for new dimensions has been messy before, so @@ -324,4 +326,151 @@ describe('LayerPanel', () => { expect(component.find(EuiPopover).prop('isOpen')).toBe(false); }); }); + + // This test is more like an integration test, since the layer panel owns all + // the coordination between drag and drop + describe('drag and drop behavior', () => { + it('should determine if the datasource supports dropping of a field onto empty dimension', () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + mockDatasource.canHandleDrop.mockReturnValue(true); + + const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a' }; + + const component = mountWithIntl( + + + + ); + + expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith( + expect.objectContaining({ + dragDropContext: expect.objectContaining({ + dragging: draggingField, + }), + }) + ); + + component.find('DragDrop[data-test-subj="lnsGroup"]').first().simulate('drop'); + + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + dragDropContext: expect.objectContaining({ + dragging: draggingField, + }), + }) + ); + }); + + it('should allow drag to move between groups', () => { + (generateId as jest.Mock).mockReturnValue(`newid`); + + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: ['a'], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroupA', + }, + { + groupLabel: 'B', + groupId: 'b', + accessors: ['b'], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroupB', + }, + ], + }); + + mockDatasource.canHandleDrop.mockReturnValue(true); + + const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a' }; + + const component = mountWithIntl( + + + + ); + + expect(mockDatasource.canHandleDrop).toHaveBeenCalledTimes(2); + expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith( + expect.objectContaining({ + dragDropContext: expect.objectContaining({ + dragging: draggingOperation, + }), + }) + ); + + // Simulate drop on the pre-populated dimension + component.find('DragDrop[data-test-subj="lnsGroupB"]').at(0).simulate('drop'); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + columnId: 'b', + dragDropContext: expect.objectContaining({ + dragging: draggingOperation, + }), + }) + ); + + // Simulate drop on the empty dimension + component.find('DragDrop[data-test-subj="lnsGroupB"]').at(1).simulate('drop'); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + columnId: 'newid', + dragDropContext: expect.objectContaining({ + dragging: draggingOperation, + }), + }) + ); + }); + + it('should prevent dropping in the same group', () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: ['a', 'b'], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a' }; + + const component = mountWithIntl( + + + + ); + + expect(mockDatasource.canHandleDrop).not.toHaveBeenCalled(); + + component.find('DragDrop[data-test-subj="lnsGroup"]').at(0).simulate('drop'); + expect(mockDatasource.onDrop).not.toHaveBeenCalled(); + + component.find('DragDrop[data-test-subj="lnsGroup"]').at(1).simulate('drop'); + expect(mockDatasource.onDrop).not.toHaveBeenCalled(); + + component.find('DragDrop[data-test-subj="lnsGroup"]').at(2).simulate('drop'); + expect(mockDatasource.onDrop).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index b2804cfddba58c..b45dd13bfa4fd6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -17,8 +17,9 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import classNames from 'classnames'; import { NativeRenderer } from '../../../native_renderer'; -import { StateSetter } from '../../../types'; +import { StateSetter, isDraggedOperation } from '../../../types'; import { DragContext, DragDrop, ChildDragDropProvider } from '../../../drag_drop'; import { LayerSettings } from './layer_settings'; import { trackUiEvent } from '../../../lens_ui_telemetry'; @@ -154,6 +155,7 @@ export function LayerPanel( {groups.map((group, index) => { const newId = generateId(); const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; + return ( { + // If we are dragging another column, add an indication that the behavior will be a replacement' + if ( + isDraggedOperation(dragDropContext.dragging) && + group.groupId !== dragDropContext.dragging.groupId + ) { + return 'lnsLayerPanel__dimension-isReplacing'; + } + return ''; + }} data-test-subj={group.dataTestSubj} + draggable={true} + value={{ columnId: accessor, groupId: group.groupId, layerId }} + label={group.groupLabel} droppable={ - dragDropContext.dragging && + Boolean(dragDropContext.dragging) && + // Verify that the dragged item is not coming from the same group + // since this would be a reorder + (!isDraggedOperation(dragDropContext.dragging) || + dragDropContext.dragging.groupId !== group.groupId) && layerDatasource.canHandleDrop({ ...layerDatasourceDropProps, columnId: accessor, @@ -226,12 +250,22 @@ export function LayerPanel( }) } onDrop={(droppedItem) => { - layerDatasource.onDrop({ + const dropResult = layerDatasource.onDrop({ ...layerDatasourceDropProps, droppedItem, columnId: accessor, filterOperations: group.filterOperations, }); + if (typeof dropResult === 'object') { + // When a column is moved, we delete the reference to the old + props.updateVisualization( + activeVisualization.removeDimension({ + layerId, + columnId: dropResult.deleted, + prevState: props.visualizationState, + }) + ); + } }} > { - const dropSuccess = layerDatasource.onDrop({ + const dropResult = layerDatasource.onDrop({ ...layerDatasourceDropProps, droppedItem, columnId: newId, filterOperations: group.filterOperations, }); - if (dropSuccess) { + if (dropResult) { props.updateVisualization( activeVisualization.setDimension({ layerId, @@ -338,6 +376,17 @@ export function LayerPanel( prevState: props.visualizationState, }) ); + + if (typeof dropResult === 'object') { + // When a column is moved, we delete the reference to the old + props.updateVisualization( + activeVisualization.removeDimension({ + layerId, + columnId: dropResult.deleted, + prevState: props.visualizationState, + }) + ); + } } }} > diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 3ee109376d975b..f184d5628ab1c1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -1378,6 +1378,66 @@ describe('IndexPatternDimensionEditorPanel', () => { ).toBe(false); }); + it('is droppable if the dragged column is compatible', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }, + }, + state: dragDropState(), + columnId: 'col2', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }) + ).toBe(true); + }); + + it('is not droppable if the dragged column is the same as the current column', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }, + }, + state: dragDropState(), + columnId: 'col1', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is not droppable if the dragged column is incompatible', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }, + }, + state: dragDropState(), + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(false); + }); + it('appends the dropped column when a field is dropped', () => { const dragging = { field: { type: 'number', name: 'bar', aggregatable: true }, @@ -1526,5 +1586,109 @@ describe('IndexPatternDimensionEditorPanel', () => { }, }); }); + + it('updates the column id when moving an operation to an empty dimension', () => { + const dragging = { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }; + const testState = dragDropState(); + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col2'], + columns: { + col2: testState.layers.myLayer.columns.col1, + }, + }, + }, + }); + }); + + it('replaces an operation when moving to a populated dimension', () => { + const dragging = { + columnId: 'col2', + groupId: 'a', + layerId: 'myLayer', + }; + const testState = dragDropState(); + testState.layers.myLayer = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.myLayer.columns.col1, + + col2: { + label: 'Top values of src', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col3' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'src', + }, + col3: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + sourceField: 'Records', + }, + }, + }; + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col1', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col3'], + columns: { + col1: testState.layers.myLayer.columns.col2, + col3: testState.layers.myLayer.columns.col3, + }, + }, + }, + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index 1e8f73b19a3b09..1fbbefd8f1117b 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -15,6 +15,7 @@ import { DatasourceDimensionEditorProps, DatasourceDimensionDropProps, DatasourceDimensionDropHandlerProps, + isDraggedOperation, } from '../../types'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { IndexPatternColumn, OperationType } from '../indexpattern'; @@ -99,16 +100,25 @@ export function canHandleDrop(props: DatasourceDimensionDropProps -): boolean { +export function onDrop(props: DatasourceDimensionDropHandlerProps) { const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); const droppedItem = props.droppedItem; @@ -116,6 +126,42 @@ export function onDrop( return Boolean(operationFieldSupportMatrix.operationByField[field.name]); } + if (isDraggedOperation(droppedItem) && droppedItem.layerId === props.layerId) { + const layer = props.state.layers[props.layerId]; + const op = { ...layer.columns[droppedItem.columnId] }; + if (!props.filterOperations(op)) { + return false; + } + + const newColumns = { ...layer.columns }; + delete newColumns[droppedItem.columnId]; + newColumns[props.columnId] = op; + + const newColumnOrder = [...layer.columnOrder]; + const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); + const newIndex = newColumnOrder.findIndex((c) => c === props.columnId); + + if (newIndex === -1) { + newColumnOrder[oldIndex] = props.columnId; + } else { + newColumnOrder.splice(oldIndex, 1); + } + + // Time to replace + props.setState({ + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...layer, + columnOrder: newColumnOrder, + columns: newColumns, + }, + }, + }); + return { deleted: droppedItem.columnId }; + } + if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { // TODO: What do we do if we couldn't find a column? return false; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts index 0cd92fd96c952f..374dbe77b4ca37 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { DataType } from '../types'; import { DraggedField } from './indexpattern'; import { BaseIndexPatternColumn, FieldBasedIndexPatternColumn, } from './operations/definitions/column_types'; -import { DataType } from '../types'; /** * Normalizes the specified operation type. (e.g. document operations diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 729daed7223fe3..d8b77afdfe004b 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -157,7 +157,7 @@ export interface Datasource { renderDimensionEditor: (domElement: Element, props: DatasourceDimensionEditorProps) => void; renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps) => void; canHandleDrop: (props: DatasourceDimensionDropProps) => boolean; - onDrop: (props: DatasourceDimensionDropHandlerProps) => boolean; + onDrop: (props: DatasourceDimensionDropHandlerProps) => false | true | { deleted: string }; toExpression: (state: T, layerId: string) => Ast | string | null; @@ -230,6 +230,22 @@ export interface DatasourceLayerPanelProps { setState: StateSetter; } +export interface DraggedOperation { + layerId: string; + groupId: string; + columnId: string; +} + +export function isDraggedOperation( + operationCandidate: unknown +): operationCandidate is DraggedOperation { + return ( + typeof operationCandidate === 'object' && + operationCandidate !== null && + 'columnId' in operationCandidate + ); +} + export type DatasourceDimensionDropProps = SharedDimensionProps & { layerId: string; columnId: string; diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index b02a82f98af91e..0c31015fc9f5e0 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -375,7 +375,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'single,single', page: '1', per_page: '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', }, signal: abortCtrl.signal, @@ -408,7 +408,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'single', page: '1', per_page: '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', }, signal: abortCtrl.signal, @@ -441,7 +441,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'agnostic', page: '1', per_page: '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', }, signal: abortCtrl.signal, @@ -474,7 +474,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'agnostic', page: '1', per_page: '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', }, signal: abortCtrl.signal, @@ -508,7 +508,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'agnostic', page: '1', per_page: '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', }, signal: abortCtrl.signal, diff --git a/x-pack/plugins/lists/public/exceptions/api.ts b/x-pack/plugins/lists/public/exceptions/api.ts index 3f5ec803205035..824a25296260f0 100644 --- a/x-pack/plugins/lists/public/exceptions/api.ts +++ b/x-pack/plugins/lists/public/exceptions/api.ts @@ -288,7 +288,7 @@ export const fetchExceptionListsItemsByListIds = async ({ namespace_type: namespaceTypes.join(','), page: pagination.page ? `${pagination.page}` : '1', per_page: pagination.perPage ? `${pagination.perPage}` : '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', ...(filters.trim() !== '' ? { filter: filters } : {}), }; diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_editor/pipeline_editor.js b/x-pack/plugins/logstash/public/application/components/pipeline_editor/pipeline_editor.js index 694e50cfe3e366..6cb1f87648da15 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_editor/pipeline_editor.js +++ b/x-pack/plugins/logstash/public/application/components/pipeline_editor/pipeline_editor.js @@ -125,11 +125,11 @@ class PipelineEditorUi extends React.Component { onPipelineSave = () => { const { pipelineService, toastNotifications, intl } = this.props; - const { id } = this.state.pipeline; + const { id, ...pipelineToStore } = this.state.pipeline; return pipelineService .savePipeline({ id, - upstreamJSON: this.state.pipeline, + upstreamJSON: pipelineToStore, }) .then(() => { toastNotifications.addSuccess( diff --git a/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts b/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts index 8ce04c83afdbf3..0b7c3888b6f03c 100755 --- a/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts +++ b/x-pack/plugins/logstash/server/models/pipeline/pipeline.ts @@ -11,14 +11,14 @@ import { i18n } from '@kbn/i18n'; interface PipelineOptions { id: string; - description: string; + description?: string; pipeline: string; username?: string; settings?: Record; } interface DownstreamPipeline { - description: string; + description?: string; pipeline: string; settings?: Record; } @@ -27,7 +27,7 @@ interface DownstreamPipeline { */ export class Pipeline { public readonly id: string; - public readonly description: string; + public readonly description?: string; public readonly username?: string; public readonly pipeline: string; private readonly settings: Record; diff --git a/x-pack/plugins/logstash/server/routes/pipeline/save.ts b/x-pack/plugins/logstash/server/routes/pipeline/save.ts index e484d0e221b6d1..755a82e670a2a7 100644 --- a/x-pack/plugins/logstash/server/routes/pipeline/save.ts +++ b/x-pack/plugins/logstash/server/routes/pipeline/save.ts @@ -22,8 +22,7 @@ export function registerPipelineSaveRoute(router: IRouter, security?: SecurityPl id: schema.string(), }), body: schema.object({ - id: schema.string(), - description: schema.string(), + description: schema.maybe(schema.string()), pipeline: schema.string(), settings: schema.maybe(schema.object({}, { unknowns: 'allow' })), }), diff --git a/x-pack/plugins/ml/common/constants/settings.ts b/x-pack/plugins/ml/common/constants/settings.ts index 2df2ecd22e0788..bab2aa2f2a0aed 100644 --- a/x-pack/plugins/ml/common/constants/settings.ts +++ b/x-pack/plugins/ml/common/constants/settings.ts @@ -5,3 +5,11 @@ */ export const FILE_DATA_VISUALIZER_MAX_FILE_SIZE = 'ml:fileDataVisualizerMaxFileSize'; +export const ANOMALY_DETECTION_ENABLE_TIME_RANGE = 'ml:anomalyDetection:results:enableTimeDefaults'; +export const ANOMALY_DETECTION_DEFAULT_TIME_RANGE = 'ml:anomalyDetection:results:timeDefaults'; + +export const DEFAULT_AD_RESULTS_TIME_FILTER = { + from: 'now-15m', + to: 'now', +}; +export const DEFAULT_ENABLE_AD_RESULTS_TIME_FILTER = false; diff --git a/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts b/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts new file mode 100644 index 00000000000000..368e758a027c49 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/custom_hooks/use_create_ad_links.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useCallback } from 'react'; +import { useMlKibana, useUiSettings } from '../../contexts/kibana'; +import { + ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + ANOMALY_DETECTION_ENABLE_TIME_RANGE, +} from '../../../../common/constants/settings'; +import { mlJobService } from '../../services/job_service'; + +export const useCreateADLinks = () => { + const { + services: { + http: { basePath }, + }, + } = useMlKibana(); + + const useUserTimeSettings = useUiSettings().get(ANOMALY_DETECTION_ENABLE_TIME_RANGE); + const userTimeSettings = useUiSettings().get(ANOMALY_DETECTION_DEFAULT_TIME_RANGE); + const createLinkWithUserDefaults = useCallback( + (location, jobList) => { + const resultsPageUrl = mlJobService.createResultsUrlForJobs( + jobList, + location, + useUserTimeSettings === true && userTimeSettings !== undefined + ? userTimeSettings + : undefined + ); + return `${basePath.get()}/app/ml${resultsPageUrl}`; + }, + [basePath] + ); + return { createLinkWithUserDefaults }; +}; diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx index 62a74ed142ccf0..6c57b3d08180d2 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_flyout.tsx @@ -237,7 +237,7 @@ export const JobSelectorFlyout: FC = ({ { @@ -298,7 +315,6 @@ export class Explorer extends React.Component {
- {stoppedPartitions && ( {singleMetricVisible && ( = ({ jobsList }) => { values: { jobsCount: jobsList.length, jobId: jobsList[0] && jobsList[0].id }, } ); + const { createLinkWithUserDefaults } = useCreateADLinks(); return ( = ({ jobsWithTim const [globalState, setGlobalState] = useUrlState('_g'); const [lastRefresh, setLastRefresh] = useState(0); const [stoppedPartitions, setStoppedPartitions] = useState(); - + const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const { jobIds } = useJobSelection(jobsWithTimeRange); @@ -99,6 +99,9 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim // `timefilter.getBounds()` to update `bounds` in this component's state. useEffect(() => { if (globalState?.time !== undefined) { + if (globalState.time.mode === 'invalid') { + setInValidTimeRangeError(true); + } timefilter.setTime({ from: globalState.time.from, to: globalState.time.to, @@ -236,6 +239,7 @@ const ExplorerUrlStateManager: FC = ({ jobsWithTim showCharts, severity: tableSeverity.val, stoppedPartitions, + invalidTimeRangeError, }} />
diff --git a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx index 1f122ed18a8512..817c9754159971 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/timeseriesexplorer.tsx @@ -91,6 +91,7 @@ export const TimeSeriesExplorerUrlStateManager: FC(); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); + const [invalidTimeRangeError, setInValidTimeRangeError] = useState(false); const refresh = useRefresh(); useEffect(() => { @@ -114,6 +115,9 @@ export const TimeSeriesExplorerUrlStateManager: FC(undefined); useEffect(() => { if (globalState?.time !== undefined) { + if (globalState.time.mode === 'invalid') { + setInValidTimeRangeError(true); + } timefilter.setTime({ from: globalState.time.from, to: globalState.time.to, @@ -300,6 +304,7 @@ export const TimeSeriesExplorerUrlStateManager: FC ); diff --git a/x-pack/plugins/ml/public/application/services/job_service.d.ts b/x-pack/plugins/ml/public/application/services/job_service.d.ts index 2134d157e1baae..30b2ec044285a2 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.d.ts +++ b/x-pack/plugins/ml/public/application/services/job_service.d.ts @@ -5,6 +5,7 @@ */ import { SearchResponse } from 'elasticsearch'; +import { TimeRange } from 'src/plugins/data/common/query/timefilter/types'; import { CombinedJob } from '../../../common/types/anomaly_detection_jobs'; import { Calendar } from '../../../common/types/calendars'; @@ -15,7 +16,7 @@ export interface ExistingJobsAndGroups { declare interface JobService { jobs: CombinedJob[]; - createResultsUrlForJobs: (jobs: any[], target: string) => string; + createResultsUrlForJobs: (jobs: any[], target: string, timeRange?: TimeRange) => string; tempJobCloningObjects: { job: any; skipTimeRangeStep: boolean; 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 704d76059f75cc..640f63617b7d4a 100644 --- a/x-pack/plugins/ml/public/application/services/job_service.js +++ b/x-pack/plugins/ml/public/application/services/job_service.js @@ -21,7 +21,7 @@ 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'; - +import { validateTimeRange } from '../util/date_utils'; const msgs = mlMessageBarService; let jobs = []; let datafeedIds = {}; @@ -790,8 +790,8 @@ class JobService { return groups; } - createResultsUrlForJobs(jobsList, resultsPage) { - return createResultsUrlForJobs(jobsList, resultsPage); + createResultsUrlForJobs(jobsList, resultsPage, timeRange) { + return createResultsUrlForJobs(jobsList, resultsPage, timeRange); } createResultsUrl(jobIds, from, to, resultsPage) { @@ -932,31 +932,54 @@ function createJobStats(jobsList, jobStats) { jobStats.activeNodes.value = Object.keys(mlNodes).length; } -function createResultsUrlForJobs(jobsList, resultsPage) { +function createResultsUrlForJobs(jobsList, resultsPage, userTimeRange) { let from = undefined; let to = undefined; - if (jobsList.length === 1) { - from = jobsList[0].earliestTimestampMs; - to = jobsList[0].latestResultsTimestampMs; // Will be max(latest source data, latest bucket results) + let mode = 'absolute'; + const jobIds = jobsList.map((j) => j.id); + + // if the custom default time filter is set and enabled in advanced settings + // if time is either absolute date or proper datemath format + if (validateTimeRange(userTimeRange)) { + from = userTimeRange.from; + to = userTimeRange.to; + // if both pass datemath's checks but are not technically absolute dates, use 'quick' + // e.g. "now-15m" "now+1d" + const fromFieldAValidDate = moment(userTimeRange.from).isValid(); + const toFieldAValidDate = moment(userTimeRange.to).isValid(); + if (!fromFieldAValidDate && !toFieldAValidDate) { + return createResultsUrl(jobIds, from, to, resultsPage, 'quick'); + } } else { - const jobsWithData = jobsList.filter((j) => j.earliestTimestampMs !== undefined); - if (jobsWithData.length > 0) { - from = Math.min(...jobsWithData.map((j) => j.earliestTimestampMs)); - to = Math.max(...jobsWithData.map((j) => j.latestResultsTimestampMs)); + // if time range is specified but with incorrect format + // change back to the default time range but alert the user + // that the advanced setting config is invalid + if (userTimeRange) { + mode = 'invalid'; + } + + if (jobsList.length === 1) { + from = jobsList[0].earliestTimestampMs; + to = jobsList[0].latestResultsTimestampMs; // Will be max(latest source data, latest bucket results) + } else { + const jobsWithData = jobsList.filter((j) => j.earliestTimestampMs !== undefined); + if (jobsWithData.length > 0) { + from = Math.min(...jobsWithData.map((j) => j.earliestTimestampMs)); + to = Math.max(...jobsWithData.map((j) => j.latestResultsTimestampMs)); + } } } const fromString = moment(from).format(TIME_FORMAT); // Defaults to 'now' if 'from' is undefined const toString = moment(to).format(TIME_FORMAT); // Defaults to 'now' if 'to' is undefined - const jobIds = jobsList.map((j) => j.id); - return createResultsUrl(jobIds, fromString, toString, resultsPage); + return createResultsUrl(jobIds, fromString, toString, resultsPage, mode); } -function createResultsUrl(jobIds, start, end, resultsPage) { +function createResultsUrl(jobIds, start, end, resultsPage, mode = 'absolute') { const idString = jobIds.map((j) => `'${j}'`).join(','); - const from = moment(start).toISOString(); - const to = moment(end).toISOString(); + let from; + let to; let path = ''; if (resultsPage !== undefined) { @@ -964,9 +987,20 @@ function createResultsUrl(jobIds, start, end, resultsPage) { path += resultsPage; } + if (mode === 'quick') { + from = start; + to = end; + } else { + from = moment(start).toISOString(); + to = moment(end).toISOString(); + } + path += `?_g=(ml:(jobIds:!(${idString}))`; path += `,refreshInterval:(display:Off,pause:!f,value:0),time:(from:'${from}'`; - path += `,mode:absolute,to:'${to}'`; + path += `,to:'${to}'`; + if (mode === 'invalid') { + path += `,mode:invalid`; + } path += "))&_a=(query:(query_string:(analyze_wildcard:!t,query:'*')))"; return path; diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js index 83a789074d353c..0e99d64cf202f8 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/timeseriesexplorer.js @@ -83,6 +83,7 @@ import { getFocusData, } from './timeseriesexplorer_utils'; import { EMPTY_FIELD_VALUE_LABEL } from './components/entity_control/entity_control'; +import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; // Used to indicate the chart is being plotted across // all partition field values, where the cardinality of the field cannot be @@ -833,6 +834,22 @@ export class TimeSeriesExplorer extends React.Component { } componentDidMount() { + // if timeRange used in the url is incorrect + // perhaps due to user's advanced setting using incorrect date-maths + const { invalidTimeRangeError } = this.props; + if (invalidTimeRangeError) { + const toastNotifications = getToastNotifications(); + toastNotifications.addWarning( + i18n.translate('xpack.ml.timeSeriesExplorer.invalidTimeRangeInUrlCallout', { + defaultMessage: + 'The time filter was changed to the full range for this job due to an invalid default time filter. Check the advanced settings for {field}.', + values: { + field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + }, + }) + ); + } + // Required to redraw the time series chart when the container is resized. this.resizeChecker = new ResizeChecker(this.resizeRef.current); this.resizeChecker.on('resize', () => { diff --git a/x-pack/plugins/ml/public/application/util/date_utils.ts b/x-pack/plugins/ml/public/application/util/date_utils.ts index 8f3215b6cd211f..21adc0b4b9c66a 100644 --- a/x-pack/plugins/ml/public/application/util/date_utils.ts +++ b/x-pack/plugins/ml/public/application/util/date_utils.ts @@ -8,7 +8,8 @@ // @ts-ignore import { formatDate } from '@elastic/eui/lib/services/format'; - +import dateMath from '@elastic/datemath'; +import { TimeRange } from '../../../../../../src/plugins/data/common'; export function formatHumanReadableDate(ts: number) { return formatDate(ts, 'MMMM Do YYYY'); } @@ -20,3 +21,10 @@ export function formatHumanReadableDateTime(ts: number): string { export function formatHumanReadableDateTimeSeconds(ts: number) { return formatDate(ts, 'MMMM Do YYYY, HH:mm:ss'); } + +export function validateTimeRange(time?: TimeRange): boolean { + if (!time) return false; + const momentDateFrom = dateMath.parse(time.from); + const momentDateTo = dateMath.parse(time.to); + return !!(momentDateFrom && momentDateFrom.isValid() && momentDateTo && momentDateTo.isValid()); +} diff --git a/x-pack/plugins/ml/server/lib/register_settings.ts b/x-pack/plugins/ml/server/lib/register_settings.ts index 38b1f5e3fc0834..a9ee24fbb5cea9 100644 --- a/x-pack/plugins/ml/server/lib/register_settings.ts +++ b/x-pack/plugins/ml/server/lib/register_settings.ts @@ -7,7 +7,13 @@ import { CoreSetup } from 'kibana/server'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { FILE_DATA_VISUALIZER_MAX_FILE_SIZE } from '../../common/constants/settings'; +import { + FILE_DATA_VISUALIZER_MAX_FILE_SIZE, + ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + ANOMALY_DETECTION_ENABLE_TIME_RANGE, + DEFAULT_AD_RESULTS_TIME_FILTER, + DEFAULT_ENABLE_AD_RESULTS_TIME_FILTER, +} from '../../common/constants/settings'; import { MAX_FILE_SIZE } from '../../common/constants/file_datavisualizer'; export function registerKibanaSettings(coreSetup: CoreSetup) { @@ -30,5 +36,40 @@ export function registerKibanaSettings(coreSetup: CoreSetup) { }), }, }, + [ANOMALY_DETECTION_ENABLE_TIME_RANGE]: { + name: i18n.translate('xpack.ml.advancedSettings.enableAnomalyDetectionDefaultTimeRangeName', { + defaultMessage: 'Enable time filter defaults for anomaly detection results', + }), + value: DEFAULT_ENABLE_AD_RESULTS_TIME_FILTER, + schema: schema.boolean(), + description: i18n.translate( + 'xpack.ml.advancedSettings.enableAnomalyDetectionDefaultTimeRangeDesc', + { + defaultMessage: + 'Use the default time filter in the Single Metric Viewer and Anomaly Explorer. If not enabled, the results for the full time range of the job are displayed.', + } + ), + category: ['Machine Learning'], + }, + [ANOMALY_DETECTION_DEFAULT_TIME_RANGE]: { + name: i18n.translate('xpack.ml.advancedSettings.anomalyDetectionDefaultTimeRangeName', { + defaultMessage: 'Time filter defaults for anomaly detection results', + }), + type: 'json', + value: JSON.stringify(DEFAULT_AD_RESULTS_TIME_FILTER, null, 2), + description: i18n.translate( + 'xpack.ml.advancedSettings.anomalyDetectionDefaultTimeRangeDesc', + { + defaultMessage: + 'The time filter selection to use when viewing anomaly detection job results.', + } + ), + schema: schema.object({ + from: schema.string(), + to: schema.string(), + }), + requiresPageReload: true, + category: ['Machine Learning'], + }, }); } diff --git a/x-pack/plugins/reporting/server/plugin.test.ts b/x-pack/plugins/reporting/server/plugin.test.ts index 420fa8347cdebb..e0d018869cef1e 100644 --- a/x-pack/plugins/reporting/server/plugin.test.ts +++ b/x-pack/plugins/reporting/server/plugin.test.ts @@ -86,6 +86,15 @@ describe('Reporting Plugin', () => { expect(plugin.start(coreStart, pluginStart)).not.toHaveProperty('then'); }); + it('registers an advanced setting for PDF logos', async () => { + const plugin = new ReportingPlugin(initContext); + plugin.setup(coreSetup, pluginSetup); + expect(coreSetup.uiSettings.register).toHaveBeenCalled(); + expect(coreSetup.uiSettings.register.mock.calls[0][0]).toHaveProperty( + 'xpackReporting:customPdfLogo' + ); + }); + it('logs start issues', async () => { const plugin = new ReportingPlugin(initContext); // @ts-ignore overloading error logger diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index 20e22c2db00e35..8c0e352aa06c5d 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -4,16 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ +import { schema } from '@kbn/config-schema'; +import { i18n } from '@kbn/i18n'; import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/server'; +import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from '../common/constants'; import { ReportingCore } from './'; import { initializeBrowserDriverFactory } from './browsers'; import { buildConfig, ReportingConfigType } from './config'; -import { createQueueFactory, LevelLogger, runValidations, ReportingStore } from './lib'; +import { createQueueFactory, LevelLogger, ReportingStore, runValidations } from './lib'; import { registerRoutes } from './routes'; import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; import { registerReportingUsageCollector } from './usage'; +const kbToBase64Length = (kb: number) => Math.floor((kb * 1024 * 8) / 6); + declare module 'src/core/server' { interface RequestHandlerContext { reporting?: ReportingStart | null; @@ -34,7 +39,7 @@ export class ReportingPlugin public setup(core: CoreSetup, plugins: ReportingSetupDeps) { // prevent throwing errors in route handlers about async deps not being initialized - core.http.registerRouteHandlerContext('reporting', () => { + core.http.registerRouteHandlerContext(PLUGIN_ID, () => { if (this.reportingCore.pluginIsStarted()) { return {}; // ReportingStart contract } else { @@ -42,6 +47,28 @@ export class ReportingPlugin } }); + core.uiSettings.register({ + [UI_SETTINGS_CUSTOM_PDF_LOGO]: { + name: i18n.translate('xpack.reporting.pdfFooterImageLabel', { + defaultMessage: 'PDF footer image', + }), + value: null, + description: i18n.translate('xpack.reporting.pdfFooterImageDescription', { + defaultMessage: `Custom image to use in the PDF's footer`, + }), + type: 'image', + schema: schema.nullable(schema.byteSize({ max: '200kb' })), + category: [PLUGIN_ID], + // Used client-side for size validation + validation: { + maxSize: { + length: kbToBase64Length(200), + description: '200 kB', + }, + }, + }, + }); + const { elasticsearch, http } = core; const { licensing, security } = plugins; const { initializerContext: initContext, reportingCore } = this; 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 a9f39d2db6080f..498b561a818f2d 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 @@ -283,6 +283,9 @@ export type Status = t.TypeOf; export const job_status = t.keyof({ succeeded: null, failed: null, 'going to run': null }); export type JobStatus = t.TypeOf; +export const conflicts = t.keyof({ abort: null, proceed: null }); +export type Conflicts = t.TypeOf; + // TODO: Create a regular expression type or custom date math part type here export const to = t.string; export type To = t.TypeOf; @@ -338,7 +341,7 @@ export const sortFieldOrUndefined = t.union([sort_field, t.undefined]); export type SortFieldOrUndefined = t.TypeOf; export const sort_order = t.keyof({ asc: null, desc: null }); -export type sortOrder = t.TypeOf; +export type SortOrder = t.TypeOf; export const sortOrderOrUndefined = t.union([sort_order, t.undefined]); export type SortOrderOrUndefined = t.TypeOf; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/index.ts new file mode 100644 index 00000000000000..abfbc391896430 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export * from './add_prepackaged_rules_schema'; +export * from './create_rules_bulk_schema'; +export * from './create_rules_schema'; +export * from './export_rules_schema'; +export * from './find_rules_schema'; +export * from './import_rules_schema'; +export * from './patch_rules_bulk_schema'; +export * from './patch_rules_schema'; +export * from './query_rules_schema'; +export * from './query_signals_index_schema'; +export * from './set_signal_status_schema'; +export * from './update_rules_bulk_schema'; +export * from './update_rules_schema'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts index 1464896e502941..b039558d827bbc 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts @@ -6,13 +6,14 @@ import * as t from 'io-ts'; -import { signal_ids, signal_status_query, status } from '../common/schemas'; +import { conflicts, signal_ids, signal_status_query, status } from '../common/schemas'; export const setSignalsStatusSchema = t.intersection([ t.type({ status, }), t.partial({ + conflicts, signal_ids, query: signal_status_query, }), diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts new file mode 100644 index 00000000000000..6c22b8140e738f --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export * from './error_schema'; +export * from './find_rules_schema'; +export * from './import_rules_schema'; +export * from './prepackaged_rules_schema'; +export * from './prepackaged_rules_status_schema'; +export * from './rules_bulk_schema'; +export * from './rules_schema'; +export * from './type_timeline_only_schema'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts index 0576871a2bf814..b55226b08b800d 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts @@ -80,10 +80,6 @@ export interface Explanation { details: Explanation[]; } -export interface TotalValue { - value: number; - relation: string; -} export interface ShardsResponse { total: number; successful: number; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts index 17adf38559a9ec..7721f2ae97d758 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/index.ts @@ -27,6 +27,8 @@ import { NetworkHttpRequestOptions, NetworkTopCountriesStrategyResponse, NetworkTopCountriesRequestOptions, + NetworkTopNFlowStrategyResponse, + NetworkTopNFlowRequestOptions, } from './network'; import { DocValueFields, @@ -77,6 +79,8 @@ export type StrategyResponseType = T extends HostsQ ? NetworkHttpStrategyResponse : T extends NetworkQueries.topCountries ? NetworkTopCountriesStrategyResponse + : T extends NetworkQueries.topNFlow + ? NetworkTopNFlowStrategyResponse : never; export type StrategyRequestType = T extends HostsQueries.hosts @@ -95,4 +99,6 @@ export type StrategyRequestType = T extends HostsQu ? NetworkHttpRequestOptions : T extends NetworkQueries.topCountries ? NetworkTopCountriesRequestOptions + : T extends NetworkQueries.topNFlow + ? NetworkTopNFlowRequestOptions : never; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/common/index.ts index a6ae956a421877..66676569b3c9eb 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/common/index.ts @@ -4,7 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ +import { GeoEcs } from '../../../../ecs/geo'; +import { Maybe } from '../../..'; + +export enum NetworkTopTablesFields { + bytes_in = 'bytes_in', + bytes_out = 'bytes_out', + flows = 'flows', + destination_ips = 'destination_ips', + source_ips = 'source_ips', +} + export enum FlowTargetSourceDest { destination = 'destination', source = 'source', } + +export interface TopNetworkTablesEcsField { + bytes_in?: Maybe; + bytes_out?: Maybe; +} + +export interface GeoItem { + geo?: Maybe; + flowTarget?: Maybe; +} diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts index ac5e6fdacc94b9..2992ee32f8ac73 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/index.ts @@ -8,9 +8,11 @@ export * from './common'; export * from './http'; export * from './tls'; export * from './top_countries'; +export * from './top_n_flow'; export enum NetworkQueries { http = 'http', tls = 'tls', topCountries = 'topCountries', + topNFlow = 'topNFlow', } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_countries/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_countries/index.ts index 3188a26dd69fd6..f499db82d6479f 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_countries/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_countries/index.ts @@ -5,18 +5,14 @@ */ import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; -import { GeoEcs } from '../../../../ecs/geo'; import { CursorType, Inspect, Maybe, PageInfoPaginated } from '../../../common'; import { RequestOptionsPaginated } from '../..'; -import { FlowTargetSourceDest } from '../common'; - -export enum NetworkTopTablesFields { - bytes_in = 'bytes_in', - bytes_out = 'bytes_out', - flows = 'flows', - destination_ips = 'destination_ips', - source_ips = 'source_ips', -} +import { + GeoItem, + FlowTargetSourceDest, + NetworkTopTablesFields, + TopNetworkTablesEcsField, +} from '../common'; export enum NetworkDnsFields { dnsName = 'dnsName', @@ -33,11 +29,6 @@ export enum FlowTarget { source = 'source', } -export interface GeoItem { - geo?: Maybe; - flowTarget?: Maybe; -} - export interface TopCountriesItemSource { country?: Maybe; destination_ips?: Maybe; @@ -79,11 +70,6 @@ export interface TopCountriesItemDestination { source_ips?: Maybe; } -export interface TopNetworkTablesEcsField { - bytes_in?: Maybe; - bytes_out?: Maybe; -} - export interface NetworkTopCountriesBuckets { country: string; key: string; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_n_flow/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_n_flow/index.ts new file mode 100644 index 00000000000000..d6be2d29c6eda9 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/network/top_n_flow/index.ts @@ -0,0 +1,124 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; +import { + GeoItem, + FlowTargetSourceDest, + TopNetworkTablesEcsField, + NetworkTopTablesFields, +} from '../common'; +import { + CursorType, + Inspect, + Maybe, + PageInfoPaginated, + TotalValue, + GenericBuckets, +} from '../../../common'; +import { RequestOptionsPaginated } from '../..'; + +export interface NetworkTopNFlowRequestOptions + extends RequestOptionsPaginated { + flowTarget: FlowTargetSourceDest; + ip?: Maybe; +} + +export interface NetworkTopNFlowStrategyResponse extends IEsSearchResponse { + edges: NetworkTopNFlowEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe; +} + +export interface NetworkTopNFlowEdges { + node: NetworkTopNFlowItem; + cursor: CursorType; +} + +export interface NetworkTopNFlowItem { + _id?: Maybe; + source?: Maybe; + destination?: Maybe; + network?: Maybe; +} + +export interface TopNFlowItemSource { + autonomous_system?: Maybe; + domain?: Maybe; + ip?: Maybe; + location?: Maybe; + flows?: Maybe; + destination_ips?: Maybe; +} + +export interface AutonomousSystemItem { + name?: Maybe; + number?: Maybe; +} + +export interface TopNFlowItemDestination { + autonomous_system?: Maybe; + domain?: Maybe; + ip?: Maybe; + location?: Maybe; + flows?: Maybe; + source_ips?: Maybe; +} + +export interface AutonomousSystemHit { + doc_count: number; + top_as: { + hits: { + total: TotalValue | number; + max_score: number | null; + hits: Array<{ + _source: T; + sort?: [number]; + _index?: string; + _type?: string; + _id?: string; + _score?: number | null; + }>; + }; + }; +} + +export interface NetworkTopNFlowBuckets { + key: string; + autonomous_system: AutonomousSystemHit; + bytes_in: { + value: number; + }; + bytes_out: { + value: number; + }; + domain: { + buckets: GenericBuckets[]; + }; + location: LocationHit; + flows: number; + destination_ips?: number; + source_ips?: number; +} + +export interface LocationHit { + doc_count: number; + top_geo: { + hits: { + total: TotalValue | number; + max_score: number | null; + hits: Array<{ + _source: T; + sort?: [number]; + _index?: string; + _type?: string; + _id?: string; + _score?: number | null; + }>; + }; + }; +} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index c46eb1b6b59cc8..c1befabdd78095 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -26,6 +26,7 @@ import { CreateExceptionListItemSchema, ExceptionListType, } from '../../../../../public/lists_plugin_deps'; +import * as i18nCommon from '../../../translations'; import * as i18n from './translations'; import * as sharedI18n from '../translations'; import { TimelineNonEcsData, Ecs } from '../../../../graphql/types'; @@ -49,6 +50,7 @@ import { } from '../helpers'; import { ErrorInfo, ErrorCallout } from '../error_callout'; import { useFetchIndexPatterns } from '../../../../detections/containers/detection_engine/rules'; +import { ExceptionsBuilderExceptionItem } from '../types'; export interface AddExceptionModalBaseProps { ruleName: string; @@ -117,7 +119,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ Array >([]); const [fetchOrCreateListError, setFetchOrCreateListError] = useState(null); - const { addError, addSuccess } = useAppToasts(); + const { addError, addSuccess, addWarning } = useAppToasts(); const { loading: isSignalIndexLoading, signalIndexName } = useSignalIndex(); const [ { isLoading: isSignalIndexPatternLoading, indexPatterns: signalIndexPatterns }, @@ -129,16 +131,26 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ); const onError = useCallback( - (error: Error) => { + (error: Error): void => { addError(error, { title: i18n.ADD_EXCEPTION_ERROR }); onCancel(); }, [addError, onCancel] ); - const onSuccess = useCallback(() => { - addSuccess(i18n.ADD_EXCEPTION_SUCCESS); - onConfirm(shouldCloseAlert, shouldBulkCloseAlert); - }, [addSuccess, onConfirm, shouldBulkCloseAlert, shouldCloseAlert]); + + const onSuccess = useCallback( + (updated: number, conflicts: number): void => { + addSuccess(i18n.ADD_EXCEPTION_SUCCESS); + onConfirm(shouldCloseAlert, shouldBulkCloseAlert); + if (conflicts > 0) { + addWarning({ + title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } + }, + [addSuccess, addWarning, onConfirm, shouldBulkCloseAlert, shouldCloseAlert] + ); const [{ isLoading: addExceptionIsLoading }, addOrUpdateExceptionItems] = useAddOrUpdateException( { @@ -153,7 +165,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ exceptionItems, }: { exceptionItems: Array; - }) => { + }): void => { setExceptionItemsToAdd(exceptionItems); }, [setExceptionItemsToAdd] @@ -186,7 +198,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ); const handleFetchOrCreateExceptionListError = useCallback( - (error: Error, statusCode: number | null, message: string | null) => { + (error: Error, statusCode: number | null, message: string | null): void => { setFetchOrCreateListError({ reason: error.message, code: statusCode, @@ -205,7 +217,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ onSuccess: handleRuleChange, }); - const initialExceptionItems = useMemo(() => { + const initialExceptionItems = useMemo((): ExceptionsBuilderExceptionItem[] => { if (exceptionListType === 'endpoint' && alertData !== undefined && ruleExceptionList) { return defaultEndpointExceptionItems( exceptionListType, @@ -218,7 +230,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ } }, [alertData, exceptionListType, ruleExceptionList, ruleName]); - useEffect(() => { + useEffect((): void => { if (isSignalIndexPatternLoading === false && isSignalIndexLoading === false) { setShouldDisableBulkClose( entryHasListType(exceptionItemsToAdd) || @@ -234,34 +246,34 @@ export const AddExceptionModal = memo(function AddExceptionModal({ signalIndexPatterns, ]); - useEffect(() => { + useEffect((): void => { if (shouldDisableBulkClose === true) { setShouldBulkCloseAlert(false); } }, [shouldDisableBulkClose]); const onCommentChange = useCallback( - (value: string) => { + (value: string): void => { setComment(value); }, [setComment] ); const onCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent) => { + (event: React.ChangeEvent): void => { setShouldCloseAlert(event.currentTarget.checked); }, [setShouldCloseAlert] ); const onBulkCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent) => { + (event: React.ChangeEvent): void => { setShouldBulkCloseAlert(event.currentTarget.checked); }, [setShouldBulkCloseAlert] ); - const retrieveAlertOsTypes = useCallback(() => { + const retrieveAlertOsTypes = useCallback((): string[] => { const osDefaults = ['windows', 'macos']; if (alertData) { const osTypes = getMappedNonEcsValue({ @@ -276,7 +288,9 @@ export const AddExceptionModal = memo(function AddExceptionModal({ return osDefaults; }, [alertData]); - const enrichExceptionItems = useCallback(() => { + const enrichExceptionItems = useCallback((): Array< + ExceptionListItemSchema | CreateExceptionListItemSchema + > => { let enriched: Array = []; enriched = comment !== '' @@ -289,7 +303,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ return enriched; }, [comment, exceptionItemsToAdd, exceptionListType, retrieveAlertOsTypes]); - const onAddExceptionConfirm = useCallback(() => { + const onAddExceptionConfirm = useCallback((): void => { if (addOrUpdateExceptionItems !== null) { const alertIdToClose = shouldCloseAlert && alertData ? alertData.ecsData._id : undefined; const bulkCloseIndex = @@ -306,7 +320,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ ]); const isSubmitButtonDisabled = useMemo( - () => + (): boolean => fetchOrCreateListError != null || exceptionItemsToAdd.every((item) => item.entries.length === 0), [fetchOrCreateListError, exceptionItemsToAdd] diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx index 46923e07d225ad..2398f8d799c2ce 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -49,7 +49,7 @@ describe('useAddOrUpdateException', () => { const onError = jest.fn(); const onSuccess = jest.fn(); const alertIdToClose = 'idToClose'; - const bulkCloseIndex = ['.signals']; + const bulkCloseIndex = ['.custom']; const itemsToAdd: CreateExceptionListItemSchema[] = [ { ...getCreateExceptionListItemSchemaMock(), diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index be289b0e85e66b..dbd634e97a3281 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -5,6 +5,7 @@ */ import { useEffect, useRef, useState, useCallback } from 'react'; +import { UpdateDocumentByQueryResponse } from 'elasticsearch'; import { HttpStart } from '../../../../../../../src/core/public'; import { @@ -43,7 +44,7 @@ export type ReturnUseAddOrUpdateException = [ export interface UseAddOrUpdateExceptionProps { http: HttpStart; onError: (arg: Error, code: number | null, message: string | null) => void; - onSuccess: () => void; + onSuccess: (updated: number, conficts: number) => void; } /** @@ -122,8 +123,10 @@ export const useAddOrUpdateException = ({ ) => { try { setIsLoading(true); - if (alertIdToClose !== null && alertIdToClose !== undefined) { - await updateAlertStatus({ + let alertIdResponse: UpdateDocumentByQueryResponse | undefined; + let bulkResponse: UpdateDocumentByQueryResponse | undefined; + if (alertIdToClose != null) { + alertIdResponse = await updateAlertStatus({ query: getUpdateAlertsQuery([alertIdToClose]), status: 'closed', signal: abortCtrl.signal, @@ -139,7 +142,8 @@ export const useAddOrUpdateException = ({ prepareExceptionItemsForBulkClose(exceptionItemsToAddOrUpdate), false ); - await updateAlertStatus({ + + bulkResponse = await updateAlertStatus({ query: { query: filter, }, @@ -150,9 +154,18 @@ export const useAddOrUpdateException = ({ await addOrUpdateItems(exceptionItemsToAddOrUpdate); + // NOTE: there could be some overlap here... it's possible that the first response had conflicts + // but that the alert was closed in the second call. In this case, a conflict will be reported even + // though it was already resolved. I'm not sure that there's an easy way to solve this, but it should + // have minimal impact on the user... they'd see a warning that indicates a possible conflict, but the + // state of the alerts and their representation in the UI would be consistent. + const updated = (alertIdResponse?.updated ?? 0) + (bulkResponse?.updated ?? 0); + const conflicts = + alertIdResponse?.version_conflicts ?? 0 + (bulkResponse?.version_conflicts ?? 0); + if (isSubscribed) { setIsLoading(false); - onSuccess(); + onSuccess(updated, conflicts); } } catch (error) { if (isSubscribed) { diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts index e0e629793952a7..da43d0c5109974 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.test.ts @@ -14,13 +14,16 @@ jest.mock('../lib/kibana'); describe('useDeleteList', () => { let addErrorMock: jest.Mock; let addSuccessMock: jest.Mock; + let addWarningMock: jest.Mock; beforeEach(() => { addErrorMock = jest.fn(); addSuccessMock = jest.fn(); + addWarningMock = jest.fn(); (useToasts as jest.Mock).mockImplementation(() => ({ addError: addErrorMock, addSuccess: addSuccessMock, + addWarning: addWarningMock, })); }); diff --git a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts index bc59d87100058a..ae811e7400737e 100644 --- a/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts +++ b/x-pack/plugins/security_solution/public/common/hooks/use_app_toasts.ts @@ -10,7 +10,7 @@ import { ErrorToastOptions, ToastsStart, Toast } from '../../../../../../src/cor import { useToasts } from '../lib/kibana'; import { isAppError, AppError } from '../utils/api'; -export type UseAppToasts = Pick & { +export type UseAppToasts = Pick & { api: ToastsStart; addError: (error: unknown, options: ErrorToastOptions) => Toast; }; @@ -19,6 +19,7 @@ export const useAppToasts = (): UseAppToasts => { const toasts = useToasts(); const addError = useRef(toasts.addError.bind(toasts)).current; const addSuccess = useRef(toasts.addSuccess.bind(toasts)).current; + const addWarning = useRef(toasts.addWarning.bind(toasts)).current; const addAppError = useCallback( (error: AppError, options: ErrorToastOptions) => @@ -44,5 +45,5 @@ export const useAppToasts = (): UseAppToasts => { [addAppError, addError] ); - return { api: toasts, addError: _addError, addSuccess }; + return { api: toasts, addError: _addError, addSuccess, addWarning }; }; diff --git a/x-pack/plugins/security_solution/public/common/translations.ts b/x-pack/plugins/security_solution/public/common/translations.ts index 3b94ac8959496f..c4a9540f629142 100644 --- a/x-pack/plugins/security_solution/public/common/translations.ts +++ b/x-pack/plugins/security_solution/public/common/translations.ts @@ -61,3 +61,17 @@ export const EMPTY_ACTION_ENDPOINT_DESCRIPTION = i18n.translate( 'Protect your hosts with threat prevention, detection, and deep security data visibility.', } ); + +export const UPDATE_ALERT_STATUS_FAILED = (conflicts: number) => + i18n.translate('xpack.securitySolution.pages.common.updateAlertStatusFailed', { + values: { conflicts }, + defaultMessage: + 'Failed to update { conflicts } {conflicts, plural, =1 {alert} other {alerts}}.', + }); + +export const UPDATE_ALERT_STATUS_FAILED_DETAILED = (updated: number, conflicts: number) => + i18n.translate('xpack.securitySolution.pages.common.updateAlertStatusFailedDetailed', { + values: { updated, conflicts }, + defaultMessage: `{ updated } {updated, plural, =1 {alert was} other {alerts were}} updated successfully, but { conflicts } failed to update + because { conflicts, plural, =1 {it was} other {they were}} already being modified.`, + }); 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 3545bfd91e553d..972a8aa4b08717 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 @@ -9,6 +9,7 @@ import dateMath from '@elastic/datemath'; import { get, getOr, isEmpty, find } from 'lodash/fp'; import moment from 'moment'; +import { i18n } from '@kbn/i18n'; import { TimelineId } from '../../../../common/types/timeline'; import { updateAlertStatus } from '../../containers/detection_engine/alerts/api'; @@ -83,7 +84,18 @@ export const updateAlertStatusAction = async ({ // TODO: Only delete those that were successfully updated from updatedRules setEventsDeleted({ eventIds: alertIds, isDeleted: true }); - onAlertStatusUpdateSuccess(response.updated, selectedStatus); + if (response.version_conflicts > 0 && alertIds.length === 1) { + throw new Error( + i18n.translate( + 'xpack.securitySolution.detectionEngine.alerts.updateAlertStatusFailedSingleAlert', + { + defaultMessage: 'Failed to update alert because it was already being modified.', + } + ) + ); + } + + onAlertStatusUpdateSuccess(response.updated, response.version_conflicts, selectedStatus); } catch (error) { onAlertStatusUpdateFailure(selectedStatus, error); } finally { diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx index 63e1c8aca9082b..0416b3d2a459f9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/index.tsx @@ -9,10 +9,10 @@ import { isEmpty } from 'lodash/fp'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { connect, ConnectedProps } from 'react-redux'; import { Dispatch } from 'redux'; - import { Status } from '../../../../common/detection_engine/schemas/common/schemas'; import { Filter, esQuery } from '../../../../../../../src/plugins/data/public'; import { TimelineIdLiteral } from '../../../../common/types/timeline'; +import { useAppToasts } from '../../../common/hooks/use_app_toasts'; import { useFetchIndexPatterns } from '../../containers/detection_engine/rules/fetch_index_patterns'; import { StatefulEventsViewer } from '../../../common/components/events_viewer'; import { HeaderSection } from '../../../common/components/header_section'; @@ -32,6 +32,7 @@ import { } from './default_config'; import { FILTER_OPEN, AlertsTableFilterGroup } from './alerts_filter_group'; import { AlertsUtilityBar } from './alerts_utility_bar'; +import * as i18nCommon from '../../../common/translations'; import * as i18n from './translations'; import { SetEventsDeletedProps, @@ -90,6 +91,7 @@ export const AlertsTableComponent: React.FC = ({ ); const kibana = useKibana(); const [, dispatchToaster] = useStateToaster(); + const { addWarning } = useAppToasts(); const { initializeTimeline, setSelectAll, setIndexToAdd } = useManageTimeline(); const getGlobalQuery = useCallback( @@ -130,21 +132,29 @@ export const AlertsTableComponent: React.FC = ({ ); const onAlertStatusUpdateSuccess = useCallback( - (count: number, status: Status) => { - let title: string; - switch (status) { - case 'closed': - title = i18n.CLOSED_ALERT_SUCCESS_TOAST(count); - break; - case 'open': - title = i18n.OPENED_ALERT_SUCCESS_TOAST(count); - break; - case 'in-progress': - title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(count); + (updated: number, conflicts: number, status: Status) => { + if (conflicts > 0) { + // Partial failure + addWarning({ + title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } else { + let title: string; + switch (status) { + case 'closed': + title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated); + break; + case 'open': + title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated); + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated); + } + displaySuccessToast(title, dispatchToaster); } - displaySuccessToast(title, dispatchToaster); }, - [dispatchToaster] + [addWarning, dispatchToaster] ); const onAlertStatusUpdateFailure = useCallback( diff --git a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx index 216ed0cbe264d3..cbf0e08fef5cde 100644 --- a/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/alerts_table/timeline_actions/alert_context_menu.tsx @@ -15,6 +15,7 @@ import { } from '@elastic/eui'; import styled from 'styled-components'; +import { useAppToasts } from '../../../../common/hooks/use_app_toasts'; import { TimelineId } from '../../../../../common/types/timeline'; import { DEFAULT_INDEX_PATTERN } from '../../../../../common/constants'; import { Status, Type } from '../../../../../common/detection_engine/schemas/common/schemas'; @@ -32,6 +33,7 @@ import { AddExceptionModalBaseProps, } from '../../../../common/components/exceptions/add_exception_modal'; import { getMappedNonEcsValue } from '../../../../common/components/exceptions/helpers'; +import * as i18nCommon from '../../../../common/translations'; import * as i18n from '../translations'; import { useStateToaster, @@ -72,6 +74,8 @@ const AlertContextMenuComponent: React.FC = ({ ); const eventId = ecsRowData._id; + const { addWarning } = useAppToasts(); + const onButtonClick = useCallback(() => { setPopover(!isPopoverOpen); }, [isPopoverOpen]); @@ -124,22 +128,30 @@ const AlertContextMenuComponent: React.FC = ({ ); const onAlertStatusUpdateSuccess = useCallback( - (count: number, newStatus: Status) => { - let title: string; - switch (newStatus) { - case 'closed': - title = i18n.CLOSED_ALERT_SUCCESS_TOAST(count); - break; - case 'open': - title = i18n.OPENED_ALERT_SUCCESS_TOAST(count); - break; - case 'in-progress': - title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(count); + (updated: number, conflicts: number, newStatus: Status) => { + if (conflicts > 0) { + // Partial failure + addWarning({ + title: i18nCommon.UPDATE_ALERT_STATUS_FAILED(conflicts), + text: i18nCommon.UPDATE_ALERT_STATUS_FAILED_DETAILED(updated, conflicts), + }); + } else { + let title: string; + switch (newStatus) { + case 'closed': + title = i18n.CLOSED_ALERT_SUCCESS_TOAST(updated); + break; + case 'open': + title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated); + break; + case 'in-progress': + title = i18n.IN_PROGRESS_ALERT_SUCCESS_TOAST(updated); + } + displaySuccessToast(title, dispatchToaster); } - displaySuccessToast(title, dispatchToaster); setAlertStatus(newStatus); }, - [dispatchToaster] + [dispatchToaster, addWarning] ); const onAlertStatusUpdateFailure = useCallback( 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 d8ba0ab2d40b9e..f8b3cd6af8b8a2 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 @@ -44,7 +44,7 @@ export interface UpdateAlertStatusActionProps { selectedStatus: Status; setEventsLoading: ({ eventIds, isLoading }: SetEventsLoadingProps) => void; setEventsDeleted: ({ eventIds, isDeleted }: SetEventsDeletedProps) => void; - onAlertStatusUpdateSuccess: (count: number, status: Status) => void; + onAlertStatusUpdateSuccess: (updated: number, conflicts: number, status: Status) => void; onAlertStatusUpdateFailure: (status: Status, error: Error) => void; } diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts index 3cd819b55685c0..19007c4d2e432b 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.test.ts @@ -67,7 +67,7 @@ describe('Detections Alerts API', () => { }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/signals/status', { body: - '{"status":"closed","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}', + '{"conflicts":"proceed","status":"closed","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}', method: 'POST', signal: abortCtrl.signal, }); @@ -81,7 +81,7 @@ describe('Detections Alerts API', () => { }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/signals/status', { body: - '{"status":"open","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}', + '{"conflicts":"proceed","status":"open","bool":{"filter":{"terms":{"_id":["b4ee5c32e3a321057edcc953ca17228c6fdfe5ba43fdbbdaffa8cefa11605cc5"]}}}}', method: 'POST', signal: abortCtrl.signal, }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts index 3fe676fe2c7d6f..a8a2ae10a3bbd1 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/alerts/api.ts @@ -58,7 +58,7 @@ export const updateAlertStatus = async ({ }: UpdateAlertStatusProps): Promise => KibanaServices.get().http.fetch(DETECTION_ENGINE_SIGNALS_STATUS_URL, { method: 'POST', - body: JSON.stringify({ status, ...query }), + body: JSON.stringify({ conflicts: 'proceed', status, ...query }), signal, }); 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 f12a5d523bade7..0ed091f2c18a63 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 @@ -5,9 +5,9 @@ */ import { - AddRulesProps, PatchRuleProps, - NewRule, + CreateRulesProps, + UpdateRulesProps, PrePackagedRulesStatusResponse, BasicFetchProps, RuleStatusResponse, @@ -16,13 +16,18 @@ import { FetchRulesResponse, FetchRulesProps, } from '../types'; -import { ruleMock, savedRuleMock, rulesMock } from '../mock'; +import { savedRuleMock, rulesMock } from '../mock'; +import { getRulesSchemaMock } from '../../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; +import { RulesSchema } from '../../../../../../common/detection_engine/schemas/response'; -export const addRule = async ({ rule, signal }: AddRulesProps): Promise => - Promise.resolve(ruleMock); +export const updateRule = async ({ rule, signal }: UpdateRulesProps): Promise => + Promise.resolve(getRulesSchemaMock()); -export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise => - Promise.resolve(ruleMock); +export const createRule = async ({ rule, signal }: CreateRulesProps): Promise => + Promise.resolve(getRulesSchemaMock()); + +export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise => + Promise.resolve(getRulesSchemaMock()); export const getPrePackagedRulesStatus = async ({ signal, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts index f58c95ed71e29e..cd1ded544cfe5a 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts @@ -6,7 +6,9 @@ import { KibanaServices } from '../../../../common/lib/kibana'; import { - addRule, + createRule, + updateRule, + patchRule, fetchRules, fetchRuleById, enableRules, @@ -19,9 +21,12 @@ import { fetchTags, getPrePackagedRulesStatus, } from './api'; -import { ruleMock, rulesMock } from './mock'; +import { getRulesSchemaMock } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; +import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/create_rules_schema.mock'; +import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/update_rules_schema.mock'; +import { getPatchRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema.mock'; +import { rulesMock } from './mock'; import { buildEsQuery } from 'src/plugins/data/common'; - const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; jest.mock('../../../../common/lib/kibana'); @@ -30,25 +35,56 @@ const fetchMock = jest.fn(); mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); describe('Detections Rules API', () => { - describe('addRule', () => { + describe('createRule', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue(ruleMock); + fetchMock.mockResolvedValue(getRulesSchemaMock()); }); - test('check parameter url, body', async () => { - await addRule({ rule: ruleMock, signal: abortCtrl.signal }); + test('POSTs rule', async () => { + const payload = getCreateRulesSchemaMock(); + await createRule({ rule: payload, signal: abortCtrl.signal }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', { body: - '{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[],"throttle":null}', + '{"description":"Detecting root and admin users","name":"Query with a rule id","query":"user.name: root or user.name: admin","severity":"high","type":"query","risk_score":55,"language":"kuery","rule_id":"rule-1"}', method: 'POST', signal: abortCtrl.signal, }); }); + }); - test('happy path', async () => { - const ruleResp = await addRule({ rule: ruleMock, signal: abortCtrl.signal }); - expect(ruleResp).toEqual(ruleMock); + describe('updateRule', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(getRulesSchemaMock()); + }); + + test('PUTs rule', async () => { + const payload = getUpdateRulesSchemaMock(); + await updateRule({ rule: payload, signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', { + body: + '{"description":"some description","name":"Query with a rule id","query":"user.name: root or user.name: admin","severity":"high","type":"query","risk_score":55,"language":"kuery","rule_id":"rule-1"}', + method: 'PUT', + signal: abortCtrl.signal, + }); + }); + }); + + describe('patchRule', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(getRulesSchemaMock()); + }); + + test('PATCHs rule', async () => { + const payload = getPatchRulesSchemaMock(); + await patchRule({ ruleProperties: payload, signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', { + body: JSON.stringify(payload), + method: 'PATCH', + signal: abortCtrl.signal, + }); }); }); @@ -280,7 +316,7 @@ describe('Detections Rules API', () => { describe('fetchRuleById', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue(ruleMock); + fetchMock.mockResolvedValue(getRulesSchemaMock()); }); test('check parameter url, query', async () => { @@ -296,7 +332,7 @@ describe('Detections Rules API', () => { test('happy path', async () => { const ruleResp = await fetchRuleById({ id: 'mySuperRuleId', signal: abortCtrl.signal }); - expect(ruleResp).toEqual(ruleMock); + expect(ruleResp).toEqual(getRulesSchemaMock()); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 3538d8ec8c9b95..e254516d110768 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { HttpStart } from '../../../../../../../../src/core/public'; import { DETECTION_ENGINE_RULES_URL, @@ -13,13 +12,13 @@ import { DETECTION_ENGINE_TAGS_URL, } from '../../../../../common/constants'; import { - AddRulesProps, + UpdateRulesProps, + CreateRulesProps, DeleteRulesProps, DuplicateRulesProps, EnableRulesProps, FetchRulesProps, FetchRulesResponse, - NewRule, Rule, FetchRuleProps, BasicFetchProps, @@ -33,32 +32,51 @@ import { } from './types'; import { KibanaServices } from '../../../../common/lib/kibana'; import * as i18n from '../../../pages/detection_engine/rules/translations'; +import { RulesSchema } from '../../../../../common/detection_engine/schemas/response'; /** - * Add provided Rule + * Create provided Rule * - * @param rule to add + * @param rule CreateRulesSchema to add * @param signal to cancel request * * @throws An error if response is not OK */ -export const addRule = async ({ rule, signal }: AddRulesProps): Promise => - KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_URL, { - method: rule.id != null ? 'PUT' : 'POST', +export const createRule = async ({ rule, signal }: CreateRulesProps): Promise => + KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_URL, { + method: 'POST', + body: JSON.stringify(rule), + signal, + }); + +/** + * Update provided Rule using PUT + * + * @param rule UpdateRulesSchema to be updated + * @param signal to cancel request + * + * @throws An error if response is not OK + */ +export const updateRule = async ({ rule, signal }: UpdateRulesProps): Promise => + KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_URL, { + method: 'PUT', body: JSON.stringify(rule), signal, }); /** - * Patch provided Rule + * Patch provided rule + * NOTE: The rule edit flow does NOT use patch as it relies on the + * functionality of PUT to delete field values when not provided, if + * just expecting changes, use this `patchRule` * * @param ruleProperties to patch * @param signal to cancel request * * @throws An error if response is not OK */ -export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise => - KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_URL, { +export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise => + KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_URL, { method: 'PATCH', body: JSON.stringify(ruleProperties), signal, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts index c7ecfb33cd9052..a40ab2e4878519 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts @@ -6,7 +6,8 @@ export * from './api'; export * from './fetch_index_patterns'; -export * from './persist_rule'; +export * from './use_update_rule'; +export * from './use_create_rule'; export * from './types'; export * from './use_rule'; export * from './use_rules'; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts index fa11cfabcdf8ba..c0397b0af6db9f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts @@ -4,36 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { NewRule, FetchRulesResponse, Rule } from './types'; - -export const ruleMock: NewRule = { - description: 'some desc', - enabled: true, - false_positives: [], - filters: [], - from: 'now-360s', - index: [ - 'apm-*-transaction*', - 'auditbeat-*', - 'endgame-*', - 'filebeat-*', - 'packetbeat-*', - 'winlogbeat-*', - ], - interval: '5m', - rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf', - language: 'kuery', - risk_score: 75, - name: 'Test rule', - query: "user.email: 'root@elastic.co'", - references: [], - severity: 'high', - tags: ['APM'], - to: 'now', - type: 'query', - threat: [], - throttle: null, -}; +import { FetchRulesResponse, Rule } from './types'; export const savedRuleMock: Rule = { author: [], 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 166bb90113aef7..e94e57ad82bcfe 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 @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import { + SortOrder, author, building_block_type, license, @@ -17,11 +18,12 @@ import { threshold, type, } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { listArray } from '../../../../../common/detection_engine/schemas/types'; import { - listArray, - listArrayOrUndefined, -} from '../../../../../common/detection_engine/schemas/types'; -import { PatchRulesSchema } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema'; + CreateRulesSchema, + PatchRulesSchema, + UpdateRulesSchema, +} from '../../../../../common/detection_engine/schemas/request'; /** * Params is an "record", since it is a type of AlertActionParams which is action templates. @@ -36,48 +38,13 @@ export const action = t.exact( }) ); -export const NewRuleSchema = t.intersection([ - t.type({ - description: t.string, - enabled: t.boolean, - interval: t.string, - name: t.string, - risk_score: t.number, - severity: t.string, - type, - }), - t.partial({ - actions: t.array(action), - anomaly_threshold: t.number, - created_by: t.string, - false_positives: t.array(t.string), - filters: t.array(t.unknown), - from: t.string, - id: t.string, - index: t.array(t.string), - language: t.string, - machine_learning_job_id: t.string, - max_signals: t.number, - query: t.string, - references: t.array(t.string), - rule_id: t.string, - 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, - note: t.string, - exceptions_list: listArrayOrUndefined, - }), -]); - -export const NewRulesSchema = t.array(NewRuleSchema); -export type NewRule = t.TypeOf; +export interface CreateRulesProps { + rule: CreateRulesSchema; + signal: AbortSignal; +} -export interface AddRulesProps { - rule: NewRule; +export interface UpdateRulesProps { + rule: UpdateRulesSchema; signal: AbortSignal; } @@ -185,7 +152,7 @@ export interface FetchRulesProps { export interface FilterOptions { filter: string; sortField: string; - sortOrder: 'asc' | 'desc'; + sortOrder: SortOrder; showCustomRules?: boolean; showElasticRules?: boolean; tags?: string[]; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.test.tsx similarity index 68% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.test.tsx rename to x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.test.tsx index 1bf21623992e61..42d6a2a92a4c2f 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.test.tsx @@ -6,25 +6,25 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { usePersistRule, ReturnPersistRule } from './persist_rule'; -import { ruleMock } from './mock'; +import { useCreateRule, ReturnCreateRule } from './use_create_rule'; +import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/update_rules_schema.mock'; jest.mock('./api'); -describe('usePersistRule', () => { +describe('useCreateRule', () => { test('init', async () => { - const { result } = renderHook(() => usePersistRule()); + const { result } = renderHook(() => useCreateRule()); expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]); }); test('saving rule with isLoading === true', async () => { await act(async () => { - const { result, rerender, waitForNextUpdate } = renderHook(() => - usePersistRule() + const { result, rerender, waitForNextUpdate } = renderHook(() => + useCreateRule() ); await waitForNextUpdate(); - result.current[1](ruleMock); + result.current[1](getUpdateRulesSchemaMock()); rerender(); expect(result.current).toEqual([{ isLoading: true, isSaved: false }, result.current[1]]); }); @@ -32,11 +32,11 @@ describe('usePersistRule', () => { test('saved rule with isSaved === true', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook(() => - usePersistRule() + const { result, waitForNextUpdate } = renderHook(() => + useCreateRule() ); await waitForNextUpdate(); - result.current[1](ruleMock); + result.current[1](getUpdateRulesSchemaMock()); await waitForNextUpdate(); expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx similarity index 76% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.tsx rename to x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx index fd139d59c0a271..2bbd27994fc771 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx @@ -7,20 +7,20 @@ import { useEffect, useState, Dispatch } from 'react'; import { errorToToaster, useStateToaster } from '../../../../common/components/toasters'; +import { CreateRulesSchema } from '../../../../../common/detection_engine/schemas/request'; -import { addRule as persistRule } from './api'; +import { createRule } from './api'; import * as i18n from './translations'; -import { NewRule } from './types'; -interface PersistRuleReturn { +interface CreateRuleReturn { isLoading: boolean; isSaved: boolean; } -export type ReturnPersistRule = [PersistRuleReturn, Dispatch]; +export type ReturnCreateRule = [CreateRuleReturn, Dispatch]; -export const usePersistRule = (): ReturnPersistRule => { - const [rule, setRule] = useState(null); +export const useCreateRule = (): ReturnCreateRule => { + const [rule, setRule] = useState(null); const [isSaved, setIsSaved] = useState(false); const [isLoading, setIsLoading] = useState(false); const [, dispatchToaster] = useStateToaster(); @@ -33,7 +33,7 @@ export const usePersistRule = (): ReturnPersistRule => { if (rule != null) { try { setIsLoading(true); - await persistRule({ rule, signal: abortCtrl.signal }); + await createRule({ rule, signal: abortCtrl.signal }); if (isSubscribed) { setIsSaved(true); } diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx index 6721d89f2799b7..2ba78cd90cf9b0 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx @@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; import * as api from './api'; -import { ruleMock } from './mock'; +import { getRulesSchemaMock } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; import { ReturnUseDissasociateExceptionList, UseDissasociateExceptionListProps, @@ -23,7 +23,7 @@ describe('useDissasociateExceptionList', () => { const onSuccess = jest.fn(); beforeEach(() => { - jest.spyOn(api, 'patchRule').mockResolvedValue(ruleMock); + jest.spyOn(api, 'patchRule').mockResolvedValue(getRulesSchemaMock()); }); afterEach(() => { 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 9a6ea4f60fdcc9..92d46a785b0349 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 @@ -10,7 +10,7 @@ import * as api from './api'; jest.mock('./api'); -describe('usePersistRule', () => { +describe('usePrePackagedRules', () => { beforeEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.test.tsx new file mode 100644 index 00000000000000..9603a4151933a4 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useUpdateRule, ReturnUpdateRule } from './use_update_rule'; +import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/update_rules_schema.mock'; + +jest.mock('./api'); + +describe('useUpdateRule', () => { + test('init', async () => { + const { result } = renderHook(() => useUpdateRule()); + + expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]); + }); + + test('saving rule with isLoading === true', async () => { + await act(async () => { + const { result, rerender, waitForNextUpdate } = renderHook(() => + useUpdateRule() + ); + await waitForNextUpdate(); + result.current[1](getUpdateRulesSchemaMock()); + rerender(); + expect(result.current).toEqual([{ isLoading: true, isSaved: false }, result.current[1]]); + }); + }); + + test('saved rule with isSaved === true', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook(() => + useUpdateRule() + ); + await waitForNextUpdate(); + result.current[1](getUpdateRulesSchemaMock()); + await waitForNextUpdate(); + expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx new file mode 100644 index 00000000000000..a437974e93ba30 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState, Dispatch } from 'react'; + +import { errorToToaster, useStateToaster } from '../../../../common/components/toasters'; +import { UpdateRulesSchema } from '../../../../../common/detection_engine/schemas/request'; + +import { updateRule } from './api'; +import * as i18n from './translations'; + +interface UpdateRuleReturn { + isLoading: boolean; + isSaved: boolean; +} + +export type ReturnUpdateRule = [UpdateRuleReturn, Dispatch]; + +export const useUpdateRule = (): ReturnUpdateRule => { + const [rule, setRule] = useState(null); + const [isSaved, setIsSaved] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + setIsSaved(false); + async function saveRule() { + if (rule != null) { + try { + setIsLoading(true); + await updateRule({ rule, signal: abortCtrl.signal }); + if (isSubscribed) { + setIsSaved(true); + } + } catch (error) { + if (isSubscribed) { + errorToToaster({ title: i18n.RULE_ADD_FAILURE, error, dispatchToaster }); + } + } + if (isSubscribed) { + setIsLoading(false); + } + } + } + + saveRule(); + return () => { + isSubscribed = false; + abortCtrl.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rule]); + + return [{ isLoading, isSaved }, setRule]; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts index d6dc97fbae1587..79488231b29ee6 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts @@ -5,7 +5,8 @@ */ import { List } from '../../../../../../common/detection_engine/schemas/types'; -import { NewRule } from '../../../../containers/detection_engine/rules'; +import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request/create_rules_schema'; +import { Rule } from '../../../../containers/detection_engine/rules'; import { getListMock, getEndpointListMock, @@ -721,13 +722,13 @@ describe('helpers', () => { mockActions = mockActionsStepRule(); }); - test('returns NewRule with type of saved_query when saved_id exists', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); + test('returns rule with type of saved_query when saved_id exists', () => { + const result: Rule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); expect(result.type).toEqual('saved_query'); }); - test('returns NewRule with type of query when saved_id does not exist', () => { + test('returns rule with type of query when saved_id does not exist', () => { const mockDefineStepRuleWithoutSavedId = { ...mockDefine, queryBar: { @@ -735,7 +736,7 @@ describe('helpers', () => { saved_id: '', }, }; - const result: NewRule = formatRule( + const result: CreateRulesSchema = formatRule( mockDefineStepRuleWithoutSavedId, mockAbout, mockSchedule, @@ -745,10 +746,15 @@ describe('helpers', () => { expect(result.type).toEqual('query'); }); - test('returns NewRule without id if ruleId does not exist', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); + test('returns rule without id if ruleId does not exist', () => { + const result: CreateRulesSchema = formatRule( + mockDefine, + mockAbout, + mockSchedule, + mockActions + ); - expect(result.id).toBeUndefined(); + expect(result).not.toHaveProperty('id'); }); }); 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 f4a40b771c9fa9..874ef032b7c5ec 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 @@ -14,7 +14,7 @@ import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { isThresholdRule } from '../../../../../../common/detection_engine/utils'; import { List } from '../../../../../../common/detection_engine/schemas/types'; import { ENDPOINT_LIST_ID } from '../../../../../shared_imports'; -import { NewRule, Rule } from '../../../../containers/detection_engine/rules'; +import { Rule } from '../../../../containers/detection_engine/rules'; import { Type } from '../../../../../../common/detection_engine/schemas/common/schemas'; import { @@ -237,16 +237,19 @@ export const formatActionsStepData = (actionsStepData: ActionsStepRule): Actions }; }; -export const formatRule = ( +// Used to format form data in rule edit and +// create flows so "T" here would likely +// either be CreateRulesSchema or Rule +export const formatRule = ( defineStepData: DefineStepRule, aboutStepData: AboutStepRule, scheduleData: ScheduleStepRule, actionsData: ActionsStepRule, rule?: Rule | null -): NewRule => - deepmerge.all([ +): T => + (deepmerge.all([ formatDefineStepData(defineStepData), formatAboutStepData(aboutStepData, rule?.exceptions_list), formatScheduleStepData(scheduleData), formatActionsStepData(actionsData), - ]) as NewRule; + ]) as unknown) as T; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index d2eb3228cbbf36..22d84c593b4156 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -9,7 +9,8 @@ import React, { useCallback, useRef, useState, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; import styled, { StyledComponent } from 'styled-components'; -import { usePersistRule } from '../../../../containers/detection_engine/rules'; +import { useCreateRule } from '../../../../containers/detection_engine/rules'; +import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { @@ -122,7 +123,7 @@ const CreateRulePageComponent: React.FC = () => { [RuleStep.scheduleRule]: false, [RuleStep.ruleActions]: false, }); - const [{ isLoading, isSaved }, setRule] = usePersistRule(); + const [{ isLoading, isSaved }, setRule] = useCreateRule(); const actionMessageParams = useMemo( () => getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule)?.ruleType), @@ -159,7 +160,7 @@ const CreateRulePageComponent: React.FC = () => { stepsData.current[RuleStep.scheduleRule].isValid ) { setRule( - formatRule( + formatRule( stepsData.current[RuleStep.defineRule].data as DefineStepRule, stepsData.current[RuleStep.aboutRule].data as AboutStepRule, stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx index 530222ee19624e..b251b2eba10ae0 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -17,7 +17,7 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams, useHistory } from 'react-router-dom'; -import { useRule, usePersistRule } from '../../../../containers/detection_engine/rules'; +import { useRule, useUpdateRule } from '../../../../containers/detection_engine/rules'; import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { WrapperPage } from '../../../../../common/components/wrapper_page'; import { @@ -51,6 +51,7 @@ import { } from '../types'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../../app/types'; +import { UpdateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; interface StepRuleForm { isValid: boolean; @@ -113,7 +114,7 @@ const EditRulePageComponent: FC = () => { [RuleStep.scheduleRule]: null, [RuleStep.ruleActions]: null, }); - const [{ isLoading, isSaved }, setRule] = usePersistRule(); + const [{ isLoading, isSaved }, setRule] = useUpdateRule(); const [tabHasError, setTabHasError] = useState([]); // eslint-disable-next-line react-hooks/exhaustive-deps const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule]); @@ -261,7 +262,7 @@ const EditRulePageComponent: FC = () => { if (invalidForms.length === 0 && activeForm != null) { setTabHasError([]); setRule({ - ...formatRule( + ...formatRule( (activeFormId === RuleStep.defineRule ? activeForm.data : myDefineRuleForm.data) as DefineStepRule, 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 891af4b8ca80ef..a7603051add498 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 @@ -18,6 +18,7 @@ import { RiskScoreMapping, RuleNameOverride, SeverityMapping, + SortOrder, TimestampOverride, Type, } from '../../../../../common/detection_engine/schemas/common/schemas'; @@ -25,7 +26,7 @@ import { List } from '../../../../../common/detection_engine/schemas/types'; export interface EuiBasicTableSortTypes { field: string; - direction: 'asc' | 'desc'; + direction: SortOrder; } export interface EuiBasicTableOnChange { diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx index 6bed779d49638a..747f5e4f502dd8 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_countries/index.tsx @@ -50,14 +50,12 @@ interface UseNetworkTopCountries { endDate: string; startDate: string; skip: boolean; - id?: string; } export const useNetworkTopCountries = ({ endDate, filterQuery, flowTarget, - id = ID, skip, startDate, type, @@ -101,7 +99,7 @@ export const useNetworkTopCountries = ({ NetworkTopCountriesArgs >({ networkTopCountries: [], - id: ID, + id: `${ID}-${flowTarget}`, inspect: { dsl: [], response: [], diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx index 770574b0813c1c..cc0da816c57eca 100644 --- a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/index.tsx @@ -4,161 +4,196 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getOr } from 'lodash/fp'; -import React from 'react'; -import { Query } from 'react-apollo'; -import { connect } from 'react-redux'; -import { compose } from 'redux'; +import { noop } from 'lodash/fp'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { shallowEqual, useSelector } from 'react-redux'; +import deepEqual from 'fast-deep-equal'; +import { ESTermQuery } from '../../../../common/typed_json'; import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; +import { inputsModel, State } from '../../../common/store'; +import { useKibana } from '../../../common/lib/kibana'; +import { createFilter } from '../../../common/containers/helpers'; +import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; +import { networkModel, networkSelectors } from '../../store'; import { FlowTargetSourceDest, - GetNetworkTopNFlowQuery, + NetworkQueries, NetworkTopNFlowEdges, - NetworkTopTablesSortField, + NetworkTopNFlowRequestOptions, + NetworkTopNFlowStrategyResponse, PageInfoPaginated, -} from '../../../graphql/types'; -import { withKibana, WithKibanaProps } from '../../../common/lib/kibana'; -import { inputsModel, inputsSelectors, State } from '../../../common/store'; -import { generateTablePaginationOptions } from '../../../common/components/paginated_table/helpers'; -import { createFilter, getDefaultFetchPolicy } from '../../../common/containers/helpers'; -import { - QueryTemplatePaginated, - QueryTemplatePaginatedProps, -} from '../../../common/containers/query_template_paginated'; -import { networkTopNFlowQuery } from './index.gql_query'; -import { networkModel, networkSelectors } from '../../store'; +} from '../../../../common/search_strategy'; +import { AbortError } from '../../../../../../../src/plugins/data/common'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; +import * as i18n from './translations'; const ID = 'networkTopNFlowQuery'; export interface NetworkTopNFlowArgs { id: string; - ip?: string; - inspect: inputsModel.InspectQuery; + inspect: InspectResponse; isInspected: boolean; - loading: boolean; loadPage: (newActivePage: number) => void; - networkTopNFlow: NetworkTopNFlowEdges[]; pageInfo: PageInfoPaginated; refetch: inputsModel.Refetch; + networkTopNFlow: NetworkTopNFlowEdges[]; totalCount: number; } -export interface OwnProps extends QueryTemplatePaginatedProps { - children: (args: NetworkTopNFlowArgs) => React.ReactNode; +interface UseNetworkTopNFlow { flowTarget: FlowTargetSourceDest; ip?: string; type: networkModel.NetworkType; + filterQuery?: ESTermQuery | string; + endDate: string; + startDate: string; + skip: boolean; } -export interface NetworkTopNFlowComponentReduxProps { - activePage: number; - isInspected: boolean; - limit: number; - sort: NetworkTopTablesSortField; -} +export const useNetworkTopNFlow = ({ + endDate, + filterQuery, + flowTarget, + skip, + startDate, + type, +}: UseNetworkTopNFlow): [boolean, NetworkTopNFlowArgs] => { + const getTopNFlowSelector = networkSelectors.topNFlowSelector(); + const { activePage, limit, sort } = useSelector( + (state: State) => getTopNFlowSelector(state, type, flowTarget), + shallowEqual + ); + const { data, notifications, uiSettings } = useKibana().services; + const refetch = useRef(noop); + const abortCtrl = useRef(new AbortController()); + const defaultIndex = uiSettings.get(DEFAULT_INDEX_KEY); + const [loading, setLoading] = useState(false); + + const [networkTopNFlowRequest, setTopNFlowRequest] = useState({ + defaultIndex, + factoryQueryType: NetworkQueries.topNFlow, + filterQuery: createFilter(filterQuery), + flowTarget, + pagination: generateTablePaginationOptions(activePage, limit), + sort, + timerange: { + interval: '12h', + from: startDate ? startDate : '', + to: endDate ? endDate : new Date(Date.now()).toISOString(), + }, + }); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setTopNFlowRequest((prevRequest) => ({ + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + })); + }, + [limit] + ); -type NetworkTopNFlowProps = OwnProps & NetworkTopNFlowComponentReduxProps & WithKibanaProps; + const [networkTopNFlowResponse, setNetworkTopNFlowResponse] = useState({ + networkTopNFlow: [], + id: `${ID}-${flowTarget}`, + inspect: { + dsl: [], + response: [], + }, + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + totalCount: -1, + }); -class NetworkTopNFlowComponentQuery extends QueryTemplatePaginated< - NetworkTopNFlowProps, - GetNetworkTopNFlowQuery.Query, - GetNetworkTopNFlowQuery.Variables -> { - public render() { - const { - activePage, - children, - endDate, - flowTarget, - filterQuery, - kibana, - id = `${ID}-${flowTarget}`, - ip, - isInspected, - limit, - skip, - sourceId, - startDate, - sort, - } = this.props; - const variables: GetNetworkTopNFlowQuery.Variables = { - defaultIndex: kibana.services.uiSettings.get(DEFAULT_INDEX_KEY), - filterQuery: createFilter(filterQuery), - flowTarget, - inspect: isInspected, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - sourceId, - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, - }, - }; - return ( - - fetchPolicy={getDefaultFetchPolicy()} - notifyOnNetworkStatusChange - query={networkTopNFlowQuery} - skip={skip} - variables={variables} - > - {({ data, loading, fetchMore, networkStatus, refetch }) => { - const networkTopNFlow = getOr([], `source.NetworkTopNFlow.edges`, data); - this.setFetchMore(fetchMore); - this.setFetchMoreOptions((newActivePage: number) => ({ - variables: { - pagination: generateTablePaginationOptions(newActivePage, limit), + const networkTopNFlowSearch = useCallback( + (request: NetworkTopNFlowRequestOptions) => { + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search(request, { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + }) + .subscribe({ + next: (response) => { + if (!response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + setNetworkTopNFlowResponse((prevResponse) => ({ + ...prevResponse, + networkTopNFlow: response.edges, + inspect: getInspectResponse(response, prevResponse.inspect), + pageInfo: response.pageInfo, + refetch: refetch.current, + totalCount: response.totalCount, + })); + } + searchSubscription$.unsubscribe(); + } else if (response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + } + // TODO: Make response error status clearer + notifications.toasts.addWarning(i18n.ERROR_NETWORK_TOP_N_FLOW); + searchSubscription$.unsubscribe(); + } }, - updateQuery: (prev, { fetchMoreResult }) => { - if (!fetchMoreResult) { - return prev; + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ + title: i18n.FAIL_NETWORK_TOP_N_FLOW, + text: msg.message, + }); } - return { - ...fetchMoreResult, - source: { - ...fetchMoreResult.source, - NetworkTopNFlow: { - ...fetchMoreResult.source.NetworkTopNFlow, - edges: [...fetchMoreResult.source.NetworkTopNFlow.edges], - }, - }, - }; }, - })); - const isLoading = this.isItAValidLoading(loading, variables, networkStatus); - return children({ - id, - inspect: getOr(null, 'source.NetworkTopNFlow.inspect', data), - isInspected, - loading: isLoading, - loadPage: this.wrappedLoadMore, - networkTopNFlow, - pageInfo: getOr({}, 'source.NetworkTopNFlow.pageInfo', data), - refetch: this.memoizedRefetchQuery(variables, limit, refetch), - totalCount: getOr(-1, 'source.NetworkTopNFlow.totalCount', data), }); - }} - - ); - } -} + }; + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts] + ); -const makeMapStateToProps = () => { - const getTopNFlowSelector = networkSelectors.topNFlowSelector(); - const getQuery = inputsSelectors.globalQueryByIdSelector(); - return (state: State, { flowTarget, id = `${ID}-${flowTarget}`, type }: OwnProps) => { - const { isInspected } = getQuery(state, id); - return { - ...getTopNFlowSelector(state, type, flowTarget), - isInspected, - }; - }; -}; + useEffect(() => { + setTopNFlowRequest((prevRequest) => { + const myRequest = { + ...prevRequest, + defaultIndex, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + sort, + }; + if (!skip && !deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [activePage, defaultIndex, endDate, filterQuery, limit, startDate, sort, skip]); -export const NetworkTopNFlowQuery = compose>( - connect(makeMapStateToProps), - withKibana -)(NetworkTopNFlowComponentQuery); + useEffect(() => { + networkTopNFlowSearch(networkTopNFlowRequest); + }, [networkTopNFlowRequest, networkTopNFlowSearch]); + + return [loading, networkTopNFlowResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/translations.ts b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/translations.ts new file mode 100644 index 00000000000000..4ea704571cf2e0 --- /dev/null +++ b/x-pack/plugins/security_solution/public/network/containers/network_top_n_flow/translations.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ERROR_NETWORK_TOP_N_FLOW = i18n.translate( + 'xpack.securitySolution.networkTopNFlow.errorSearchDescription', + { + defaultMessage: `An error has occurred on network top n flow search`, + } +); + +export const FAIL_NETWORK_TOP_N_FLOW = i18n.translate( + 'xpack.securitySolution.networkTopNFlow.failSearchDescription', + { + defaultMessage: `Failed to run search on network top n flow`, + } +); diff --git a/x-pack/plugins/security_solution/public/network/pages/ip_details/network_top_n_flow_query_table.tsx b/x-pack/plugins/security_solution/public/network/pages/ip_details/network_top_n_flow_query_table.tsx index 158b4057a7d5e3..821452201b78b0 100644 --- a/x-pack/plugins/security_solution/public/network/pages/ip_details/network_top_n_flow_query_table.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/ip_details/network_top_n_flow_query_table.tsx @@ -8,7 +8,7 @@ import { getOr } from 'lodash/fp'; import React from 'react'; import { manageQuery } from '../../../common/components/page/manage_query'; import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table'; -import { NetworkTopNFlowQuery } from '../../containers/network_top_n_flow'; +import { useNetworkTopNFlow } from '../../containers/network_top_n_flow'; import { NetworkWithIndexComponentsQueryTableProps } from './types'; const NetworkTopNFlowTableManage = manageQuery(NetworkTopNFlowTable); @@ -22,45 +22,37 @@ export const NetworkTopNFlowQueryTable = ({ skip, startDate, type, -}: NetworkWithIndexComponentsQueryTableProps) => ( - - {({ - id, - inspect, - isInspected, - loading, - loadPage, - networkTopNFlow, - pageInfo, - refetch, - totalCount, - }) => ( - - )} - -); +}: NetworkWithIndexComponentsQueryTableProps) => { + const [ + loading, + { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, + ] = useNetworkTopNFlow({ + endDate, + filterQuery, + flowTarget, + ip, + skip, + startDate, + type, + }); + + return ( + + ); +}; NetworkTopNFlowQueryTable.displayName = 'NetworkTopNFlowQueryTable'; diff --git a/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx b/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx index a9f4d504847a07..c83bf6ff809012 100644 --- a/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/network/pages/navigation/ips_query_tab_body.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { getOr } from 'lodash/fp'; import { NetworkTopNFlowTable } from '../../components/network_top_n_flow_table'; -import { NetworkTopNFlowQuery } from '../../containers/network_top_n_flow'; +import { useNetworkTopNFlow } from '../../containers/network_top_n_flow'; import { networkModel } from '../../store'; import { manageQuery } from '../../../common/components/page/manage_query'; @@ -23,44 +23,36 @@ export const IPsQueryTabBody = ({ startDate, setQuery, flowTarget, -}: IPsQueryTabBodyProps) => ( - - {({ - id, - inspect, - isInspected, - loading, - loadPage, - networkTopNFlow, - pageInfo, - refetch, - totalCount, - }) => ( - - )} - -); +}: IPsQueryTabBodyProps) => { + const [ + loading, + { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, + ] = useNetworkTopNFlow({ + endDate, + flowTarget, + filterQuery, + skip, + startDate, + type: networkModel.NetworkType.page, + }); + + return ( + + ); +}; IPsQueryTabBody.displayName = 'IPsQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts index dee53a624baff5..55d52d4ba32524 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts @@ -12,7 +12,6 @@ import { ResolverTree, ResolverEntityIndex, } from '../../../common/endpoint/types'; -import { DEFAULT_INDEX_KEY as defaultIndexKey } from '../../../common/constants'; /** * The data access layer for resolver. All communication with the Kibana server is done through this object. This object is provided to Resolver. In tests, a mock data access layer can be used instead. @@ -38,13 +37,6 @@ export function dataAccessLayerFactory( }); }, - /** - * Used to get the default index pattern from the SIEM application. - */ - indexPatterns(): string[] { - return context.services.uiSettings.get(defaultIndexKey); - }, - /** * Used to get the entity_id for an _id. */ diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts index 43282848dcf9a1..631eab18fc014e 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts @@ -12,7 +12,7 @@ import { import { mockTreeWithNoProcessEvents } from '../../mocks/resolver_tree'; import { DataAccessLayer } from '../../types'; -type EmptiableRequests = 'relatedEvents' | 'resolverTree' | 'entities' | 'indexPatterns'; +type EmptiableRequests = 'relatedEvents' | 'resolverTree' | 'entities'; interface Metadata { /** @@ -66,15 +66,6 @@ export function emptifyMock( : dataAccessLayer.resolverTree(...args); }, - /** - * Get an array of index patterns that contain events. - */ - indexPatterns(...args): string[] { - return dataShouldBeEmpty.includes('indexPatterns') - ? [] - : dataAccessLayer.indexPatterns(...args); - }, - /** * Get entities matching a document. */ diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts index b0407fa5d7c1d1..0883a3787fcced 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts @@ -78,13 +78,6 @@ export function noAncestorsTwoChildren(): { dataAccessLayer: DataAccessLayer; me ); }, - /** - * Get an array of index patterns that contain events. - */ - indexPatterns(): string[] { - return ['index pattern']; - }, - /** * Get entities matching a document. */ diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts new file mode 100644 index 00000000000000..ec0fa934857832 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../../../common/endpoint/types'; +import { mockEndpointEvent } from '../../mocks/endpoint_event'; +import { mockTreeWithNoAncestorsAnd2Children } from '../../mocks/resolver_tree'; +import { DataAccessLayer } from '../../types'; + +interface Metadata { + /** + * The `_id` of the document being analyzed. + */ + databaseDocumentID: string; + /** + * A record of entityIDs to be used in tests assertions. + */ + entityIDs: { + /** + * The entityID of the node related to the document being analyzed. + */ + origin: 'origin'; + /** + * The entityID of the first child of the origin. + */ + firstChild: 'firstChild'; + /** + * The entityID of the second child of the origin. + */ + secondChild: 'secondChild'; + }; +} + +/** + * A mock DataAccessLayer that will return an origin in two children. The `entity` response will be empty unless + * `awesome_index` is passed in the indices array. + */ +export function noAncestorsTwoChildenInIndexCalledAwesomeIndex(): { + dataAccessLayer: DataAccessLayer; + metadata: Metadata; +} { + const metadata: Metadata = { + databaseDocumentID: '_id', + entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' }, + }; + return { + metadata, + dataAccessLayer: { + /** + * Fetch related events for an entity ID + */ + relatedEvents(entityID: string): Promise { + return Promise.resolve({ + entityID, + events: [ + mockEndpointEvent({ + entityID, + name: 'event', + timestamp: 0, + }), + ], + nextEvent: null, + }); + }, + + /** + * Fetch a ResolverTree for a entityID + */ + resolverTree(): Promise { + return Promise.resolve( + mockTreeWithNoAncestorsAnd2Children({ + originID: metadata.entityIDs.origin, + firstChildID: metadata.entityIDs.firstChild, + secondChildID: metadata.entityIDs.secondChild, + }) + ); + }, + + /** + * Get entities matching a document. + */ + entities({ indices }): Promise { + // Only return values if the `indices` array contains exactly `'awesome_index'` + if (indices.length === 1 && indices[0] === 'awesome_index') { + return Promise.resolve([{ entity_id: metadata.entityIDs.origin }]); + } + return Promise.resolve([]); + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts index 01e75e3eefdbfa..95ec0cd1a5f775 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts @@ -76,13 +76,6 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): { return Promise.resolve(tree); }, - /** - * Get an array of index patterns that contain events. - */ - indexPatterns(): string[] { - return ['index pattern']; - }, - /** * Get entities matching a document. */ diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts index baddcdfd0cd841..6a4955b104b8fb 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts @@ -105,13 +105,6 @@ export function pausifyMock({ return dataAccessLayer.resolverTree(...args); }, - /** - * Get an array of index patterns that contain events. - */ - indexPatterns(...args): string[] { - return dataAccessLayer.indexPatterns(...args); - }, - /** * Get entities matching a document. */ diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/tree_fetcher_parameters.ts b/x-pack/plugins/security_solution/public/resolver/mocks/tree_fetcher_parameters.ts new file mode 100644 index 00000000000000..98efb459a0691b --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/mocks/tree_fetcher_parameters.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TreeFetcherParameters } from '../types'; + +/** + * A factory for the most basic `TreeFetcherParameters`. Many tests need to provide this even when the values aren't relevant to the test. + */ +export function mockTreeFetcherParameters(): TreeFetcherParameters { + return { + databaseDocumentID: '', + indices: [], + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.test.ts b/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.test.ts new file mode 100644 index 00000000000000..faa4edfccdc350 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TreeFetcherParameters } from '../types'; + +import { equal } from './tree_fetcher_parameters'; +describe('TreeFetcherParameters#equal:', () => { + const cases: Array<[TreeFetcherParameters, TreeFetcherParameters, boolean]> = [ + // different databaseDocumentID + [{ databaseDocumentID: 'a', indices: [] }, { databaseDocumentID: 'b', indices: [] }, false], + // different indices length + [{ databaseDocumentID: 'a', indices: [''] }, { databaseDocumentID: 'a', indices: [] }, false], + // same indices length, different databaseDocumentID + [{ databaseDocumentID: 'a', indices: [''] }, { databaseDocumentID: 'b', indices: [''] }, false], + // 1 item in `indices` + [{ databaseDocumentID: 'b', indices: [''] }, { databaseDocumentID: 'b', indices: [''] }, true], + // 2 item in `indices` + [ + { databaseDocumentID: 'b', indices: ['1', '2'] }, + { databaseDocumentID: 'b', indices: ['1', '2'] }, + true, + ], + // 2 item in `indices`, but order inversed + [ + { databaseDocumentID: 'b', indices: ['2', '1'] }, + { databaseDocumentID: 'b', indices: ['1', '2'] }, + true, + ], + ]; + describe.each(cases)('%p when compared to %p', (first, second, expected) => { + it(`should ${expected ? '' : 'not'}be equal`, () => { + expect(equal(first, second)).toBe(expected); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.ts b/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.ts new file mode 100644 index 00000000000000..d8280c7490901c --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.ts @@ -0,0 +1,40 @@ +/* + * 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 { TreeFetcherParameters } from '../types'; + +/** + * Determine if two instances of `TreeFetcherParameters` are equivalent. Use this to determine if + * a change to a `TreeFetcherParameters` warrants invaliding a request or response. + */ +export function equal(param1: TreeFetcherParameters, param2?: TreeFetcherParameters): boolean { + if (!param2) { + return false; + } + if (param1 === param2) { + return true; + } + if (param1.databaseDocumentID !== param2.databaseDocumentID) { + return false; + } + return arraysContainTheSameElements(param1.indices, param2.indices); +} + +function arraysContainTheSameElements(first: unknown[], second: unknown[]): boolean { + if (first === second) { + return true; + } + if (first.length !== second.length) { + return false; + } + const firstSet = new Set(first); + for (let index = 0; index < second.length; index++) { + if (!firstSet.has(second[index])) { + return false; + } + } + return true; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/actions.ts b/x-pack/plugins/security_solution/public/resolver/store/actions.ts index e03f24d78e2a20..7d71cbd97b9ee7 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -115,7 +115,7 @@ interface AppReceivedNewExternalProperties { /** * the `_id` of an ES document. This defines the origin of the Resolver graph. */ - databaseDocumentID?: string; + databaseDocumentID: string; /** * An ID that uniquely identifies this Resolver instance from other concurrent Resolvers. */ @@ -125,6 +125,11 @@ interface AppReceivedNewExternalProperties { * The `search` part of the URL of this page. */ locationSearch: string; + + /** + * Indices that the backend will use to find the document. + */ + indices: string[]; }; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index 466c37d4ad5f1c..59d1494ae8c272 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -5,6 +5,7 @@ */ import { ResolverRelatedEvents, ResolverTree } from '../../../../common/endpoint/types'; +import { TreeFetcherParameters } from '../../types'; interface ServerReturnedResolverData { readonly type: 'serverReturnedResolverData'; @@ -14,9 +15,9 @@ interface ServerReturnedResolverData { */ result: ResolverTree; /** - * The database document ID that was used to fetch the resolver tree + * The database parameters that was used to fetch the resolver tree */ - databaseDocumentID: string; + parameters: TreeFetcherParameters; }; } @@ -25,7 +26,7 @@ interface AppRequestedResolverData { /** * entity ID used to make the request. */ - readonly payload: string; + readonly payload: TreeFetcherParameters; } interface ServerFailedToReturnResolverData { @@ -33,7 +34,7 @@ interface ServerFailedToReturnResolverData { /** * entity ID used to make the failed request */ - readonly payload: string; + readonly payload: TreeFetcherParameters; } interface AppAbortedResolverDataRequest { @@ -41,7 +42,7 @@ interface AppAbortedResolverDataRequest { /** * entity ID used to make the aborted request */ - readonly payload: string; + readonly payload: TreeFetcherParameters; } /** diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts index 21c4f92f8e502e..e6e525334e818d 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts @@ -12,6 +12,7 @@ import { DataState } from '../../types'; import { DataAction } from './action'; import { ResolverChildNode, ResolverTree } from '../../../../common/endpoint/types'; import * as eventModel from '../../../../common/endpoint/models/event'; +import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; /** * Test the data reducer and selector. @@ -27,7 +28,7 @@ describe('Resolver Data Middleware', () => { type: 'serverReturnedResolverData', payload: { result: tree, - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }; store.dispatch(action); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index c43182ddbf835f..c8df95aaee6f42 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -7,34 +7,53 @@ import { Reducer } from 'redux'; import { DataState } from '../../types'; import { ResolverAction } from '../actions'; +import * as treeFetcherParameters from '../../models/tree_fetcher_parameters'; const initialState: DataState = { relatedEvents: new Map(), relatedEventsReady: new Map(), resolverComponentInstanceID: undefined, + tree: {}, }; export const dataReducer: Reducer = (state = initialState, action) => { if (action.type === 'appReceivedNewExternalProperties') { const nextState: DataState = { ...state, - databaseDocumentID: action.payload.databaseDocumentID, + tree: { + ...state.tree, + currentParameters: { + databaseDocumentID: action.payload.databaseDocumentID, + indices: action.payload.indices, + }, + }, resolverComponentInstanceID: action.payload.resolverComponentInstanceID, }; return nextState; } else if (action.type === 'appRequestedResolverData') { // keep track of what we're requesting, this way we know when to request and when not to. - return { + const nextState: DataState = { ...state, - pendingRequestDatabaseDocumentID: action.payload, + tree: { + ...state.tree, + pendingRequestParameters: { + databaseDocumentID: action.payload.databaseDocumentID, + indices: action.payload.indices, + }, + }, }; + return nextState; } else if (action.type === 'appAbortedResolverDataRequest') { - if (action.payload === state.pendingRequestDatabaseDocumentID) { + if (treeFetcherParameters.equal(action.payload, state.tree.pendingRequestParameters)) { // the request we were awaiting was aborted - return { + const nextState: DataState = { ...state, - pendingRequestDatabaseDocumentID: undefined, + tree: { + ...state.tree, + pendingRequestParameters: undefined, + }, }; + return nextState; } else { return state; } @@ -43,29 +62,35 @@ export const dataReducer: Reducer = (state = initialS const nextState: DataState = { ...state, - /** - * Store the last received data, as well as the databaseDocumentID it relates to. - */ - lastResponse: { - result: action.payload.result, - databaseDocumentID: action.payload.databaseDocumentID, - successful: true, - }, + tree: { + ...state.tree, + /** + * Store the last received data, as well as the databaseDocumentID it relates to. + */ + lastResponse: { + result: action.payload.result, + parameters: action.payload.parameters, + successful: true, + }, - // This assumes that if we just received something, there is no longer a pending request. - // This cannot model multiple in-flight requests - pendingRequestDatabaseDocumentID: undefined, + // This assumes that if we just received something, there is no longer a pending request. + // This cannot model multiple in-flight requests + pendingRequestParameters: undefined, + }, }; return nextState; } else if (action.type === 'serverFailedToReturnResolverData') { /** Only handle this if we are expecting a response */ - if (state.pendingRequestDatabaseDocumentID !== undefined) { + if (state.tree.pendingRequestParameters !== undefined) { const nextState: DataState = { ...state, - pendingRequestDatabaseDocumentID: undefined, - lastResponse: { - databaseDocumentID: state.pendingRequestDatabaseDocumentID, - successful: false, + tree: { + ...state.tree, + pendingRequestParameters: undefined, + lastResponse: { + parameters: state.tree.pendingRequestParameters, + successful: false, + }, }, }; return nextState; @@ -76,16 +101,18 @@ export const dataReducer: Reducer = (state = initialS action.type === 'userRequestedRelatedEventData' || action.type === 'appDetectedMissingEventData' ) { - return { + const nextState: DataState = { ...state, relatedEventsReady: new Map([...state.relatedEventsReady, [action.payload, false]]), }; + return nextState; } else if (action.type === 'serverReturnedRelatedEventData') { - return { + const nextState: DataState = { ...state, relatedEventsReady: new Map([...state.relatedEventsReady, [action.payload.entityID, true]]), relatedEvents: new Map([...state.relatedEvents, [action.payload.entityID, action.payload]]), }; + return nextState; } else { return state; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index dc478ede727902..539325faffdf03 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -18,6 +18,7 @@ import { } from '../../mocks/resolver_tree'; import { uniquePidForProcess } from '../../models/process_event'; import { EndpointEvent } from '../../../../common/endpoint/types'; +import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; describe('data state', () => { let actions: ResolverAction[] = []; @@ -39,29 +40,32 @@ describe('data state', () => { */ const viewAsAString = (dataState: DataState) => { return [ - ['is loading', selectors.isLoading(dataState)], - ['has an error', selectors.hasError(dataState)], + ['is loading', selectors.isTreeLoading(dataState)], + ['has an error', selectors.hadErrorLoadingTree(dataState)], ['has more children', selectors.hasMoreChildren(dataState)], ['has more ancestors', selectors.hasMoreAncestors(dataState)], - ['document to fetch', selectors.databaseDocumentIDToFetch(dataState)], - ['requires a pending request to be aborted', selectors.databaseDocumentIDToAbort(dataState)], + ['parameters to fetch', selectors.treeParametersToFetch(dataState)], + [ + 'requires a pending request to be aborted', + selectors.treeRequestParametersToAbort(dataState), + ], ] .map(([message, value]) => `${message}: ${JSON.stringify(value)}`) .join('\n'); }; - it(`shouldn't initially be loading, or have an error, or have more children or ancestors, or have a document to fetch, or have a pending request that needs to be aborted.`, () => { + it(`shouldn't initially be loading, or have an error, or have more children or ancestors, or have a request to make, or have a pending request that needs to be aborted.`, () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: false has an error: false has more children: false has more ancestors: false - document to fetch: null + parameters to fetch: null requires a pending request to be aborted: null" `); }); - describe('when there is a databaseDocumentID but no pending request', () => { + describe('when there are parameters to fetch but no pending request', () => { const databaseDocumentID = 'databaseDocumentID'; const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { @@ -74,12 +78,13 @@ describe('data state', () => { // `locationSearch` doesn't matter for this test locationSearch: '', + indices: [], }, }, ]; }); - it('should need to fetch the databaseDocumentID', () => { - expect(selectors.databaseDocumentIDToFetch(state())).toBe(databaseDocumentID); + it('should need to request the tree', () => { + expect(selectors.treeParametersToFetch(state())?.databaseDocumentID).toBe(databaseDocumentID); }); it('should not be loading, have an error, have more children or ancestors, or have a pending request that needs to be aborted.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` @@ -87,39 +92,41 @@ describe('data state', () => { has an error: false has more children: false has more ancestors: false - document to fetch: \\"databaseDocumentID\\" + parameters to fetch: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[]} requires a pending request to be aborted: null" `); }); }); - describe('when there is a pending request but no databaseDocumentID', () => { + describe('when there is a pending request but no current tree fetching parameters', () => { const databaseDocumentID = 'databaseDocumentID'; beforeEach(() => { actions = [ { type: 'appRequestedResolverData', - payload: databaseDocumentID, + payload: { databaseDocumentID, indices: [] }, }, ]; }); it('should be loading', () => { - expect(selectors.isLoading(state())).toBe(true); + expect(selectors.isTreeLoading(state())).toBe(true); }); it('should have a request to abort', () => { - expect(selectors.databaseDocumentIDToAbort(state())).toBe(databaseDocumentID); + expect(selectors.treeRequestParametersToAbort(state())?.databaseDocumentID).toBe( + databaseDocumentID + ); }); - it('should not have an error, more children, more ancestors, or a document to fetch.', () => { + it('should not have an error, more children, more ancestors, or request to make.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: true has an error: false has more children: false has more ancestors: false - document to fetch: null - requires a pending request to be aborted: \\"databaseDocumentID\\"" + parameters to fetch: null + requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[]}" `); }); }); - describe('when there is a pending request for the current databaseDocumentID', () => { + describe('when there is a pending request that was made using the current parameters', () => { const databaseDocumentID = 'databaseDocumentID'; const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { @@ -132,27 +139,28 @@ describe('data state', () => { // `locationSearch` doesn't matter for this test locationSearch: '', + indices: [], }, }, { type: 'appRequestedResolverData', - payload: databaseDocumentID, + payload: { databaseDocumentID, indices: [] }, }, ]; }); it('should be loading', () => { - expect(selectors.isLoading(state())).toBe(true); + expect(selectors.isTreeLoading(state())).toBe(true); }); it('should not have a request to abort', () => { - expect(selectors.databaseDocumentIDToAbort(state())).toBe(null); + expect(selectors.treeRequestParametersToAbort(state())).toBe(null); }); - it('should not have an error, more children, more ancestors, a document to begin fetching, or a pending request that should be aborted.', () => { + it('should not have an error, more children, more ancestors, a request to make, or a pending request that should be aborted.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: true has an error: false has more children: false has more ancestors: false - document to fetch: null + parameters to fetch: null requires a pending request to be aborted: null" `); }); @@ -160,28 +168,28 @@ describe('data state', () => { beforeEach(() => { actions.push({ type: 'serverFailedToReturnResolverData', - payload: databaseDocumentID, + payload: { databaseDocumentID, indices: [] }, }); }); it('should not be loading', () => { - expect(selectors.isLoading(state())).toBe(false); + expect(selectors.isTreeLoading(state())).toBe(false); }); it('should have an error', () => { - expect(selectors.hasError(state())).toBe(true); + expect(selectors.hadErrorLoadingTree(state())).toBe(true); }); - it('should not be loading, have more children, have more ancestors, have a document to fetch, or have a pending request that needs to be aborted.', () => { + it('should not be loading, have more children, have more ancestors, have a request to make, or have a pending request that needs to be aborted.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: false has an error: true has more children: false has more ancestors: false - document to fetch: null + parameters to fetch: null requires a pending request to be aborted: null" `); }); }); }); - describe('when there is a pending request for a different databaseDocumentID than the current one', () => { + describe('when there is a pending request that was made with parameters that are different than the current tree fetching parameters', () => { const firstDatabaseDocumentID = 'first databaseDocumentID'; const secondDatabaseDocumentID = 'second databaseDocumentID'; const resolverComponentInstanceID1 = 'resolverComponentInstanceID1'; @@ -196,12 +204,13 @@ describe('data state', () => { resolverComponentInstanceID: resolverComponentInstanceID1, // `locationSearch` doesn't matter for this test locationSearch: '', + indices: [], }, }, // this happens when the middleware starts the request { type: 'appRequestedResolverData', - payload: firstDatabaseDocumentID, + payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [] }, }, // receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one { @@ -211,18 +220,23 @@ describe('data state', () => { resolverComponentInstanceID: resolverComponentInstanceID2, // `locationSearch` doesn't matter for this test locationSearch: '', + indices: [], }, }, ]; }); it('should be loading', () => { - expect(selectors.isLoading(state())).toBe(true); + expect(selectors.isTreeLoading(state())).toBe(true); }); - it('should need to fetch the second databaseDocumentID', () => { - expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); + it('should need to request the tree using the second set of parameters', () => { + expect(selectors.treeParametersToFetch(state())?.databaseDocumentID).toBe( + secondDatabaseDocumentID + ); }); it('should need to abort the request for the databaseDocumentID', () => { - expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); + expect(selectors.treeParametersToFetch(state())?.databaseDocumentID).toBe( + secondDatabaseDocumentID + ); }); it('should use the correct location for the second resolver', () => { expect(selectors.resolverComponentInstanceID(state())).toBe(resolverComponentInstanceID2); @@ -233,25 +247,27 @@ describe('data state', () => { has an error: false has more children: false has more ancestors: false - document to fetch: \\"second databaseDocumentID\\" - requires a pending request to be aborted: \\"first databaseDocumentID\\"" + parameters to fetch: {\\"databaseDocumentID\\":\\"second databaseDocumentID\\",\\"indices\\":[]} + requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"first databaseDocumentID\\",\\"indices\\":[]}" `); }); describe('and when the old request was aborted', () => { beforeEach(() => { actions.push({ type: 'appAbortedResolverDataRequest', - payload: firstDatabaseDocumentID, + payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [] }, }); }); it('should not require a pending request to be aborted', () => { - expect(selectors.databaseDocumentIDToAbort(state())).toBe(null); + expect(selectors.treeRequestParametersToAbort(state())).toBe(null); }); it('should have a document to fetch', () => { - expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); + expect(selectors.treeParametersToFetch(state())?.databaseDocumentID).toBe( + secondDatabaseDocumentID + ); }); it('should not be loading', () => { - expect(selectors.isLoading(state())).toBe(false); + expect(selectors.isTreeLoading(state())).toBe(false); }); it('should not have an error, more children, or more ancestors.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` @@ -259,7 +275,7 @@ describe('data state', () => { has an error: false has more children: false has more ancestors: false - document to fetch: \\"second databaseDocumentID\\" + parameters to fetch: {\\"databaseDocumentID\\":\\"second databaseDocumentID\\",\\"indices\\":[]} requires a pending request to be aborted: null" `); }); @@ -267,14 +283,14 @@ describe('data state', () => { beforeEach(() => { actions.push({ type: 'appRequestedResolverData', - payload: secondDatabaseDocumentID, + payload: { databaseDocumentID: secondDatabaseDocumentID, indices: [] }, }); }); it('should not have a document ID to fetch', () => { - expect(selectors.databaseDocumentIDToFetch(state())).toBe(null); + expect(selectors.treeParametersToFetch(state())).toBe(null); }); it('should be loading', () => { - expect(selectors.isLoading(state())).toBe(true); + expect(selectors.isTreeLoading(state())).toBe(true); }); it('should not have an error, more children, more ancestors, or a pending request that needs to be aborted.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` @@ -282,7 +298,7 @@ describe('data state', () => { has an error: false has more children: false has more ancestors: false - document to fetch: null + parameters to fetch: null requires a pending request to be aborted: null" `); }); @@ -303,7 +319,7 @@ describe('data state', () => { secondAncestorID, }), // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -331,7 +347,7 @@ describe('data state', () => { secondAncestorID, }), // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -355,7 +371,7 @@ describe('data state', () => { payload: { result: mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -386,7 +402,7 @@ describe('data state', () => { payload: { result: tree, // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -417,7 +433,7 @@ describe('data state', () => { payload: { result: tree, // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -433,7 +449,7 @@ describe('data state', () => { payload: { result: tree, // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index eaa80b46471fad..e647828ddb6064 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -15,6 +15,7 @@ import { AABB, VisibleEntites, SectionData, + TreeFetcherParameters, } from '../../types'; import { isGraphableProcess, @@ -34,6 +35,7 @@ import { LegacyEndpointEvent, } from '../../../../common/endpoint/types'; import * as resolverTreeModel from '../../models/resolver_tree'; +import * as treeFetcherParametersModel from '../../models/tree_fetcher_parameters'; import * as isometricTaxiLayoutModel from '../../models/indexed_process_tree/isometric_taxi_layout'; import * as eventModel from '../../../../common/endpoint/models/event'; import * as vector2 from '../../models/vector2'; @@ -42,26 +44,25 @@ import { formatDate } from '../../view/panels/panel_content_utilities'; /** * If there is currently a request. */ -export function isLoading(state: DataState): boolean { - return state.pendingRequestDatabaseDocumentID !== undefined; +export function isTreeLoading(state: DataState): boolean { + return state.tree.pendingRequestParameters !== undefined; } /** - * A string for uniquely identifying the instance of resolver within the app. + * If a request was made and it threw an error or returned a failure response code. */ -export function resolverComponentInstanceID(state: DataState): string { - return state.resolverComponentInstanceID ? state.resolverComponentInstanceID : ''; +export function hadErrorLoadingTree(state: DataState): boolean { + if (state.tree.lastResponse) { + return !state.tree.lastResponse.successful; + } + return false; } /** - * If a request was made and it threw an error or returned a failure response code. + * A string for uniquely identifying the instance of resolver within the app. */ -export function hasError(state: DataState): boolean { - if (state.lastResponse && state.lastResponse.successful === false) { - return true; - } else { - return false; - } +export function resolverComponentInstanceID(state: DataState): string { + return state.resolverComponentInstanceID ? state.resolverComponentInstanceID : ''; } /** @@ -69,11 +70,7 @@ export function hasError(state: DataState): boolean { * we're currently interested in. */ const resolverTreeResponse = (state: DataState): ResolverTree | undefined => { - if (state.lastResponse && state.lastResponse.successful) { - return state.lastResponse.result; - } else { - return undefined; - } + return state.tree.lastResponse?.successful ? state.tree.lastResponse.result : undefined; }; /** @@ -458,18 +455,24 @@ export const relatedEventInfoByEntityId: ( ); /** - * If we need to fetch, this is the ID to fetch. + * If the tree resource needs to be fetched then these are the parameters that should be used. */ -export function databaseDocumentIDToFetch(state: DataState): string | null { - // If there is an ID, it must match either the last received version, or the pending version. - // Otherwise, we need to fetch it - // NB: this technique will not allow for refreshing of data. +export function treeParametersToFetch(state: DataState): TreeFetcherParameters | null { + /** + * If there are current tree parameters that don't match the parameters used in the pending request (if there is a pending request) and that don't match the parameters used in the last completed request (if there was a last completed request) then we need to fetch the tree resource using the current parameters. + */ if ( - state.databaseDocumentID !== undefined && - state.databaseDocumentID !== state.pendingRequestDatabaseDocumentID && - state.databaseDocumentID !== state.lastResponse?.databaseDocumentID + state.tree.currentParameters !== undefined && + !treeFetcherParametersModel.equal( + state.tree.currentParameters, + state.tree.lastResponse?.parameters + ) && + !treeFetcherParametersModel.equal( + state.tree.currentParameters, + state.tree.pendingRequestParameters + ) ) { - return state.databaseDocumentID; + return state.tree.currentParameters; } else { return null; } @@ -692,15 +695,18 @@ export const nodesAndEdgelines: ( /** * If there is a pending request that's for a entity ID that doesn't matche the `entityID`, then we should cancel it. */ -export function databaseDocumentIDToAbort(state: DataState): string | null { +export function treeRequestParametersToAbort(state: DataState): TreeFetcherParameters | null { /** - * If there is a pending request, and its not for the current databaseDocumentID (even, if the current databaseDocumentID is undefined) then we should abort the request. + * If there is a pending request, and its not for the current parameters (even, if the current parameters are undefined) then we should abort the request. */ if ( - state.pendingRequestDatabaseDocumentID !== undefined && - state.pendingRequestDatabaseDocumentID !== state.databaseDocumentID + state.tree.pendingRequestParameters !== undefined && + !treeFetcherParametersModel.equal( + state.tree.pendingRequestParameters, + state.tree.currentParameters + ) ) { - return state.pendingRequestDatabaseDocumentID; + return state.tree.pendingRequestParameters; } else { return null; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts index e91c455c9445ff..28948debae891e 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts @@ -12,6 +12,7 @@ import { LegacyEndpointEvent, ResolverEvent } from '../../../../common/endpoint/ import { visibleNodesAndEdgeLines } from '../selectors'; import { mockProcessEvent } from '../../models/process_event_test_helpers'; import { mock as mockResolverTree } from '../../models/resolver_tree'; +import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; describe('resolver visible entities', () => { let processA: LegacyEndpointEvent; @@ -112,7 +113,7 @@ describe('resolver visible entities', () => { ]; const action: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: mockResolverTree({ events })!, databaseDocumentID: '' }, + payload: { result: mockResolverTree({ events })!, parameters: mockTreeFetcherParameters() }, }; const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [300, 200] }; store.dispatch(action); @@ -140,7 +141,7 @@ describe('resolver visible entities', () => { ]; const action: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: mockResolverTree({ events })!, databaseDocumentID: '' }, + payload: { result: mockResolverTree({ events })!, parameters: mockTreeFetcherParameters() }, }; const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [2000, 2000] }; store.dispatch(action); 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 0ec340efbdac95..ef4ca2380ebf42 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 @@ -28,32 +28,31 @@ export function ResolverTreeFetcher( // if the entityID changes while return async () => { const state = api.getState(); - const databaseDocumentIDToFetch = selectors.databaseDocumentIDToFetch(state); + const databaseParameters = selectors.treeParametersToFetch(state); - if (selectors.databaseDocumentIDToAbort(state) && lastRequestAbortController) { + if (selectors.treeRequestParametersToAbort(state) && lastRequestAbortController) { lastRequestAbortController.abort(); // calling abort will cause an action to be fired - } else if (databaseDocumentIDToFetch !== null) { + } else if (databaseParameters !== null) { lastRequestAbortController = new AbortController(); let result: ResolverTree | undefined; // Inform the state that we've made the request. Without this, the middleware will try to make the request again // immediately. api.dispatch({ type: 'appRequestedResolverData', - payload: databaseDocumentIDToFetch, + payload: databaseParameters, }); try { - const indices: string[] = dataAccessLayer.indexPatterns(); const matchingEntities: ResolverEntityIndex = await dataAccessLayer.entities({ - _id: databaseDocumentIDToFetch, - indices, + _id: databaseParameters.databaseDocumentID, + indices: databaseParameters.indices ?? [], signal: lastRequestAbortController.signal, }); if (matchingEntities.length < 1) { // If no entity_id could be found for the _id, bail out with a failure. api.dispatch({ type: 'serverFailedToReturnResolverData', - payload: databaseDocumentIDToFetch, + payload: databaseParameters, }); return; } @@ -67,12 +66,12 @@ export function ResolverTreeFetcher( if (error instanceof DOMException && error.name === 'AbortError') { api.dispatch({ type: 'appAbortedResolverDataRequest', - payload: databaseDocumentIDToFetch, + payload: databaseParameters, }); } else { api.dispatch({ type: 'serverFailedToReturnResolverData', - payload: databaseDocumentIDToFetch, + payload: databaseParameters, }); } } @@ -81,7 +80,7 @@ export function ResolverTreeFetcher( type: 'serverReturnedResolverData', payload: { result, - databaseDocumentID: databaseDocumentIDToFetch, + parameters: databaseParameters, }, }); } diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts index f113e861d3ce94..d15274f0363ac2 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts @@ -14,6 +14,7 @@ import { mockTreeWithNoAncestorsAnd2Children, } from '../mocks/resolver_tree'; import { SafeResolverEvent } from '../../../common/endpoint/types'; +import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters'; describe('resolver selectors', () => { const actions: ResolverAction[] = []; @@ -43,7 +44,7 @@ describe('resolver selectors', () => { secondAncestorID, }), // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -77,7 +78,7 @@ describe('resolver selectors', () => { payload: { result: mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index bdea08df3d7f50..8ea0bc9199cb6a 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -84,14 +84,14 @@ export const layout: (state: ResolverState) => IsometricTaxiLayout = composeSele /** * If we need to fetch, this is the entity ID to fetch. */ -export const databaseDocumentIDToFetch = composeSelectors( +export const treeParametersToFetch = composeSelectors( dataStateSelector, - dataSelectors.databaseDocumentIDToFetch + dataSelectors.treeParametersToFetch ); -export const databaseDocumentIDToAbort = composeSelectors( +export const treeRequestParametersToAbort = composeSelectors( dataStateSelector, - dataSelectors.databaseDocumentIDToAbort + dataSelectors.treeRequestParametersToAbort ); export const resolverComponentInstanceID = composeSelectors( @@ -207,12 +207,15 @@ function uiStateSelector(state: ResolverState) { /** * Whether or not the resolver is pending fetching data */ -export const isLoading = composeSelectors(dataStateSelector, dataSelectors.isLoading); +export const isTreeLoading = composeSelectors(dataStateSelector, dataSelectors.isTreeLoading); /** * Whether or not the resolver encountered an error while fetching data */ -export const hasError = composeSelectors(dataStateSelector, dataSelectors.hasError); +export const hadErrorLoadingTree = composeSelectors( + dataStateSelector, + dataSelectors.hadErrorLoadingTree +); /** * True if the children cursor is not null diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index a6520c8f0e06f3..9d10d1c2b64a77 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -49,6 +49,7 @@ export class Simulator { dataAccessLayer, resolverComponentInstanceID, databaseDocumentID, + indices, history, }: { /** @@ -59,10 +60,14 @@ export class Simulator { * A string that uniquely identifies this Resolver instance among others mounted in the DOM. */ resolverComponentInstanceID: string; + /** + * Indices that the backend would use to find the document ID. + */ + indices: string[]; /** * a databaseDocumentID to pass to Resolver. Resolver will use this in requests to the mock data layer. */ - databaseDocumentID?: string; + databaseDocumentID: string; history?: HistoryPackageHistoryInterface; }) { // create the spy middleware (for debugging tests) @@ -99,6 +104,7 @@ export class Simulator { store={this.store} coreStart={coreStart} databaseDocumentID={databaseDocumentID} + indices={indices} /> ); } @@ -124,6 +130,20 @@ export class Simulator { this.wrapper.setProps({ resolverComponentInstanceID: value }); } + /** + * Change the indices (updates the React component props.) + */ + public set indices(value: string[]) { + this.wrapper.setProps({ indices: value }); + } + + /** + * Get the indices (updates the React component props.) + */ + public get indices(): string[] { + return this.wrapper.prop('indices'); + } + /** * Call this to console.log actions (and state). Use this to debug your tests. * State and actions aren't exposed otherwise because the tests using this simulator should diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx index 5d5a414761dbf8..89218e9fca8ce7 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx @@ -99,6 +99,7 @@ export const MockResolver = React.memo((props: MockResolverProps) => { ref={resolverRef} databaseDocumentID={props.databaseDocumentID} resolverComponentInstanceID={props.resolverComponentInstanceID} + indices={props.indices} /> diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index e8304bf838e2d7..1e7e2a8eba8a83 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -194,53 +194,68 @@ export interface VisibleEntites { connectingEdgeLineSegments: EdgeLineSegment[]; } +export interface TreeFetcherParameters { + /** + * The `_id` for an ES document. Used to select a process that we'll show the graph for. + */ + databaseDocumentID: string; + + /** + * The indices that the backend will use to search for the document ID. + */ + indices: string[]; +} + /** * State for `data` reducer which handles receiving Resolver data from the back-end. */ export interface DataState { readonly relatedEvents: Map; readonly relatedEventsReady: Map; - /** - * The `_id` for an ES document. Used to select a process that we'll show the graph for. - */ - readonly databaseDocumentID?: string; - /** - * The id used for the pending request, if there is one. - */ - readonly pendingRequestDatabaseDocumentID?: string; + + readonly tree: { + /** + * The parameters passed from the resolver properties + */ + readonly currentParameters?: TreeFetcherParameters; + + /** + * The id used for the pending request, if there is one. + */ + readonly pendingRequestParameters?: TreeFetcherParameters; + /** + * The parameters and response from the last successful request. + */ + readonly lastResponse?: { + /** + * The id used in the request. + */ + readonly parameters: TreeFetcherParameters; + } & ( + | { + /** + * If a response with a success code was received, this is `true`. + */ + readonly successful: true; + /** + * The ResolverTree parsed from the response. + */ + readonly result: ResolverTree; + } + | { + /** + * If the request threw an exception or the response had a failure code, this will be false. + */ + readonly successful: false; + } + ); + }; /** * An ID that is used to differentiate this Resolver instance from others concurrently running on the same page. * Used to prevent collisions in things like query parameters. */ readonly resolverComponentInstanceID?: string; - - /** - * The parameters and response from the last successful request. - */ - readonly lastResponse?: { - /** - * The id used in the request. - */ - readonly databaseDocumentID: string; - } & ( - | { - /** - * If a response with a success code was received, this is `true`. - */ - readonly successful: true; - /** - * The ResolverTree parsed from the response. - */ - readonly result: ResolverTree; - } - | { - /** - * If the request threw an exception or the response had a failure code, this will be false. - */ - readonly successful: false; - } - ); } /** @@ -494,11 +509,6 @@ export interface DataAccessLayer { */ resolverTree: (entityID: string, signal: AbortSignal) => Promise; - /** - * Get an array of index patterns that contain events. - */ - indexPatterns: () => string[]; - /** * Get entities matching a document. */ @@ -524,13 +534,18 @@ export interface ResolverProps { * The `_id` value of an event in ES. * Used as the origin of the Resolver graph. */ - databaseDocumentID?: string; + databaseDocumentID: string; /** * An ID that is used to differentiate this Resolver instance from others concurrently running on the same page. * Used to prevent collisions in things like query parameters. */ resolverComponentInstanceID: string; + + /** + * Indices that the backend should use to find the originating document. + */ + indices: string[]; } /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx index 1e5ac093cac776..223ce728f42671 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { noAncestorsTwoChildenInIndexCalledAwesomeIndex } from '../data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index'; import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children'; import { Simulator } from '../test_utilities/simulator'; // Extend jest with a custom matcher @@ -19,6 +20,62 @@ let entityIDs: { origin: string; firstChild: string; secondChild: string }; // the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances const resolverComponentInstanceID = 'resolverComponentInstanceID'; +describe("Resolver, when rendered with the `indices` prop set to `[]` and the `databaseDocumentID` prop set to `_id`, and when the document is found in an index called 'awesome_index'", () => { + beforeEach(async () => { + // create a mock data access layer + const { + metadata: dataAccessLayerMetadata, + dataAccessLayer, + } = noAncestorsTwoChildenInIndexCalledAwesomeIndex(); + + // save a reference to the entity IDs exposed by the mock data layer + entityIDs = dataAccessLayerMetadata.entityIDs; + + // save a reference to the `_id` supported by the mock data layer + databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + indices: [], + }); + }); + + it('should render no processes', async () => { + await expect( + simulator.map(() => ({ + processes: simulator.processNodeElements().length, + })) + ).toYieldEqualTo({ + processes: 0, + }); + }); + + describe("when rerendered with the `indices` prop set to `['awesome_index'`]", () => { + beforeEach(async () => { + simulator.indices = ['awesome_index']; + }); + // Combining assertions here for performance. Unfortunately, Enzyme + jsdom + React is slow. + it(`should have 3 nodes, with the entityID's 'origin', 'firstChild', and 'secondChild'. 'origin' should be selected when the simulator has the right indices`, async () => { + await expect( + simulator.map(() => ({ + selectedOriginCount: simulator.selectedProcessNode(entityIDs.origin).length, + unselectedFirstChildCount: simulator.unselectedProcessNode(entityIDs.firstChild).length, + unselectedSecondChildCount: simulator.unselectedProcessNode(entityIDs.secondChild).length, + nodePrimaryButtonCount: simulator.testSubject('resolver:node:primary-button').length, + })) + ).toYieldEqualTo({ + selectedOriginCount: 1, + unselectedFirstChildCount: 1, + unselectedSecondChildCount: 1, + nodePrimaryButtonCount: 3, + }); + }); + }); +}); + describe('Resolver, when analyzing a tree that has no ancestors and 2 children', () => { beforeEach(async () => { // create a mock data access layer @@ -31,7 +88,12 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children', databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; // create a resolver simulator, using the data access layer and an arbitrary component instance ID - simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + indices: [], + }); }); describe('when it has loaded', () => { @@ -159,7 +221,12 @@ describe('Resolver, when analyzing a tree that has two related events for the or databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; // create a resolver simulator, using the data access layer and an arbitrary component instance ID - simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + indices: [], + }); }); describe('when it has loaded', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx index 6497cc2971980c..95fe68d95d7025 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx @@ -34,6 +34,7 @@ describe('graph controls: when relsover is loaded with an origin node', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); originEntityID = entityIDs.origin; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx index 1add907ae933d4..7021e476e6439d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx @@ -49,6 +49,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an dataAccessLayer, resolverComponentInstanceID, history: memoryHistory, + indices: [], }); return simulatorInstance; } diff --git a/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts b/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts index a86237e0e2b45c..e42de5009a0f1a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts @@ -29,7 +29,12 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children', databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; // create a resolver simulator, using the data access layer and an arbitrary component instance ID - simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + indices: [], + }); }); describe("when the second child node's first button has been clicked", () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx index c357ee18acfebd..d8d8de640d7863 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx @@ -26,6 +26,7 @@ describe('Resolver: data loading and resolution states', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); }); @@ -56,6 +57,7 @@ describe('Resolver: data loading and resolution states', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); }); @@ -85,6 +87,7 @@ describe('Resolver: data loading and resolution states', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); }); @@ -114,6 +117,7 @@ describe('Resolver: data loading and resolution states', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); }); @@ -145,6 +149,7 @@ describe('Resolver: data loading and resolution states', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index aa845e7283ebe6..f4d471b384b350 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -31,7 +31,7 @@ export const ResolverWithoutProviders = React.memo( * Use `forwardRef` so that the `Simulator` used in testing can access the top level DOM element. */ React.forwardRef(function ( - { className, databaseDocumentID, resolverComponentInstanceID }: ResolverProps, + { className, databaseDocumentID, resolverComponentInstanceID, indices }: ResolverProps, refToForward ) { useResolverQueryParamCleaner(); @@ -39,7 +39,7 @@ export const ResolverWithoutProviders = React.memo( * This is responsible for dispatching actions that include any external data. * `databaseDocumentID` */ - useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); + useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID, indices }); const { timestamp } = useContext(SideEffectContext); @@ -69,8 +69,8 @@ export const ResolverWithoutProviders = React.memo( }, [cameraRef, refToForward] ); - const isLoading = useSelector(selectors.isLoading); - const hasError = useSelector(selectors.hasError); + const isLoading = useSelector(selectors.isTreeLoading); + const hasError = useSelector(selectors.hadErrorLoadingTree); const activeDescendantId = useSelector(selectors.ariaActiveDescendant); const { colorMap } = useResolverTheme(); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index 630ee2f7ff7f08..495cd238d22fc7 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -20,6 +20,7 @@ import { mock as mockResolverTree } from '../models/resolver_tree'; import { ResolverAction } from '../store/actions'; import { createStore } from 'redux'; import { resolverReducer } from '../store/reducer'; +import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters'; describe('useCamera on an unpainted element', () => { let element: HTMLElement; @@ -181,7 +182,7 @@ describe('useCamera on an unpainted element', () => { if (tree !== null) { const serverResponseAction: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: tree, databaseDocumentID: '' }, + payload: { result: tree, parameters: mockTreeFetcherParameters() }, }; act(() => { store.dispatch(serverResponseAction); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts index eaba4438bb1fef..7f3cdcbec76a27 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts @@ -15,19 +15,21 @@ import { useResolverDispatch } from './use_resolver_dispatch'; export function useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID, + indices, }: { /** * The `_id` of an event in ES. Used to determine the origin of the Resolver graph. */ - databaseDocumentID?: string; + databaseDocumentID: string; resolverComponentInstanceID: string; + indices: string[]; }) { const dispatch = useResolverDispatch(); const locationSearch = useLocation().search; useLayoutEffect(() => { dispatch({ type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID, resolverComponentInstanceID, locationSearch }, + payload: { databaseDocumentID, resolverComponentInstanceID, locationSearch, indices }, }); - }, [dispatch, databaseDocumentID, resolverComponentInstanceID, locationSearch]); + }, [dispatch, databaseDocumentID, resolverComponentInstanceID, locationSearch, indices]); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index ededf701529683..dc9557da70f915 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -19,7 +19,7 @@ import styled from 'styled-components'; import { FULL_SCREEN } from '../timeline/body/column_headers/translations'; import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations'; -import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; +import { DEFAULT_INDEX_KEY, FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; import { useFullScreen } from '../../../common/containers/use_full_screen'; import { State } from '../../../common/store'; import { TimelineId, TimelineType } from '../../../../common/types/timeline'; @@ -33,6 +33,8 @@ import { Resolver } from '../../../resolver/view'; import { useAllCasesModal } from '../../../cases/components/use_all_cases_modal'; import * as i18n from './translations'; +import { useUiSetting$ } from '../../../common/lib/kibana'; +import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; const OverlayContainer = styled.div` height: 100%; @@ -137,6 +139,16 @@ const GraphOverlayComponent = ({ globalFullScreen, ]); + const { signalIndexName } = useSignalIndex(); + const [siemDefaultIndices] = useUiSetting$(DEFAULT_INDEX_KEY); + const indices: string[] | null = useMemo(() => { + if (signalIndexName === null) { + return null; + } else { + return [...siemDefaultIndices, signalIndexName]; + } + }, [signalIndexName, siemDefaultIndices]); + return ( @@ -178,10 +190,13 @@ const GraphOverlayComponent = ({ - + {graphEventId !== undefined && indices !== null && ( + + )} ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx index 2792b264ba7e26..d01e8634a489fd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/index.tsx @@ -102,12 +102,10 @@ const StatefulRowRenderersBrowserComponent: React.FC setShow(false), []); const handleDisableAll = useCallback(() => { - // eslint-disable-next-line no-unused-expressions tableRef?.current?.setSelection([]); }, [tableRef]); const handleEnableAll = useCallback(() => { - // eslint-disable-next-line no-unused-expressions tableRef?.current?.setSelection(renderers); }, [tableRef]); diff --git a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx index 7baa7c42fb45ec..f1414724e243fe 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx @@ -88,7 +88,7 @@ const RowRenderersBrowserComponent = React.forwardRef( (item: RowRendererOption) => () => { const newSelection = xor([item], notExcludedRowRenderers); // @ts-expect-error - ref?.current?.setSelection(newSelection); // eslint-disable-line no-unused-expressions + ref?.current?.setSelection(newSelection); }, [notExcludedRowRenderers, ref] ); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts index bcac559d61f799..510bb6c5455587 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts @@ -18,11 +18,6 @@ export function handleEntities(): RequestHandler { }, }, async (context, request, response) => { - const { signal_ids: signalIds, query, status } = request.body; + const { conflicts, signal_ids: signalIds, query, status } = request.body; const clusterClient = context.core.elasticsearch.legacy.client; const siemClient = context.securitySolution?.getAppClient(); const siemResponse = buildSiemResponse(response); const validationErrors = setSignalStatusValidateTypeDependents(request.body); + if (validationErrors.length) { return siemResponse.error({ statusCode: 400, body: validationErrors }); } @@ -55,6 +56,7 @@ export const setSignalsStatusRoute = (router: IRouter) => { try { const result = await clusterClient.callAsCurrentUser('updateByQuery', { index: siemClient.getSignalsIndex(), + conflicts: conflicts ?? 'abort', refresh: 'wait_for', body: { script: { diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts index 9e73312bdb8e11..c5c98e5facbdfa 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts @@ -13,9 +13,11 @@ import { SecuritySolutionFactory } from '../types'; import { networkHttp } from './http'; import { networkTls } from './tls'; import { networkTopCountries } from './top_countries'; +import { networkTopNFlow } from './top_n_flow'; export const networkFactory: Record> = { [NetworkQueries.http]: networkHttp, [NetworkQueries.tls]: networkTls, [NetworkQueries.topCountries]: networkTopCountries, + [NetworkQueries.topNFlow]: networkTopNFlow, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts new file mode 100644 index 00000000000000..720661e12bd966 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/helpers.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getOr } from 'lodash/fp'; + +import { assertUnreachable } from '../../../../../../common/utility_types'; +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; +import { + Direction, + GeoItem, + SortField, + NetworkTopNFlowBuckets, + NetworkTopNFlowEdges, + NetworkTopNFlowRequestOptions, + NetworkTopTablesFields, + FlowTargetSourceDest, + AutonomousSystemItem, +} from '../../../../../../common/search_strategy'; +import { getOppositeField } from '../helpers'; + +export const getTopNFlowEdges = ( + response: IEsSearchResponse, + options: NetworkTopNFlowRequestOptions +): NetworkTopNFlowEdges[] => + formatTopNFlowEdges( + getOr([], `aggregations.${options.flowTarget}.buckets`, response.rawResponse), + options.flowTarget + ); + +const formatTopNFlowEdges = ( + buckets: NetworkTopNFlowBuckets[], + flowTarget: FlowTargetSourceDest +): NetworkTopNFlowEdges[] => + buckets.map((bucket: NetworkTopNFlowBuckets) => ({ + node: { + _id: bucket.key, + [flowTarget]: { + domain: bucket.domain.buckets.map((bucketDomain) => bucketDomain.key), + ip: bucket.key, + location: getGeoItem(bucket), + autonomous_system: getAsItem(bucket), + flows: getOr(0, 'flows.value', bucket), + [`${getOppositeField(flowTarget)}_ips`]: getOr( + 0, + `${getOppositeField(flowTarget)}_ips.value`, + bucket + ), + }, + network: { + bytes_in: getOr(0, 'bytes_in.value', bucket), + bytes_out: getOr(0, 'bytes_out.value', bucket), + }, + }, + cursor: { + value: bucket.key, + tiebreaker: null, + }, + })); + +const getFlowTargetFromString = (flowAsString: string) => + flowAsString === 'source' ? FlowTargetSourceDest.source : FlowTargetSourceDest.destination; + +const getGeoItem = (result: NetworkTopNFlowBuckets): GeoItem | null => + result.location.top_geo.hits.hits.length > 0 && result.location.top_geo.hits.hits[0]._source + ? { + geo: getOr( + '', + `location.top_geo.hits.hits[0]._source.${ + Object.keys(result.location.top_geo.hits.hits[0]._source)[0] + }.geo`, + result + ), + flowTarget: getFlowTargetFromString( + Object.keys(result.location.top_geo.hits.hits[0]._source)[0] + ), + } + : null; + +const getAsItem = (result: NetworkTopNFlowBuckets): AutonomousSystemItem | null => + result.autonomous_system.top_as.hits.hits.length > 0 && + result.autonomous_system.top_as.hits.hits[0]._source + ? { + number: getOr( + null, + `autonomous_system.top_as.hits.hits[0]._source.${ + Object.keys(result.autonomous_system.top_as.hits.hits[0]._source)[0] + }.as.number`, + result + ), + name: getOr( + '', + `autonomous_system.top_as.hits.hits[0]._source.${ + Object.keys(result.autonomous_system.top_as.hits.hits[0]._source)[0] + }.as.organization.name`, + result + ), + } + : null; + +type QueryOrder = + | { bytes_in: Direction } + | { bytes_out: Direction } + | { flows: Direction } + | { destination_ips: Direction } + | { source_ips: Direction }; + +export const getQueryOrder = ( + networkTopNFlowSortField: SortField +): QueryOrder => { + switch (networkTopNFlowSortField.field) { + case NetworkTopTablesFields.bytes_in: + return { bytes_in: networkTopNFlowSortField.direction }; + case NetworkTopTablesFields.bytes_out: + return { bytes_out: networkTopNFlowSortField.direction }; + case NetworkTopTablesFields.flows: + return { flows: networkTopNFlowSortField.direction }; + case NetworkTopTablesFields.destination_ips: + return { destination_ips: networkTopNFlowSortField.direction }; + case NetworkTopTablesFields.source_ips: + return { source_ips: networkTopNFlowSortField.direction }; + } + assertUnreachable(networkTopNFlowSortField.field); +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/index.ts new file mode 100644 index 00000000000000..198368d9818004 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/index.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getOr } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; + +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { + NetworkTopNFlowStrategyResponse, + NetworkQueries, + NetworkTopNFlowRequestOptions, + NetworkTopNFlowEdges, +} from '../../../../../../common/search_strategy/security_solution/network'; + +import { inspectStringifyObject } from '../../../../../utils/build_query'; +import { SecuritySolutionFactory } from '../../types'; + +import { getTopNFlowEdges } from './helpers'; +import { buildTopNFlowQuery } from './query.top_n_flow_network.dsl'; + +export const networkTopNFlow: SecuritySolutionFactory = { + buildDsl: (options: NetworkTopNFlowRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildTopNFlowQuery(options); + }, + parse: async ( + options: NetworkTopNFlowRequestOptions, + response: IEsSearchResponse + ): Promise => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.top_n_flow_count.value', response.rawResponse); + const networkTopNFlowEdges: NetworkTopNFlowEdges[] = getTopNFlowEdges(response, options); + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + const edges = networkTopNFlowEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildTopNFlowQuery(options))], + }; + const showMorePagesIndicator = totalCount > fakeTotalCount; + + return { + ...response, + edges, + inspect, + pageInfo: { + activePage: activePage ? activePage : 0, + fakeTotalCount, + showMorePagesIndicator, + }, + totalCount, + }; + }, +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/query.top_n_flow_network.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/query.top_n_flow_network.dsl.ts new file mode 100644 index 00000000000000..374dfa4d485fa7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/top_n_flow/query.top_n_flow_network.dsl.ts @@ -0,0 +1,157 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + SortField, + FlowTargetSourceDest, + NetworkTopTablesFields, + NetworkTopNFlowRequestOptions, +} from '../../../../../../common/search_strategy'; +import { createQueryFilterClauses } from '../../../../../utils/build_query'; +import { getOppositeField } from '../helpers'; +import { getQueryOrder } from './helpers'; + +const getCountAgg = (flowTarget: FlowTargetSourceDest) => ({ + top_n_flow_count: { + cardinality: { + field: `${flowTarget}.ip`, + }, + }, +}); + +export const buildTopNFlowQuery = ({ + defaultIndex, + filterQuery, + flowTarget, + sort, + pagination: { querySize }, + timerange: { from, to }, + ip, +}: NetworkTopNFlowRequestOptions) => { + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { gte: from, lte: to, format: 'strict_date_optional_time' }, + }, + }, + ]; + + const dslQuery = { + allowNoIndices: true, + index: defaultIndex, + ignoreUnavailable: true, + body: { + aggregations: { + ...getCountAgg(flowTarget), + ...getFlowTargetAggs(sort, flowTarget, querySize), + }, + query: { + bool: ip + ? { + filter, + should: [ + { + term: { + [`${getOppositeField(flowTarget)}.ip`]: ip, + }, + }, + ], + minimum_should_match: 1, + } + : { + filter, + }, + }, + }, + size: 0, + track_total_hits: false, + }; + return dslQuery; +}; + +const getFlowTargetAggs = ( + sort: SortField, + flowTarget: FlowTargetSourceDest, + querySize: number +) => ({ + [flowTarget]: { + terms: { + field: `${flowTarget}.ip`, + size: querySize, + order: { + ...getQueryOrder(sort), + }, + }, + aggs: { + bytes_in: { + sum: { + field: `${getOppositeField(flowTarget)}.bytes`, + }, + }, + bytes_out: { + sum: { + field: `${flowTarget}.bytes`, + }, + }, + domain: { + terms: { + field: `${flowTarget}.domain`, + order: { + timestamp: 'desc', + }, + }, + aggs: { + timestamp: { + max: { + field: '@timestamp', + }, + }, + }, + }, + location: { + filter: { + exists: { + field: `${flowTarget}.geo`, + }, + }, + aggs: { + top_geo: { + top_hits: { + _source: `${flowTarget}.geo.*`, + size: 1, + }, + }, + }, + }, + autonomous_system: { + filter: { + exists: { + field: `${flowTarget}.as`, + }, + }, + aggs: { + top_as: { + top_hits: { + _source: `${flowTarget}.as.*`, + size: 1, + }, + }, + }, + }, + flows: { + cardinality: { + field: 'network.community_id', + }, + }, + [`${getOppositeField(flowTarget)}_ips`]: { + cardinality: { + field: `${getOppositeField(flowTarget)}.ip`, + }, + }, + }, + }, +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index da9f3b53ad85c2..eacb1febd20ff8 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9555,7 +9555,6 @@ "xpack.lens.configure.editConfig": "構成の編集", "xpack.lens.configure.emptyConfig": "ここにフィールドをドロップ", "xpack.lens.dataPanelWrapper.switchDatasource": "データソースに切り替える", - "xpack.lens.datatable.columns": "フィールド", "xpack.lens.datatable.conjunctionSign": " と ", "xpack.lens.datatable.expressionHelpLabel": "データベースレンダー", "xpack.lens.datatable.label": "データテーブル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 774b3763c6ef76..bd30703dd5bd6c 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9561,7 +9561,6 @@ "xpack.lens.configure.editConfig": "编辑配置", "xpack.lens.configure.emptyConfig": "将字段拖放到此处", "xpack.lens.dataPanelWrapper.switchDatasource": "切换到数据源", - "xpack.lens.datatable.columns": "字段", "xpack.lens.datatable.conjunctionSign": " & ", "xpack.lens.datatable.expressionHelpLabel": "数据表呈现器", "xpack.lens.datatable.label": "数据表", diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts b/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts index 2a8cde85ee3cd9..e3d68ef690359e 100644 --- a/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts +++ b/x-pack/test/api_integration/apis/logstash/pipeline/delete.ts @@ -18,7 +18,6 @@ export default function ({ getService }: FtrProviderContext) { .put('/api/logstash/pipeline/fast_generator') .set('kbn-xsrf', 'xxx') .send({ - id: 'fast_generator', description: 'foobar baz', pipeline: 'input { generator {} }\n\n output { stdout {} }', }) diff --git a/x-pack/test/api_integration/apis/logstash/pipeline/save.ts b/x-pack/test/api_integration/apis/logstash/pipeline/save.ts index f44c5e6252d5f6..1c2f23e81eafc0 100644 --- a/x-pack/test/api_integration/apis/logstash/pipeline/save.ts +++ b/x-pack/test/api_integration/apis/logstash/pipeline/save.ts @@ -26,7 +26,6 @@ export default function ({ getService }: FtrProviderContext) { .put('/api/logstash/pipeline/fast_generator') .set('kbn-xsrf', 'xxx') .send({ - id: 'fast_generator', description: 'foobar baz', pipeline: 'input { generator {} }\n\n output { stdout {} }', }) diff --git a/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz b/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz new file mode 100644 index 00000000000000..e1b9c01101f6e2 Binary files /dev/null and b/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/endpoint/resolver/signals/mappings.json b/x-pack/test/functional/es_archives/endpoint/resolver/signals/mappings.json new file mode 100644 index 00000000000000..ad77961a41445f --- /dev/null +++ b/x-pack/test/functional/es_archives/endpoint/resolver/signals/mappings.json @@ -0,0 +1,3239 @@ +{ + "type": "index", + "value": { + "aliases": { + ".siem-signals-default": { + "is_write_index": true + } + }, + "index": ".siem-signals-default-000001", + "mappings": { + "dynamic": "false", + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dll": { + "properties": { + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "doc_values": false, + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mime_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "integer" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "inner": { + "properties": { + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "observer": { + "properties": { + "egress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "signal": { + "properties": { + "ancestors": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "original_event": { + "properties": { + "action": { + "type": "keyword" + }, + "category": { + "type": "keyword" + }, + "code": { + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + }, + "module": { + "type": "keyword" + }, + "original": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "outcome": { + "type": "keyword" + }, + "provider": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "original_time": { + "type": "date" + }, + "parent": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "type": "keyword" + }, + "building_block_type": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "enabled": { + "type": "keyword" + }, + "false_positives": { + "type": "keyword" + }, + "filters": { + "type": "object" + }, + "from": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "immutable": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "language": { + "type": "keyword" + }, + "license": { + "type": "keyword" + }, + "max_signals": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "output_index": { + "type": "keyword" + }, + "query": { + "type": "keyword" + }, + "references": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_mapping": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + }, + "rule_id": { + "type": "keyword" + }, + "rule_name_override": { + "type": "keyword" + }, + "saved_id": { + "type": "keyword" + }, + "severity": { + "type": "keyword" + }, + "severity_mapping": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "severity": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + }, + "size": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + } + } + } + } + }, + "threshold": { + "properties": { + "field": { + "type": "keyword" + }, + "value": { + "type": "float" + } + } + }, + "timeline_id": { + "type": "keyword" + }, + "timeline_title": { + "type": "keyword" + }, + "timestamp_override": { + "type": "keyword" + }, + "to": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "status": { + "type": "keyword" + }, + "threshold_count": { + "type": "float" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "tls": { + "properties": { + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "lifecycle": { + "name": ".siem-signals-default", + "rollover_alias": ".siem-signals-default" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "routing": { + "allocation": { + "include": { + "_tier": "data_hot" + } + } + } + } + } + } +} diff --git a/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx b/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx index 4afd71fd67a69c..f3d1eb60bf1c0d 100644 --- a/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx +++ b/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx @@ -82,6 +82,7 @@ const AppRoot = React.memo( diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts new file mode 100644 index 00000000000000..7fbba4e04798d4 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.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 expect from '@kbn/expect'; +import { eventsIndexPattern } from '../../../../plugins/security_solution/common/endpoint/constants'; +import { ResolverEntityIndex } from '../../../../plugins/security_solution/common/endpoint/types'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('Resolver tests for the entity route', () => { + before(async () => { + await esArchiver.load('endpoint/resolver/signals'); + }); + + after(async () => { + await esArchiver.unload('endpoint/resolver/signals'); + }); + + it('returns an event even if it does not have a mapping for entity_id', async () => { + // this id is from the es archive + const _id = 'fa7eb1546f44fd47d8868be8d74e0082e19f22df493c67a7725457978eb648ab'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).eql([ + { + // this value is from the es archive + entity_id: + 'MTIwNWY1NWQtODRkYS00MzkxLWIyNWQtYTNkNGJmNDBmY2E1LTc1NTItMTMyNDM1NDY1MTQuNjI0MjgxMDA=', + }, + ]); + }); + + it('does not return an event when it does not have the entity_id field in the document', async () => { + // this id is from the es archive + const _id = 'no-entity-id-field'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).to.be.empty(); + }); + + it('does not return an event when it does not have the process field in the document', async () => { + // this id is from the es archive + const _id = 'no-process-field'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).to.be.empty(); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts index fc603af3619a4d..ecfc1ef5bb7f53 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts @@ -10,6 +10,7 @@ export default function (providerContext: FtrProviderContext) { describe('Resolver tests', () => { loadTestFile(require.resolve('./entity_id')); + loadTestFile(require.resolve('./entity')); loadTestFile(require.resolve('./children')); loadTestFile(require.resolve('./tree')); loadTestFile(require.resolve('./alerts'));