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/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/server/legacy/config/index.ts b/src/core/server/legacy/config/index.ts index f10e3f22d53c5e..b56b83ca324cbc 100644 --- a/src/core/server/legacy/config/index.ts +++ b/src/core/server/legacy/config/index.ts @@ -19,4 +19,3 @@ export { ensureValidConfiguration } from './ensure_valid_configuration'; export { LegacyObjectToConfigAdapter } from './legacy_object_to_config_adapter'; -export { convertLegacyDeprecationProvider } from './legacy_deprecation_adapters'; diff --git a/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts b/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts deleted file mode 100644 index b09f9d00b3bedb..00000000000000 --- a/src/core/server/legacy/config/legacy_deprecation_adapters.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * 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 { ConfigDeprecation } from '../../config'; -import { configDeprecationFactory } from '../../config/deprecation/deprecation_factory'; -import { applyDeprecations } from '../../config/deprecation/apply_deprecations'; -import { LegacyConfigDeprecationProvider } from '../types'; -import { convertLegacyDeprecationProvider } from './legacy_deprecation_adapters'; - -jest.spyOn(configDeprecationFactory, 'unusedFromRoot'); -jest.spyOn(configDeprecationFactory, 'renameFromRoot'); - -const executeHandlers = (handlers: ConfigDeprecation[]) => { - handlers.forEach((handler) => { - handler({}, '', () => null); - }); -}; - -describe('convertLegacyDeprecationProvider', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('returns the same number of handlers', async () => { - const legacyProvider: LegacyConfigDeprecationProvider = ({ rename, unused }) => [ - rename('a', 'b'), - unused('c'), - unused('d'), - ]; - - const migrated = await convertLegacyDeprecationProvider(legacyProvider); - const handlers = migrated(configDeprecationFactory); - expect(handlers).toHaveLength(3); - }); - - it('invokes the factory "unusedFromRoot" when using legacy "unused"', async () => { - const legacyProvider: LegacyConfigDeprecationProvider = ({ rename, unused }) => [ - rename('a', 'b'), - unused('c'), - unused('d'), - ]; - - const migrated = await convertLegacyDeprecationProvider(legacyProvider); - const handlers = migrated(configDeprecationFactory); - executeHandlers(handlers); - - expect(configDeprecationFactory.unusedFromRoot).toHaveBeenCalledTimes(2); - expect(configDeprecationFactory.unusedFromRoot).toHaveBeenCalledWith('c'); - expect(configDeprecationFactory.unusedFromRoot).toHaveBeenCalledWith('d'); - }); - - it('invokes the factory "renameFromRoot" when using legacy "rename"', async () => { - const legacyProvider: LegacyConfigDeprecationProvider = ({ rename, unused }) => [ - rename('a', 'b'), - unused('c'), - rename('d', 'e'), - ]; - - const migrated = await convertLegacyDeprecationProvider(legacyProvider); - const handlers = migrated(configDeprecationFactory); - executeHandlers(handlers); - - expect(configDeprecationFactory.renameFromRoot).toHaveBeenCalledTimes(2); - expect(configDeprecationFactory.renameFromRoot).toHaveBeenCalledWith('a', 'b'); - expect(configDeprecationFactory.renameFromRoot).toHaveBeenCalledWith('d', 'e'); - }); - - it('properly works in a real use case', async () => { - const legacyProvider: LegacyConfigDeprecationProvider = ({ rename, unused }) => [ - rename('old', 'new'), - unused('unused'), - unused('notpresent'), - ]; - - const convertedProvider = await convertLegacyDeprecationProvider(legacyProvider); - const handlers = convertedProvider(configDeprecationFactory); - - const rawConfig = { - old: 'oldvalue', - unused: 'unused', - goodValue: 'good', - }; - - const migrated = applyDeprecations( - rawConfig, - handlers.map((handler) => ({ deprecation: handler, path: '' })) - ); - expect(migrated).toEqual({ new: 'oldvalue', goodValue: 'good' }); - }); -}); diff --git a/src/core/server/legacy/config/legacy_deprecation_adapters.ts b/src/core/server/legacy/config/legacy_deprecation_adapters.ts deleted file mode 100644 index 1e0733969e6628..00000000000000 --- a/src/core/server/legacy/config/legacy_deprecation_adapters.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * 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 { ConfigDeprecation, ConfigDeprecationProvider } from '../../config/deprecation'; -import { configDeprecationFactory } from '../../config/deprecation/deprecation_factory'; -import { LegacyConfigDeprecation, LegacyConfigDeprecationProvider } from '../types'; - -const convertLegacyDeprecation = ( - legacyDeprecation: LegacyConfigDeprecation -): ConfigDeprecation => (config, fromPath, logger) => { - legacyDeprecation(config, logger); - return config; -}; - -const legacyUnused = (unusedKey: string): LegacyConfigDeprecation => (settings, log) => { - const deprecation = configDeprecationFactory.unusedFromRoot(unusedKey); - deprecation(settings, '', log); -}; - -const legacyRename = (oldKey: string, newKey: string): LegacyConfigDeprecation => ( - settings, - log -) => { - const deprecation = configDeprecationFactory.renameFromRoot(oldKey, newKey); - deprecation(settings, '', log); -}; - -/** - * Async deprecation provider converter for legacy deprecation implementation - * - * @internal - */ -export const convertLegacyDeprecationProvider = async ( - legacyProvider: LegacyConfigDeprecationProvider -): Promise => { - const legacyDeprecations = await legacyProvider({ - rename: legacyRename, - unused: legacyUnused, - }); - return () => legacyDeprecations.map(convertLegacyDeprecation); -}; diff --git a/src/core/server/legacy/legacy_service.test.ts b/src/core/server/legacy/legacy_service.test.ts index 45869fd12d2b4b..d0492ea88c5e8a 100644 --- a/src/core/server/legacy/legacy_service.test.ts +++ b/src/core/server/legacy/legacy_service.test.ts @@ -19,9 +19,7 @@ jest.mock('../../../legacy/server/kbn_server'); jest.mock('../../../cli/cluster/cluster_manager'); -jest.mock('./config/legacy_deprecation_adapters', () => ({ - convertLegacyDeprecationProvider: (provider: any) => Promise.resolve(provider), -})); + import { findLegacyPluginSpecsMock, logLegacyThirdPartyPluginDeprecationWarningMock, @@ -446,46 +444,8 @@ describe('#discoverPlugins()', () => { expect(findLegacyPluginSpecs).toHaveBeenCalledWith(expect.any(Object), logger, env.packageInfo); }); - it(`register legacy plugin's deprecation providers`, async () => { - findLegacyPluginSpecsMock.mockImplementation( - (settings) => - Promise.resolve({ - pluginSpecs: [ - { - getDeprecationsProvider: () => undefined, - }, - { - getDeprecationsProvider: () => 'providerA', - }, - { - getDeprecationsProvider: () => 'providerB', - }, - ], - pluginExtendedConfig: settings, - disabledPluginSpecs: [], - uiExports: {}, - navLinks: [], - }) as any - ); - - const legacyService = new LegacyService({ - coreId, - env, - logger, - configService: configService as any, - }); - - await legacyService.discoverPlugins(); - expect(configService.addDeprecationProvider).toHaveBeenCalledTimes(2); - expect(configService.addDeprecationProvider).toHaveBeenCalledWith('', 'providerA'); - expect(configService.addDeprecationProvider).toHaveBeenCalledWith('', 'providerB'); - }); - it(`logs deprecations for legacy third party plugins`, async () => { - const pluginSpecs = [ - { getId: () => 'pluginA', getDeprecationsProvider: () => undefined }, - { getId: () => 'pluginB', getDeprecationsProvider: () => undefined }, - ]; + const pluginSpecs = [{ getId: () => 'pluginA' }, { getId: () => 'pluginB' }]; findLegacyPluginSpecsMock.mockImplementation( (settings) => Promise.resolve({ diff --git a/src/core/server/legacy/legacy_service.ts b/src/core/server/legacy/legacy_service.ts index adfdecdd7c9761..880011d2e19238 100644 --- a/src/core/server/legacy/legacy_service.ts +++ b/src/core/server/legacy/legacy_service.ts @@ -21,7 +21,7 @@ import { combineLatest, ConnectableObservable, EMPTY, Observable, Subscription } import { first, map, publishReplay, tap } from 'rxjs/operators'; import { CoreService } from '../../types'; -import { Config, ConfigDeprecationProvider } from '../config'; +import { Config } from '../config'; import { CoreContext } from '../core_context'; import { CspConfigType, config as cspConfig } from '../csp'; import { DevConfig, DevConfigType, config as devConfig } from '../dev'; @@ -29,7 +29,6 @@ import { BasePathProxyServer, HttpConfig, HttpConfigType, config as httpConfig } import { Logger } from '../logging'; import { PathConfigType } from '../path'; import { findLegacyPluginSpecs, logLegacyThirdPartyPluginDeprecationWarning } from './plugins'; -import { convertLegacyDeprecationProvider } from './config'; import { ILegacyInternals, LegacyServiceSetupDeps, @@ -145,18 +144,6 @@ export class LegacyService implements CoreService { navLinks, }; - const deprecationProviders = await pluginSpecs - .map((spec) => spec.getDeprecationsProvider()) - .reduce(async (providers, current) => { - if (current) { - return [...(await providers), await convertLegacyDeprecationProvider(current)]; - } - return providers; - }, Promise.resolve([] as ConfigDeprecationProvider[])); - deprecationProviders.forEach((provider) => - this.coreContext.configService.addDeprecationProvider('', provider) - ); - this.legacyRawConfig = pluginExtendedConfig; // check for unknown uiExport types diff --git a/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts b/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts index dfa2396d5904bc..2317f1036ce42b 100644 --- a/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts +++ b/src/core/server/legacy/plugins/log_legacy_plugins_warning.test.ts @@ -26,7 +26,6 @@ const createPluginSpec = ({ id, path }: { id: string; path: string }): LegacyPlu getId: () => id, getExpectedKibanaVersion: () => 'kibana', getConfigPrefix: () => 'plugin.config', - getDeprecationsProvider: () => undefined, getPack: () => ({ getPath: () => path, }), diff --git a/src/core/server/legacy/types.ts b/src/core/server/legacy/types.ts index 98f8d874c7088b..cf08689a6d0d42 100644 --- a/src/core/server/legacy/types.ts +++ b/src/core/server/legacy/types.ts @@ -51,36 +51,6 @@ export interface LegacyConfig { set(config: LegacyVars): void; } -/** - * Representation of a legacy configuration deprecation factory used for - * legacy plugin deprecations. - * - * @internal - * @deprecated - */ -export interface LegacyConfigDeprecationFactory { - rename(oldKey: string, newKey: string): LegacyConfigDeprecation; - unused(unusedKey: string): LegacyConfigDeprecation; -} - -/** - * Representation of a legacy configuration deprecation. - * - * @internal - * @deprecated - */ -export type LegacyConfigDeprecation = (settings: LegacyVars, log: (msg: string) => void) => void; - -/** - * Representation of a legacy configuration deprecation provider. - * - * @internal - * @deprecated - */ -export type LegacyConfigDeprecationProvider = ( - factory: LegacyConfigDeprecationFactory -) => LegacyConfigDeprecation[] | Promise; - /** * @internal * @deprecated @@ -97,7 +67,6 @@ export interface LegacyPluginSpec { getId: () => unknown; getExpectedKibanaVersion: () => string; getConfigPrefix: () => string; - getDeprecationsProvider: () => LegacyConfigDeprecationProvider | undefined; getPack: () => LegacyPluginPack; } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 1123433e30ac54..3270e5a09afdec 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2949,11 +2949,11 @@ export const validBodyOutput: readonly ["data", "stream"]; // Warnings were encountered during analysis: // // src/core/server/http/router/response.ts:316:3 - (ae-forgotten-export) The symbol "KibanaResponse" needs to be exported by the entry point index.d.ts -// src/core/server/legacy/types.ts:163:3 - (ae-forgotten-export) The symbol "VarsProvider" needs to be exported by the entry point index.d.ts -// src/core/server/legacy/types.ts:164:3 - (ae-forgotten-export) The symbol "VarsReplacer" needs to be exported by the entry point index.d.ts -// src/core/server/legacy/types.ts:165:3 - (ae-forgotten-export) The symbol "LegacyNavLinkSpec" needs to be exported by the entry point index.d.ts -// src/core/server/legacy/types.ts:166:3 - (ae-forgotten-export) The symbol "LegacyAppSpec" needs to be exported by the entry point index.d.ts -// src/core/server/legacy/types.ts:167:16 - (ae-forgotten-export) The symbol "LegacyPluginSpec" needs to be exported by the entry point index.d.ts +// src/core/server/legacy/types.ts:132:3 - (ae-forgotten-export) The symbol "VarsProvider" needs to be exported by the entry point index.d.ts +// src/core/server/legacy/types.ts:133:3 - (ae-forgotten-export) The symbol "VarsReplacer" needs to be exported by the entry point index.d.ts +// src/core/server/legacy/types.ts:134:3 - (ae-forgotten-export) The symbol "LegacyNavLinkSpec" needs to be exported by the entry point index.d.ts +// src/core/server/legacy/types.ts:135:3 - (ae-forgotten-export) The symbol "LegacyAppSpec" needs to be exported by the entry point index.d.ts +// src/core/server/legacy/types.ts:136:16 - (ae-forgotten-export) The symbol "LegacyPluginSpec" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "KibanaConfigType" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:266:3 - (ae-forgotten-export) The symbol "SharedGlobalConfigKeys" needs to be exported by the entry point index.d.ts // src/core/server/plugins/types.ts:268:3 - (ae-forgotten-export) The symbol "PathConfigType" needs to be exported by the entry point index.d.ts 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/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/common_fields/processor_type_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/processor_type_field.tsx index e4ad90f61af0a1..32649234428866 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/processor_type_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/processor_type_field.tsx @@ -5,7 +5,7 @@ */ import { EuiComboBox, EuiComboBoxOptionOption, EuiFormRow } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import React, { FunctionComponent } from 'react'; +import React, { FunctionComponent, ReactNode } from 'react'; import { flow } from 'fp-ts/lib/function'; import { map } from 'fp-ts/lib/Array'; @@ -68,13 +68,18 @@ export const ProcessorTypeField: FunctionComponent = ({ initialType }) => config={typeConfig} defaultValue={initialType} path="type"> {(typeField) => { let selectedOptions: ProcessorTypeAndLabel[]; + let description: string | ReactNode = ''; + if (typeField.value?.length) { const type = typeField.value; - const descriptor = getProcessorDescriptor(type); - selectedOptions = descriptor - ? [{ label: descriptor.label, value: type }] - : // If there is no label for this processor type, just use the type as the label - [{ label: type, value: type }]; + const processorDescriptor = getProcessorDescriptor(type); + if (processorDescriptor) { + description = processorDescriptor.description || ''; + selectedOptions = [{ label: processorDescriptor.label, value: type }]; + } else { + // If there is no label for this processor type, just use the type as the label + selectedOptions = [{ label: type, value: type }]; + } } else { selectedOptions = []; } @@ -102,9 +107,7 @@ export const ProcessorTypeField: FunctionComponent = ({ initialType }) => { @@ -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 = () => { ; @@ -58,6 +62,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.append', { defaultMessage: 'Append', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.append', { + defaultMessage: + "Appends values to a field's array. If the field contains a single value, the processor first converts it to an array. If the field doesn't exist, the processor creates an array containing the appended values.", + }), }, bytes: { FieldsComponent: Bytes, @@ -65,6 +73,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.bytes', { defaultMessage: 'Bytes', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.bytes', { + defaultMessage: + 'Converts digital storage units to bytes. For example, 1KB becomes 1024 bytes.', + }), }, circle: { FieldsComponent: Circle, @@ -72,6 +84,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.circle', { defaultMessage: 'Circle', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.circle', { + defaultMessage: 'Converts a circle definition into an approximate polygon.', + }), }, convert: { FieldsComponent: Convert, @@ -79,6 +94,10 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.convert', { defaultMessage: 'Convert', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.convert', { + defaultMessage: + 'Converts a field to a different data type. For example, you can convert a string to an long.', + }), }, csv: { FieldsComponent: CSV, @@ -86,6 +105,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.csv', { defaultMessage: 'CSV', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.csv', { + defaultMessage: 'Extracts fields values from CSV data.', + }), }, date: { FieldsComponent: DateProcessor, @@ -93,6 +115,9 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.date', { defaultMessage: 'Date', }), + description: i18n.translate('xpack.ingestPipelines.processors.description.date', { + defaultMessage: 'Converts a date to a document timestamp.', + }), }, date_index_name: { FieldsComponent: DateIndexName, @@ -100,6 +125,13 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.dateIndexName', { defaultMessage: 'Date index name', }), + description: () => ( + {'my-index-yyyy-MM-dd'} }} + /> + ), }, dissect: { FieldsComponent: Dissect, @@ -107,6 +139,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, @@ -114,6 +149,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, @@ -121,6 +160,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/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 e13aa3b8980e9b..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; 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/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/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index ba1de0e40e270c..d9d9fde8fc8cc9 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -58,6 +58,8 @@ import { createAndActivateRule, fillAboutRuleAndContinue, fillDefineCustomRuleWithImportedQueryAndContinue, + expectDefineFormToRepopulateAndContinue, + expectAboutFormToRepopulateAndContinue, } from '../tasks/create_new_rule'; import { esArchiverLoad, esArchiverUnload } from '../tasks/es_archiver'; import { loginAndWaitForPageWithoutDateRange } from '../tasks/login'; @@ -82,6 +84,8 @@ describe('Detection rules, custom', () => { goToCreateNewRule(); fillDefineCustomRuleWithImportedQueryAndContinue(newRule); fillAboutRuleAndContinue(newRule); + expectDefineFormToRepopulateAndContinue(newRule); + expectAboutFormToRepopulateAndContinue(newRule); createAndActivateRule(); cy.get(CUSTOM_RULES_BTN).invoke('text').should('eql', 'Custom rules (1)'); diff --git a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts index 83ace877cd51df..397d0c01421796 100644 --- a/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/screens/create_new_rule.ts @@ -6,6 +6,8 @@ export const ABOUT_CONTINUE_BTN = '[data-test-subj="about-continue"]'; +export const ABOUT_EDIT_BUTTON = '[data-test-subj="edit-about-rule"]'; + export const ADD_FALSE_POSITIVE_BTN = '[data-test-subj="detectionEngineStepAboutRuleFalsePositives"] .euiButtonEmpty__text'; @@ -26,6 +28,8 @@ export const CUSTOM_QUERY_INPUT = '[data-test-subj="queryInput"]'; export const DEFINE_CONTINUE_BUTTON = '[data-test-subj="define-continue"]'; +export const DEFINE_EDIT_BUTTON = '[data-test-subj="edit-define-rule"]'; + export const IMPORT_QUERY_FROM_SAVED_TIMELINE_LINK = '[data-test-subj="importQueryFromSavedTimeline"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index 1cce72a48e0f0e..3fa300ce9d8d0f 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -48,6 +48,8 @@ import { THRESHOLD_FIELD_SELECTION, THRESHOLD_INPUT_AREA, THRESHOLD_TYPE, + DEFINE_EDIT_BUTTON, + ABOUT_EDIT_BUTTON, } from '../screens/create_new_rule'; import { TIMELINE } from '../screens/timeline'; @@ -175,6 +177,20 @@ export const fillDefineCustomRuleWithImportedQueryAndContinue = ( cy.get(CUSTOM_QUERY_INPUT).should('not.exist'); }; +export const expectDefineFormToRepopulateAndContinue = (rule: CustomRule) => { + cy.get(DEFINE_EDIT_BUTTON).click(); + cy.get(CUSTOM_QUERY_INPUT).invoke('text').should('eq', rule.customQuery); + cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); + cy.get(DEFINE_CONTINUE_BUTTON).should('not.exist'); +}; + +export const expectAboutFormToRepopulateAndContinue = (rule: CustomRule) => { + cy.get(ABOUT_EDIT_BUTTON).click(); + cy.get(RULE_NAME_INPUT).invoke('val').should('eq', rule.name); + cy.get(ABOUT_CONTINUE_BTN).should('exist').click({ force: true }); + cy.get(ABOUT_CONTINUE_BTN).should('not.exist'); +}; + export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => { const thresholdField = 0; const threshold = 1; 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/lib/kibana/kibana_react.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts index 075f06084384bb..b4fb307a62b6c3 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/kibana_react.ts @@ -19,12 +19,11 @@ export interface WithKibanaProps { kibana: KibanaContext; } -// eslint-disable-next-line react-hooks/rules-of-hooks -const typedUseKibana = () => useKibana(); +const useTypedKibana = () => useKibana(); export { KibanaContextProvider, - typedUseKibana as useKibana, + useTypedKibana as useKibana, useUiSetting, useUiSetting$, withKibana, 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/components/rules/description_step/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx index 8b3d05ce5a574f..8179e5865e4efc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow, mount } from 'enzyme'; import { - StepRuleDescriptionComponent, + StepRuleDescription, addFilterStateIfNotThere, buildListItems, getDescriptionItem, @@ -52,24 +52,24 @@ describe('description_step', () => { mockAboutStep = mockAboutStepRule(); }); - describe('StepRuleDescriptionComponent', () => { + describe('StepRuleDescription', () => { test('renders tow columns when "columns" is "multi"', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="listItemColumnStepRuleDescription"]')).toHaveLength(2); }); test('renders single column when "columns" is "single"', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="listItemColumnStepRuleDescription"]')).toHaveLength(1); }); test('renders one column with title and description split when "columns" is "singleSplit', () => { const wrapper = shallow( - + ); expect(wrapper.find('[data-test-subj="listItemColumnStepRuleDescription"]')).toHaveLength(1); expect( @@ -299,7 +299,6 @@ describe('description_step', () => { describe('queryBar', () => { test('returns array of ListItems when queryBar exist', () => { const mockQueryBar = { - isNew: false, queryBar: { query: { query: 'user.name: root or user.name: admin', @@ -369,7 +368,6 @@ describe('description_step', () => { describe('threshold', () => { test('returns threshold description when threshold exist and field is empty', () => { const mockThreshold = { - isNew: false, threshold: { field: [''], value: 100, @@ -391,7 +389,6 @@ describe('description_step', () => { test('returns threshold description when threshold exist and field is set', () => { const mockThreshold = { - isNew: false, threshold: { field: ['user.name'], value: 100, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx index cf27fa97b14794..99e36669f78bb6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/index.tsx @@ -37,7 +37,6 @@ import { buildRuleTypeDescription, buildThresholdDescription, } from './helpers'; -import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs'; import { buildMlJobDescription } from './ml_job_description'; import { buildActionsDescription } from './actions_description'; import { buildThrottleDescription } from './throttle_description'; @@ -52,22 +51,21 @@ const DescriptionListContainer = styled(EuiDescriptionList)` } `; -interface StepRuleDescriptionProps { +interface StepRuleDescriptionProps { columns?: 'multi' | 'single' | 'singleSplit'; data: unknown; indexPatterns?: IIndexPattern; - schema: FormSchema; + schema: FormSchema; } -export const StepRuleDescriptionComponent: React.FC = ({ +export const StepRuleDescriptionComponent = ({ data, columns = 'multi', indexPatterns, schema, -}) => { +}: StepRuleDescriptionProps) => { const kibana = useKibana(); const [filterManager] = useState(new FilterManager(kibana.services.uiSettings)); - const { jobs } = useSecurityJobs(false); const keys = Object.keys(schema); const listItems = keys.reduce((acc: ListItems[], key: string) => { @@ -76,8 +74,7 @@ export const StepRuleDescriptionComponent: React.FC = ...acc, buildMlJobDescription( get(key, data) as string, - (get(key, schema) as { label: string }).label, - jobs + (get(key, schema) as { label: string }).label ), ]; } @@ -125,11 +122,13 @@ export const StepRuleDescriptionComponent: React.FC = ); }; -export const StepRuleDescription = memo(StepRuleDescriptionComponent); +export const StepRuleDescription = memo( + StepRuleDescriptionComponent +) as typeof StepRuleDescriptionComponent; -export const buildListItems = ( +export const buildListItems = ( data: unknown, - schema: FormSchema, + schema: FormSchema, filterManager: FilterManager, indexPatterns?: IIndexPattern ): ListItems[] => diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx index 3152fef12c6523..ec46610286b46d 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.test.tsx @@ -14,7 +14,7 @@ jest.mock('../../../../common/lib/kibana'); describe('MlJobDescription', () => { it('renders correctly', () => { - const wrapper = shallow(); + const wrapper = shallow(); expect(wrapper.find('[data-test-subj="machineLearningJobId"]')).toHaveLength(1); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx index 6baa2abab33d1a..414f6f2c2d3bb9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/description_step/ml_job_description.tsx @@ -10,6 +10,7 @@ import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; import { MlSummaryJob } from '../../../../../../ml/public'; import { isJobStarted } from '../../../../../common/machine_learning/helpers'; +import { useSecurityJobs } from '../../../../common/components/ml_popover/hooks/use_security_jobs'; import { useKibana } from '../../../../common/lib/kibana'; import { ListItems } from './types'; import { ML_JOB_STARTED, ML_JOB_STOPPED } from './translations'; @@ -69,35 +70,33 @@ const Wrapper = styled.div` overflow: hidden; `; -const MlJobDescriptionComponent: React.FC<{ job: MlSummaryJob }> = ({ job }) => { +const MlJobDescriptionComponent: React.FC<{ jobId: string }> = ({ jobId }) => { + const { jobs } = useSecurityJobs(false); const jobUrl = useKibana().services.application.getUrlForApp( - `ml#/jobs?mlManagement=(jobId:${encodeURI(job.id)})` + `ml#/jobs?mlManagement=(jobId:${encodeURI(jobId)})` ); + const job = jobs.find(({ id }) => id === jobId); - return ( + const jobIdSpan = {jobId}; + + return job != null ? (
- - {job.id} + + {jobIdSpan}
+ ) : ( + jobIdSpan ); }; export const MlJobDescription = React.memo(MlJobDescriptionComponent); -export const buildMlJobDescription = ( - jobId: string, - label: string, - jobs: MlSummaryJob[] -): ListItems => { - const job = jobs.find(({ id }) => id === jobId); - - return { - title: label, - description: job ? : jobId, - }; -}; +export const buildMlJobDescription = (jobId: string, label: string): ListItems => ({ + title: label, + description: , +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx index d97c2b4c8c0aa4..7c8f5230cc8baa 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/next_step/index.tsx @@ -9,7 +9,7 @@ import { EuiHorizontalRule, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elasti import * as RuleI18n from '../../../pages/detection_engine/rules/translations'; interface NextStepProps { - onClick: () => Promise; + onClick: () => void; isDisabled: boolean; dataTestSubj?: string; } diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx index 1ef3edf8c720e4..08cea23d89e1e9 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/data.tsx @@ -8,12 +8,12 @@ import styled from 'styled-components'; import { EuiHealth } from '@elastic/eui'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; import React from 'react'; -import * as I18n from './translations'; -export type SeverityValue = 'low' | 'medium' | 'high' | 'critical'; +import { Severity } from '../../../../../common/detection_engine/schemas/common/schemas'; +import * as I18n from './translations'; export interface SeverityOptionItem { - value: SeverityValue; + value: Severity; inputDisplay: React.ReactElement; } @@ -44,7 +44,7 @@ export const severityOptions: SeverityOptionItem[] = [ }, ]; -export const defaultRiskScoreBySeverity: Record = { +export const defaultRiskScoreBySeverity: Record = { low: 21, medium: 47, high: 73, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts index b9c3e4f84c18ee..56c61c2ad67665 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/default_value.ts @@ -21,9 +21,8 @@ export const stepAboutDefaultValue: AboutStepRule = { description: '', isAssociatedToEndpointList: false, isBuildingBlock: false, - isNew: true, severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, - riskScore: { value: 50, mapping: [], isMappingChecked: false }, + riskScore: { value: 21, mapping: [], isMappingChecked: false }, references: [''], falsePositives: [''], license: '', diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx index 0c834b9fff33af..cb25785eaa5b29 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.test.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; -import { act } from 'react-dom/test-utils'; import { mount, shallow } from 'enzyme'; import { ThemeProvider } from 'styled-components'; import euiDarkVars from '@elastic/eui/dist/eui_theme_light.json'; @@ -14,9 +13,11 @@ import { StepAboutRule } from '.'; import { mockAboutStepRule } from '../../../pages/detection_engine/rules/all/__mocks__/mock'; import { StepRuleDescription } from '../description_step'; import { stepAboutDefaultValue } from './default_value'; -// we don't have the types for waitFor just yet, so using "as waitFor" until when we do -import { wait as waitFor } from '@testing-library/react'; -import { AboutStepRule } from '../../../pages/detection_engine/rules/types'; +import { + AboutStepRule, + RuleStepsFormHooks, + RuleStep, +} from '../../../pages/detection_engine/rules/types'; import { fillEmptySeverityMappings } from '../../../pages/detection_engine/rules/helpers'; const theme = () => ({ eui: euiDarkVars, darkMode: true }); @@ -33,6 +34,18 @@ afterAll(() => { /* eslint-enable no-console */ describe('StepAboutRuleComponent', () => { + let formHook: RuleStepsFormHooks[RuleStep.aboutRule] | null = null; + const setFormHook = ( + step: K, + hook: RuleStepsFormHooks[K] + ) => { + formHook = hook as typeof formHook; + }; + + beforeEach(() => { + formHook = null; + }); + test('it renders StepRuleDescription if isReadOnlyView is true and "name" property exists', () => { const wrapper = shallow( { expect(wrapper.find(StepRuleDescription).exists()).toBeTruthy(); }); - test('it prevents user from clicking continue if no "description" defined', () => { + it('is invalid if description is not present', async () => { const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={jest.fn()} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') .first() .simulate('change', { target: { value: 'Test name text' } }); - const descriptionInput = wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') - .first(); - wrapper.find('button[data-test-subj="about-continue"]').first().simulate('click'); - - expect( - wrapper.find('[data-test-subj="detectionEngineStepAboutRuleName"] input').first().props() - .value - ).toEqual('Test name text'); - expect(descriptionInput.props().value).toEqual(''); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] label') - .first() - .hasClass('euiFormLabel-isInvalid') - ).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] EuiTextArea') - .first() - .prop('isInvalid') - ).toBeTruthy(); + const result = await formHook(); + expect(result?.isValid).toEqual(false); }); - test('it prevents user from clicking continue if no "name" defined', () => { + it('is invalid if no "name" is present', async () => { const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={jest.fn()} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') .first() .simulate('change', { target: { value: 'Test description text' } }); - const nameInput = wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') - .first(); - - wrapper.find('button[data-test-subj="about-continue"]').first().simulate('click'); - - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') - .first() - .props().value - ).toEqual('Test description text'); - expect(nameInput.props().value).toEqual(''); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleName"] label') - .first() - .hasClass('euiFormLabel-isInvalid') - ).toBeTruthy(); - expect( - wrapper - .find('[data-test-subj="detectionEngineStepAboutRuleName"] EuiFieldText') - .first() - .prop('isInvalid') - ).toBeTruthy(); + const result = await formHook(); + expect(result?.isValid).toEqual(false); }); - test('it allows user to click continue if "name" and "description" are defined', async () => { - const stepDataMock = jest.fn(); + it('is valid if both "name" and "description" are present', async () => { const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={stepDataMock} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') .first() .simulate('change', { target: { value: 'Test description text' } }); - wrapper .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') .first() .simulate('change', { target: { value: 'Test name text' } }); - wrapper.find('button[data-test-subj="about-continue"]').first().simulate('click').update(); - await waitFor(() => { - const expected: Omit = { - author: [], - isAssociatedToEndpointList: false, - isBuildingBlock: false, - license: '', - ruleNameOverride: '', - timestampOverride: '', - description: 'Test description text', - falsePositives: [''], - name: 'Test name text', - note: '', - references: [''], - riskScore: { value: 50, mapping: [], isMappingChecked: false }, - severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, - tags: [], - threat: [ - { - framework: 'MITRE ATT&CK', - tactic: { id: 'none', name: 'none', reference: 'none' }, - technique: [], - }, - ], - }; - expect(stepDataMock.mock.calls[1][1]).toEqual(expected); - }); + const expected: AboutStepRule = { + author: [], + isAssociatedToEndpointList: false, + isBuildingBlock: false, + license: '', + ruleNameOverride: '', + timestampOverride: '', + description: 'Test description text', + falsePositives: [''], + name: 'Test name text', + note: '', + references: [''], + riskScore: { value: 21, mapping: [], isMappingChecked: false }, + severity: { value: 'low', mapping: fillEmptySeverityMappings([]), isMappingChecked: false }, + tags: [], + threat: [ + { + framework: 'MITRE ATT&CK', + tactic: { id: 'none', name: 'none', reference: 'none' }, + technique: [], + }, + ], + }; + + const result = await formHook(); + expect(result?.isValid).toEqual(true); + expect(result?.data).toEqual(expected); }); test('it allows user to set the risk score as a number (and not a string)', async () => { - const stepDataMock = jest.fn(); const wrapper = mount( { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={stepDataMock} /> ); + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + wrapper .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') .first() @@ -224,11 +203,7 @@ describe('StepAboutRuleComponent', () => { .first() .simulate('change', { target: { value: '80' } }); - await act(async () => { - wrapper.find('[data-test-subj="about-continue"]').first().simulate('click').update(); - }); - - const expected: Omit = { + const expected: AboutStepRule = { author: [], isAssociatedToEndpointList: false, isBuildingBlock: false, @@ -251,6 +226,52 @@ describe('StepAboutRuleComponent', () => { }, ], }; - expect(stepDataMock.mock.calls[1][1]).toEqual(expected); + + const result = await formHook(); + expect(result?.isValid).toEqual(true); + expect(result?.data).toEqual(expected); + }); + + it('does not modify the provided risk score until the user changes the severity', async () => { + const wrapper = mount( + + + + ); + + if (!formHook) { + throw new Error('Form hook not set, but tests depend on it'); + } + + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleName"] input') + .first() + .simulate('change', { target: { value: 'Test name text' } }); + + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleDescription"] textarea') + .first() + .simulate('change', { target: { value: 'Test description text' } }); + + const result = await formHook(); + expect(result?.isValid).toEqual(true); + expect(result?.data?.riskScore.value).toEqual(21); + + wrapper + .find('[data-test-subj="detectionEngineStepAboutRuleSeverity"] [data-test-subj="select"]') + .last() + .simulate('click'); + wrapper.find('button#medium').simulate('click'); + + const result2 = await formHook(); + expect(result2?.isValid).toEqual(true); + expect(result2?.data?.riskScore.value).toEqual(47); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx index 295b13717f0768..d2c84883fa99b5 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/index.tsx @@ -22,13 +22,14 @@ import { AddMitreThreat } from '../mitre'; import { Field, Form, - FormDataProvider, getUseField, UseField, useForm, + useFormData, + FieldHook, } from '../../../../shared_imports'; -import { defaultRiskScoreBySeverity, severityOptions, SeverityValue } from './data'; +import { defaultRiskScoreBySeverity, severityOptions } from './data'; import { stepAboutDefaultValue } from './default_value'; import { isUrlInvalid } from '../../../../common/utils/validators'; import { schema } from './schema'; @@ -68,47 +69,69 @@ const StepAboutRuleComponent: FC = ({ isReadOnlyView, isUpdateView = false, isLoading, + onSubmit, setForm, - setStepData, }) => { const initialState = defaultValues ?? stepAboutDefaultValue; - const [myStepData, setMyStepData] = useState(initialState); + const [severityValue, setSeverityValue] = useState(initialState.severity.value); const [{ isLoading: indexPatternLoading, indexPatterns }] = useFetchIndexPatterns( defineRuleData?.index ?? [], - 'step_about_rule' + RuleStep.aboutRule ); const canUseExceptions = defineRuleData?.ruleType && !isMlRule(defineRuleData.ruleType) && !isThresholdRule(defineRuleData.ruleType); - const { form } = useForm({ + const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const { getFields, submit } = form; + const { getFields, getFormData, submit } = form; + const [{ severity: formSeverity }] = (useFormData({ + form, + watch: ['severity'], + }) as unknown) as [Partial]; - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.aboutRule, null, false); - const { isValid, data } = await submit(); - if (isValid) { - setStepData(RuleStep.aboutRule, data, isValid); - setMyStepData({ ...data, isNew: false } as AboutStepRule); + useEffect(() => { + const formSeverityValue = formSeverity?.value; + if (formSeverityValue != null && formSeverityValue !== severityValue) { + setSeverityValue(formSeverityValue); + + const newRiskScoreValue = defaultRiskScoreBySeverity[formSeverityValue]; + if (newRiskScoreValue != null) { + const riskScoreField = getFields().riskScore as FieldHook; + riskScoreField.setValue({ ...riskScoreField.value, value: newRiskScoreValue }); } } - }, [setStepData, submit]); + }, [formSeverity?.value, getFields, severityValue]); + + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); + + const handleSubmit = useCallback(() => { + if (onSubmit) { + onSubmit(); + } + }, [onSubmit]); useEffect(() => { if (setForm) { - setForm(RuleStep.aboutRule, form); + setForm(RuleStep.aboutRule, getData); } - }, [setForm, form]); + }, [getData, setForm]); - return isReadOnlyView && myStepData.name != null ? ( + return isReadOnlyView ? ( - + ) : ( <> @@ -305,26 +328,11 @@ const StepAboutRuleComponent: FC = ({ }} /> - - {({ severity }) => { - const newRiskScore = defaultRiskScoreBySeverity[severity as SeverityValue]; - const severityField = getFields().severity; - const riskScoreField = getFields().riskScore; - if ( - severityField.value !== severity && - newRiskScore != null && - riskScoreField.value !== newRiskScore - ) { - riskScoreField.setValue(newRiskScore); - } - return null; - }} - {!isUpdateView && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx index 2264a11341eb85..6df94eca429c5c 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_about_rule/schema.tsx @@ -13,7 +13,7 @@ import { ValidationFunc, ERROR_CODE, } from '../../../../shared_imports'; -import { IMitreEnterpriseAttack } from '../../../pages/detection_engine/rules/types'; +import { IMitreEnterpriseAttack, AboutStepRule } from '../../../pages/detection_engine/rules/types'; import { isMitreAttackInvalid } from '../mitre/helpers'; import { OptionalFieldLabel } from '../optional_field_label'; import { isUrlInvalid } from '../../../../common/utils/validators'; @@ -21,7 +21,7 @@ import * as I18n from './translations'; const { emptyField } = fieldValidators; -export const schema: FormSchema = { +export const schema: FormSchema = { author: { type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx index 8a1f9618835196..158f323b739e61 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/index.tsx @@ -7,15 +7,14 @@ import { EuiButtonEmpty, EuiFormRow } from '@elastic/eui'; import React, { FC, memo, useCallback, useState, useEffect } from 'react'; import styled from 'styled-components'; -import deepEqual from 'fast-deep-equal'; +import isEqual from 'lodash/isEqual'; import { DEFAULT_INDEX_KEY } from '../../../../../common/constants'; +import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; import { hasMlLicense } from '../../../../../common/machine_learning/has_ml_license'; -import { IIndexPattern } from '../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../containers/detection_engine/rules'; -import { DEFAULT_TIMELINE_TITLE } from '../../../../timelines/components/timeline/translations'; import { useMlCapabilities } from '../../../../common/components/ml/hooks/use_ml_capabilities'; import { useUiSetting$ } from '../../../../common/lib/kibana'; import { @@ -42,9 +41,8 @@ import { getUseField, UseField, UseMultiFields, - FormDataProvider, useForm, - FormSchema, + useFormData, } from '../../../../shared_imports'; import { schema } from './schema'; import * as i18n from './translations'; @@ -52,13 +50,12 @@ import * as i18n from './translations'; const CommonUseField = getUseField({ component: Field }); interface StepDefineRuleProps extends RuleStepProps { - defaultValues?: DefineStepRule | null; + defaultValues?: DefineStepRule; } const stepDefineDefaultValue: DefineStepRule = { anomalyThreshold: 50, index: [], - isNew: true, machineLearningJobId: '', ruleType: 'query', queryBar: { @@ -103,8 +100,8 @@ const StepDefineRuleComponent: FC = ({ isReadOnlyView, isLoading, isUpdateView = false, + onSubmit, setForm, - setStepData, }) => { const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); @@ -112,38 +109,54 @@ const StepDefineRuleComponent: FC = ({ const [indicesConfig] = useUiSetting$(DEFAULT_INDEX_KEY); const initialState = defaultValues ?? { ...stepDefineDefaultValue, - index: indicesConfig ?? [], + index: indicesConfig, }; - const [localRuleType, setLocalRuleType] = useState(initialState.ruleType); - const [myStepData, setMyStepData] = useState(initialState); - const [ - { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - ] = useFetchIndexPatterns(myStepData.index, 'step_define_rule'); - - const { form } = useForm({ + const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const { getFields, reset, submit } = form; - const clearErrors = useCallback(() => reset({ resetValues: false }), [reset]); + const { getFields, getFormData, reset, submit } = form; + const [{ index: formIndex, ruleType: formRuleType }] = (useFormData({ + form, + watch: ['index', 'ruleType'], + }) as unknown) as [Partial]; + const index = formIndex || initialState.index; + const ruleType = formRuleType || initialState.ruleType; + const [ + { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, + ] = useFetchIndexPatterns(index, RuleStep.defineRule); - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.defineRule, null, false); - const { isValid, data } = await submit(); - if (isValid && setStepData) { - setStepData(RuleStep.defineRule, data, isValid); - setMyStepData({ ...data, isNew: false } as DefineStepRule); - } + // reset form when rule type changes + useEffect(() => { + reset({ resetValues: false }); + }, [reset, ruleType]); + + useEffect(() => { + setIndexModified(!isEqual(index, indicesConfig)); + }, [index, indicesConfig]); + + const handleSubmit = useCallback(() => { + if (onSubmit) { + onSubmit(); } - }, [setStepData, submit]); + }, [onSubmit]); + + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); useEffect(() => { if (setForm) { - setForm(RuleStep.defineRule, form); + setForm(RuleStep.defineRule, getData); } - }, [form, setForm]); + }, [getData, setForm]); const handleResetIndices = useCallback(() => { const indexField = getFields().index; @@ -173,9 +186,9 @@ const StepDefineRuleComponent: FC = ({ ) : ( @@ -192,7 +205,7 @@ const StepDefineRuleComponent: FC = ({ isMlAdmin: hasMlAdminPermissions(mlCapabilities), }} /> - + <> = ({ /> - + <> = ({ @@ -269,11 +282,9 @@ const StepDefineRuleComponent: FC = ({ fields={{ thresholdField: { path: 'threshold.field', - defaultValue: initialState.threshold.field, }, thresholdValue: { path: 'threshold.value', - defaultValue: initialState.threshold.value, }, }} > @@ -290,31 +301,11 @@ const StepDefineRuleComponent: FC = ({ dataTestSubj: 'detectionEngineStepDefineRuleTimeline', }} /> - - {({ index, ruleType }) => { - if (index != null) { - if (deepEqual(index, indicesConfig) && indexModified) { - setIndexModified(false); - } else if (!deepEqual(index, indicesConfig) && !indexModified) { - setIndexModified(true); - } - if (myStepData.index !== index) { - setMyStepData((prevValue) => ({ ...prevValue, index })); - } - } - - if (ruleType !== localRuleType) { - setLocalRuleType(ruleType); - clearErrors(); - } - return null; - }} - {!isUpdateView && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx index 333b28bf27bbf2..07eff94bbbef4b 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_define_rule/schema.tsx @@ -19,9 +19,10 @@ import { FormSchema, ValidationFunc, } from '../../../../shared_imports'; +import { DefineStepRule } from '../../../pages/detection_engine/rules/types'; import { CUSTOM_QUERY_REQUIRED, INVALID_CUSTOM_QUERY, INDEX_HELPER_TEXT } from './translations'; -export const schema: FormSchema = { +export const schema: FormSchema = { index: { type: FIELD_TYPES.COMBO_BOX, label: i18n.translate( diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index 5b4f7677dbc30a..e6f1c25bf9dacc 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -13,7 +13,7 @@ import { EuiSpacer, } from '@elastic/eui'; import { findIndex } from 'lodash/fp'; -import React, { FC, memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect, useMemo } from 'react'; import { ActionVariable } from '../../../../../../triggers_actions_ui/public'; import { @@ -22,7 +22,7 @@ import { ActionsStepRule, } from '../../../pages/detection_engine/rules/types'; import { StepRuleDescription } from '../description_step'; -import { Form, UseField, useForm } from '../../../../shared_imports'; +import { Form, UseField, useForm, useFormData } from '../../../../shared_imports'; import { StepContentWrapper } from '../step_content_wrapper'; import { ThrottleSelectField, @@ -40,9 +40,8 @@ interface StepRuleActionsProps extends RuleStepProps { actionMessageParams: ActionVariable[]; } -const stepActionsDefaultValue = { +const stepActionsDefaultValue: ActionsStepRule = { enabled: true, - isNew: true, actions: [], kibanaSiemAppUrl: '', throttle: DEFAULT_THROTTLE_OPTION.value, @@ -65,27 +64,16 @@ const StepRuleActionsComponent: FC = ({ isReadOnlyView, isLoading, isUpdateView = false, - setStepData, + onSubmit, setForm, actionMessageParams, }) => { - const initialState = defaultValues ?? stepActionsDefaultValue; - const [myStepData, setMyStepData] = useState(initialState); const { services: { application, triggers_actions_ui: { actionTypeRegistry }, }, } = useKibana(); - const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); - - const { form } = useForm({ - defaultValue: initialState, - options: { stripEmptyFields: false }, - schema, - }); - const { submit } = form; - const kibanaAbsoluteUrl = useMemo( () => application.getUrlForApp(`${APP_ID}`, { @@ -93,37 +81,52 @@ const StepRuleActionsComponent: FC = ({ }), [application] ); - - const onSubmit = useCallback( - async (enabled: boolean) => { - if (setStepData) { - setStepData(RuleStep.ruleActions, null, false); - const { isValid: newIsValid, data } = await submit(); - if (newIsValid) { - setStepData(RuleStep.ruleActions, { ...data, enabled }, newIsValid); - setMyStepData({ ...data, isNew: false } as ActionsStepRule); - } + const initialState = { + ...(defaultValues ?? stepActionsDefaultValue), + kibanaSiemAppUrl: kibanaAbsoluteUrl, + }; + const schema = useMemo(() => getSchema({ actionTypeRegistry }), [actionTypeRegistry]); + const { form } = useForm({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema, + }); + const { getFields, getFormData, submit } = form; + const [{ throttle: formThrottle }] = (useFormData({ + form, + watch: ['throttle'], + }) as unknown) as [Partial]; + const throttle = formThrottle || initialState.throttle; + + const handleSubmit = useCallback( + (enabled: boolean) => { + getFields().enabled.setValue(enabled); + if (onSubmit) { + onSubmit(); } }, - [setStepData, submit] + [getFields, onSubmit] ); + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); + useEffect(() => { if (setForm) { - setForm(RuleStep.ruleActions, form); + setForm(RuleStep.ruleActions, getData); } - }, [form, setForm]); - - const updateThrottle = useCallback((throttle) => setMyStepData({ ...myStepData, throttle }), [ - myStepData, - setMyStepData, - ]); + }, [getData, setForm]); const throttleOptions = useMemo(() => { - const throttle = myStepData.throttle; - return getThrottleOptions(throttle); - }, [myStepData]); + }, [throttle]); const throttleFieldComponentProps = useMemo( () => ({ @@ -131,18 +134,16 @@ const StepRuleActionsComponent: FC = ({ isDisabled: isLoading, dataTestSubj: 'detectionEngineStepRuleActionsThrottle', hasNoInitialSelection: false, - handleChange: updateThrottle, euiFieldProps: { options: throttleOptions, }, }), - // eslint-disable-next-line react-hooks/exhaustive-deps - [isLoading, updateThrottle] + [isLoading, throttleOptions] ); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView ? ( - + ) : ( <> @@ -154,12 +155,11 @@ const StepRuleActionsComponent: FC = ({ component={ThrottleSelectField} componentProps={throttleFieldComponentProps} /> - {myStepData.throttle !== stepActionsDefaultValue.throttle ? ( + {throttle !== stepActionsDefaultValue.throttle ? ( <> = ({ /> ) : ( - + )} - - + + @@ -197,7 +189,7 @@ const StepRuleActionsComponent: FC = ({ fill={false} isDisabled={isLoading} isLoading={isLoading} - onClick={onSubmit.bind(null, false)} + onClick={() => handleSubmit(false)} > {I18n.COMPLETE_WITHOUT_ACTIVATING} @@ -207,7 +199,7 @@ const StepRuleActionsComponent: FC = ({ fill isDisabled={isLoading} isLoading={isLoading} - onClick={onSubmit.bind(null, true)} + onClick={() => handleSubmit(true)} data-test-subj="create-activate" > {I18n.COMPLETE_WITH_ACTIVATING} diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx index a093f991afaf79..38de3a2026eca6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/schema.tsx @@ -12,7 +12,8 @@ import { AlertAction, ActionTypeRegistryContract, } from '../../../../../../triggers_actions_ui/public'; -import { FormSchema, FormData, ValidationFunc, ERROR_CODE } from '../../../../shared_imports'; +import { FormSchema, ValidationFunc, ERROR_CODE } from '../../../../shared_imports'; +import { ActionsStepRule } from '../../../pages/detection_engine/rules/types'; import * as I18n from './translations'; import { isUuidv4, getActionTypeName, validateMustache, validateActionParams } from './utils'; @@ -61,7 +62,7 @@ export const getSchema = ({ actionTypeRegistry, }: { actionTypeRegistry: ActionTypeRegistryContract; -}): FormSchema => ({ +}): FormSchema => ({ actions: { validations: [ { diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx index 52f04f8423bec5..d451932a6b634a 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, memo, useCallback, useEffect, useState } from 'react'; +import React, { FC, memo, useCallback, useEffect } from 'react'; import { RuleStep, @@ -22,9 +22,8 @@ interface StepScheduleRuleProps extends RuleStepProps { defaultValues?: ScheduleStepRule | null; } -const stepScheduleDefaultValue = { +const stepScheduleDefaultValue: ScheduleStepRule = { interval: '5m', - isNew: true, from: '1m', }; @@ -35,39 +34,44 @@ const StepScheduleRuleComponent: FC = ({ isReadOnlyView, isLoading, isUpdateView = false, - setStepData, + onSubmit, setForm, }) => { const initialState = defaultValues ?? stepScheduleDefaultValue; - const [myStepData, setMyStepData] = useState(initialState); - const { form } = useForm({ + const { form } = useForm({ defaultValue: initialState, options: { stripEmptyFields: false }, schema, }); - const { submit } = form; - const onSubmit = useCallback(async () => { - if (setStepData) { - setStepData(RuleStep.scheduleRule, null, false); - const { isValid: newIsValid, data } = await submit(); - if (newIsValid) { - setStepData(RuleStep.scheduleRule, { ...data }, newIsValid); - setMyStepData({ ...data, isNew: false } as ScheduleStepRule); - } + const { getFormData, submit } = form; + + const handleSubmit = useCallback(() => { + if (onSubmit) { + onSubmit(); } - }, [setStepData, submit]); + }, [onSubmit]); + + const getData = useCallback(async () => { + const result = await submit(); + return result?.isValid + ? result + : { + isValid: false, + data: getFormData(), + }; + }, [getFormData, submit]); useEffect(() => { if (setForm) { - setForm(RuleStep.scheduleRule, form); + setForm(RuleStep.scheduleRule, getData); } - }, [form, setForm]); + }, [getData, setForm]); - return isReadOnlyView && myStepData != null ? ( + return isReadOnlyView ? ( - + ) : ( <> @@ -96,7 +100,7 @@ const StepScheduleRuleComponent: FC = ({ {!isUpdateView && ( - + )} ); diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx index f4c371a2364f65..cf93a9b61710c4 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_schedule_rule/schema.tsx @@ -9,9 +9,10 @@ import { i18n } from '@kbn/i18n'; import { OptionalFieldLabel } from '../optional_field_label'; +import { ScheduleStepRule } from '../../../pages/detection_engine/rules/types'; import { FormSchema } from '../../../../shared_imports'; -export const schema: FormSchema = { +export const schema: FormSchema = { interval: { label: i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepScheduleRule.fieldIntervalLabel', 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/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 8c6e91254314eb..5a626ce0ff00a2 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts @@ -165,8 +165,7 @@ export const mockRuleWithEverything = (id: string): Rule => ({ }); // TODO: update types mapping -export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ - isNew, +export const mockAboutStepRule = (): AboutStepRule => ({ author: ['Elastic'], isAssociatedToEndpointList: false, isBuildingBlock: false, @@ -200,16 +199,14 @@ export const mockAboutStepRule = (isNew = false): AboutStepRule => ({ note: '# this is some markdown documentation', }); -export const mockActionsStepRule = (isNew = false, enabled = false): ActionsStepRule => ({ - isNew, +export const mockActionsStepRule = (enabled = false): ActionsStepRule => ({ actions: [], kibanaSiemAppUrl: 'http://localhost:5601/app/siem', enabled, throttle: 'no_actions', }); -export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ - isNew, +export const mockDefineStepRule = (): DefineStepRule => ({ ruleType: 'query', anomalyThreshold: 50, machineLearningJobId: '', @@ -225,8 +222,7 @@ export const mockDefineStepRule = (isNew = false): DefineStepRule => ({ }, }); -export const mockScheduleStepRule = (isNew = false): ScheduleStepRule => ({ - isNew, +export const mockScheduleStepRule = (): ScheduleStepRule => ({ interval: '5m', from: '6m', to: 'now', 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 874ef032b7c5ec..0137777f8f8f22 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 @@ -13,7 +13,7 @@ import { transformAlertToRuleAction } from '../../../../../../common/detection_e 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 { ENDPOINT_LIST_ID, ExceptionListType, NamespaceType } from '../../../../../shared_imports'; import { Rule } from '../../../../containers/detection_engine/rules'; import { Type } from '../../../../../../common/detection_engine/schemas/common/schemas'; @@ -26,6 +26,8 @@ import { ScheduleStepRuleJson, AboutStepRuleJson, ActionsStepRuleJson, + RuleStepsFormData, + RuleStep, } from '../types'; export const getTimeTypeValue = (time: string): { unit: string; value: number } => { @@ -33,8 +35,8 @@ export const getTimeTypeValue = (time: string): { unit: string; value: number } unit: '', value: 0, }; - const filterTimeVal = (time as string).match(/\d+/g); - const filterTimeType = (time as string).match(/[a-zA-Z]+/g); + const filterTimeVal = time.match(/\d+/g); + const filterTimeType = time.match(/[a-zA-Z]+/g); if (!isEmpty(filterTimeVal) && filterTimeVal != null && !isNaN(Number(filterTimeVal[0]))) { timeObj.value = Number(filterTimeVal[0]); } @@ -48,6 +50,23 @@ export const getTimeTypeValue = (time: string): { unit: string; value: number } return timeObj; }; +export const stepIsValid = ( + formData?: T +): formData is { [K in keyof T]: Exclude } => + !!formData?.isValid && !!formData.data; + +export const isDefineStep = (input: unknown): input is RuleStepsFormData[RuleStep.defineRule] => + has('data.ruleType', input); + +export const isAboutStep = (input: unknown): input is RuleStepsFormData[RuleStep.aboutRule] => + has('data.name', input); + +export const isScheduleStep = (input: unknown): input is RuleStepsFormData[RuleStep.scheduleRule] => + has('data.interval', input); + +export const isActionsStep = (input: unknown): input is RuleStepsFormData[RuleStep.ruleActions] => + has('data.actions', input); + export interface RuleFields { anomalyThreshold: unknown; machineLearningJobId: unknown; @@ -129,7 +148,7 @@ export const formatDefineStepData = (defineStepData: DefineStepRule): DefineStep }; export const formatScheduleStepData = (scheduleData: ScheduleStepRule): ScheduleStepRuleJson => { - const { isNew, ...formatScheduleData } = scheduleData; + const { ...formatScheduleData } = scheduleData; if (!isEmpty(formatScheduleData.interval) && !isEmpty(formatScheduleData.from)) { const { unit: intervalUnit, value: intervalValue } = getTimeTypeValue( formatScheduleData.interval @@ -161,7 +180,6 @@ export const formatAboutStepData = ( threat, isAssociatedToEndpointList, isBuildingBlock, - isNew, note, ruleNameOverride, timestampOverride, @@ -180,11 +198,11 @@ export const formatAboutStepData = ( { id: ENDPOINT_LIST_ID, list_id: ENDPOINT_LIST_ID, - namespace_type: 'agnostic', - type: 'endpoint', + namespace_type: 'agnostic' as NamespaceType, + type: 'endpoint' as ExceptionListType, }, ...detectionExceptionLists, - ] as AboutStepRuleJson['exceptions_list'], + ], } : exceptionsList != null ? { 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 22d84c593b4156..48247392dfe7f6 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 @@ -22,32 +22,20 @@ import { displaySuccessToast, useStateToaster } from '../../../../../common/comp import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; import { useUserData } from '../../../../components/user_info'; import { AccordionTitle } from '../../../../components/rules/accordion_title'; -import { FormData, FormHook } from '../../../../../shared_imports'; -import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepDefineRule } from '../../../../components/rules/step_define_rule'; +import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepScheduleRule } from '../../../../components/rules/step_schedule_rule'; import { StepRuleActions } from '../../../../components/rules/step_rule_actions'; import { DetectionEngineHeaderPage } from '../../../../components/detection_engine_header_page'; import * as RuleI18n from '../translations'; import { redirectToDetections, getActionMessageParams, userHasNoPermissions } from '../helpers'; -import { - AboutStepRule, - DefineStepRule, - RuleStep, - RuleStepData, - ScheduleStepRule, - ActionsStepRule, -} from '../types'; -import { formatRule } from './helpers'; +import { RuleStep, RuleStepsFormData, RuleStepsFormHooks } from '../types'; +import { formatRule, stepIsValid } from './helpers'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../../app/types'; +import { ruleStepsOrder } from '../utils'; -const stepsRuleOrder = [ - RuleStep.defineRule, - RuleStep.aboutRule, - RuleStep.scheduleRule, - RuleStep.ruleActions, -]; +const formHookNoop = async (): Promise => undefined; const MyEuiPanel = styled(EuiPanel)<{ zindex?: number; @@ -100,95 +88,137 @@ const CreateRulePageComponent: React.FC = () => { } = useListsConfig(); const loading = userInfoLoading || listsConfigLoading; const [, dispatchToaster] = useStateToaster(); - const [openAccordionId, setOpenAccordionId] = useState(RuleStep.defineRule); + const [activeStep, setActiveStep] = useState(RuleStep.defineRule); + const getNextStep = (step: RuleStep): RuleStep | undefined => + ruleStepsOrder[ruleStepsOrder.indexOf(step) + 1]; const defineRuleRef = useRef(null); const aboutRuleRef = useRef(null); const scheduleRuleRef = useRef(null); const ruleActionsRef = useRef(null); - const stepsForm = useRef | null>>({ - [RuleStep.defineRule]: null, - [RuleStep.aboutRule]: null, - [RuleStep.scheduleRule]: null, - [RuleStep.ruleActions]: null, + const formHooks = useRef({ + [RuleStep.defineRule]: formHookNoop, + [RuleStep.aboutRule]: formHookNoop, + [RuleStep.scheduleRule]: formHookNoop, + [RuleStep.ruleActions]: formHookNoop, }); - const stepsData = useRef>({ + const setFormHook = useCallback( + (step: K, hook: RuleStepsFormHooks[K]) => { + formHooks.current[step] = hook; + }, + [] + ); + const stepsData = useRef({ [RuleStep.defineRule]: { isValid: false, data: undefined }, [RuleStep.aboutRule]: { isValid: false, data: undefined }, [RuleStep.scheduleRule]: { isValid: false, data: undefined }, [RuleStep.ruleActions]: { isValid: false, data: undefined }, }); - const [isStepRuleInReadOnlyView, setIsStepRuleInEditView] = useState>({ + const setStepData = ( + step: K, + data: RuleStepsFormData[K] + ): void => { + stepsData.current[step] = data; + }; + const [openSteps, setOpenSteps] = useState({ [RuleStep.defineRule]: false, [RuleStep.aboutRule]: false, [RuleStep.scheduleRule]: false, [RuleStep.ruleActions]: false, }); const [{ isLoading, isSaved }, setRule] = useCreateRule(); - const actionMessageParams = useMemo( - () => - getActionMessageParams((stepsData.current['define-rule'].data as DefineStepRule)?.ruleType), - // eslint-disable-next-line react-hooks/exhaustive-deps - [stepsData.current['define-rule'].data] - ); + const ruleType = stepsData.current[RuleStep.defineRule].data?.ruleType; + const ruleName = stepsData.current[RuleStep.aboutRule].data?.name; + const actionMessageParams = useMemo(() => getActionMessageParams(ruleType), [ruleType]); const history = useHistory(); - const setStepData = useCallback( - (step: RuleStep, data: unknown, isValid: boolean) => { - stepsData.current[step] = { ...stepsData.current[step], data, isValid }; - if (isValid) { - const stepRuleIdx = stepsRuleOrder.findIndex((item) => step === item); - if ([0, 1, 2].includes(stepRuleIdx)) { - if (isStepRuleInReadOnlyView[stepsRuleOrder[stepRuleIdx + 1]]) { - setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [step]: true, - [stepsRuleOrder[stepRuleIdx + 1]]: false, - }); - } else if (openAccordionId !== stepsRuleOrder[stepRuleIdx + 1]) { - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [step]: true, - }); - openCloseAccordion(stepsRuleOrder[stepRuleIdx + 1]); - setOpenAccordionId(stepsRuleOrder[stepRuleIdx + 1]); + const handleAccordionToggle = useCallback( + (step: RuleStep, isOpen: boolean) => + setOpenSteps((_openSteps) => ({ + ..._openSteps, + [step]: isOpen, + })), + [] + ); + const goToStep = useCallback( + (step: RuleStep) => { + if (ruleStepsOrder.indexOf(step) > ruleStepsOrder.indexOf(activeStep) && !openSteps[step]) { + toggleStepAccordion(step); + } + setActiveStep(step); + }, + [activeStep, openSteps] + ); + + const toggleStepAccordion = (step: RuleStep | null) => { + if (step === RuleStep.defineRule) { + defineRuleRef.current?.onToggle(); + } else if (step === RuleStep.aboutRule) { + aboutRuleRef.current?.onToggle(); + } else if (step === RuleStep.scheduleRule) { + scheduleRuleRef.current?.onToggle(); + } else if (step === RuleStep.ruleActions) { + ruleActionsRef.current?.onToggle(); + } + }; + + const editStep = useCallback( + async (step: RuleStep) => { + const activeStepData = await formHooks.current[activeStep](); + + if (activeStepData?.isValid) { + setStepData(activeStep, activeStepData); + goToStep(step); + } + }, + [activeStep, goToStep] + ); + const submitStep = useCallback( + async (step: RuleStep) => { + const stepData = await formHooks.current[step](); + + if (stepData?.isValid) { + setStepData(step, stepData); + const nextStep = getNextStep(step); + + if (nextStep != null) { + goToStep(nextStep); + } else { + const defineStep = await stepsData.current[RuleStep.defineRule]; + const aboutStep = await stepsData.current[RuleStep.aboutRule]; + const scheduleStep = await stepsData.current[RuleStep.scheduleRule]; + const actionsStep = await stepsData.current[RuleStep.ruleActions]; + + if ( + stepIsValid(defineStep) && + stepIsValid(aboutStep) && + stepIsValid(scheduleStep) && + stepIsValid(actionsStep) + ) { + setRule( + formatRule( + defineStep.data, + aboutStep.data, + scheduleStep.data, + actionsStep.data + ) + ); } - } else if ( - stepRuleIdx === 3 && - stepsData.current[RuleStep.defineRule].isValid && - stepsData.current[RuleStep.aboutRule].isValid && - stepsData.current[RuleStep.scheduleRule].isValid - ) { - setRule( - formatRule( - stepsData.current[RuleStep.defineRule].data as DefineStepRule, - stepsData.current[RuleStep.aboutRule].data as AboutStepRule, - stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule, - stepsData.current[RuleStep.ruleActions].data as ActionsStepRule - ) - ); } } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [isStepRuleInReadOnlyView, openAccordionId, stepsData.current, setRule] + [goToStep, setRule] ); - const setStepsForm = useCallback((step: RuleStep, form: FormHook) => { - stepsForm.current[step] = form; - }, []); - const getAccordionType = useCallback( - (accordionId: RuleStep) => { - if (accordionId === openAccordionId) { + (step: RuleStep) => { + if (step === activeStep) { return 'active'; - } else if (stepsData.current[accordionId].isValid) { + } else if (stepsData.current[step].isValid) { return 'valid'; } return 'passive'; }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [openAccordionId, stepsData.current] + [activeStep] ); const defineRuleButton = ( @@ -198,7 +228,6 @@ const CreateRulePageComponent: React.FC = () => { type={getAccordionType(RuleStep.defineRule)} /> ); - const aboutRuleButton = ( { type={getAccordionType(RuleStep.aboutRule)} /> ); - const scheduleRuleButton = ( { type={getAccordionType(RuleStep.scheduleRule)} /> ); - const ruleActionsButton = ( { /> ); - const openCloseAccordion = (accordionId: RuleStep | null) => { - if (accordionId != null) { - if (accordionId === RuleStep.defineRule && defineRuleRef.current != null) { - defineRuleRef.current.onToggle(); - } else if (accordionId === RuleStep.aboutRule && aboutRuleRef.current != null) { - aboutRuleRef.current.onToggle(); - } else if (accordionId === RuleStep.scheduleRule && scheduleRuleRef.current != null) { - scheduleRuleRef.current.onToggle(); - } else if (accordionId === RuleStep.ruleActions && ruleActionsRef.current != null) { - ruleActionsRef.current.onToggle(); - } - } - }; - - const manageAccordions = useCallback( - (id: RuleStep, isOpen: boolean) => { - const activeRuleIdx = stepsRuleOrder.findIndex((step) => step === openAccordionId); - const stepRuleIdx = stepsRuleOrder.findIndex((step) => step === id); - - if ((id === openAccordionId || stepRuleIdx < activeRuleIdx) && !isOpen) { - openCloseAccordion(id); - } else if (stepRuleIdx >= activeRuleIdx) { - if ( - openAccordionId !== id && - !stepsData.current[openAccordionId].isValid && - !isStepRuleInReadOnlyView[id] && - isOpen - ) { - openCloseAccordion(id); - } - } - }, - [isStepRuleInReadOnlyView, openAccordionId, stepsData] - ); - - const manageIsEditable = useCallback( - async (id: RuleStep) => { - const activeForm = await stepsForm.current[openAccordionId]?.submit(); - if (activeForm != null && activeForm?.isValid) { - stepsData.current[openAccordionId] = { - ...stepsData.current[openAccordionId], - data: activeForm.data, - isValid: activeForm.isValid, - }; - setOpenAccordionId(id); - setIsStepRuleInEditView({ - ...isStepRuleInReadOnlyView, - [openAccordionId]: true, - [id]: false, - }); - } - }, - [isStepRuleInReadOnlyView, openAccordionId] - ); - - if (isSaved) { - const ruleName = (stepsData.current[RuleStep.aboutRule].data as AboutStepRule).name; + if (isSaved && ruleName) { displaySuccessToast(i18n.SUCCESSFULLY_CREATED_RULES(ruleName), dispatchToaster); history.replace(getRulesUrl()); return null; @@ -320,13 +291,14 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={defineRuleButton} paddingSize="xs" ref={defineRuleRef} - onToggle={manageAccordions.bind(null, RuleStep.defineRule)} + onToggle={handleAccordionToggle.bind(null, RuleStep.defineRule)} extraAction={ stepsData.current[RuleStep.defineRule].isValid && ( editStep(RuleStep.defineRule)} > {i18n.EDIT_RULE} @@ -336,11 +308,11 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.defineRule)} descriptionColumns="singleSplit" /> @@ -353,13 +325,14 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={aboutRuleButton} paddingSize="xs" ref={aboutRuleRef} - onToggle={manageAccordions.bind(null, RuleStep.aboutRule)} + onToggle={handleAccordionToggle.bind(null, RuleStep.aboutRule)} extraAction={ stepsData.current[RuleStep.aboutRule].isValid && ( editStep(RuleStep.aboutRule)} > {i18n.EDIT_RULE} @@ -369,13 +342,13 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.aboutRule)} /> @@ -387,13 +360,13 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={scheduleRuleButton} paddingSize="xs" ref={scheduleRuleRef} - onToggle={manageAccordions.bind(null, RuleStep.scheduleRule)} + onToggle={handleAccordionToggle.bind(null, RuleStep.scheduleRule)} extraAction={ stepsData.current[RuleStep.scheduleRule].isValid && ( editStep(RuleStep.scheduleRule)} > {i18n.EDIT_RULE} @@ -403,12 +376,12 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.scheduleRule)} /> @@ -420,13 +393,13 @@ const CreateRulePageComponent: React.FC = () => { buttonContent={ruleActionsButton} paddingSize="xs" ref={ruleActionsRef} - onToggle={manageAccordions.bind(null, RuleStep.ruleActions)} + onToggle={handleAccordionToggle.bind(null, RuleStep.ruleActions)} extraAction={ stepsData.current[RuleStep.ruleActions].isValid && ( editStep(RuleStep.ruleActions)} > {i18n.EDIT_RULE} @@ -436,10 +409,11 @@ const CreateRulePageComponent: React.FC = () => { submitStep(RuleStep.ruleActions)} actionMessageParams={actionMessageParams} /> 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 b251b2eba10ae0..5f4fd596691034 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,6 +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 { UpdateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; 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'; @@ -28,13 +29,19 @@ import { displaySuccessToast, useStateToaster } from '../../../../../common/comp import { SpyRoute } from '../../../../../common/utils/route/spy_routes'; import { useUserData } from '../../../../components/user_info'; import { DetectionEngineHeaderPage } from '../../../../components/detection_engine_header_page'; -import { FormHook, FormData } from '../../../../../shared_imports'; import { StepPanel } from '../../../../components/rules/step_panel'; import { StepAboutRule } from '../../../../components/rules/step_about_rule'; import { StepDefineRule } from '../../../../components/rules/step_define_rule'; import { StepScheduleRule } from '../../../../components/rules/step_schedule_rule'; import { StepRuleActions } from '../../../../components/rules/step_rule_actions'; -import { formatRule } from '../create/helpers'; +import { + formatRule, + stepIsValid, + isDefineStep, + isAboutStep, + isScheduleStep, + isActionsStep, +} from '../create/helpers'; import { getStepsData, redirectToDetections, @@ -42,33 +49,12 @@ import { userHasNoPermissions, } from '../helpers'; import * as ruleI18n from '../translations'; -import { - RuleStep, - DefineStepRule, - AboutStepRule, - ScheduleStepRule, - ActionsStepRule, -} from '../types'; +import { RuleStep, RuleStepsFormHooks, RuleStepsFormData, RuleStepsData } from '../types'; import * as i18n from './translations'; import { SecurityPageName } from '../../../../../app/types'; -import { UpdateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; - -interface StepRuleForm { - isValid: boolean; -} -interface AboutStepRuleForm extends StepRuleForm { - data: AboutStepRule | null; -} -interface DefineStepRuleForm extends StepRuleForm { - data: DefineStepRule | null; -} -interface ScheduleStepRuleForm extends StepRuleForm { - data: ScheduleStepRule | null; -} +import { ruleStepsOrder } from '../utils'; -interface ActionsStepRuleForm extends StepRuleForm { - data: ActionsStepRule | null; -} +const formHookNoop = async (): Promise => undefined; const EditRulePageComponent: FC = () => { const history = useHistory(); @@ -86,49 +72,49 @@ const EditRulePageComponent: FC = () => { loading: listsConfigLoading, needsConfiguration: needsListsConfiguration, } = useListsConfig(); - const initLoading = userInfoLoading || listsConfigLoading; const { detailName: ruleId } = useParams<{ detailName: string | undefined }>(); - const [loading, rule] = useRule(ruleId); + const [ruleLoading, rule] = useRule(ruleId); + const loading = ruleLoading || userInfoLoading || listsConfigLoading; - const [initForm, setInitForm] = useState(false); - const [myAboutRuleForm, setMyAboutRuleForm] = useState({ - data: null, - isValid: false, - }); - const [myDefineRuleForm, setMyDefineRuleForm] = useState({ - data: null, - isValid: false, + const formHooks = useRef({ + [RuleStep.defineRule]: formHookNoop, + [RuleStep.aboutRule]: formHookNoop, + [RuleStep.scheduleRule]: formHookNoop, + [RuleStep.ruleActions]: formHookNoop, }); - const [myScheduleRuleForm, setMyScheduleRuleForm] = useState({ - data: null, - isValid: false, + const stepsData = useRef({ + [RuleStep.defineRule]: { isValid: false, data: undefined }, + [RuleStep.aboutRule]: { isValid: false, data: undefined }, + [RuleStep.scheduleRule]: { isValid: false, data: undefined }, + [RuleStep.ruleActions]: { isValid: false, data: undefined }, }); - const [myActionsRuleForm, setMyActionsRuleForm] = useState({ - data: null, - isValid: false, - }); - const [selectedTab, setSelectedTab] = useState(); - const stepsForm = useRef | null>>({ - [RuleStep.defineRule]: null, - [RuleStep.aboutRule]: null, - [RuleStep.scheduleRule]: null, - [RuleStep.ruleActions]: null, + const defineStep = stepsData.current[RuleStep.defineRule]; + const aboutStep = stepsData.current[RuleStep.aboutRule]; + const scheduleStep = stepsData.current[RuleStep.scheduleRule]; + const actionsStep = stepsData.current[RuleStep.ruleActions]; + const [activeStep, setActiveStep] = useState(RuleStep.defineRule); + const invalidSteps = ruleStepsOrder.filter((step) => { + const stepData = stepsData.current[step]; + return stepData.data != null && !stepIsValid(stepData); }); const [{ isLoading, isSaved }, setRule] = useUpdateRule(); - const [tabHasError, setTabHasError] = useState([]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule]); - const setStepsForm = useCallback( - (step: RuleStep, form: FormHook) => { - stepsForm.current[step] = form; - if (initForm && step === (selectedTab?.id as RuleStep) && form.isSubmitted === false) { - setInitForm(false); - form.submit(); + const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule?.type]); + const setFormHook = useCallback( + (step: K, hook: RuleStepsFormHooks[K]) => { + formHooks.current[step] = hook; + if (step === activeStep) { + hook(); } }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [initForm, selectedTab] + [activeStep] + ); + const setStepData = useCallback( + (step: K, data: RuleStepsData[K], isValid: boolean) => { + stepsData.current[step] = { ...stepsData.current[step], data, isValid }; + }, + [] ); + const tabs = useMemo( () => [ { @@ -138,14 +124,14 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myDefineRuleForm.data != null && ( + + {defineStep.data != null && ( )} @@ -160,15 +146,15 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myAboutRuleForm.data != null && myDefineRuleForm.data != null && ( + + {aboutStep.data != null && defineStep.data != null && ( )} @@ -183,14 +169,14 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myScheduleRuleForm.data != null && ( + + {scheduleStep.data != null && ( )} @@ -204,14 +190,14 @@ const EditRulePageComponent: FC = () => { content: ( <> - - {myActionsRuleForm.data != null && ( + + {actionsStep.data != null && ( )} @@ -221,76 +207,56 @@ const EditRulePageComponent: FC = () => { ), }, ], - // eslint-disable-next-line react-hooks/exhaustive-deps [ - rule, + rule?.immutable, loading, - initLoading, + defineStep.data, isLoading, - myAboutRuleForm, - myDefineRuleForm, - myScheduleRuleForm, - myActionsRuleForm, - setStepsForm, - stepsForm, + setFormHook, + aboutStep.data, + scheduleStep.data, + actionsStep.data, actionMessageParams, ] ); const onSubmit = useCallback(async () => { - const activeFormId = selectedTab?.id as RuleStep; - const activeForm = await stepsForm.current[activeFormId]?.submit(); - - const invalidForms = [ - RuleStep.aboutRule, - RuleStep.defineRule, - RuleStep.scheduleRule, - RuleStep.ruleActions, - ].reduce((acc, step) => { - if ( - (step === activeFormId && activeForm != null && !activeForm?.isValid) || - (step === RuleStep.aboutRule && !myAboutRuleForm.isValid) || - (step === RuleStep.defineRule && !myDefineRuleForm.isValid) || - (step === RuleStep.scheduleRule && !myScheduleRuleForm.isValid) || - (step === RuleStep.ruleActions && !myActionsRuleForm.isValid) - ) { - return [...acc, step]; - } - return acc; - }, []); + const activeStepData = await formHooks.current[activeStep](); + if (activeStepData?.data != null) { + setStepData(activeStep, activeStepData.data, activeStepData.isValid); + } + const define = isDefineStep(activeStepData) ? activeStepData : defineStep; + const about = isAboutStep(activeStepData) ? activeStepData : aboutStep; + const schedule = isScheduleStep(activeStepData) ? activeStepData : scheduleStep; + const actions = isActionsStep(activeStepData) ? activeStepData : actionsStep; - if (invalidForms.length === 0 && activeForm != null) { - setTabHasError([]); + if ( + stepIsValid(define) && + stepIsValid(about) && + stepIsValid(schedule) && + stepIsValid(actions) + ) { setRule({ ...formatRule( - (activeFormId === RuleStep.defineRule - ? activeForm.data - : myDefineRuleForm.data) as DefineStepRule, - (activeFormId === RuleStep.aboutRule - ? activeForm.data - : myAboutRuleForm.data) as AboutStepRule, - (activeFormId === RuleStep.scheduleRule - ? activeForm.data - : myScheduleRuleForm.data) as ScheduleStepRule, - (activeFormId === RuleStep.ruleActions - ? activeForm.data - : myActionsRuleForm.data) as ActionsStepRule, + define.data, + about.data, + schedule.data, + actions.data, rule ), ...(ruleId ? { id: ruleId } : {}), }); - } else { - setTabHasError(invalidForms); } - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ - stepsForm, - myAboutRuleForm, - myDefineRuleForm, - myScheduleRuleForm, - myActionsRuleForm, - selectedTab, + aboutStep, + actionsStep, + activeStep, + defineStep, + rule, ruleId, + scheduleStep, + setRule, + setStepData, ]); useEffect(() => { @@ -298,48 +264,29 @@ const EditRulePageComponent: FC = () => { const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ rule, }); - setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); - setMyDefineRuleForm({ data: defineRuleData, isValid: true }); - setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); - setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); + setStepData(RuleStep.defineRule, defineRuleData, true); + setStepData(RuleStep.aboutRule, aboutRuleData, true); + setStepData(RuleStep.scheduleRule, scheduleRuleData, true); + setStepData(RuleStep.ruleActions, ruleActionsData, true); } - }, [rule]); + }, [rule, setStepData]); + + const goToStep = useCallback(async (step: RuleStep) => { + setActiveStep(step); + await formHooks.current[step](); + }, []); const onTabClick = useCallback( async (tab: EuiTabbedContentTab) => { - if (selectedTab != null) { - const ruleStep = selectedTab.id as RuleStep; - const respForm = await stepsForm.current[ruleStep]?.submit(); + const targetStep = tab.id as RuleStep; + const activeStepData = await formHooks.current[activeStep](); - if (respForm != null) { - if (ruleStep === RuleStep.aboutRule) { - setMyAboutRuleForm({ - data: respForm.data as AboutStepRule, - isValid: respForm.isValid, - }); - } else if (ruleStep === RuleStep.defineRule) { - setMyDefineRuleForm({ - data: respForm.data as DefineStepRule, - isValid: respForm.isValid, - }); - } else if (ruleStep === RuleStep.scheduleRule) { - setMyScheduleRuleForm({ - data: respForm.data as ScheduleStepRule, - isValid: respForm.isValid, - }); - } else if (ruleStep === RuleStep.ruleActions) { - setMyActionsRuleForm({ - data: respForm.data as ActionsStepRule, - isValid: respForm.isValid, - }); - } - } + if (activeStepData?.data != null) { + setStepData(activeStep, activeStepData.data, activeStepData.isValid); + goToStep(targetStep); } - setInitForm(true); - setSelectedTab(tab); }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [selectedTab, stepsForm.current] + [activeStep, goToStep, setStepData] ); const goToDetailsRule = useCallback( @@ -351,23 +298,13 @@ const EditRulePageComponent: FC = () => { ); useEffect(() => { - if (rule != null) { - const { aboutRuleData, defineRuleData, scheduleRuleData, ruleActionsData } = getStepsData({ - rule, - }); - setMyAboutRuleForm({ data: aboutRuleData, isValid: true }); - setMyDefineRuleForm({ data: defineRuleData, isValid: true }); - setMyScheduleRuleForm({ data: scheduleRuleData, isValid: true }); - setMyActionsRuleForm({ data: ruleActionsData, isValid: true }); + if (rule?.immutable) { + setActiveStep(RuleStep.ruleActions); + } else { + setActiveStep(RuleStep.defineRule); } }, [rule]); - useEffect(() => { - const tabIndex = rule?.immutable ? 3 : 0; - setSelectedTab(tabs[tabIndex]); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rule]); - if (isSaved) { displaySuccessToast(i18n.SUCCESSFULLY_SAVED_RULE(rule?.name ?? ''), dispatchToaster); history.replace(getRuleDetailsUrl(ruleId ?? '')); @@ -401,14 +338,14 @@ const EditRulePageComponent: FC = () => { isLoading={isLoading} title={i18n.PAGE_TITLE} /> - {tabHasError.length > 0 && ( + {invalidSteps.length > 0 && ( { if (t === RuleStep.aboutRule) { return ruleI18n.ABOUT; @@ -429,7 +366,7 @@ const EditRulePageComponent: FC = () => { t.id === selectedTab?.id)} + selectedTab={tabs.find((t) => t.id === activeStep)} onTabClick={onTabClick} tabs={tabs} /> @@ -454,7 +391,7 @@ const EditRulePageComponent: FC = () => { onClick={onSubmit} iconType="save" isLoading={isLoading} - isDisabled={initLoading} + isDisabled={loading} > {i18n.SAVE_CHANGES} diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx index 10a20807d6f879..f11b0ac4ec3f88 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.test.tsx @@ -43,7 +43,6 @@ describe('rule helpers', () => { rule: mockRuleWithEverything('test-id'), }); const defineRuleStepData = { - isNew: false, ruleType: 'saved_query', anomalyThreshold: 50, index: ['auditbeat-*'], @@ -93,7 +92,6 @@ describe('rule helpers', () => { falsePositives: ['test'], isAssociatedToEndpointList: false, isBuildingBlock: false, - isNew: false, license: 'Elastic License', name: 'Query with rule-id', note: '# this is some markdown documentation', @@ -121,11 +119,10 @@ describe('rule helpers', () => { ], timestampOverride: 'event.ingested', }; - const scheduleRuleStepData = { from: '0s', interval: '5m', isNew: false }; + const scheduleRuleStepData = { from: '0s', interval: '5m' }; const ruleActionsStepData = { enabled: true, throttle: 'no_actions', - isNew: false, actions: [], }; const aboutRuleDataDetailsData = { @@ -202,7 +199,6 @@ describe('rule helpers', () => { test('returns with saved_id if value exists on rule', () => { const result: DefineStepRule = getDefineStepsData(mockRule('test-id')); const expected = { - isNew: false, ruleType: 'saved_query', anomalyThreshold: 50, machineLearningJobId: '', @@ -235,7 +231,6 @@ describe('rule helpers', () => { delete mockedRule.saved_id; const result: DefineStepRule = getDefineStepsData(mockedRule); const expected = { - isNew: false, ruleType: 'saved_query', anomalyThreshold: 50, machineLearningJobId: '', @@ -311,7 +306,6 @@ describe('rule helpers', () => { }; const result: ScheduleStepRule = getScheduleStepsData(mockedRule); const expected = { - isNew: false, interval: mockedRule.interval, from: '0s', }; @@ -344,7 +338,6 @@ describe('rule helpers', () => { }, ], enabled: mockedRule.enabled, - isNew: false, throttle: 'no_actions', }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx index 8178f5ae5ba1d4..aab73c5d5a1e5f 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/helpers.tsx @@ -27,6 +27,7 @@ import { import { SeverityMapping, Type, + Severity, } from '../../../../../common/detection_engine/schemas/common/schemas'; import { severityOptions } from '../../../components/rules/step_about_rule/data'; @@ -67,7 +68,6 @@ export const getActionsStepsData = ( return { actions: actions?.map(transformRuleToAlertAction), - isNew: false, throttle, kibanaSiemAppUrl: meta?.kibana_siem_app_url, enabled, @@ -75,7 +75,6 @@ export const getActionsStepsData = ( }; export const getDefineStepsData = (rule: Rule): DefineStepRule => ({ - isNew: false, ruleType: rule.type, anomalyThreshold: rule.anomaly_threshold ?? 50, machineLearningJobId: rule.machine_learning_job_id ?? '', @@ -100,7 +99,6 @@ export const getScheduleStepsData = (rule: Rule): ScheduleStepRule => { const fromHumanizedValue = getHumanizedDuration(from, interval); return { - isNew: false, interval, from: fromHumanizedValue, }; @@ -142,7 +140,6 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu } = rule; return { - isNew: false, author, isAssociatedToEndpointList: exceptionsList?.some(({ id }) => id === ENDPOINT_LIST_ID) ?? false, isBuildingBlock: buildingBlockType !== undefined, @@ -154,7 +151,7 @@ export const getAboutStepsData = (rule: Rule, detailsView: boolean): AboutStepRu note: note!, references, severity: { - value: severity, + value: severity as Severity, mapping: fillEmptySeverityMappings(severityMapping), isMappingChecked: severityMapping.length > 0, }, 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 a7603051add498..e3d0ea123872a4 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 @@ -7,7 +7,6 @@ import { RuleAlertAction } from '../../../../../common/detection_engine/types'; import { AlertAction } from '../../../../../../alerts/common'; import { Filter } from '../../../../../../../../src/plugins/data/common'; -import { FormData, FormHook } from '../../../../shared_imports'; import { FieldValueQueryBar } from '../../../components/rules/query_bar'; import { FieldValueTimeline } from '../../../components/rules/pick_timeline'; import { FieldValueThreshold } from '../../../components/rules/threshold_input'; @@ -21,6 +20,7 @@ import { SortOrder, TimestampOverride, Type, + Severity, } from '../../../../../common/detection_engine/schemas/common/schemas'; import { List } from '../../../../../common/detection_engine/schemas/types'; @@ -37,34 +37,51 @@ export interface EuiBasicTableOnChange { sort?: EuiBasicTableSortTypes; } +export type RuleStatusType = 'passive' | 'active' | 'valid'; + export enum RuleStep { defineRule = 'define-rule', aboutRule = 'about-rule', scheduleRule = 'schedule-rule', ruleActions = 'rule-actions', } -export type RuleStatusType = 'passive' | 'active' | 'valid'; +export type RuleStepsOrder = [ + RuleStep.defineRule, + RuleStep.aboutRule, + RuleStep.scheduleRule, + RuleStep.ruleActions +]; -export interface RuleStepData { - data: unknown; - isValid: boolean; +export interface RuleStepsData { + [RuleStep.defineRule]: DefineStepRule; + [RuleStep.aboutRule]: AboutStepRule; + [RuleStep.scheduleRule]: ScheduleStepRule; + [RuleStep.ruleActions]: ActionsStepRule; } +export type RuleStepsFormData = { + [K in keyof RuleStepsData]: { + data: RuleStepsData[K] | undefined; + isValid: boolean; + }; +}; + +export type RuleStepsFormHooks = { + [K in keyof RuleStepsData]: () => Promise; +}; + export interface RuleStepProps { addPadding?: boolean; descriptionColumns?: 'multi' | 'single' | 'singleSplit'; - setStepData?: (step: RuleStep, data: unknown, isValid: boolean) => void; isReadOnlyView: boolean; isUpdateView?: boolean; isLoading: boolean; + onSubmit?: () => void; resizeParentContainer?: (height: number) => void; - setForm?: (step: RuleStep, form: FormHook) => void; + setForm?: (step: K, hook: RuleStepsFormHooks[K]) => void; } -interface StepRuleData { - isNew: boolean; -} -export interface AboutStepRule extends StepRuleData { +export interface AboutStepRule { author: string[]; name: string; description: string; @@ -88,7 +105,7 @@ export interface AboutStepRuleDetails { } export interface AboutStepSeverity { - value: string; + value: Severity; mapping: SeverityMapping; isMappingChecked: boolean; } @@ -99,7 +116,7 @@ export interface AboutStepRiskScore { isMappingChecked: boolean; } -export interface DefineStepRule extends StepRuleData { +export interface DefineStepRule { anomalyThreshold: number; index: string[]; machineLearningJobId: string; @@ -109,13 +126,13 @@ export interface DefineStepRule extends StepRuleData { threshold: FieldValueThreshold; } -export interface ScheduleStepRule extends StepRuleData { +export interface ScheduleStepRule { interval: string; from: string; to?: string; } -export interface ActionsStepRule extends StepRuleData { +export interface ActionsStepRule { actions: AlertAction[]; enabled: boolean; kibanaSiemAppUrl?: string; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts index f862a06807e6f7..890746838b0d03 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/utils.ts @@ -20,6 +20,14 @@ import { RouteSpyState } from '../../../../common/utils/route/types'; import { GetUrlForApp } from '../../../../common/components/navigation/types'; import { SecurityPageName } from '../../../../app/types'; import { APP_ID } from '../../../../../common/constants'; +import { RuleStep, RuleStepsOrder } from './types'; + +export const ruleStepsOrder: RuleStepsOrder = [ + RuleStep.defineRule, + RuleStep.aboutRule, + RuleStep.scheduleRule, + RuleStep.ruleActions, +]; const getTabBreadcrumb = (pathname: string, search: string[], getUrlForApp: GetUrlForApp) => { const tabPath = pathname.split('/')[1]; 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/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 097166a9c866a9..08e9fb854e5a2c 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -21,6 +21,7 @@ export { UseMultiFields, useForm, useFormContext, + useFormData, ValidationFunc, VALIDATION_TYPES, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; 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/lib/detection_engine/routes/signals/open_close_signals_route.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts index ac5132d93a0698..be6e57aee6d0cf 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/signals/open_close_signals_route.ts @@ -28,11 +28,12 @@ export const setSignalsStatusRoute = (router: IRouter) => { }, }, 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/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 {} }', })