diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b4563dd1f9a9..bcb477447584 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 9f13c152b4cb..a64a0330ae43 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 88858c36643e..13c1d20552fa 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 f6a02b9038c0..83e7edc5a016 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 <<action-settings, `xpack.actions.whitelistedHosts`>> setting, make sure this hostname is whitelisted. +Host:: Host name of the service provider. If you are using the <<action-settings, `xpack.actions.allowedHosts`>> 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 5fd85a104526..2c9add5233c9 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 <<action-settings, `xpack.actions.whitelistedHosts`>> 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 <<action-settings, `xpack.actions.allowedHosts`>> 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 99bf73c0f559..a1fe7a2521b2 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 <<action-settings, `xpack.actions.whitelistedHosts`>> 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 <<action-settings, `xpack.actions.allowedHosts`>> 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 c91c24430e98..659c3afad6bd 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 <<action-settings, `xpack.actions.whitelistedHosts`>> setting, make sure the hostname is whitelisted. +URL:: The request URL. If you are using the <<action-settings, `xpack.actions.allowedHosts`>> setting, make sure the hostname is added to the allowed hosts. Method:: HTTP request method, either `post`(default) or `put`. Headers:: A set of key-value pairs sent as headers with the request User:: An optional username. If set, HTTP basic authentication is used. Currently only basic authentication is supported. diff --git a/docs/visualize/images/lens_drag_drop.gif b/docs/visualize/images/lens_drag_drop.gif index ca62115e7ea3..1f8580d46270 100644 Binary files a/docs/visualize/images/lens_drag_drop.gif and b/docs/visualize/images/lens_drag_drop.gif differ diff --git a/examples/embeddable_examples/public/book/book_embeddable.tsx b/examples/embeddable_examples/public/book/book_embeddable.tsx index b033fe86cd1c..48b81c27e8b8 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<BookSavedObjectAttributes>, { 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<BookByValueInput> => { + readonly getInputAsValueType = async (): Promise<BookByValueInput> => { const input = this.attributeService.getExplicitInputFromEmbeddable(this); return this.attributeService.getInputAsValueType(input); }; - getInputAsRefType = async (): Promise<BookByReferenceInput> => { + readonly getInputAsRefType = async (): Promise<BookByReferenceInput> => { 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 4c144c3843c4..292261ee16c5 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<BookSavedObjectAttributes>; constructor(private getStartServices: () => Promise<StartServices>) {} @@ -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 5b14dc85b1fc..3541ace1e5e7 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<StartServic }, execute: async ({ embeddable }: ActionContext) => { const { openModal, getAttributeService } = await getStartServices(); - const attributeService = getAttributeService< - BookSavedObjectAttributes, - BookByValueInput, - BookByReferenceInput - >(BOOK_SAVED_OBJECT); + const attributeService = getAttributeService<BookSavedObjectAttributes>(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/package.json b/package.json index cbf8fd6bc3bd..ff487510f7a3 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "**/istanbul-instrumenter-loader/schema-utils": "1.0.0", "**/image-diff/gm/debug": "^2.6.9", "**/load-grunt-config/lodash": "^4.17.20", + "**/node-jose/node-forge": "^0.10.0", "**/react-dom": "^16.12.0", "**/react": "^16.12.0", "**/react-test-renderer": "^16.12.0", @@ -191,7 +192,7 @@ "moment-timezone": "^0.5.27", "mustache": "2.3.2", "node-fetch": "1.7.3", - "node-forge": "^0.9.1", + "node-forge": "^0.10.0", "opn": "^5.5.0", "oppsy": "^2.0.0", "p-map": "^4.0.0", @@ -305,7 +306,7 @@ "@types/moment-timezone": "^0.5.12", "@types/mustache": "^0.8.31", "@types/node": ">=10.17.17 <10.20.0", - "@types/node-forge": "^0.9.0", + "@types/node-forge": "^0.9.5", "@types/normalize-path": "^3.0.0", "@types/opn": "^5.1.0", "@types/pegjs": "^0.10.1", diff --git a/packages/elastic-eslint-config-kibana/typescript.js b/packages/elastic-eslint-config-kibana/typescript.js index 18b11eb62bee..d3e80b744815 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 f10e3f22d53c..b56b83ca324c 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 b09f9d00b3be..000000000000 --- 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 1e0733969e66..000000000000 --- 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<ConfigDeprecationProvider> => { - 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 45869fd12d2b..d0492ea88c5e 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 adfdecdd7c97..880011d2e192 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 dfa2396d5904..2317f1036ce4 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 98f8d874c708..cf08689a6d0d 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<LegacyConfigDeprecation[]>; - /** * @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 1123433e30ac..3270e5a09afd 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 1a9e6253bff7..000000000000 --- 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 0083881e9f74..a05e383394ec 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/legacy/server/logging/log_format.js b/src/legacy/server/logging/log_format.js index 8a80cbef1a9c..6edda8c4be90 100644 --- a/src/legacy/server/logging/log_format.js +++ b/src/legacy/server/logging/log_format.js @@ -91,7 +91,7 @@ export default class TransformObjStream extends Stream.Transform { method: event.method || '', headers: event.headers, remoteAddress: source.remoteAddress, - userAgent: source.remoteAddress, + userAgent: source.userAgent, referer: source.referer, }; diff --git a/src/legacy/server/logging/log_format_json.test.js b/src/legacy/server/logging/log_format_json.test.js index f4fb93975056..ec7296d21672 100644 --- a/src/legacy/server/logging/log_format_json.test.js +++ b/src/legacy/server/logging/log_format_json.test.js @@ -65,12 +65,14 @@ describe('KbnLoggerJsonFormat', () => { }, }; const result = await createPromiseFromStreams([createListStream([event]), format]); - const { type, method, statusCode, message } = JSON.parse(result); + const { type, method, statusCode, message, req } = JSON.parse(result); expect(type).toBe('response'); expect(method).toBe('GET'); expect(statusCode).toBe(200); expect(message).toBe('GET /path/to/resource 200 12000ms - 13.0B'); + expect(req.remoteAddress).toBe('127.0.0.1'); + expect(req.userAgent).toBe('Test Thing'); }); it('ops', async () => { diff --git a/src/plugins/dashboard/public/application/dashboard_app_controller.tsx b/src/plugins/dashboard/public/application/dashboard_app_controller.tsx index 0d20fdee07df..212b54be9ae0 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 000000000000..06f380ca3862 --- /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<TestAttributes>(defaultTestType); + const customAttributeService = mockAttributeService<TestAttributes, TestByValueInput>( + 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<TestAttributes>( + defaultTestType, + undefined, + core + ); + expect(await attributeService.unwrapAttributes(byReferenceInput)).toEqual(attributes); + }); + + it('returns attributes when when given value type input', async () => { + const attributeService = mockAttributeService<TestAttributes>(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<TestAttributes>( + 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<TestAttributes>(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<TestAttributes>( + 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<TestAttributes>( + 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<TestAttributes>(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<TestAttributes>(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 fe5f6a0c8e2b..a36363d22d87 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<A extends { title: string }> { + customSaveMethod?: ( + type: string, + attributes: A, + savedObjectId?: string + ) => Promise<{ id: string }>; + customUnwrapMethod?: (savedObject: SimpleSavedObject<A>) => 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<SavedObjectAttributes> ) { - 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<SavedObjectAttributes> { @@ -79,43 +97,54 @@ export class AttributeService< const savedObject: SimpleSavedObject<SavedObjectAttributes> = 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<Omit<ValType | RefType, 'id'>> { + 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<RefType> => { 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( <SavedObjectSaveModal onSave={onSave} onClose={() => 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 000000000000..321a53361fc7 --- /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<A>, + customCore?: jest.Mocked<CoreStart> +): AttributeService<A, V, R> => { + const core = customCore ? customCore : coreMock.createStart(); + const service = new AttributeService<A, V, R>( + 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 8a9954cc77a2..e22d1f038a45 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 3df52f4e7a20..0ce6f9489ea0 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<typeof createDashboardContainerByValueRenderer>; 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<A> ) => AttributeService<A, V, R>; } @@ -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/src/plugins/ui_actions/public/triggers/select_range_trigger.ts b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts index f6d5547f6248..312e75314bd9 100644 --- a/src/plugins/ui_actions/public/triggers/select_range_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/select_range_trigger.ts @@ -27,6 +27,6 @@ export const selectRangeTrigger: Trigger<'SELECT_RANGE_TRIGGER'> = { defaultMessage: 'Range selection', }), description: i18n.translate('uiActions.triggers.selectRangeDescription', { - defaultMessage: 'Select a group of values', + defaultMessage: 'A range of values on the visualization', }), }; diff --git a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts index e1e7b6507d82..e63ff28f42d9 100644 --- a/src/plugins/ui_actions/public/triggers/value_click_trigger.ts +++ b/src/plugins/ui_actions/public/triggers/value_click_trigger.ts @@ -27,6 +27,6 @@ export const valueClickTrigger: Trigger<'VALUE_CLICK_TRIGGER'> = { defaultMessage: 'Single click', }), description: i18n.translate('uiActions.triggers.valueClickDescription', { - defaultMessage: 'A single point clicked on a visualization', + defaultMessage: 'A single point on the visualization', }), }; diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js index 94a271987ecd..faf272daba09 100644 --- a/test/functional/apps/discover/_discover.js +++ b/test/functional/apps/discover/_discover.js @@ -224,9 +224,7 @@ export default function ({ getService, getPageObjects }) { await kibanaServer.uiSettings.replace({ 'dateFormat:tz': 'America/Phoenix' }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitKibanaChrome(); - await queryBar.setQuery(''); - // To remove focus of the of the search bar so date/time picker can show - await PageObjects.discover.selectIndexPattern(defaultSettings.defaultIndex); + await queryBar.clearQuery(); await PageObjects.timePicker.setDefaultAbsoluteRange(); log.debug( diff --git a/test/functional/apps/visualize/_tsvb_chart.ts b/test/functional/apps/visualize/_tsvb_chart.ts index 06828e8e98cc..bfe0da7a5b24 100644 --- a/test/functional/apps/visualize/_tsvb_chart.ts +++ b/test/functional/apps/visualize/_tsvb_chart.ts @@ -48,8 +48,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await PageObjects.visualBuilder.checkVisualBuilderIsPresent(); }); - // FLAKY: https://github.com/elastic/kibana/issues/75127 - describe.skip('metric', () => { + describe('metric', () => { beforeEach(async () => { await PageObjects.visualBuilder.resetPage(); await PageObjects.visualBuilder.clickMetric(); diff --git a/test/functional/apps/visualize/_tsvb_markdown.ts b/test/functional/apps/visualize/_tsvb_markdown.ts index ba60aa83d92d..4f12a45cf5f6 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/test/functional/apps/visualize/input_control_vis/chained_controls.js b/test/functional/apps/visualize/input_control_vis/chained_controls.js index 035245b50d43..e1a58e1da34f 100644 --- a/test/functional/apps/visualize/input_control_vis/chained_controls.js +++ b/test/functional/apps/visualize/input_control_vis/chained_controls.js @@ -26,8 +26,7 @@ export default function ({ getService, getPageObjects }) { const find = getService('find'); const comboBox = getService('comboBox'); - // FLAKY: https://github.com/elastic/kibana/issues/68472 - describe.skip('chained controls', function () { + describe('chained controls', function () { this.tags('includeFirefox'); before(async () => { diff --git a/test/functional/page_objects/common_page.ts b/test/functional/page_objects/common_page.ts index ce7a3f9e132f..31f4e393f019 100644 --- a/test/functional/page_objects/common_page.ts +++ b/test/functional/page_objects/common_page.ts @@ -346,6 +346,10 @@ export function CommonPageProvider({ getService, getPageObjects }: FtrProviderCo await browser.pressKeys(browser.keys.ENTER); } + async pressTabKey() { + await browser.pressKeys(browser.keys.TAB); + } + // Pause the browser at a certain place for debugging // Not meant for usage in CI, only for dev-usage async pause() { diff --git a/test/functional/services/query_bar.ts b/test/functional/services/query_bar.ts index 7c7fd2d81f17..8cd63fb2f4a5 100644 --- a/test/functional/services/query_bar.ts +++ b/test/functional/services/query_bar.ts @@ -54,6 +54,11 @@ export function QueryBarProvider({ getService, getPageObjects }: FtrProviderCont }); } + public async clearQuery(): Promise<void> { + await this.setQuery(''); + await PageObjects.common.pressTabKey(); + } + public async submitQuery(): Promise<void> { log.debug('QueryBar.submitQuery'); await testSubjects.click('queryInput'); diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx deleted file mode 100644 index 58916f26121d..000000000000 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx +++ /dev/null @@ -1,142 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import { EuiCallOut, EuiFieldText, EuiFormRow, EuiSpacer, EuiSwitch } from '@elastic/eui'; -import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/public'; -import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; -import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; -import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; -import { - SELECT_RANGE_TRIGGER, - VALUE_CLICK_TRIGGER, -} from '../../../../../src/plugins/ui_actions/public'; -import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public'; - -function isValidUrl(url: string) { - try { - new URL(url); - return true; - } catch { - return false; - } -} - -export type ActionContext = ChartActionContext; - -export interface Config { - url: string; - openInNewTab: boolean; -} - -type UrlTrigger = typeof VALUE_CLICK_TRIGGER | typeof SELECT_RANGE_TRIGGER; - -export type CollectConfigProps = CollectConfigPropsBase<Config, { triggers: UrlTrigger[] }>; - -const SAMPLE_DASHBOARD_TO_URL_DRILLDOWN = 'SAMPLE_DASHBOARD_TO_URL_DRILLDOWN'; - -export class DashboardToUrlDrilldown implements Drilldown<Config, UrlTrigger> { - public readonly id = SAMPLE_DASHBOARD_TO_URL_DRILLDOWN; - - public readonly order = 8; - - readonly minimalLicense = 'gold'; // example of minimal license support - readonly licenseFeatureName = 'Sample URL Drilldown'; - - public readonly getDisplayName = () => 'Go to URL (example)'; - - public readonly euiIcon = 'link'; - - supportedTriggers(): UrlTrigger[] { - return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER]; - } - - private readonly ReactCollectConfig: React.FC<CollectConfigProps> = ({ - config, - onConfig, - context, - }) => ( - <> - <EuiCallOut title="Example warning!" color="warning" iconType="help"> - <p> - This is an example drilldown. It is meant as a starting point for developers, so they can - grab this code and get started. It does not provide a complete working functionality but - serves as a getting started example. - </p> - <p> - Implementation of the actual <em>Go to URL</em> drilldown is tracked in{' '} - <a href="https://github.com/elastic/kibana/issues/55324">#55324</a> - </p> - </EuiCallOut> - <EuiSpacer size="xl" /> - <EuiFormRow label="Enter target URL" fullWidth> - <EuiFieldText - fullWidth - name="url" - placeholder="Enter URL" - value={config.url} - onChange={(event) => onConfig({ ...config, url: event.target.value })} - onBlur={() => { - if (!config.url) return; - if (/https?:\/\//.test(config.url)) return; - onConfig({ ...config, url: 'https://' + config.url }); - }} - /> - </EuiFormRow> - <EuiFormRow hasChildLabel={false}> - <EuiSwitch - name="openInNewTab" - label="Open in new tab?" - checked={config.openInNewTab} - onChange={() => onConfig({ ...config, openInNewTab: !config.openInNewTab })} - /> - </EuiFormRow> - <EuiSpacer size="xl" /> - <EuiCallOut> - {/* just demo how can access selected triggers*/} - <p>Will be attached to triggers: {JSON.stringify(context.triggers)}</p> - </EuiCallOut> - </> - ); - - public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); - - public readonly createConfig = () => ({ - url: '', - openInNewTab: false, - }); - - public readonly isConfigValid = (config: Config): config is Config => { - if (!config.url) return false; - return isValidUrl(config.url); - }; - - /** - * `getHref` is need to support mouse middle-click and Cmd + Click behavior - * to open a link in new tab. - */ - public readonly getHref = async (config: Config, context: ActionContext) => { - return config.url; - }; - - public readonly execute = async ( - config: Config, - context: ActionExecutionContext<ActionContext> - ) => { - // Just for showcasing: - // we can get trigger a which caused this drilldown execution - // eslint-disable-next-line no-console - console.log(context.trigger?.id); - - const url = await this.getHref(config, context); - - if (config.openInNewTab) { - window.open(url, '_blank'); - } else { - window.location.href = url; - } - }; -} diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts index 7f2c9a9b3bbc..3f0b64a2ac9e 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts +++ b/x-pack/examples/ui_actions_enhanced_examples/public/plugin.ts @@ -11,7 +11,6 @@ import { AdvancedUiActionsStart, } from '../../../../x-pack/plugins/ui_actions_enhanced/public'; import { DashboardHelloWorldDrilldown } from './dashboard_hello_world_drilldown'; -import { DashboardToUrlDrilldown } from './dashboard_to_url_drilldown'; import { DashboardToDiscoverDrilldown } from './dashboard_to_discover_drilldown'; import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; import { DiscoverSetup, DiscoverStart } from '../../../../src/plugins/discover/public'; @@ -39,7 +38,6 @@ export class UiActionsEnhancedExamplesPlugin uiActions.registerDrilldown(new DashboardHelloWorldDrilldown()); uiActions.registerDrilldown(new DashboardHelloWorldOnlyRangeSelectDrilldown()); - uiActions.registerDrilldown(new DashboardToUrlDrilldown()); uiActions.registerDrilldown(new DashboardToDiscoverDrilldown({ start })); } diff --git a/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap index 708758f2c6e5..e9763082a399 100644 --- a/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/apm_telemetry.test.ts.snap @@ -536,61 +536,54 @@ exports[`APM telemetry helpers getApmTelemetry generates a JSON object with the }, "transaction_count": { "type": "long" - } - } - }, - "no_observer_name": { - "properties": { - "expected_metric_document_count": { - "type": "long" }, - "transaction_count": { + "ratio": { "type": "long" } } }, - "no_rum": { + "no_observer_name": { "properties": { "expected_metric_document_count": { "type": "long" }, "transaction_count": { "type": "long" - } - } - }, - "no_rum_no_observer_name": { - "properties": { - "expected_metric_document_count": { - "type": "long" }, - "transaction_count": { + "ratio": { "type": "long" } } }, - "only_rum": { + "with_country": { "properties": { "expected_metric_document_count": { "type": "long" }, "transaction_count": { "type": "long" - } - } - }, - "only_rum_no_observer_name": { - "properties": { - "expected_metric_document_count": { - "type": "long" }, - "transaction_count": { + "ratio": { "type": "long" } } } } }, + "environments": { + "properties": { + "services_without_environment": { + "type": "long" + }, + "services_with_multiple_environments": { + "type": "long" + }, + "top_enviroments": { + "type": "keyword", + "ignore_above": 1024 + } + } + }, "cloud": { "properties": { "availability_zone": { @@ -952,6 +945,17 @@ exports[`APM telemetry helpers getApmTelemetry generates a JSON object with the } } }, + "environments": { + "properties": { + "took": { + "properties": { + "ms": { + "type": "long" + } + } + } + } + }, "groupings": { "properties": { "took": { diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index aecf4af66760..48ff69d3afcb 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -68,9 +68,9 @@ exports[`Error METRIC_SYSTEM_FREE_MEMORY 1`] = `undefined`; exports[`Error METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; -exports[`Error OBSERVER_LISTENING 1`] = `undefined`; +exports[`Error OBSERVER_HOSTNAME 1`] = `undefined`; -exports[`Error OBSERVER_NAME 1`] = `"an observer"`; +exports[`Error OBSERVER_LISTENING 1`] = `undefined`; exports[`Error OBSERVER_VERSION_MAJOR 1`] = `8`; @@ -220,9 +220,9 @@ exports[`Span METRIC_SYSTEM_FREE_MEMORY 1`] = `undefined`; exports[`Span METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; -exports[`Span OBSERVER_LISTENING 1`] = `undefined`; +exports[`Span OBSERVER_HOSTNAME 1`] = `undefined`; -exports[`Span OBSERVER_NAME 1`] = `"an observer"`; +exports[`Span OBSERVER_LISTENING 1`] = `undefined`; exports[`Span OBSERVER_VERSION_MAJOR 1`] = `8`; @@ -372,9 +372,9 @@ exports[`Transaction METRIC_SYSTEM_FREE_MEMORY 1`] = `undefined`; exports[`Transaction METRIC_SYSTEM_TOTAL_MEMORY 1`] = `undefined`; -exports[`Transaction OBSERVER_LISTENING 1`] = `undefined`; +exports[`Transaction OBSERVER_HOSTNAME 1`] = `undefined`; -exports[`Transaction OBSERVER_NAME 1`] = `"an observer"`; +exports[`Transaction OBSERVER_LISTENING 1`] = `undefined`; exports[`Transaction OBSERVER_VERSION_MAJOR 1`] = `8`; diff --git a/x-pack/plugins/apm/common/apm_telemetry.ts b/x-pack/plugins/apm/common/apm_telemetry.ts index 318b956cd3b3..3e885f4948c1 100644 --- a/x-pack/plugins/apm/common/apm_telemetry.ts +++ b/x-pack/plugins/apm/common/apm_telemetry.ts @@ -78,6 +78,7 @@ export function getApmTelemetryMapping() { properties: { expected_metric_document_count: long, transaction_count: long, + ratio: long, }, }; @@ -102,10 +103,14 @@ export function getApmTelemetryMapping() { properties: { current_implementation: aggregatedTransactionsProperties, no_observer_name: aggregatedTransactionsProperties, - no_rum: aggregatedTransactionsProperties, - no_rum_no_observer_name: aggregatedTransactionsProperties, - only_rum: aggregatedTransactionsProperties, - only_rum_no_observer_name: aggregatedTransactionsProperties, + with_country: aggregatedTransactionsProperties, + }, + }, + environments: { + properties: { + services_without_environment: long, + services_with_multiple_environments: long, + top_enviroments: keyword, }, }, cloud: { @@ -227,6 +232,7 @@ export function getApmTelemetryMapping() { agents: tookProperties, cardinality: tookProperties, cloud: tookProperties, + environments: tookProperties, groupings: tookProperties, indices_stats: tookProperties, integrations: tookProperties, diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index 4aa68de9b8b3..f7b838df9ea2 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -31,7 +31,7 @@ export const USER_AGENT_NAME = 'user_agent.name'; export const DESTINATION_ADDRESS = 'destination.address'; -export const OBSERVER_NAME = 'observer.name'; +export const OBSERVER_HOSTNAME = 'observer.hostname'; export const OBSERVER_VERSION_MAJOR = 'observer.version_major'; export const OBSERVER_LISTENING = 'observer.listening'; export const PROCESSOR_EVENT = 'processor.event'; diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx index 7e5e7cdc53c5..12d8efdbd27f 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownFilter.tsx @@ -5,64 +5,85 @@ */ import React from 'react'; -import { BreakdownGroup } from './BreakdownGroup'; -import { BreakdownItem } from '../../../../../typings/ui_filters'; +import { EuiSuperSelect } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { CLIENT_GEO_COUNTRY_ISO_CODE, USER_AGENT_DEVICE, USER_AGENT_NAME, USER_AGENT_OS, } from '../../../../../common/elasticsearch_fieldnames'; +import { BreakdownItem } from '../../../../../typings/ui_filters'; interface Props { - id: string; - selectedBreakdowns: BreakdownItem[]; - onBreakdownChange: (values: BreakdownItem[]) => void; + selectedBreakdown: BreakdownItem | null; + onBreakdownChange: (value: BreakdownItem | null) => void; } export function BreakdownFilter({ - id, - selectedBreakdowns, + selectedBreakdown, onBreakdownChange, }: Props) { - const categories: BreakdownItem[] = [ + const NO_BREAKDOWN = 'noBreakdown'; + + const items: BreakdownItem[] = [ { - name: 'Browser', + name: i18n.translate('xpack.apm.csm.breakDownFilter.noBreakdown', { + defaultMessage: 'No breakdown', + }), + fieldName: NO_BREAKDOWN, type: 'category', - count: 0, - selected: selectedBreakdowns.some(({ name }) => name === 'Browser'), - fieldName: USER_AGENT_NAME, }, { - name: 'OS', + name: i18n.translate('xpack.apm.csm.breakdownFilter.browser', { + defaultMessage: 'Browser', + }), + fieldName: USER_AGENT_NAME, type: 'category', - count: 0, - selected: selectedBreakdowns.some(({ name }) => name === 'OS'), - fieldName: USER_AGENT_OS, }, { - name: 'Device', + name: i18n.translate('xpack.apm.csm.breakdownFilter.os', { + defaultMessage: 'OS', + }), + fieldName: USER_AGENT_OS, type: 'category', - count: 0, - selected: selectedBreakdowns.some(({ name }) => name === 'Device'), - fieldName: USER_AGENT_DEVICE, }, { - name: 'Location', + name: i18n.translate('xpack.apm.csm.breakdownFilter.device', { + defaultMessage: 'Device', + }), + fieldName: USER_AGENT_DEVICE, type: 'category', - count: 0, - selected: selectedBreakdowns.some(({ name }) => name === 'Location'), + }, + { + name: i18n.translate('xpack.apm.csm.breakdownFilter.location', { + defaultMessage: 'Location', + }), fieldName: CLIENT_GEO_COUNTRY_ISO_CODE, + type: 'category', }, ]; + const options = items.map(({ name, fieldName }) => ({ + inputDisplay: fieldName === NO_BREAKDOWN ? name : <strong>{name}</strong>, + value: fieldName, + dropdownDisplay: name, + })); + + const onOptionChange = (value: string) => { + if (value === NO_BREAKDOWN) { + onBreakdownChange(null); + } + onBreakdownChange(items.find(({ fieldName }) => fieldName === value)!); + }; + return ( - <BreakdownGroup - id={id} - items={categories} - onChange={(selValues: BreakdownItem[]) => { - onBreakdownChange(selValues); - }} + <EuiSuperSelect + fullWidth + compressed + options={options} + valueOfSelected={selectedBreakdown?.fieldName ?? NO_BREAKDOWN} + onChange={(value) => onOptionChange(value)} /> ); } diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx deleted file mode 100644 index d4f80667ce98..000000000000 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Breakdowns/BreakdownGroup.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { - EuiPopover, - EuiFilterButton, - EuiFilterGroup, - EuiPopoverTitle, - EuiFilterSelectItem, -} from '@elastic/eui'; -import React, { MouseEvent, useCallback, useEffect, useState } from 'react'; -import { BreakdownItem } from '../../../../../typings/ui_filters'; -import { I18LABELS } from '../translations'; - -export interface BreakdownGroupProps { - id: string; - disabled?: boolean; - items: BreakdownItem[]; - onChange: (values: BreakdownItem[]) => void; -} - -export function BreakdownGroup({ - id, - disabled, - onChange, - items, -}: BreakdownGroupProps) { - const [isOpen, setIsOpen] = useState<boolean>(false); - - const [activeItems, setActiveItems] = useState<BreakdownItem[]>(items); - - useEffect(() => { - setActiveItems(items); - }, [items]); - - const getSelItems = () => activeItems.filter((item) => item.selected); - - const onFilterItemClick = useCallback( - (name: string) => (_event: MouseEvent<HTMLButtonElement>) => { - setActiveItems((prevItems) => - prevItems.map((item) => ({ - ...item, - selected: name === item.name ? !item.selected : item.selected, - })) - ); - }, - [] - ); - - return ( - <EuiFilterGroup> - <EuiPopover - button={ - <EuiFilterButton - isDisabled={disabled && getSelItems().length === 0} - isSelected={getSelItems().length > 0} - numFilters={activeItems.length} - numActiveFilters={getSelItems().length} - hasActiveFilters={getSelItems().length !== 0} - iconType="arrowDown" - onClick={() => { - setIsOpen(!isOpen); - }} - size="s" - > - {I18LABELS.breakdown} - </EuiFilterButton> - } - closePopover={() => { - setIsOpen(false); - onChange(getSelItems()); - }} - data-cy={`breakdown-popover_${id}`} - id={id} - isOpen={isOpen} - ownFocus={true} - withTitle - zIndex={10000} - > - <EuiPopoverTitle>{I18LABELS.selectBreakdown}</EuiPopoverTitle> - <div className="euiFilterSelect__items" style={{ minWidth: 200 }}> - {activeItems.map(({ name, count, selected }) => ( - <EuiFilterSelectItem - checked={!!selected ? 'on' : undefined} - data-cy={`filter-breakdown-item_${name}`} - key={name + count} - onClick={onFilterItemClick(name)} - disabled={!selected && getSelItems().length > 0} - > - {name} - </EuiFilterSelectItem> - ))} - </div> - </EuiPopover> - </EuiFilterGroup> - ); -} diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx index 33573052dbcb..c832ec9fcc0d 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/Charts/PageLoadDistChart.tsx @@ -43,7 +43,7 @@ interface PageLoadData { interface Props { onPercentileChange: (min: number, max: number) => void; data?: PageLoadData | null; - breakdowns: BreakdownItem[]; + breakdown: BreakdownItem | null; percentileRange: PercentileRange; loading: boolean; } @@ -57,7 +57,7 @@ const PageLoadChart = styled(Chart)` export function PageLoadDistChart({ onPercentileChange, data, - breakdowns, + breakdown, loading, percentileRange, }: Props) { @@ -122,17 +122,17 @@ export function PageLoadDistChart({ data={data?.pageLoadDistribution ?? []} curve={CurveType.CURVE_CATMULL_ROM} /> - {breakdowns.map(({ name, type }) => ( + {breakdown && ( <BreakdownSeries - key={`${type}-${name}`} - field={type} - value={name} + key={`${breakdown.type}-${breakdown.name}`} + field={breakdown.type} + value={breakdown.name} percentileRange={percentileRange} onLoadingChange={(bLoading) => { setBreakdownLoading(bLoading); }} /> - ))} + )} </PageLoadChart> )} </ChartWrapper> diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx index 53f2d5ae238c..3e35f1525493 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageLoadDistribution/index.tsx @@ -34,7 +34,7 @@ export function PageLoadDistribution() { max: null, }); - const [breakdowns, setBreakdowns] = useState<BreakdownItem[]>([]); + const [breakdown, setBreakdown] = useState<BreakdownItem | null>(null); const { data, status } = useFetcher( (callApmApi) => { @@ -94,11 +94,10 @@ export function PageLoadDistribution() { {I18LABELS.resetZoom} </EuiButtonEmpty> </EuiFlexItem> - <EuiFlexItem grow={false}> + <EuiFlexItem grow={false} style={{ width: 170 }}> <BreakdownFilter - id={'pageLoad'} - selectedBreakdowns={breakdowns} - onBreakdownChange={setBreakdowns} + selectedBreakdown={breakdown} + onBreakdownChange={setBreakdown} /> </EuiFlexItem> </EuiFlexGroup> @@ -107,7 +106,7 @@ export function PageLoadDistribution() { data={data} onPercentileChange={onPercentileChange} loading={status !== 'success'} - breakdowns={breakdowns} + breakdown={breakdown} percentileRange={{ max: percentileRange.max || data?.maxDuration, min: percentileRange.min || data?.minDuration, diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx index 0f43c0ddf540..a67f6dd8e3cb 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/PageViewsTrend/index.tsx @@ -18,7 +18,7 @@ export function PageViewsTrend() { const { start, end, serviceName } = urlParams; - const [breakdowns, setBreakdowns] = useState<BreakdownItem[]>([]); + const [breakdown, setBreakdown] = useState<BreakdownItem | null>(null); const { data, status } = useFetcher( (callApmApi) => { @@ -30,9 +30,9 @@ export function PageViewsTrend() { start, end, uiFilters: JSON.stringify(uiFilters), - ...(breakdowns.length > 0 + ...(breakdown ? { - breakdowns: JSON.stringify(breakdowns), + breakdowns: JSON.stringify(breakdown), } : {}), }, @@ -41,13 +41,9 @@ export function PageViewsTrend() { } return Promise.resolve(undefined); }, - [end, start, serviceName, uiFilters, breakdowns] + [end, start, serviceName, uiFilters, breakdown] ); - const onBreakdownChange = (values: BreakdownItem[]) => { - setBreakdowns(values); - }; - return ( <div> <EuiFlexGroup responsive={false}> @@ -56,11 +52,10 @@ export function PageViewsTrend() { <h3>{I18LABELS.pageViews}</h3> </EuiTitle> </EuiFlexItem> - <EuiFlexItem grow={false}> + <EuiFlexItem grow={false} style={{ width: 170 }}> <BreakdownFilter - id={'pageView'} - selectedBreakdowns={breakdowns} - onBreakdownChange={onBreakdownChange} + selectedBreakdown={breakdown} + onBreakdownChange={setBreakdown} /> </EuiFlexItem> </EuiFlexGroup> 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 000000000000..bc9df71c534e --- /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 <TransactionList isLoading={false} items={items} />; + } +); 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 2b1c1b8e8c11..d8c6d7d28fa9 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 ( - <EuiToolTip - id="transaction-name-link-tooltip" - content={sample.transaction.name || NOT_AVAILABLE_LABEL} + <TransactionNameLink + serviceName={sample.service.name} + transactionId={sample.transaction.id} + traceId={sample.trace.id} + transactionName={sample.transaction.name} + transactionType={sample.transaction.type} > - <TransactionNameLink - serviceName={sample.service.name} - transactionId={sample.transaction.id} - traceId={sample.trace.id} - transactionName={sample.transaction.name} - transactionType={sample.transaction.type} + <EuiToolTip + id="transaction-name-link-tooltip" + content={sample.transaction.name || NOT_AVAILABLE_LABEL} > - {sample.transaction.name || NOT_AVAILABLE_LABEL} - </TransactionNameLink> - </EuiToolTip> + <>{sample.transaction.name || NOT_AVAILABLE_LABEL}</> + </EuiToolTip> + </TransactionNameLink> ); }, }, 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 81d8a6f80737..5999988abe84 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 000000000000..c9b7c7740984 --- /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 ( + <MockApmPluginContextWrapper>{storyFn()}</MockApmPluginContextWrapper> + ); + }) + .add('Example', () => { + return ( + <ApmHeader> + <EuiTitle size="l"> + <h1> + GET + /api/v1/regions/azure-eastus2/clusters/elasticsearch/xc18de071deb4262be54baebf5f6a1ce/proxy/_snapshot/found-snapshots/_all + </h1> + </EuiTitle> + </ApmHeader> + ); + }); 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 4ffd42280181..9f67ba99103e 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 ( <> <EuiFlexGroup alignItems="center" gutterSize="s"> - <EuiFlexItem>{children}</EuiFlexItem> + <ChildrenContainerFlexItem>{children}</ChildrenContainerFlexItem> <EuiFlexItem grow={false}> <DatePicker /> </EuiFlexItem> 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 da9adbb8dfea..081654186536 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/apm_telemetry/collect_data_telemetry/tasks.test.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts index 9d06fc2ad930..f0ae8467b215 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.test.ts @@ -4,9 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AGENT_NAME } from '../../../../common/elasticsearch_fieldnames'; import { ApmIndicesConfig } from '../../settings/apm_indices/get_apm_indices'; import { tasks } from './tasks'; +import { + SERVICE_NAME, + SERVICE_ENVIRONMENT, +} from '../../../../common/elasticsearch_fieldnames'; describe('data telemetry collection tasks', () => { const indices = { @@ -18,103 +21,136 @@ describe('data telemetry collection tasks', () => { /* eslint-enable @typescript-eslint/naming-convention */ } as ApmIndicesConfig; + describe('environments', () => { + const task = tasks.find((t) => t.name === 'environments'); + + it('returns environment information', async () => { + const search = jest.fn().mockResolvedValueOnce({ + aggregations: { + environments: { + buckets: [ + { + key: 'production', + }, + { + key: 'testing', + }, + ], + }, + service_environments: { + buckets: [ + { + key: { + [SERVICE_NAME]: 'opbeans-node', + [SERVICE_ENVIRONMENT]: 'production', + }, + }, + { + key: { + [SERVICE_NAME]: 'opbeans-node', + [SERVICE_ENVIRONMENT]: null, + }, + }, + { + key: { + [SERVICE_NAME]: 'opbeans-java', + [SERVICE_ENVIRONMENT]: 'production', + }, + }, + { + key: { + [SERVICE_NAME]: 'opbeans-rum', + [SERVICE_ENVIRONMENT]: null, + }, + }, + ], + }, + }, + }); + + expect(await task?.executor({ search, indices } as any)).toEqual({ + environments: { + services_with_multiple_environments: 1, + services_without_environment: 2, + top_environments: ['production', 'testing'], + }, + }); + }); + }); + describe('aggregated_transactions', () => { const task = tasks.find((t) => t.name === 'aggregated_transactions'); - it('returns aggregated transaction counts', async () => { - // This mock implementation returns different values based on the parameters, - // which should simulate all the queries that are done. For most of them we'll - // simulate the number of buckets by using the length of the key, but for a - // couple we'll simulate being paginated by returning an after_key. - const search = jest.fn().mockImplementation((params) => { - const isRumResult = - params.body.query.bool.filter && - params.body.query.bool.filter.some( - (filter: any) => - filter.terms && filter.terms[AGENT_NAME]?.includes('rum-js') - ); - const isNonRumResult = - params.body.query.bool.filter && - params.body.query.bool.filter.some( - (filter: any) => - filter.terms && !filter.terms[AGENT_NAME]?.includes('rum-js') - ); - const isPagedResult = - !!params.body.aggs?.current_implementation?.composite.after || - !!params.body.aggs?.no_observer_name?.composite.after; - const isTotalResult = 'track_total_hits' in params.body; - const key = Object.keys(params.body.aggs ?? [])[0]; - - if (isRumResult) { - if (isTotalResult) { - return Promise.resolve({ hits: { total: { value: 3000 } } }); - } - } - - if (isNonRumResult) { - if (isTotalResult) { - return Promise.resolve({ hits: { total: { value: 2000 } } }); - } - } + describe('without transactions', () => { + it('returns an empty result', async () => { + const search = jest.fn().mockReturnValueOnce({ + hits: { + hits: [], + total: { + value: 0, + }, + }, + }); - if (isPagedResult && key) { - return Promise.resolve({ - hits: { total: { value: key.length } }, - aggregations: { [key]: { buckets: [{}] } }, - }); - } + expect(await task?.executor({ indices, search } as any)).toEqual({}); + }); + }); - if (isTotalResult) { - return Promise.resolve({ hits: { total: { value: 1000 } } }); - } + it('returns aggregated transaction counts', async () => { + const search = jest + .fn() + // The first call to `search` asks for a transaction to get + // a fixed date range. + .mockReturnValueOnce({ + hits: { + hits: [{ _source: { '@timestamp': new Date().toISOString() } }], + }, + total: { + value: 1, + }, + }) + // Later calls are all composite aggregations. We return 2 pages of + // results to test if scrolling works. + .mockImplementation((params) => { + let arrayLength = 1000; + let nextAfter: Record<string, any> = { after_key: {} }; + + if (params.body.aggs.transaction_metric_groups.composite.after) { + arrayLength = 250; + nextAfter = {}; + } - if ( - key === 'current_implementation' || - (key === 'no_observer_name' && !isPagedResult) - ) { return Promise.resolve({ - hits: { total: { value: key.length } }, - aggregations: { - [key]: { after_key: {}, buckets: key.split('').map((_) => ({})) }, + hits: { + total: { + value: 5000, + }, }, - }); - } - - if (key) { - return Promise.resolve({ - hits: { total: { value: key.length } }, aggregations: { - [key]: { buckets: key.split('').map((_) => ({})) }, + transaction_metric_groups: { + buckets: new Array(arrayLength), + ...nextAfter, + }, }, }); - } - }); + }); expect(await task?.executor({ indices, search } as any)).toEqual({ aggregated_transactions: { current_implementation: { - expected_metric_document_count: 23, - transaction_count: 1000, + expected_metric_document_count: 1250, + transaction_count: 5000, + ratio: 0.25, }, no_observer_name: { - expected_metric_document_count: 17, - transaction_count: 1000, - }, - no_rum: { - expected_metric_document_count: 6, - transaction_count: 2000, - }, - no_rum_no_observer_name: { - expected_metric_document_count: 23, - transaction_count: 2000, - }, - only_rum: { - expected_metric_document_count: 8, - transaction_count: 3000, + expected_metric_document_count: 1250, + transaction_count: 5000, + ratio: 0.25, }, - only_rum_no_observer_name: { - expected_metric_document_count: 25, - transaction_count: 3000, + with_country: { + expected_metric_document_count: 1250, + transaction_count: 5000, + ratio: 0.25, }, }, }); diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts index 840f47b04341..a53068d152d0 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/collect_data_telemetry/tasks.ts @@ -3,7 +3,10 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { flatten, merge, sortBy, sum } from 'lodash'; +import { ValuesType } from 'utility-types'; +import { flatten, merge, sortBy, sum, pickBy } from 'lodash'; +import { AggregationOptionsByType } from '../../../../typings/elasticsearch/aggregations'; +import { ProcessorEvent } from '../../../../common/processor_event'; import { TelemetryTask } from '.'; import { AGENT_NAMES, RUM_AGENTS } from '../../../../common/agent_name'; import { @@ -16,7 +19,7 @@ import { CONTAINER_ID, ERROR_GROUP_ID, HOST_NAME, - OBSERVER_NAME, + OBSERVER_HOSTNAME, PARENT_ID, POD_NAME, PROCESSOR_EVENT, @@ -32,10 +35,8 @@ import { TRANSACTION_NAME, TRANSACTION_RESULT, TRANSACTION_TYPE, - USER_AGENT_NAME, USER_AGENT_ORIGINAL, } from '../../../../common/elasticsearch_fieldnames'; -import { ESFilter } from '../../../../typings/elasticsearch'; import { APMError } from '../../../../typings/es_schemas/ui/apm_error'; import { AgentName } from '../../../../typings/es_schemas/ui/fields/agent'; import { Span } from '../../../../typings/es_schemas/ui/span'; @@ -57,79 +58,114 @@ export const tasks: TelemetryTask[] = [ // the transaction count for that time range. executor: async ({ indices, search }) => { async function getBucketCountFromPaginatedQuery( - key: string, - filter: ESFilter[], - count: number = 0, + sources: Array< + ValuesType<AggregationOptionsByType['composite']['sources']>[string] + >, + prevResult?: { + transaction_count: number; + expected_metric_document_count: number; + }, after?: any - ) { + ): Promise<{ + transaction_count: number; + expected_metric_document_count: number; + ratio: number; + }> { + // eslint-disable-next-line @typescript-eslint/naming-convention + let { expected_metric_document_count } = prevResult ?? { + transaction_count: 0, + expected_metric_document_count: 0, + }; + const params = { index: [indices['apm_oss.transactionIndices']], body: { size: 0, timeout, - query: { bool: { filter } }, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: 'transaction' } }, + { range: { '@timestamp': { gte: start, lt: end } } }, + ], + }, + }, + track_total_hits: true, aggs: { - [key]: { + transaction_metric_groups: { composite: { ...(after ? { after } : {}), size: 10000, - sources: fieldMap[key].map((field) => ({ - [field]: { terms: { field, missing_bucket: true } }, - })), + sources: sources.map((source, index) => { + return { + [index]: source, + }; + }), }, }, }, }, }; + const result = await search(params); + let nextAfter: any; if (result.aggregations) { - nextAfter = result.aggregations[key].after_key; - count += result.aggregations[key].buckets.length; + nextAfter = result.aggregations.transaction_metric_groups.after_key; + expected_metric_document_count += + result.aggregations.transaction_metric_groups.buckets.length; } if (nextAfter) { - count = await getBucketCountFromPaginatedQuery( - key, - filter, - count, + return await getBucketCountFromPaginatedQuery( + sources, + { + expected_metric_document_count, + transaction_count: result.hits.total.value, + }, nextAfter ); } - return count; + return { + expected_metric_document_count, + transaction_count: result.hits.total.value, + ratio: expected_metric_document_count / result.hits.total.value, + }; } - async function totalSearch(filter: ESFilter[]) { - const result = await search({ - index: [indices['apm_oss.transactionIndices']], + // fixed date range for reliable results + const lastTransaction = ( + await search({ + index: indices['apm_oss.transactionIndices'], body: { - size: 0, - timeout, - query: { bool: { filter } }, - track_total_hits: true, + query: { + bool: { + filter: [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + ], + }, + }, + size: 1, + sort: { + '@timestamp': 'desc', + }, }, - }); + }) + ).hits.hits[0] as { _source: { '@timestamp': string } }; - return result.hits.total.value; + if (!lastTransaction) { + return {}; } - const nonRumAgentNames = AGENT_NAMES.filter( - (name) => !RUM_AGENTS.includes(name) - ); + const end = + new Date(lastTransaction._source['@timestamp']).getTime() - + 5 * 60 * 1000; - const filter: ESFilter[] = [ - { term: { [PROCESSOR_EVENT]: 'transaction' } }, - { range: { '@timestamp': { gte: 'now-1m' } } }, - ]; - const noRumFilter = [ - ...filter, - { terms: { [AGENT_NAME]: nonRumAgentNames } }, - ]; - const rumFilter = [...filter, { terms: { [AGENT_NAME]: RUM_AGENTS } }]; + const start = end - 60 * 1000; - const baseFields = [ + const simpleTermFields = [ TRANSACTION_NAME, TRANSACTION_RESULT, TRANSACTION_TYPE, @@ -139,73 +175,61 @@ export const tasks: TelemetryTask[] = [ HOST_NAME, CONTAINER_ID, POD_NAME, - ]; - - const fieldMap: Record<string, string[]> = { - current_implementation: [OBSERVER_NAME, ...baseFields, USER_AGENT_NAME], - no_observer_name: [...baseFields, USER_AGENT_NAME], - no_rum: [OBSERVER_NAME, ...baseFields], - no_rum_no_observer_name: baseFields, - only_rum: [OBSERVER_NAME, ...baseFields, USER_AGENT_NAME], - only_rum_no_observer_name: [...baseFields, USER_AGENT_NAME], - }; + ].map((field) => ({ terms: { field, missing_bucket: true } })); - // It would be more performant to do these in parallel, but we have different filters and keys and it's easier to - // understand if we make the code slower and longer - const countMap: Record<string, number> = { - current_implementation: await getBucketCountFromPaginatedQuery( - 'current_implementation', - filter - ), - no_observer_name: await getBucketCountFromPaginatedQuery( - 'no_observer_name', - filter - ), - no_rum: await getBucketCountFromPaginatedQuery('no_rum', noRumFilter), - no_rum_no_observer_name: await getBucketCountFromPaginatedQuery( - 'no_rum_no_observer_name', - noRumFilter - ), - only_rum: await getBucketCountFromPaginatedQuery('only_rum', rumFilter), - only_rum_no_observer_name: await getBucketCountFromPaginatedQuery( - 'only_rum_no_observer_name', - rumFilter - ), + const observerHostname = { + terms: { field: OBSERVER_HOSTNAME, missing_bucket: true }, }; - const [allCount, noRumCount, rumCount] = await Promise.all([ - totalSearch(filter), - totalSearch(noRumFilter), - totalSearch(rumFilter), - ]); + const baseFields = [ + ...simpleTermFields, + // user_agent.name only for page-load transactions + { + terms: { + script: ` + if (doc['transaction.type'].value == 'page-load' && doc['user_agent.name'].size() > 0) { + return doc['user_agent.name'].value; + } - return { - aggregated_transactions: { - current_implementation: { - transaction_count: allCount, - expected_metric_document_count: countMap.current_implementation, - }, - no_observer_name: { - transaction_count: allCount, - expected_metric_document_count: countMap.no_observer_name, - }, - no_rum: { - transaction_count: noRumCount, - expected_metric_document_count: countMap.no_rum, + return null; + `, + missing_bucket: true, }, - no_rum_no_observer_name: { - transaction_count: noRumCount, - expected_metric_document_count: countMap.no_rum_no_observer_name, - }, - only_rum: { - transaction_count: rumCount, - expected_metric_document_count: countMap.only_rum, - }, - only_rum_no_observer_name: { - transaction_count: rumCount, - expected_metric_document_count: countMap.only_rum_no_observer_name, + }, + // transaction.root + { + terms: { + script: `return doc['parent.id'].size() == 0`, + missing_bucket: true, }, }, + ]; + + const results = { + current_implementation: await getBucketCountFromPaginatedQuery([ + ...baseFields, + observerHostname, + ]), + with_country: await getBucketCountFromPaginatedQuery([ + ...baseFields, + observerHostname, + { + terms: { + script: ` + if (doc['transaction.type'].value == 'page-load' && doc['client.geo.country_iso_code'].size() > 0) { + return doc['client.geo.country_iso_code'].value; + } + return null; + `, + missing_bucket: true, + }, + }, + ]), + no_observer_name: await getBucketCountFromPaginatedQuery(baseFields), + }; + + return { + aggregated_transactions: results, }; }, }, @@ -270,6 +294,87 @@ export const tasks: TelemetryTask[] = [ return { cloud }; }, }, + { + name: 'environments', + executor: async ({ indices, search }) => { + const response = await search({ + index: [indices['apm_oss.transactionIndices']], + body: { + query: { + bool: { + filter: [{ range: { '@timestamp': { gte: 'now-1d' } } }], + }, + }, + aggs: { + environments: { + terms: { + field: SERVICE_ENVIRONMENT, + size: 5, + }, + }, + service_environments: { + composite: { + size: 1000, + sources: [ + { + [SERVICE_ENVIRONMENT]: { + terms: { + field: SERVICE_ENVIRONMENT, + missing_bucket: true, + }, + }, + }, + { + [SERVICE_NAME]: { + terms: { + field: SERVICE_NAME, + }, + }, + }, + ], + }, + }, + }, + }, + }); + + const topEnvironments = + response.aggregations?.environments.buckets.map( + (bucket) => bucket.key + ) ?? []; + const serviceEnvironments: Record<string, Array<string | null>> = {}; + + const buckets = response.aggregations?.service_environments.buckets ?? []; + + buckets.forEach((bucket) => { + const serviceName = bucket.key['service.name']; + const environment = bucket.key['service.environment'] as string | null; + + const environments = serviceEnvironments[serviceName] ?? []; + + serviceEnvironments[serviceName] = environments.concat(environment); + }); + + const servicesWithoutEnvironment = Object.keys( + pickBy(serviceEnvironments, (environments) => + environments.includes(null) + ) + ); + + const servicesWithMultipleEnvironments = Object.keys( + pickBy(serviceEnvironments, (environments) => environments.length > 1) + ); + + return { + environments: { + services_without_environment: servicesWithoutEnvironment.length, + services_with_multiple_environments: + servicesWithMultipleEnvironments.length, + top_environments: topEnvironments as string[], + }, + }; + }, + }, { name: 'processor_events', executor: async ({ indices, search }) => { diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts index 6f4f92c6833f..3463865d326b 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/index.ts @@ -6,6 +6,7 @@ import { Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; +import { DeepRequired } from 'utility-types'; import { CoreSetup, Logger, @@ -27,6 +28,7 @@ import { collectDataTelemetry, CollectTelemetryParams, } from './collect_data_telemetry'; +import { APMDataTelemetry } from './types'; const APM_TELEMETRY_TASK_NAME = 'apm-telemetry-task'; @@ -36,12 +38,14 @@ export async function createApmTelemetry({ usageCollector, taskManager, logger, + kibanaVersion, }: { core: CoreSetup; config$: Observable<APMConfig>; usageCollector: UsageCollectionSetup; taskManager: TaskManagerSetupContract; logger: Logger; + kibanaVersion: string; }) { taskManager.registerTaskDefinitions({ [APM_TELEMETRY_TASK_NAME]: { @@ -95,7 +99,10 @@ export async function createApmTelemetry({ await savedObjectsClient.create( APM_TELEMETRY_SAVED_OBJECT_TYPE, - dataTelemetry, + { + ...dataTelemetry, + kibanaVersion, + }, { id: APM_TELEMETRY_SAVED_OBJECT_TYPE, overwrite: true } ); }; @@ -105,12 +112,14 @@ export async function createApmTelemetry({ schema: getApmTelemetryMapping(), fetch: async () => { try { - const data = ( + const { kibanaVersion: storedKibanaVersion, ...data } = ( await savedObjectsClient.get( APM_TELEMETRY_SAVED_OBJECT_TYPE, APM_TELEMETRY_SAVED_OBJECT_ID ) - ).attributes; + ).attributes as { kibanaVersion: string } & DeepRequired< + APMDataTelemetry + >; return data; } catch (err) { @@ -126,7 +135,7 @@ export async function createApmTelemetry({ usageCollector.registerCollector(collector); - core.getStartServices().then(([_coreStart, pluginsStart]) => { + core.getStartServices().then(async ([_coreStart, pluginsStart]) => { const { taskManager: taskManagerStart } = pluginsStart as { taskManager: TaskManagerStartContract; }; @@ -141,5 +150,25 @@ export async function createApmTelemetry({ params: {}, state: {}, }); + + try { + const currentData = ( + await savedObjectsClient.get( + APM_TELEMETRY_SAVED_OBJECT_TYPE, + APM_TELEMETRY_SAVED_OBJECT_ID + ) + ).attributes as { kibanaVersion?: string }; + + if (currentData.kibanaVersion !== kibanaVersion) { + logger.debug( + `Stored telemetry is out of date. Task will run immediately. Stored: ${currentData.kibanaVersion}, expected: ${kibanaVersion}` + ); + taskManagerStart.runNow(APM_TELEMETRY_TASK_NAME); + } + } catch (err) { + if (!SavedObjectsErrorHelpers.isNotFoundError(err)) { + logger.warn('Failed to fetch saved telemetry data.'); + } + } }); } diff --git a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts index 82e4d1e395ed..c7af292e817c 100644 --- a/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts +++ b/x-pack/plugins/apm/server/lib/apm_telemetry/types.ts @@ -30,6 +30,11 @@ export type APMDataTelemetry = DeepPartial<{ patch: number; }; }; + environments: { + services_without_environments: number; + services_with_multiple_environments: number; + top_environments: string[]; + }; aggregated_transactions: { current_implementation: AggregatedTransactionsCounts; no_observer_name: AggregatedTransactionsCounts; diff --git a/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap b/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap index 22b8c226e902..2cb28d378e8f 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap +++ b/x-pack/plugins/apm/server/lib/rum_client/__snapshots__/queries.test.ts.snap @@ -148,7 +148,7 @@ Object { "body": Object { "aggs": Object { "pageViews": Object { - "aggs": Object {}, + "aggs": undefined, "auto_date_histogram": Object { "buckets": 50, "field": "@timestamp", diff --git a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts index 23169ddaca53..114137e9fad1 100644 --- a/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts +++ b/x-pack/plugins/apm/server/lib/rum_client/get_page_view_trends.ts @@ -11,7 +11,6 @@ import { SetupTimeRange, SetupUIFilters, } from '../helpers/setup_request'; -import { AggregationInputMap } from '../../../typings/elasticsearch/aggregations'; import { BreakdownItem } from '../../../typings/ui_filters'; export async function getPageViewTrends({ @@ -24,18 +23,9 @@ export async function getPageViewTrends({ const projection = getRumOverviewProjection({ setup, }); - const breakdownAggs: AggregationInputMap = {}; + let breakdownItem: BreakdownItem | null = null; if (breakdowns) { - const breakdownList: BreakdownItem[] = JSON.parse(breakdowns); - breakdownList.forEach(({ name, type, fieldName }) => { - breakdownAggs[name] = { - terms: { - field: fieldName, - size: 9, - missing: 'Other', - }, - }; - }); + breakdownItem = JSON.parse(breakdowns); } const params = mergeProjection(projection, { @@ -50,7 +40,17 @@ export async function getPageViewTrends({ field: '@timestamp', buckets: 50, }, - aggs: breakdownAggs, + aggs: breakdownItem + ? { + breakdown: { + terms: { + field: breakdownItem.fieldName, + size: 9, + missing: 'Other', + }, + }, + } + : undefined, }, }, }, @@ -68,19 +68,18 @@ export async function getPageViewTrends({ x: xVal, y: bCount, }; - - Object.keys(breakdownAggs).forEach((bKey) => { - const categoryBuckets = (bucket[bKey] as any).buckets; + if (breakdownItem) { + const categoryBuckets = (bucket.breakdown as any).buckets; categoryBuckets.forEach( ({ key, doc_count: docCount }: { key: string; doc_count: number }) => { if (key === 'Other') { - res[key + `(${bKey})`] = docCount; + res[key + `(${breakdownItem?.name})`] = docCount; } else { res[key] = docCount; } } ); - }); + } return res; }); 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 d0ba31f42c53..5c1e1839d9c5 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, any> | string; + name?: string; + key?: Record<string, any> | string; averageResponseTime: number | null | undefined; transactionsPerMinute: number; p95: number | null | undefined; impact: number; + impactRelative?: number; sample: Transaction; } diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index 71202c62e6f6..f7e3977ae7d3 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -101,6 +101,7 @@ export class APMPlugin implements Plugin<APMPluginSetup> { usageCollector: plugins.usageCollection, taskManager: plugins.taskManager, logger: this.logger, + kibanaVersion: this.initContext.env.packageInfo.version, }); } diff --git a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts index 5e48f969c670..f95761412254 100644 --- a/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts +++ b/x-pack/plugins/apm/typings/elasticsearch/aggregations.ts @@ -51,7 +51,12 @@ type GetCompositeKeys< type CompositeOptionsSource = Record< string, - { terms: { field: string; missing_bucket?: boolean } } | undefined + | { + terms: ({ field: string } | { script: Script }) & { + missing_bucket?: boolean; + }; + } + | undefined >; export interface AggregationOptionsByType { @@ -281,10 +286,9 @@ interface AggregationResponsePart< } | undefined; composite: { - after_key: Record< - GetCompositeKeys<TAggregationOptionsMap>, - string | number - >; + after_key: { + [key in GetCompositeKeys<TAggregationOptionsMap>]: TAggregationOptionsMap; + }; buckets: Array< { key: Record<GetCompositeKeys<TAggregationOptionsMap>, string | number>; 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 51c09e59d9b6..1869a4fc1bef 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 0815b7cd8816..823d12cbd809 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 63e1faa38216..898ef04ed6a0 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 3ef852ebf6dd..00795d69e13b 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 b8ebb4cf8da5..cdfe4183c96f 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 1e542dec06a7..4d98825f36b5 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/apm/typings/ui_filters.ts b/x-pack/plugins/apm/typings/ui_filters.ts index 2a727dda7241..efba6919778b 100644 --- a/x-pack/plugins/apm/typings/ui_filters.ts +++ b/x-pack/plugins/apm/typings/ui_filters.ts @@ -14,7 +14,6 @@ export type UIFilters = { export interface BreakdownItem { name: string; - count: number; type: string; fieldName: string; selected?: boolean; diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx index b5e5e248eaeb..607a11a76a06 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_create_drilldown/flyout_create_drilldown.tsx @@ -80,6 +80,7 @@ export class FlyoutCreateDrilldownAction implements ActionByType<typeof OPEN_FLY viewMode={'create'} dynamicActionManager={embeddable.enhancements.dynamicActions} supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())} + placeContext={{ embeddable }} /> ), { diff --git a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx index 6dfda93db715..30de62d0d28d 100644 --- a/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx +++ b/x-pack/plugins/dashboard_enhanced/public/services/drilldowns/actions/flyout_edit_drilldown/flyout_edit_drilldown.tsx @@ -64,6 +64,7 @@ export class FlyoutEditDrilldownAction implements ActionByType<typeof OPEN_FLYOU viewMode={'manage'} dynamicActionManager={embeddable.enhancements.dynamicActions} supportedTriggers={ensureNestedTriggers(embeddable.supportedTriggers())} + placeContext={{ embeddable }} /> ), { diff --git a/x-pack/plugins/embeddable_enhanced/kibana.json b/x-pack/plugins/embeddable_enhanced/kibana.json index 5663671de7bd..acada946fe0d 100644 --- a/x-pack/plugins/embeddable_enhanced/kibana.json +++ b/x-pack/plugins/embeddable_enhanced/kibana.json @@ -3,5 +3,6 @@ "version": "kibana", "server": false, "ui": true, - "requiredPlugins": ["embeddable", "uiActionsEnhanced"] + "requiredPlugins": ["embeddable", "kibanaReact", "uiActions", "uiActionsEnhanced"], + "requiredBundles": ["kibanaUtils"] } diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.d.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/index.ts similarity index 84% rename from x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.d.ts rename to x-pack/plugins/embeddable_enhanced/public/drilldowns/index.ts index 086ba6770312..a8d5a179dbac 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.d.ts +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export declare const addAllExtensions: any; +export * from './url_drilldown'; diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md new file mode 100644 index 000000000000..996723ccb914 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/README.md @@ -0,0 +1,24 @@ +# Basic url drilldown implementation + +Url drilldown allows navigating to external URL or to internal kibana URL. +By using variables in url template result url can be dynamic and depend on user's interaction. + +URL drilldown has 3 sources for variables: + +- Global static variables like, for example, `kibanaUrl`. Such variables won’t change depending on a place where url drilldown is used. +- Context variables are dynamic and different depending on where drilldown is created and used. +- Event variables depend on a trigger context. These variables are dynamically extracted from the action context when drilldown is executed. + +Difference between `event` and `context` variables, is that real `context` variables are available during drilldown creation (e.g. embeddable panel), +but `event` variables mapped from trigger context. Since there is no trigger context during drilldown creation, we have to provide some _mock_ variables for validating and previewing the URL. + +In current implementation url drilldown has to be used inside the embeddable and with `ValueClickTrigger` or `RangeSelectTrigger`. + +- `context` variables extracted from `embeddable` +- `event` variables extracted from `trigger` context + +In future this basic url drilldown implementation would allow injecting more variables into `context` (e.g. `dashboard` app specific variables) and would allow providing support for new trigger types from outside. +This extensibility improvements are tracked here: https://github.com/elastic/kibana/issues/55324 + +In case a solution app has a use case for url drilldown that has to be different from current basic implementation and +just extending variables list is not enough, then recommendation is to create own custom url drilldown and reuse building blocks from `ui_actions_enhanced`. diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/i18n.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/i18n.ts new file mode 100644 index 000000000000..748f6f4ceced --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/i18n.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtUrlDrilldownDisplayName = i18n.translate( + 'xpack.embeddableEnhanced.drilldowns.urlDrilldownDisplayName', + { + defaultMessage: 'Go to URL', + } +); diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/index.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/index.ts new file mode 100644 index 000000000000..61406f7d8431 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { UrlDrilldown } from './url_drilldown'; diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts new file mode 100644 index 000000000000..6a11663ea6c3 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.test.ts @@ -0,0 +1,166 @@ +/* + * 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 { UrlDrilldown, ActionContext, Config } from './url_drilldown'; +import { coreMock } from '../../../../../../src/core/public/mocks'; +import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public/lib/embeddables'; + +const mockDataPoints = [ + { + table: { + columns: [ + { + name: 'test', + id: '1-1', + meta: { + type: 'histogram', + indexPatternId: 'logstash-*', + aggConfigParams: { + field: 'bytes', + interval: 30, + otherBucket: true, + }, + }, + }, + ], + rows: [ + { + '1-1': '2048', + }, + ], + }, + column: 0, + row: 0, + value: 'test', + }, +]; + +const mockEmbeddable = ({ + getInput: () => ({ + filters: [], + timeRange: { from: 'now-15m', to: 'now' }, + query: { query: 'test', language: 'kuery' }, + }), + getOutput: () => ({}), +} as unknown) as IEmbeddable; + +const mockNavigateToUrl = jest.fn(() => Promise.resolve()); + +describe('UrlDrilldown', () => { + const urlDrilldown = new UrlDrilldown({ + getGlobalScope: () => ({ kibanaUrl: 'http://localhost:5601/' }), + getOpenModal: () => Promise.resolve(coreMock.createStart().overlays.openModal), + getSyntaxHelpDocsLink: () => 'http://localhost:5601/docs', + navigateToUrl: mockNavigateToUrl, + }); + + test('license', () => { + expect(urlDrilldown.minimalLicense).toBe('gold'); + }); + + describe('isCompatible', () => { + test('throws if no embeddable', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + }; + + await expect(urlDrilldown.isCompatible(config, context)).rejects.toThrowError(); + }); + + test('compatible if url is valid', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(true); + }); + + test('not compatible if url is invalid', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.somethingFake}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(false); + }); + }); + + describe('getHref & execute', () => { + beforeEach(() => { + mockNavigateToUrl.mockReset(); + }); + + test('valid url', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.query}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + const url = await urlDrilldown.getHref(config, context); + expect(url).toMatchInlineSnapshot(`"https://elasti.co/?test&(language:kuery,query:test)"`); + + await urlDrilldown.execute(config, context); + expect(mockNavigateToUrl).toBeCalledWith(url); + }); + + test('invalid url', async () => { + const config: Config = { + url: { + template: `https://elasti.co/?{{event.value}}&{{rison context.panel.invalid}}`, + }, + openInNewTab: false, + }; + + const context: ActionContext = { + data: { + data: mockDataPoints, + }, + embeddable: mockEmbeddable, + }; + + await expect(urlDrilldown.getHref(config, context)).rejects.toThrowError(); + await expect(urlDrilldown.execute(config, context)).rejects.toThrowError(); + expect(mockNavigateToUrl).not.toBeCalled(); + }); + }); +}); diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx new file mode 100644 index 000000000000..d5ab095fdd28 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown.tsx @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { OverlayStart } from 'kibana/public'; +import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; +import { ChartActionContext, IEmbeddable } from '../../../../../../src/plugins/embeddable/public'; +import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; +import { + SELECT_RANGE_TRIGGER, + VALUE_CLICK_TRIGGER, +} from '../../../../../../src/plugins/ui_actions/public'; +import { + UiActionsEnhancedDrilldownDefinition as Drilldown, + UrlDrilldownGlobalScope, + UrlDrilldownConfig, + UrlDrilldownCollectConfig, + urlDrilldownValidateUrlTemplate, + urlDrilldownBuildScope, + urlDrilldownCompileUrl, + UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, +} from '../../../../ui_actions_enhanced/public'; +import { getContextScope, getEventScope, getMockEventScope } from './url_drilldown_scope'; +import { txtUrlDrilldownDisplayName } from './i18n'; + +interface UrlDrilldownDeps { + getGlobalScope: () => UrlDrilldownGlobalScope; + navigateToUrl: (url: string) => Promise<void>; + getOpenModal: () => Promise<OverlayStart['openModal']>; + getSyntaxHelpDocsLink: () => string; +} + +export type ActionContext = ChartActionContext; +export type Config = UrlDrilldownConfig; +export type UrlTrigger = typeof VALUE_CLICK_TRIGGER | typeof SELECT_RANGE_TRIGGER; +export interface ActionFactoryContext extends BaseActionFactoryContext<UrlTrigger> { + embeddable?: IEmbeddable; +} +export type CollectConfigProps = CollectConfigPropsBase<Config, ActionFactoryContext>; + +const URL_DRILLDOWN = 'URL_DRILLDOWN'; + +export class UrlDrilldown implements Drilldown<Config, UrlTrigger, ActionFactoryContext> { + public readonly id = URL_DRILLDOWN; + + constructor(private deps: UrlDrilldownDeps) {} + + public readonly order = 8; + + readonly minimalLicense = 'gold'; + readonly licenseFeatureName = 'URL drilldown'; + + public readonly getDisplayName = () => txtUrlDrilldownDisplayName; + + public readonly euiIcon = 'link'; + + supportedTriggers(): UrlTrigger[] { + return [VALUE_CLICK_TRIGGER, SELECT_RANGE_TRIGGER]; + } + + private readonly ReactCollectConfig: React.FC<CollectConfigProps> = ({ + config, + onConfig, + context, + }) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const scope = React.useMemo(() => this.buildEditorScope(context), [context]); + return ( + <UrlDrilldownCollectConfig + config={config} + onConfig={onConfig} + scope={scope} + syntaxHelpDocsLink={this.deps.getSyntaxHelpDocsLink()} + /> + ); + }; + + public readonly CollectConfig = reactToUiComponent(this.ReactCollectConfig); + + public readonly createConfig = () => ({ + url: { template: '' }, + openInNewTab: false, + }); + + public readonly isConfigValid = ( + config: Config, + context: ActionFactoryContext + ): config is Config => { + const { isValid } = urlDrilldownValidateUrlTemplate(config.url, this.buildEditorScope(context)); + return isValid; + }; + + public readonly isCompatible = async (config: Config, context: ActionContext) => { + const { isValid, error } = urlDrilldownValidateUrlTemplate( + config.url, + await this.buildRuntimeScope(context) + ); + + if (!isValid) { + // eslint-disable-next-line no-console + console.warn( + `UrlDrilldown [${config.url.template}] is not valid. Error [${error}]. Skipping execution.` + ); + } + + return Promise.resolve(isValid); + }; + + public readonly getHref = async (config: Config, context: ActionContext) => + urlDrilldownCompileUrl(config.url.template, await this.buildRuntimeScope(context)); + + public readonly execute = async (config: Config, context: ActionContext) => { + const url = await urlDrilldownCompileUrl( + config.url.template, + await this.buildRuntimeScope(context, { allowPrompts: true }) + ); + if (config.openInNewTab) { + window.open(url, '_blank', 'noopener'); + } else { + await this.deps.navigateToUrl(url); + } + }; + + private buildEditorScope = (context: ActionFactoryContext) => { + return urlDrilldownBuildScope({ + globalScope: this.deps.getGlobalScope(), + contextScope: getContextScope(context), + eventScope: getMockEventScope(context.triggers), + }); + }; + + private buildRuntimeScope = async ( + context: ActionContext, + opts: { allowPrompts: boolean } = { allowPrompts: false } + ) => { + return urlDrilldownBuildScope({ + globalScope: this.deps.getGlobalScope(), + contextScope: getContextScope(context), + eventScope: await getEventScope(context, this.deps, opts), + }); + }; +} diff --git a/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx new file mode 100644 index 000000000000..d3e3510f1b24 --- /dev/null +++ b/x-pack/plugins/embeddable_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.tsx @@ -0,0 +1,319 @@ +/* + * 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. + */ + +/** + * This file contains all the logic for mapping from trigger's context and action factory context to variables for URL drilldown scope, + * Please refer to ./README.md for explanation of different scope sources + */ + +import React from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiRadioGroup, +} from '@elastic/eui'; +import uniqBy from 'lodash/uniqBy'; +import { FormattedMessage } from '@kbn/i18n/react'; +import type { Query, Filter, TimeRange } from '../../../../../../src/plugins/data/public'; +import { + IEmbeddable, + isRangeSelectTriggerContext, + isValueClickTriggerContext, + RangeSelectContext, + ValueClickContext, +} from '../../../../../../src/plugins/embeddable/public'; +import type { ActionContext, ActionFactoryContext, UrlTrigger } from './url_drilldown'; +import { SELECT_RANGE_TRIGGER } from '../../../../../../src/plugins/ui_actions/public'; +import { OverlayStart } from '../../../../../../src/core/public'; +import { toMountPoint } from '../../../../../../src/plugins/kibana_react/public'; + +type ContextScopeInput = ActionContext | ActionFactoryContext; + +/** + * Part of context scope extracted from an embeddable + * Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}` + */ +interface EmbeddableUrlDrilldownContextScope { + id: string; + title?: string; + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + savedObjectId?: string; + /** + * In case panel supports only 1 index patterns + */ + indexPatternId?: string; + /** + * In case panel supports more then 1 index patterns + */ + indexPatternIds?: string[]; +} + +/** + * Url drilldown context scope + * `{{context.$}}` + */ +interface UrlDrilldownContextScope { + panel?: EmbeddableUrlDrilldownContextScope; +} + +export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilldownContextScope { + function hasEmbeddable(val: unknown): val is { embeddable: IEmbeddable } { + if (val && typeof val === 'object' && 'embeddable' in val) return true; + return false; + } + if (!hasEmbeddable(contextScopeInput)) + throw new Error( + "UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context" + ); + + const embeddable = contextScopeInput.embeddable; + const input = embeddable.getInput(); + const output = embeddable.getOutput(); + function hasSavedObjectId(obj: Record<string, any>): obj is { savedObjectId: string } { + return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string'; + } + function getIndexPatternIds(): string[] { + function hasIndexPatterns( + _output: Record<string, any> + ): _output is { indexPatterns: Array<{ id?: string }> } { + return ( + 'indexPatterns' in _output && + Array.isArray(_output.indexPatterns) && + _output.indexPatterns.length > 0 + ); + } + return hasIndexPatterns(output) + ? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[]) + : []; + } + const indexPatternsIds = getIndexPatternIds(); + return { + panel: cleanEmptyKeys({ + id: input.id, + title: output.title ?? input.title, + savedObjectId: + output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined), + query: input.query, + timeRange: input.timeRange, + filters: input.filters, + indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined, + indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined, + }), + }; +} + +/** + * URL drilldown event scope, + * available as: {{event.key}}, {{event.from}} + */ +type UrlDrilldownEventScope = ValueClickTriggerEventScope | RangeSelectTriggerEventScope; +type EventScopeInput = ActionContext; +interface ValueClickTriggerEventScope { + key?: string; + value?: string | number | boolean; + negate: boolean; +} +interface RangeSelectTriggerEventScope { + key: string; + from?: string | number; + to?: string | number; +} + +export async function getEventScope( + eventScopeInput: EventScopeInput, + deps: { getOpenModal: () => Promise<OverlayStart['openModal']> }, + opts: { allowPrompts: boolean } = { allowPrompts: false } +): Promise<UrlDrilldownEventScope> { + if (isRangeSelectTriggerContext(eventScopeInput)) { + return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); + } else if (isValueClickTriggerContext(eventScopeInput)) { + return getEventScopeFromValueClickTriggerContext(eventScopeInput, deps, opts); + } else { + throw new Error("UrlDrilldown [getEventScope] can't build scope from not supported trigger"); + } +} + +async function getEventScopeFromRangeSelectTriggerContext( + eventScopeInput: RangeSelectContext +): Promise<RangeSelectTriggerEventScope> { + const { table, column: columnIndex, range } = eventScopeInput.data; + const column = table.columns[columnIndex]; + return cleanEmptyKeys({ + key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string, + from: toPrimitiveOrUndefined(range[0]) as string | number | undefined, + to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined, + }); +} + +async function getEventScopeFromValueClickTriggerContext( + eventScopeInput: ValueClickContext, + deps: { getOpenModal: () => Promise<OverlayStart['openModal']> }, + opts: { allowPrompts: boolean } = { allowPrompts: false } +): Promise<ValueClickTriggerEventScope> { + const negate = eventScopeInput.data.negate ?? false; + const point = await getSingleValue(eventScopeInput.data.data, deps, opts); + const { key, value } = getKeyValueFromPoint(point); + return cleanEmptyKeys({ + key, + value, + negate, + }); +} + +/** + * @remarks + * Difference between `event` and `context` variables, is that real `context` variables are available during drilldown creation (e.g. embeddable panel) + * `event` variables are mapped from trigger context. Since there is no trigger context during drilldown creation, we have to provide some _mock_ variables for validating and previewing the URL + */ +export function getMockEventScope([trigger]: UrlTrigger[]): UrlDrilldownEventScope { + if (trigger === SELECT_RANGE_TRIGGER) { + return { + key: 'event.key', + from: new Date(Date.now() - 15 * 60 * 1000).toISOString(), // 15 minutes ago + to: new Date().toISOString(), + }; + } else { + return { + key: 'event.key', + value: 'event.value', + negate: false, + }; + } +} + +function getKeyValueFromPoint( + point: ValueClickContext['data']['data'][0] +): Pick<ValueClickTriggerEventScope, 'key' | 'value'> { + const { table, column: columnIndex, value } = point; + const column = table.columns[columnIndex]; + return { + key: toPrimitiveOrUndefined(column?.meta?.aggConfigParams?.field) as string | undefined, + value: toPrimitiveOrUndefined(value), + }; +} + +function toPrimitiveOrUndefined(v: unknown): string | number | boolean | undefined { + if (typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') return v; + if (typeof v === 'object' && v instanceof Date) return v.toISOString(); + if (typeof v === 'undefined' || v === null) return undefined; + return String(v); +} + +function cleanEmptyKeys<T extends Record<string, any>>(obj: T): T { + Object.keys(obj).forEach((key) => { + if (obj[key] === undefined) { + delete obj[key]; + } + }); + return obj; +} + +/** + * VALUE_CLICK_TRIGGER could have multiple data points + * Prompt user which data point to use in a drilldown + */ +async function getSingleValue( + data: ValueClickContext['data']['data'], + deps: { getOpenModal: () => Promise<OverlayStart['openModal']> }, + opts: { allowPrompts: boolean } = { allowPrompts: false } +): Promise<ValueClickContext['data']['data'][0]> { + data = uniqBy(data.filter(Boolean), (point) => { + const { key, value } = getKeyValueFromPoint(point); + return `${key}:${value}`; + }); + if (data.length === 0) + throw new Error(`[trigger = "VALUE_CLICK_TRIGGER"][getSingleValue] no value to pick from`); + if (data.length === 1) return Promise.resolve(data[0]); + if (!opts.allowPrompts) return Promise.resolve(data[0]); + return new Promise(async (resolve, reject) => { + const openModal = await deps.getOpenModal(); + const overlay = openModal( + toMountPoint( + <GetSingleValuePopup + onCancel={() => overlay.close()} + onSubmit={(point) => { + if (point) { + resolve(point); + } + overlay.close(); + }} + data={data} + /> + ) + ); + overlay.onClose.then(() => reject()); + }); +} + +function GetSingleValuePopup({ + data, + onCancel, + onSubmit, +}: { + data: ValueClickContext['data']['data']; + onCancel: () => void; + onSubmit: (value: ValueClickContext['data']['data'][0]) => void; +}) { + const values = data + .map((point) => { + const { key, value } = getKeyValueFromPoint(point); + return { + point, + id: key ?? '', + label: `${key}:${value}`, + }; + }) + .filter((value) => Boolean(value.id)); + + const [selectedValueId, setSelectedValueId] = React.useState(values[0].id); + + return ( + <React.Fragment> + <EuiModalHeader> + <EuiModalHeaderTitle> + <FormattedMessage + id="xpack.embeddableEnhanced.drilldowns.pickSingleValuePopup.popupHeader" + defaultMessage="Select a value to drill down into" + /> + </EuiModalHeaderTitle> + </EuiModalHeader> + + <EuiModalBody> + <EuiRadioGroup + options={values} + idSelected={selectedValueId} + onChange={(id) => setSelectedValueId(id)} + name="drilldownValues" + /> + </EuiModalBody> + + <EuiModalFooter> + <EuiButtonEmpty onClick={onCancel}> + <FormattedMessage + id="xpack.embeddableEnhanced.drilldowns.pickSingleValuePopup.cancelButtonLabel" + defaultMessage="Cancel" + /> + </EuiButtonEmpty> + <EuiButton + onClick={() => onSubmit(values.find((v) => v.id === selectedValueId)?.point!)} + data-test-subj="applySingleValuePopoverButton" + fill + > + <FormattedMessage + id="xpack.embeddableEnhanced.drilldowns.pickSingleValuePopup.applyButtonLabel" + defaultMessage="Apply" + /> + </EuiButton> + </EuiModalFooter> + </React.Fragment> + ); +} diff --git a/x-pack/plugins/embeddable_enhanced/public/plugin.ts b/x-pack/plugins/embeddable_enhanced/public/plugin.ts index fd0bcc202326..37e102b40131 100644 --- a/x-pack/plugins/embeddable_enhanced/public/plugin.ts +++ b/x-pack/plugins/embeddable_enhanced/public/plugin.ts @@ -28,8 +28,11 @@ import { UiActionsEnhancedDynamicActionManager as DynamicActionManager, AdvancedUiActionsSetup, AdvancedUiActionsStart, + urlDrilldownGlobalScopeProvider, } from '../../ui_actions_enhanced/public'; import { PanelNotificationsAction, ACTION_PANEL_NOTIFICATIONS } from './actions'; +import { UrlDrilldown } from './drilldowns'; +import { createStartServicesGetter } from '../../../../src/plugins/kibana_utils/public'; declare module '../../../../src/plugins/ui_actions/public' { export interface ActionContextMapping { @@ -61,11 +64,21 @@ export class EmbeddableEnhancedPlugin public setup(core: CoreSetup<StartDependencies>, plugins: SetupDependencies): SetupContract { this.setCustomEmbeddableFactoryProvider(plugins); - + const startServices = createStartServicesGetter(core.getStartServices); const panelNotificationAction = new PanelNotificationsAction(); plugins.uiActionsEnhanced.registerAction(panelNotificationAction); plugins.uiActionsEnhanced.attachAction(PANEL_NOTIFICATION_TRIGGER, panelNotificationAction.id); + plugins.uiActionsEnhanced.registerDrilldown( + new UrlDrilldown({ + getGlobalScope: urlDrilldownGlobalScopeProvider({ core }), + navigateToUrl: (url: string) => + core.getStartServices().then(([{ application }]) => application.navigateToUrl(url)), + getOpenModal: () => core.getStartServices().then(([{ overlays }]) => overlays.openModal), + getSyntaxHelpDocsLink: () => startServices().core.docLinks.links.dashboard.drilldowns, // TODO: replace with docs https://github.com/elastic/kibana/issues/69414 + }) + ); + return {}; } diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap similarity index 89% rename from x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap rename to x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap index 39eb54b941ac..59d1723c3948 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.js.snap +++ b/x-pack/plugins/index_lifecycle_management/__jest__/__snapshots__/extend_index_management.test.tsx.snap @@ -134,6 +134,7 @@ exports[`extend index management ilm summary extension should return extension w }, "step_time_millis": 1544187776208, }, + "isFrozen": false, "name": "testy3", "primary": "1", "primary_size": "6.5kb", @@ -326,6 +327,82 @@ exports[`extend index management ilm summary extension should return extension w className="euiSpacer euiSpacer--s" /> </EuiSpacer> + <EuiPopover + anchorPosition="downCenter" + button={ + <EuiButtonEmpty + onClick={[Function]} + > + <FormattedMessage + defaultMessage="Stack trace" + id="xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.stackTraceButton" + values={Object {}} + /> + </EuiButtonEmpty> + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="stackPopover" + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + > + <EuiOutsideClickDetector + isDisabled={true} + onOutsideClick={[Function]} + > + <div + className="euiPopover euiPopover--anchorDownCenter" + id="stackPopover" + onKeyDown={[Function]} + onMouseDown={[Function]} + onMouseUp={[Function]} + onTouchEnd={[Function]} + onTouchStart={[Function]} + > + <div + className="euiPopover__anchor" + > + <EuiButtonEmpty + onClick={[Function]} + > + <button + className="euiButtonEmpty euiButtonEmpty--primary" + onClick={[Function]} + type="button" + > + <EuiButtonContent + className="euiButtonEmpty__content" + iconSide="left" + textProps={ + Object { + "className": "euiButtonEmpty__text", + } + } + > + <span + className="euiButtonContent euiButtonEmpty__content" + > + <span + className="euiButtonEmpty__text" + > + <FormattedMessage + defaultMessage="Stack trace" + id="xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.stackTraceButton" + values={Object {}} + > + Stack trace + </FormattedMessage> + </span> + </span> + </EuiButtonContent> + </button> + </EuiButtonEmpty> + </div> + </div> + </EuiOutsideClickDetector> + </EuiPopover> </div> </EuiText> </div> @@ -588,6 +665,7 @@ exports[`extend index management ilm summary extension should return extension w "step": "complete", "step_time_millis": 1544187775867, }, + "isFrozen": false, "name": "testy3", "primary": "1", "primary_size": "6.5kb", diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx similarity index 88% rename from x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js rename to x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx index 4fa183811584..17573cb81c40 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.js +++ b/x-pack/plugins/index_lifecycle_management/__jest__/extend_index_management.test.tsx @@ -8,7 +8,8 @@ import moment from 'moment-timezone'; import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; -import { mountWithIntl } from '../../../test_utils/enzyme_helpers'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { usageCollectionPluginMock } from '../../../../src/plugins/usage_collection/public/mocks'; import { retryLifecycleActionExtension, removeLifecyclePolicyActionExtension, @@ -19,19 +20,22 @@ import { } from '../public/extend_index_management'; import { init as initHttp } from '../public/application/services/http'; import { init as initUiMetric } from '../public/application/services/ui_metric'; +import { Index } from '../public/application/services/policies/types'; // We need to init the http with a mock for any tests that depend upon the http service. // For example, add_lifecycle_confirm_modal makes an API request in its componentDidMount // lifecycle method. If we don't mock this, CI will fail with "Call retries were exceeded". -initHttp(axios.create({ adapter: axiosXhrAdapter }), (path) => path); -initUiMetric({ reportUiStats: () => {} }); +// This expects HttpSetup but we're giving it AxiosInstance. +// @ts-ignore +initHttp(axios.create({ adapter: axiosXhrAdapter })); +initUiMetric(usageCollectionPluginMock.createSetupContract()); jest.mock('../../../plugins/index_management/public', async () => { - const { indexManagementMock } = await import('../../../plugins/index_management/public/mocks.ts'); + const { indexManagementMock } = await import('../../../plugins/index_management/public/mocks'); return indexManagementMock.createSetup(); }); -const indexWithoutLifecyclePolicy = { +const indexWithoutLifecyclePolicy: Index = { health: 'yellow', status: 'open', name: 'noPolicy', @@ -43,13 +47,14 @@ const indexWithoutLifecyclePolicy = { size: '3.4kb', primary_size: '3.4kb', aliases: 'none', + isFrozen: false, ilm: { index: 'testy1', managed: false, }, }; -const indexWithLifecyclePolicy = { +const indexWithLifecyclePolicy: Index = { health: 'yellow', status: 'open', name: 'testy3', @@ -61,6 +66,7 @@ const indexWithLifecyclePolicy = { size: '6.5kb', primary_size: '6.5kb', aliases: 'none', + isFrozen: false, ilm: { index: 'testy3', managed: true, @@ -87,6 +93,7 @@ const indexWithLifecycleError = { size: '6.5kb', primary_size: '6.5kb', aliases: 'none', + isFrozen: false, ilm: { index: 'testy3', managed: true, @@ -115,10 +122,12 @@ const indexWithLifecycleError = { moment.tz.setDefault('utc'); -const getUrlForApp = (appId, options) => { +const getUrlForApp = (appId: string, options: any) => { return appId + '/' + (options ? options.path : ''); }; +const reloadIndices = () => {}; + describe('extend index management', () => { describe('retry lifecycle action extension', () => { test('should return null when no indices have index lifecycle policy', () => { @@ -153,6 +162,7 @@ describe('extend index management', () => { test('should return null when no indices have index lifecycle policy', () => { const extension = removeLifecyclePolicyActionExtension({ indices: [indexWithoutLifecyclePolicy], + reloadIndices, }); expect(extension).toBeNull(); }); @@ -160,6 +170,7 @@ describe('extend index management', () => { test('should return null when some indices have index lifecycle policy', () => { const extension = removeLifecyclePolicyActionExtension({ indices: [indexWithoutLifecyclePolicy, indexWithLifecyclePolicy], + reloadIndices, }); expect(extension).toBeNull(); }); @@ -167,6 +178,7 @@ describe('extend index management', () => { test('should return extension when all indices have lifecycle policy', () => { const extension = removeLifecyclePolicyActionExtension({ indices: [indexWithLifecycleError, indexWithLifecycleError], + reloadIndices, }); expect(extension).toBeDefined(); expect(extension).toMatchSnapshot(); @@ -175,16 +187,18 @@ describe('extend index management', () => { describe('add lifecycle policy action extension', () => { test('should return null when index has index lifecycle policy', () => { - const extension = addLifecyclePolicyActionExtension( - { indices: [indexWithLifecyclePolicy] }, - getUrlForApp - ); + const extension = addLifecyclePolicyActionExtension({ + indices: [indexWithLifecyclePolicy], + reloadIndices, + getUrlForApp, + }); expect(extension).toBeNull(); }); test('should return null when more than one index is passed', () => { const extension = addLifecyclePolicyActionExtension({ indices: [indexWithoutLifecyclePolicy, indexWithoutLifecyclePolicy], + reloadIndices, getUrlForApp, }); expect(extension).toBeNull(); @@ -193,10 +207,11 @@ describe('extend index management', () => { test('should return extension when one index is passed and it does not have lifecycle policy', () => { const extension = addLifecyclePolicyActionExtension({ indices: [indexWithoutLifecyclePolicy], + reloadIndices, getUrlForApp, }); - expect(extension.renderConfirmModal).toBeDefined; - const component = extension.renderConfirmModal(jest.fn()); + expect(extension?.renderConfirmModal).toBeDefined(); + const component = extension!.renderConfirmModal(jest.fn()); const rendered = mountWithIntl(component); expect(rendered.exists('.euiModal--confirmation')); }); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts index b80e9e70c54f..e9365bfe06ea 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/api.ts @@ -12,7 +12,7 @@ import { UIM_POLICY_ATTACH_INDEX_TEMPLATE, UIM_POLICY_DETACH_INDEX, UIM_INDEX_RETRY_STEP, -} from '../constants/ui_metric'; +} from '../constants'; import { trackUiMetric } from './ui_metric'; import { sendGet, sendPost, sendDelete, useRequest } from './http'; @@ -78,7 +78,11 @@ export const removeLifecycleForIndex = async (indexNames: string[]) => { return response; }; -export const addLifecyclePolicyToIndex = async (body: GenericObject) => { +export const addLifecyclePolicyToIndex = async (body: { + indexName: string; + policyName: string; + alias: string; +}) => { const response = await sendPost(`index/add`, body); // Only track successful actions. trackUiMetric(METRIC_TYPE.COUNT, UIM_POLICY_ATTACH_INDEX); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts index c191f82cf05c..0e00b5a02b71 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Index as IndexInterface } from '../../../../../index_management/public'; + export interface SerializedPolicy { name: string; phases: Phases; @@ -169,3 +171,36 @@ export interface FrozenPhase export interface DeletePhase extends CommonPhaseSettings, PhaseWithMinAge { waitForSnapshotPolicy: string; } + +export interface IndexLifecyclePolicy { + index: string; + managed: boolean; + action?: string; + action_time_millis?: number; + age?: string; + failed_step?: string; + failed_step_retry_count?: number; + is_auto_retryable_error?: boolean; + lifecycle_date_millis?: number; + phase?: string; + phase_execution?: { + policy: string; + modified_date_in_millis: number; + version: number; + phase_definition: SerializedPhase; + }; + phase_time_millis?: number; + policy?: string; + step?: string; + step_info?: { + reason?: string; + stack_trace?: string; + type?: string; + message?: string; + }; + step_time_millis?: number; +} + +export interface Index extends IndexInterface { + ilm: IndexLifecyclePolicy; +} diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx similarity index 88% rename from x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.js rename to x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx index 0bd313c9a9f8..060b208006bf 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.js +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/add_lifecycle_confirm_modal.tsx @@ -8,6 +8,8 @@ import React, { Component, Fragment } from 'react'; import { get } from 'lodash'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { ApplicationStart } from 'kibana/public'; + import { EuiLink, EuiSelect, @@ -26,9 +28,25 @@ import { import { loadPolicies, addLifecyclePolicyToIndex } from '../../application/services/api'; import { showApiError } from '../../application/services/api_errors'; import { toasts } from '../../application/services/notification'; +import { Index, PolicyFromES } from '../../application/services/policies/types'; + +interface Props { + indexName: string; + closeModal: () => void; + index: Index; + reloadIndices: () => void; + getUrlForApp: ApplicationStart['getUrlForApp']; +} + +interface State { + selectedPolicyName: string; + selectedAlias: string; + policies: PolicyFromES[]; + policyErrorMessage?: string; +} -export class AddLifecyclePolicyConfirmModal extends Component { - constructor(props) { +export class AddLifecyclePolicyConfirmModal extends Component<Props, State> { + constructor(props: Props) { super(props); this.state = { policies: [], @@ -41,7 +59,7 @@ export class AddLifecyclePolicyConfirmModal extends Component { const { selectedPolicyName, selectedAlias } = this.state; if (!selectedPolicyName) { this.setState({ - policyError: i18n.translate( + policyErrorMessage: i18n.translate( 'xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.noPolicySelectedErrorMessage', { defaultMessage: 'You must select a policy.' } ), @@ -81,7 +99,7 @@ export class AddLifecyclePolicyConfirmModal extends Component { ); } }; - renderAliasFormElement = (selectedPolicy) => { + renderAliasFormElement = (selectedPolicy?: PolicyFromES) => { const { selectedAlias } = this.state; const { index } = this.props; const showAliasSelect = @@ -109,7 +127,7 @@ export class AddLifecyclePolicyConfirmModal extends Component { defaultMessage="Policy {policyName} is configured for rollover, but index {indexName} does not have an alias, which is required for rollover." values={{ - policyName: selectedPolicy.name, + policyName: selectedPolicy?.name, indexName: index.name, }} /> @@ -117,7 +135,7 @@ export class AddLifecyclePolicyConfirmModal extends Component { </Fragment> ); } - const aliasOptions = aliases.map((alias) => { + const aliasOptions = (aliases as string[]).map((alias: string) => { return { text: alias, value: alias, @@ -152,10 +170,10 @@ export class AddLifecyclePolicyConfirmModal extends Component { ); }; renderForm() { - const { policies, selectedPolicyName, policyError } = this.state; + const { policies, selectedPolicyName, policyErrorMessage } = this.state; const selectedPolicy = selectedPolicyName ? policies.find((policy) => policy.name === selectedPolicyName) - : null; + : undefined; const options = policies.map(({ name }) => { return { @@ -175,8 +193,8 @@ export class AddLifecyclePolicyConfirmModal extends Component { return ( <EuiForm> <EuiFormRow - isInvalid={!!policyError} - error={policyError} + isInvalid={!!policyErrorMessage} + error={policyErrorMessage} label={ <FormattedMessage id="xpack.indexLifecycleMgmt.indexManagementTable.addLifecyclePolicyConfirmModal.choosePolicyLabel" @@ -188,7 +206,7 @@ export class AddLifecyclePolicyConfirmModal extends Component { options={options} value={selectedPolicyName} onChange={(e) => { - this.setState({ policyError: null, selectedPolicyName: e.target.value }); + this.setState({ policyErrorMessage: undefined, selectedPolicyName: e.target.value }); }} /> </EuiFormRow> @@ -198,7 +216,7 @@ export class AddLifecyclePolicyConfirmModal extends Component { } async componentDidMount() { try { - const policies = await loadPolicies(false, this.props.httpClient); + const policies = await loadPolicies(false); this.setState({ policies }); } catch (err) { showApiError( diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx similarity index 69% rename from x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.js rename to x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx index 9e9dc009e4c4..02e4595a333b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.js +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/components/index_lifecycle_summary.tsx @@ -24,44 +24,71 @@ import { EuiPopoverTitle, } from '@elastic/eui'; +import { ApplicationStart } from 'kibana/public'; import { getPolicyPath } from '../../application/services/navigation'; +import { Index, IndexLifecyclePolicy } from '../../application/services/policies/types'; -const getHeaders = () => { - return { - policy: i18n.translate( - 'xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.headers.lifecyclePolicyHeader', - { - defaultMessage: 'Lifecycle policy', - } - ), - phase: i18n.translate( - 'xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.headers.currentPhaseHeader', - { - defaultMessage: 'Current phase', - } - ), - action: i18n.translate( - 'xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.headers.currentActionHeader', - { - defaultMessage: 'Current action', - } - ), - action_time_millis: i18n.translate( - 'xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.headers.currentActionTimeHeader', - { - defaultMessage: 'Current action time', - } - ), - failed_step: i18n.translate( - 'xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.headers.failedStepHeader', - { - defaultMessage: 'Failed step', - } - ), - }; +const getHeaders = (): Array<[keyof IndexLifecyclePolicy, string]> => { + return [ + [ + 'policy', + i18n.translate( + 'xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.headers.lifecyclePolicyHeader', + { + defaultMessage: 'Lifecycle policy', + } + ), + ], + [ + 'phase', + i18n.translate( + 'xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.headers.currentPhaseHeader', + { + defaultMessage: 'Current phase', + } + ), + ], + [ + 'action', + i18n.translate( + 'xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.headers.currentActionHeader', + { + defaultMessage: 'Current action', + } + ), + ], + [ + 'action_time_millis', + i18n.translate( + 'xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.headers.currentActionTimeHeader', + { + defaultMessage: 'Current action time', + } + ), + ], + [ + 'failed_step', + i18n.translate( + 'xpack.indexLifecycleMgmt.indexLifecycleMgmtSummary.headers.failedStepHeader', + { + defaultMessage: 'Failed step', + } + ), + ], + ]; }; -export class IndexLifecycleSummary extends Component { - constructor(props) { + +interface Props { + index: Index; + getUrlForApp: ApplicationStart['getUrlForApp']; +} +interface State { + showStackPopover: boolean; + showPhaseExecutionPopover: boolean; +} + +export class IndexLifecycleSummary extends Component<Props, State> { + constructor(props: Props) { super(props); this.state = { showStackPopover: false, @@ -80,8 +107,8 @@ export class IndexLifecycleSummary extends Component { closePhaseExecutionPopover = () => { this.setState({ showPhaseExecutionPopover: false }); }; - renderStackPopoverButton(ilm) { - if (!ilm.stack_trace) { + renderStackPopoverButton(ilm: IndexLifecyclePolicy) { + if (!ilm.step_info!.stack_trace) { return null; } const button = ( @@ -100,15 +127,12 @@ export class IndexLifecycleSummary extends Component { closePopover={this.closeStackPopover} > <div style={{ maxHeight: '400px', width: '900px', overflowY: 'scroll' }}> - <pre>{ilm.step_info.stack_trace}</pre> + <pre>{ilm.step_info!.stack_trace}</pre> </div> </EuiPopover> ); } - renderPhaseExecutionPopoverButton(ilm) { - if (!ilm.phase_execution) { - return null; - } + renderPhaseExecutionPopoverButton(ilm: IndexLifecyclePolicy) { const button = ( <EuiLink onClick={this.togglePhaseExecutionPopover}> <FormattedMessage @@ -150,15 +174,18 @@ export class IndexLifecycleSummary extends Component { } buildRows() { const { - index: { ilm = {} }, + index: { ilm }, } = this.props; const headers = getHeaders(); - const rows = { + const rows: { + left: JSX.Element[]; + right: JSX.Element[]; + } = { left: [], right: [], }; - Object.keys(headers).forEach((fieldName, arrayIndex) => { - const value = ilm[fieldName]; + headers.forEach(([fieldName, label], arrayIndex) => { + const value: any = ilm[fieldName]; let content; if (fieldName === 'action_time_millis') { content = moment(value).format('YYYY-MM-DD HH:mm:ss'); @@ -176,34 +203,38 @@ export class IndexLifecycleSummary extends Component { content = value; } content = content || '-'; - const cell = [ - <EuiDescriptionListTitle key={fieldName}> - <strong>{headers[fieldName]}</strong> - </EuiDescriptionListTitle>, - <EuiDescriptionListDescription key={fieldName + '_desc'}> - {content} - </EuiDescriptionListDescription>, - ]; + const cell = ( + <> + <EuiDescriptionListTitle key={fieldName}> + <strong>{label}</strong> + </EuiDescriptionListTitle> + <EuiDescriptionListDescription key={fieldName + '_desc'}> + {content} + </EuiDescriptionListDescription> + </> + ); if (arrayIndex % 2 === 0) { rows.left.push(cell); } else { rows.right.push(cell); } }); - rows.right.push(this.renderPhaseExecutionPopoverButton(ilm)); + if (ilm.phase_execution) { + rows.right.push(this.renderPhaseExecutionPopoverButton(ilm)); + } return rows; } render() { const { - index: { ilm = {} }, + index: { ilm }, } = this.props; if (!ilm.managed) { return null; } const { left, right } = this.buildRows(); return ( - <Fragment> + <> <EuiTitle size="s"> <h3> <FormattedMessage @@ -213,7 +244,7 @@ export class IndexLifecycleSummary extends Component { </h3> </EuiTitle> {ilm.step_info && ilm.step_info.type ? ( - <Fragment> + <> <EuiSpacer size="s" /> <EuiCallOut color="danger" @@ -229,10 +260,10 @@ export class IndexLifecycleSummary extends Component { <EuiSpacer size="s" /> {this.renderStackPopoverButton(ilm)} </EuiCallOut> - </Fragment> + </> ) : null} - {ilm.step_info && ilm.step_info.message && !ilm.step_info.stack_trace ? ( - <Fragment> + {ilm.step_info && ilm.step_info!.message && !ilm.step_info!.stack_trace ? ( + <> <EuiSpacer size="s" /> <EuiCallOut color="primary" @@ -243,9 +274,9 @@ export class IndexLifecycleSummary extends Component { /> } > - {ilm.step_info.message} + {ilm.step_info!.message} </EuiCallOut> - </Fragment> + </> ) : null} <EuiSpacer size="m" /> <EuiFlexGroup> @@ -256,7 +287,7 @@ export class IndexLifecycleSummary extends Component { <EuiDescriptionList type="column">{right}</EuiDescriptionList> </EuiFlexItem> </EuiFlexGroup> - </Fragment> + </> ); } } diff --git a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.tsx similarity index 81% rename from x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js rename to x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.tsx index 8d01f4a4c200..bb5642cf3a47 100644 --- a/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.js +++ b/x-pack/plugins/index_lifecycle_management/public/extend_index_management/index.tsx @@ -8,22 +8,27 @@ import React from 'react'; import { get, every, some } from 'lodash'; import { i18n } from '@kbn/i18n'; import { EuiSearchBar } from '@elastic/eui'; +import { ApplicationStart } from 'kibana/public'; + +import { IndexManagementPluginSetup } from '../../../index_management/public'; import { retryLifecycleForIndex } from '../application/services/api'; import { IndexLifecycleSummary } from './components/index_lifecycle_summary'; + import { AddLifecyclePolicyConfirmModal } from './components/add_lifecycle_confirm_modal'; import { RemoveLifecyclePolicyConfirmModal } from './components/remove_lifecycle_confirm_modal'; +import { Index } from '../application/services/policies/types'; const stepPath = 'ilm.step'; -export const retryLifecycleActionExtension = ({ indices }) => { +export const retryLifecycleActionExtension = ({ indices }: { indices: Index[] }) => { const allHaveErrors = every(indices, (index) => { return index.ilm && index.ilm.failed_step; }); if (!allHaveErrors) { return null; } - const indexNames = indices.map(({ name }) => name); + const indexNames = indices.map(({ name }: Index) => name); return { requestMethod: retryLifecycleForIndex, icon: 'play', @@ -35,22 +40,28 @@ export const retryLifecycleActionExtension = ({ indices }) => { 'xpack.indexLifecycleMgmt.retryIndexLifecycleAction.retriedLifecycleMessage', { defaultMessage: 'Called retry lifecycle step for: {indexNames}', - values: { indexNames: indexNames.map((indexName) => `"${indexName}"`).join(', ') }, + values: { indexNames: indexNames.map((indexName: string) => `"${indexName}"`).join(', ') }, } ), }; }; -export const removeLifecyclePolicyActionExtension = ({ indices, reloadIndices }) => { +export const removeLifecyclePolicyActionExtension = ({ + indices, + reloadIndices, +}: { + indices: Index[]; + reloadIndices: () => void; +}) => { const allHaveIlm = every(indices, (index) => { return index.ilm && index.ilm.managed; }); if (!allHaveIlm) { return null; } - const indexNames = indices.map(({ name }) => name); + const indexNames = indices.map(({ name }: Index) => name); return { - renderConfirmModal: (closeModal) => { + renderConfirmModal: (closeModal: () => void) => { return ( <RemoveLifecyclePolicyConfirmModal indexNames={indexNames} @@ -67,7 +78,15 @@ export const removeLifecyclePolicyActionExtension = ({ indices, reloadIndices }) }; }; -export const addLifecyclePolicyActionExtension = ({ indices, reloadIndices, getUrlForApp }) => { +export const addLifecyclePolicyActionExtension = ({ + indices, + reloadIndices, + getUrlForApp, +}: { + indices: Index[]; + reloadIndices: () => void; + getUrlForApp: ApplicationStart['getUrlForApp']; +}) => { if (indices.length !== 1) { return null; } @@ -79,7 +98,7 @@ export const addLifecyclePolicyActionExtension = ({ indices, reloadIndices, getU } const indexName = index.name; return { - renderConfirmModal: (closeModal) => { + renderConfirmModal: (closeModal: () => void) => { return ( <AddLifecyclePolicyConfirmModal indexName={indexName} @@ -97,12 +116,12 @@ export const addLifecyclePolicyActionExtension = ({ indices, reloadIndices, getU }; }; -export const ilmBannerExtension = (indices) => { +export const ilmBannerExtension = (indices: Index[]) => { const { Query } = EuiSearchBar; if (!indices.length) { return null; } - const indicesWithLifecycleErrors = indices.filter((index) => { + const indicesWithLifecycleErrors = indices.filter((index: Index) => { return get(index, stepPath) === 'ERROR'; }); const numIndicesWithLifecycleErrors = indicesWithLifecycleErrors.length; @@ -124,11 +143,14 @@ export const ilmBannerExtension = (indices) => { }; }; -export const ilmSummaryExtension = (index, getUrlForApp) => { +export const ilmSummaryExtension = ( + index: Index, + getUrlForApp: ApplicationStart['getUrlForApp'] +) => { return <IndexLifecycleSummary index={index} getUrlForApp={getUrlForApp} />; }; -export const ilmFilterExtension = (indices) => { +export const ilmFilterExtension = (indices: Index[]) => { const hasIlm = some(indices, (index) => index.ilm && index.ilm.managed); if (!hasIlm) { return []; @@ -200,7 +222,9 @@ export const ilmFilterExtension = (indices) => { } }; -export const addAllExtensions = (extensionsService) => { +export const addAllExtensions = ( + extensionsService: IndexManagementPluginSetup['extensionsService'] +) => { extensionsService.addAction(retryLifecycleActionExtension); extensionsService.addAction(removeLifecyclePolicyActionExtension); extensionsService.addAction(addLifecyclePolicyActionExtension); diff --git a/x-pack/plugins/index_management/common/types/indices.ts b/x-pack/plugins/index_management/common/types/indices.ts index ecf5ba21fe60..354e4fe67cd1 100644 --- a/x-pack/plugins/index_management/common/types/indices.ts +++ b/x-pack/plugins/index_management/common/types/indices.ts @@ -41,3 +41,18 @@ export interface IndexSettings { analysis?: AnalysisModule; [key: string]: any; } + +export interface Index { + health: string; + status: string; + name: string; + uuid: string; + primary: string; + replica: string; + documents: any; + size: any; + isFrozen: boolean; + aliases: string | string[]; + data_stream?: string; + [key: string]: any; +} diff --git a/x-pack/plugins/index_management/public/index.ts b/x-pack/plugins/index_management/public/index.ts index a2e9a41feb16..538dcaf25c47 100644 --- a/x-pack/plugins/index_management/public/index.ts +++ b/x-pack/plugins/index_management/public/index.ts @@ -14,3 +14,5 @@ export const plugin = () => { export { IndexManagementPluginSetup }; export { getIndexListUri } from './application/services/routing'; + +export { Index } from '../common'; diff --git a/x-pack/plugins/index_management/server/index.ts b/x-pack/plugins/index_management/server/index.ts index 4d9409e4a516..bf52d8a09c84 100644 --- a/x-pack/plugins/index_management/server/index.ts +++ b/x-pack/plugins/index_management/server/index.ts @@ -18,5 +18,5 @@ export const config = { /** @public */ export { Dependencies } from './types'; export { IndexManagementPluginSetup } from './plugin'; -export { Index } from './types'; +export { Index } from '../common'; export { IndexManagementConfig } from './config'; diff --git a/x-pack/plugins/index_management/server/lib/fetch_indices.ts b/x-pack/plugins/index_management/server/lib/fetch_indices.ts index ae10629e069e..e9eaec3e2242 100644 --- a/x-pack/plugins/index_management/server/lib/fetch_indices.ts +++ b/x-pack/plugins/index_management/server/lib/fetch_indices.ts @@ -5,7 +5,8 @@ */ import { CatIndicesParams } from 'elasticsearch'; import { IndexDataEnricher } from '../services'; -import { Index, CallAsCurrentUser } from '../types'; +import { CallAsCurrentUser } from '../types'; +import { Index } from '../index'; interface Hit { health: string; @@ -44,7 +45,9 @@ async function fetchIndicesCall( // This call retrieves alias and settings (incl. hidden status) information about indices const indices: GetIndicesResponse = await callAsCurrentUser('transport.request', { method: 'GET', - path: `/${indexNamesString}`, + // transport.request doesn't do any URI encoding, unlike other JS client APIs. This enables + // working with Logstash indices with names like %{[@metadata][beat]}-%{[@metadata][version]}. + path: `/${encodeURIComponent(indexNamesString)}`, query: { expand_wildcards: 'hidden,all', }, diff --git a/x-pack/plugins/index_management/server/services/index_data_enricher.ts b/x-pack/plugins/index_management/server/services/index_data_enricher.ts index 7a62ce9f7a3c..80bdf76820f2 100644 --- a/x-pack/plugins/index_management/server/services/index_data_enricher.ts +++ b/x-pack/plugins/index_management/server/services/index_data_enricher.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Index, CallAsCurrentUser } from '../types'; +import { CallAsCurrentUser } from '../types'; +import { Index } from '../index'; export type Enricher = (indices: Index[], callAsCurrentUser: CallAsCurrentUser) => Promise<Index[]>; diff --git a/x-pack/plugins/index_management/server/types.ts b/x-pack/plugins/index_management/server/types.ts index cd3eb5dfecd4..fce0414dee93 100644 --- a/x-pack/plugins/index_management/server/types.ts +++ b/x-pack/plugins/index_management/server/types.ts @@ -26,19 +26,4 @@ export interface RouteDependencies { }; } -export interface Index { - health: string; - status: string; - name: string; - uuid: string; - primary: string; - replica: string; - documents: any; - size: any; - isFrozen: boolean; - aliases: string | string[]; - data_stream?: string; - [key: string]: any; -} - export type CallAsCurrentUser = LegacyScopedClusterClient['callAsCurrentUser']; 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 5077bccdc1ca..c1d4fc8b8d3c 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 028dd0d3a1a7..740fc8b7bafc 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<LogEntryC const showLoadDataErrorNotification = useCallback( (error: Error) => { - // 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 de72ac5c5a57..b33eaf7e77bc 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_manager/package.json b/x-pack/plugins/ingest_manager/package.json index 8826ed57ab10..871972729b9f 100644 --- a/x-pack/plugins/ingest_manager/package.json +++ b/x-pack/plugins/ingest_manager/package.json @@ -5,6 +5,7 @@ "private": true, "license": "Elastic-License", "dependencies": { - "abort-controller": "^3.0.0" + "abort-controller": "^3.0.0", + "ajv": "^6.12.4" } } diff --git a/x-pack/plugins/ingest_manager/server/errors.test.ts b/x-pack/plugins/ingest_manager/server/errors.test.ts new file mode 100644 index 000000000000..70e3a3b4150a --- /dev/null +++ b/x-pack/plugins/ingest_manager/server/errors.test.ts @@ -0,0 +1,191 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { httpServerMock } from 'src/core/server/mocks'; +import { createAppContextStartContractMock } from './mocks'; + +import { + IngestManagerError, + RegistryError, + PackageNotFoundError, + defaultIngestErrorHandler, +} from './errors'; +import { appContextService } from './services'; + +describe('defaultIngestErrorHandler', () => { + let mockContract: ReturnType<typeof createAppContextStartContractMock>; + beforeEach(async () => { + // prevents `Logger not set.` and other appContext errors + mockContract = createAppContextStartContractMock(); + appContextService.start(mockContract); + }); + + afterEach(async () => { + jest.clearAllMocks(); + appContextService.stop(); + }); + + describe('IngestManagerError', () => { + it('502: RegistryError', async () => { + const error = new RegistryError('xyz'); + const response = httpServerMock.createResponseFactory(); + + await defaultIngestErrorHandler({ error, response }); + + // response + expect(response.ok).toHaveBeenCalledTimes(0); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 502, + body: { message: error.message }, + }); + + // logging + expect(mockContract.logger?.error).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.error).toHaveBeenCalledWith(error.message); + }); + + it('404: PackageNotFoundError', async () => { + const error = new PackageNotFoundError('123'); + const response = httpServerMock.createResponseFactory(); + + await defaultIngestErrorHandler({ error, response }); + + // response + expect(response.ok).toHaveBeenCalledTimes(0); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 404, + body: { message: error.message }, + }); + + // logging + expect(mockContract.logger?.error).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.error).toHaveBeenCalledWith(error.message); + }); + + it('400: IngestManagerError', async () => { + const error = new IngestManagerError('123'); + const response = httpServerMock.createResponseFactory(); + + await defaultIngestErrorHandler({ error, response }); + + // response + expect(response.ok).toHaveBeenCalledTimes(0); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 400, + body: { message: error.message }, + }); + + // logging + expect(mockContract.logger?.error).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.error).toHaveBeenCalledWith(error.message); + }); + }); + + describe('Boom', () => { + it('500: constructor - one arg', async () => { + const error = new Boom('bam'); + const response = httpServerMock.createResponseFactory(); + + await defaultIngestErrorHandler({ error, response }); + + // response + expect(response.ok).toHaveBeenCalledTimes(0); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 500, + body: { message: 'An internal server error occurred' }, + }); + + // logging + expect(mockContract.logger?.error).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.error).toHaveBeenCalledWith('An internal server error occurred'); + }); + + it('custom: constructor - 2 args', async () => { + const error = new Boom('Problem doing something', { + statusCode: 456, + }); + const response = httpServerMock.createResponseFactory(); + + await defaultIngestErrorHandler({ error, response }); + + // response + expect(response.ok).toHaveBeenCalledTimes(0); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 456, + body: { message: error.message }, + }); + + // logging + expect(mockContract.logger?.error).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.error).toHaveBeenCalledWith('Problem doing something'); + }); + + it('400: Boom.badRequest', async () => { + const error = Boom.badRequest('nope'); + const response = httpServerMock.createResponseFactory(); + + await defaultIngestErrorHandler({ error, response }); + + // response + expect(response.ok).toHaveBeenCalledTimes(0); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 400, + body: { message: error.message }, + }); + + // logging + expect(mockContract.logger?.error).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.error).toHaveBeenCalledWith('nope'); + }); + + it('404: Boom.notFound', async () => { + const error = Boom.notFound('sorry'); + const response = httpServerMock.createResponseFactory(); + + await defaultIngestErrorHandler({ error, response }); + + // response + expect(response.ok).toHaveBeenCalledTimes(0); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 404, + body: { message: error.message }, + }); + + // logging + expect(mockContract.logger?.error).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.error).toHaveBeenCalledWith('sorry'); + }); + }); + + describe('all other errors', () => { + it('500', async () => { + const error = new Error('something'); + const response = httpServerMock.createResponseFactory(); + + await defaultIngestErrorHandler({ error, response }); + + // response + expect(response.ok).toHaveBeenCalledTimes(0); + expect(response.customError).toHaveBeenCalledTimes(1); + expect(response.customError).toHaveBeenCalledWith({ + statusCode: 500, + body: { message: error.message }, + }); + + // logging + expect(mockContract.logger?.error).toHaveBeenCalledTimes(1); + expect(mockContract.logger?.error).toHaveBeenCalledWith(error); + }); + }); +}); diff --git a/x-pack/plugins/ingest_manager/server/errors.ts b/x-pack/plugins/ingest_manager/server/errors.ts index e6ef4a51284b..9829a4de23d7 100644 --- a/x-pack/plugins/ingest_manager/server/errors.ts +++ b/x-pack/plugins/ingest_manager/server/errors.ts @@ -5,6 +5,26 @@ */ /* eslint-disable max-classes-per-file */ +import Boom, { isBoom } from 'boom'; +import { + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, +} from 'src/core/server'; +import { appContextService } from './services'; + +type IngestErrorHandler = ( + params: IngestErrorHandlerParams +) => IKibanaResponse | Promise<IKibanaResponse>; + +interface IngestErrorHandlerParams { + error: IngestManagerError | Boom | Error; + response: KibanaResponseFactory; + request?: KibanaRequest; + context?: RequestHandlerContext; +} + export class IngestManagerError extends Error { constructor(message?: string) { super(message); @@ -12,7 +32,7 @@ export class IngestManagerError extends Error { } } -export const getHTTPResponseCode = (error: IngestManagerError): number => { +const getHTTPResponseCode = (error: IngestManagerError): number => { if (error instanceof RegistryError) { return 502; // Bad Gateway } @@ -23,6 +43,40 @@ export const getHTTPResponseCode = (error: IngestManagerError): number => { return 400; // Bad Request }; +export const defaultIngestErrorHandler: IngestErrorHandler = async ({ + error, + response, +}: IngestErrorHandlerParams): Promise<IKibanaResponse> => { + const logger = appContextService.getLogger(); + + // our "expected" errors + if (error instanceof IngestManagerError) { + // only log the message + logger.error(error.message); + return response.customError({ + statusCode: getHTTPResponseCode(error), + body: { message: error.message }, + }); + } + + // handle any older Boom-based errors or the few places our app uses them + if (isBoom(error)) { + // only log the message + logger.error(error.output.payload.message); + return response.customError({ + statusCode: error.output.statusCode, + body: { message: error.output.payload.message }, + }); + } + + // not sure what type of error this is. log as much as possible + logger.error(error); + return response.customError({ + statusCode: 500, + body: { message: error.message }, + }); +}; + export class RegistryError extends IngestManagerError {} export class RegistryConnectionError extends RegistryError {} export class RegistryResponseError extends RegistryError {} diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts index 564f5d03e945..b0439b30e897 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/acks_handlers.ts @@ -7,19 +7,13 @@ // handlers that handle events from agents in response to actions received import { RequestHandler } from 'kibana/server'; -import { TypeOf } from '@kbn/config-schema'; -import { PostAgentAcksRequestSchema } from '../../types/rest_spec'; import { AcksService } from '../../services/agents'; import { AgentEvent } from '../../../common/types/models'; -import { PostAgentAcksResponse } from '../../../common/types/rest_spec'; +import { PostAgentAcksRequest, PostAgentAcksResponse } from '../../../common/types/rest_spec'; export const postAgentAcksHandlerBuilder = function ( ackService: AcksService -): RequestHandler< - TypeOf<typeof PostAgentAcksRequestSchema.params>, - undefined, - TypeOf<typeof PostAgentAcksRequestSchema.body> -> { +): RequestHandler<PostAgentAcksRequest['params'], undefined, PostAgentAcksRequest['body']> { return async (context, request, response) => { try { const soClient = ackService.getSavedObjectsClientContract(request); diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts index 2bce8daa6637..605e4db230ce 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/handlers.ts @@ -15,6 +15,7 @@ import { PostAgentEnrollResponse, GetAgentStatusResponse, PutAgentReassignResponse, + PostAgentEnrollRequest, } from '../../../common/types'; import { GetAgentsRequestSchema, @@ -22,8 +23,7 @@ import { UpdateAgentRequestSchema, DeleteAgentRequestSchema, GetOneAgentEventsRequestSchema, - PostAgentCheckinRequestSchema, - PostAgentEnrollRequestSchema, + PostAgentCheckinRequest, GetAgentStatusRequestSchema, PutAgentReassignRequestSchema, } from '../../types'; @@ -159,9 +159,9 @@ export const updateAgentHandler: RequestHandler< }; export const postAgentCheckinHandler: RequestHandler< - TypeOf<typeof PostAgentCheckinRequestSchema.params>, + PostAgentCheckinRequest['params'], undefined, - TypeOf<typeof PostAgentCheckinRequestSchema.body> + PostAgentCheckinRequest['body'] > = async (context, request, response) => { try { const soClient = appContextService.getInternalUserSOClient(request); @@ -218,7 +218,7 @@ export const postAgentCheckinHandler: RequestHandler< export const postAgentEnrollHandler: RequestHandler< undefined, undefined, - TypeOf<typeof PostAgentEnrollRequestSchema.body> + PostAgentEnrollRequest['body'] > = async (context, request, response) => { try { const soClient = appContextService.getInternalUserSOClient(request); diff --git a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts index a84b0f8d0a35..a2e5c742ad6b 100644 --- a/x-pack/plugins/ingest_manager/server/routes/agent/index.ts +++ b/x-pack/plugins/ingest_manager/server/routes/agent/index.ts @@ -9,7 +9,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { IRouter } from 'src/core/server'; +import { IRouter, RouteValidationResultFactory } from 'src/core/server'; +import Ajv from 'ajv'; import { PLUGIN_ID, AGENT_API_ROUTES, LIMITED_CONCURRENCY_ROUTE_TAG } from '../../constants'; import { GetAgentsRequestSchema, @@ -17,13 +18,15 @@ import { GetOneAgentEventsRequestSchema, UpdateAgentRequestSchema, DeleteAgentRequestSchema, - PostAgentCheckinRequestSchema, - PostAgentEnrollRequestSchema, - PostAgentAcksRequestSchema, + PostAgentCheckinRequestBodyJSONSchema, + PostAgentCheckinRequestParamsJSONSchema, + PostAgentAcksRequestParamsJSONSchema, + PostAgentAcksRequestBodyJSONSchema, PostAgentUnenrollRequestSchema, GetAgentStatusRequestSchema, PostNewAgentActionRequestSchema, PutAgentReassignRequestSchema, + PostAgentEnrollRequestBodyJSONSchema, } from '../../types'; import { getAgentsHandler, @@ -43,6 +46,29 @@ import { appContextService } from '../../services'; import { postAgentsUnenrollHandler } from './unenroll_handler'; import { IngestManagerConfigType } from '../..'; +const ajv = new Ajv({ + coerceTypes: true, + useDefaults: true, + removeAdditional: true, + allErrors: false, + nullable: true, +}); + +function schemaErrorsText(errors: Ajv.ErrorObject[], dataVar: any) { + return errors.map((e) => `${dataVar + (e.dataPath || '')} ${e.message}`).join(', '); +} + +function makeValidator(jsonSchema: any) { + const validator = ajv.compile(jsonSchema); + return function validateWithAJV(data: any, r: RouteValidationResultFactory) { + if (validator(data)) { + return r.ok(data); + } + + return r.badRequest(schemaErrorsText(validator.errors || [], data)); + }; +} + export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) => { // Get one router.get( @@ -86,7 +112,10 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) router.post( { path: AGENT_API_ROUTES.CHECKIN_PATTERN, - validate: PostAgentCheckinRequestSchema, + validate: { + params: makeValidator(PostAgentCheckinRequestParamsJSONSchema), + body: makeValidator(PostAgentCheckinRequestBodyJSONSchema), + }, options: { tags: [], ...(pollingRequestTimeout @@ -105,7 +134,9 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) router.post( { path: AGENT_API_ROUTES.ENROLL_PATTERN, - validate: PostAgentEnrollRequestSchema, + validate: { + body: makeValidator(PostAgentEnrollRequestBodyJSONSchema), + }, options: { tags: [LIMITED_CONCURRENCY_ROUTE_TAG] }, }, postAgentEnrollHandler @@ -115,7 +146,10 @@ export const registerRoutes = (router: IRouter, config: IngestManagerConfigType) router.post( { path: AGENT_API_ROUTES.ACKS_PATTERN, - validate: PostAgentAcksRequestSchema, + validate: { + params: makeValidator(PostAgentAcksRequestParamsJSONSchema), + body: makeValidator(PostAgentAcksRequestBodyJSONSchema), + }, options: { tags: [LIMITED_CONCURRENCY_ROUTE_TAG] }, }, postAgentAcksHandlerBuilder({ diff --git a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts index 6400d6e215f9..6d7252ffec41 100644 --- a/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/epm/handlers.ts @@ -32,7 +32,7 @@ import { getLimitedPackages, getInstallationObject, } from '../../services/epm/packages'; -import { IngestManagerError, getHTTPResponseCode } from '../../errors'; +import { IngestManagerError, defaultIngestErrorHandler } from '../../errors'; import { splitPkgKey } from '../../services/epm/registry'; export const getCategoriesHandler: RequestHandler< @@ -45,11 +45,8 @@ export const getCategoriesHandler: RequestHandler< response: res, }; return response.ok({ body }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -69,11 +66,8 @@ export const getListHandler: RequestHandler< return response.ok({ body, }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -87,11 +81,8 @@ export const getLimitedListHandler: RequestHandler = async (context, request, re return response.ok({ body, }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -112,11 +103,8 @@ export const getFileHandler: RequestHandler<TypeOf<typeof GetFileRequestSchema.p customResponseObj.headers = { 'Content-Type': contentType }; } return response.custom(customResponseObj); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -135,11 +123,8 @@ export const getInfoHandler: RequestHandler<TypeOf<typeof GetInfoRequestSchema.p response: res, }; return response.ok({ body }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -165,14 +150,12 @@ export const installPackageHandler: RequestHandler< }; return response.ok({ body }); } catch (e) { + // could have also done `return defaultIngestErrorHandler({ error: e, response })` at each of the returns, + // but doing it this way will log the outer/install errors before any inner/rollback errors + const defaultResult = await defaultIngestErrorHandler({ error: e, response }); if (e instanceof IngestManagerError) { - logger.error(e); - return response.customError({ - statusCode: getHTTPResponseCode(e), - body: { message: e.message }, - }); + return defaultResult; } - // if there is an unknown server error, uninstall any package assets try { const installedPkg = await getInstallationObject({ savedObjectsClient, pkgName }); @@ -183,11 +166,7 @@ export const installPackageHandler: RequestHandler< } catch (error) { logger.error(`could not remove failed installation ${error}`); } - logger.error(e); - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + return defaultResult; } }; @@ -203,16 +182,7 @@ export const deletePackageHandler: RequestHandler<TypeOf< response: res, }; return response.ok({ body }); - } catch (e) { - if (e.isBoom) { - return response.customError({ - statusCode: e.output.statusCode, - body: { message: e.output.payload.message }, - }); - } - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts index 1daa63800f4e..ee7dab6ef1a8 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts @@ -9,7 +9,7 @@ import { outputService, appContextService } from '../../services'; import { GetFleetStatusResponse, PostIngestSetupResponse } from '../../../common'; import { setupIngestManager, setupFleet } from '../../services/setup'; import { PostFleetSetupRequestSchema } from '../../types'; -import { IngestManagerError, getHTTPResponseCode } from '../../errors'; +import { defaultIngestErrorHandler } from '../../errors'; export const getFleetStatusHandler: RequestHandler = async (context, request, response) => { const soClient = context.core.savedObjects.client; @@ -46,11 +46,8 @@ export const getFleetStatusHandler: RequestHandler = async (context, request, re return response.ok({ body, }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; @@ -70,44 +67,22 @@ export const createFleetSetupHandler: RequestHandler< return response.ok({ body: { isInitialized: true }, }); - } catch (e) { - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; export const ingestManagerSetupHandler: RequestHandler = async (context, request, response) => { const soClient = context.core.savedObjects.client; const callCluster = context.core.elasticsearch.legacy.client.callAsCurrentUser; - const logger = appContextService.getLogger(); + try { const body: PostIngestSetupResponse = { isInitialized: true }; await setupIngestManager(soClient, callCluster); return response.ok({ body, }); - } catch (e) { - if (e instanceof IngestManagerError) { - logger.error(e.message); - return response.customError({ - statusCode: getHTTPResponseCode(e), - body: { message: e.message }, - }); - } - if (e.isBoom) { - logger.error(e.output.payload.message); - return response.customError({ - statusCode: e.output.statusCode, - body: { message: e.output.payload.message }, - }); - } - logger.error(e.message); - logger.error(e.stack); - return response.customError({ - statusCode: 500, - body: { message: e.message }, - }); + } catch (error) { + return defaultIngestErrorHandler({ error, response }); } }; diff --git a/x-pack/plugins/ingest_manager/server/types/index.tsx b/x-pack/plugins/ingest_manager/server/types/index.tsx index aabe4bd3e359..e01568cfbb3c 100644 --- a/x-pack/plugins/ingest_manager/server/types/index.tsx +++ b/x-pack/plugins/ingest_manager/server/types/index.tsx @@ -63,6 +63,9 @@ export { IndexTemplateMappings, Settings, SettingsSOAttributes, + // Agent Request types + PostAgentEnrollRequest, + PostAgentCheckinRequest, } from '../../common'; export type CallESAsCurrentUser = LegacyScopedClusterClient['callAsCurrentUser']; diff --git a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts index 3302b0ab84ba..43ee0c89126e 100644 --- a/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts +++ b/x-pack/plugins/ingest_manager/server/types/rest_spec/agent.ts @@ -5,12 +5,7 @@ */ import { schema } from '@kbn/config-schema'; -import { - AckEventSchema, - NewAgentEventSchema, - AgentTypeSchema, - NewAgentActionSchema, -} from '../models'; +import { NewAgentActionSchema } from '../models'; export const GetAgentsRequestSchema = { query: schema.object({ @@ -27,37 +22,134 @@ export const GetOneAgentRequestSchema = { }), }; -export const PostAgentCheckinRequestSchema = { - params: schema.object({ - agentId: schema.string(), - }), - body: schema.object({ - status: schema.maybe( - schema.oneOf([schema.literal('online'), schema.literal('error'), schema.literal('degraded')]) - ), - local_metadata: schema.maybe(schema.recordOf(schema.string(), schema.any())), - events: schema.maybe(schema.arrayOf(NewAgentEventSchema)), - }), +export const PostAgentCheckinRequestParamsJSONSchema = { + type: 'object', + properties: { + agentId: { type: 'string' }, + }, + required: ['agentId'], }; -export const PostAgentEnrollRequestSchema = { - body: schema.object({ - type: AgentTypeSchema, - shared_id: schema.maybe(schema.string()), - metadata: schema.object({ - local: schema.recordOf(schema.string(), schema.any()), - user_provided: schema.recordOf(schema.string(), schema.any()), - }), - }), +export const PostAgentCheckinRequestBodyJSONSchema = { + type: 'object', + properties: { + status: { type: 'string', enum: ['online', 'error', 'degraded'] }, + local_metadata: { + additionalProperties: { + anyOf: [{ type: 'string' }, { type: 'number' }, { type: 'object' }], + }, + }, + events: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string', enum: ['STATE', 'ERROR', 'ACTION_RESULT', 'ACTION'] }, + subtype: { + type: 'string', + enum: [ + 'RUNNING', + 'STARTING', + 'IN_PROGRESS', + 'CONFIG', + 'FAILED', + 'STOPPING', + 'STOPPED', + 'DEGRADED', + 'DATA_DUMP', + 'ACKNOWLEDGED', + 'UNKNOWN', + ], + }, + timestamp: { type: 'string' }, + message: { type: 'string' }, + payload: { type: 'object', additionalProperties: true }, + agent_id: { type: 'string' }, + action_id: { type: 'string' }, + policy_id: { type: 'string' }, + stream_id: { type: 'string' }, + }, + required: ['type', 'subtype', 'timestamp', 'message', 'agent_id'], + additionalProperties: false, + }, + }, + }, + additionalProperties: false, }; -export const PostAgentAcksRequestSchema = { - body: schema.object({ - events: schema.arrayOf(AckEventSchema), - }), - params: schema.object({ - agentId: schema.string(), - }), +export const PostAgentEnrollRequestBodyJSONSchema = { + type: 'object', + properties: { + type: { type: 'string', enum: ['EPHEMERAL', 'PERMANENT', 'TEMPORARY'] }, + shared_id: { type: 'string' }, + metadata: { + type: 'object', + properties: { + local: { + type: 'object', + additionalProperties: true, + }, + user_provided: { + type: 'object', + additionalProperties: true, + }, + }, + additionalProperties: false, + required: ['local', 'user_provided'], + }, + }, + additionalProperties: false, + required: ['type', 'metadata'], +}; + +export const PostAgentAcksRequestParamsJSONSchema = { + type: 'object', + properties: { + agentId: { type: 'string' }, + }, + required: ['agentId'], +}; + +export const PostAgentAcksRequestBodyJSONSchema = { + type: 'object', + properties: { + events: { + type: 'array', + item: { + type: 'object', + properties: { + type: { type: 'string', enum: ['STATE', 'ERROR', 'ACTION_RESULT', 'ACTION'] }, + subtype: { + type: 'string', + enum: [ + 'RUNNING', + 'STARTING', + 'IN_PROGRESS', + 'CONFIG', + 'FAILED', + 'STOPPING', + 'STOPPED', + 'DEGRADED', + 'DATA_DUMP', + 'ACKNOWLEDGED', + 'UNKNOWN', + ], + }, + timestamp: { type: 'string' }, + message: { type: 'string' }, + payload: { type: 'object', additionalProperties: true }, + agent_id: { type: 'string' }, + action_id: { type: 'string' }, + policy_id: { type: 'string' }, + stream_id: { type: 'string' }, + }, + required: ['type', 'subtype', 'timestamp', 'message', 'agent_id', 'action_id'], + additionalProperties: false, + }, + }, + }, + additionalProperties: false, + required: ['events'], }; export const PostNewAgentActionRequestSchema = { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/field_components/xjson_editor.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/field_components/xjson_editor.tsx index 7fb92e89c9f6..e00f9c002e5b 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/field_components/xjson_editor.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/field_components/xjson_editor.tsx @@ -14,6 +14,11 @@ interface Props { editorProps: { [key: string]: any }; } +const defaultEditorOptions = { + minimap: { enabled: false }, + lineNumbers: 'off', +}; + export const XJsonEditor: FunctionComponent<Props> = ({ field, editorProps }) => { const { value, setValue } = field; const { xJson, setXJson, convertToJson } = Monaco.useXJsonMode(value); @@ -31,7 +36,7 @@ export const XJsonEditor: FunctionComponent<Props> = ({ field, editorProps }) => editorProps={{ value: xJson, languageId: XJsonLang.ID, - options: { minimap: { enabled: false } }, + options: defaultEditorOptions, onChange, ...editorProps, }} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processor_settings_fields.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processor_settings_fields.tsx index 9adb3957ea9f..bda64c0a7561 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processor_settings_fields.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processor_settings_fields.tsx @@ -33,10 +33,22 @@ export const ProcessorSettingsFields: FunctionComponent<Props> = ({ processor }) const formDescriptor = getProcessorDescriptor(type as any); if (formDescriptor?.FieldsComponent) { + const renderedFields = ( + <formDescriptor.FieldsComponent + key={type} + initialFieldValues={processor?.options} + /> + ); return ( <> - <formDescriptor.FieldsComponent key={type} /> - <EuiHorizontalRule /> + {renderedFields ? ( + <> + {renderedFields} + <EuiHorizontalRule /> + </> + ) : ( + renderedFields + )} <CommonProcessorFields /> </> ); 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 23425297f342..52750529684b 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 = () => { <> <FieldNameField helpText={i18n.translate('xpack.ingestPipelines.pipelineEditor.appendForm.fieldHelpText', { - defaultMessage: 'The field to be appended to.', + defaultMessage: 'Field to append values to.', })} /> 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 a76e1a6f3ce9..6633f9e5de94 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 = () => { <FieldNameField helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.bytesForm.fieldNameHelpText', - { defaultMessage: 'The field to convert.' } + { + defaultMessage: + 'Field to convert. If the field contains an array, each array value is converted.', + } )} /> 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 599d2fdbfd41..70df18acfd0a 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: () => ( + <FormattedMessage + id="xpack.ingestPipelines.pipelineEditor.circleForm.errorDistanceHelpText" + defaultMessage="Difference between the side of the inscribed shape to the encompassing circle. Determines the accuracy of the output polygon. Measured in meters for {geo_shape}, but uses no units for {shape}." + values={{ + geo_shape: <EuiCode>{'geo_shape'}</EuiCode>, + shape: <EuiCode>{'shape'}</EuiCode>, + }} + /> ), 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 = () => { <FieldNameField helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.circleForm.fieldNameHelpText', - { defaultMessage: 'The string-valued field to trim whitespace from.' } + { defaultMessage: 'Field to convert.' } )} /> 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 8089b8e7dfad..1777cac2a561 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 @@ -16,12 +16,12 @@ import { } from '../../../../../../../shared_imports'; import { TextEditor } from '../../field_components'; -import { to, from } from '../shared'; +import { to, from, EDITOR_PX_HEIGHT } from '../shared'; const ignoreFailureConfig: FieldConfig = { defaultValue: false, deserializer: to.booleanOrUndef, - serializer: from.defaultBoolToUndef(false), + serializer: from.undefinedIfValue(false), label: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.commonFields.ignoreFailureFieldLabel', { @@ -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, }; @@ -64,8 +64,11 @@ export const CommonProcessorFields: FunctionComponent = () => { componentProps={{ editorProps: { languageId: 'painless', - height: 75, - options: { minimap: { enabled: false } }, + height: EDITOR_PX_HEIGHT.extraSmall, + options: { + lineNumbers: 'off', + minimap: { enabled: false }, + }, }, }} path="fields.if" diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/ignore_missing_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/ignore_missing_field.tsx index 63ebb47dfc57..3d38f9238cdd 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/ignore_missing_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/ignore_missing_field.tsx @@ -22,7 +22,7 @@ export const fieldsConfig: FieldsConfig = { type: FIELD_TYPES.TOGGLE, defaultValue: false, deserializer: to.booleanOrUndef, - serializer: from.defaultBoolToUndef(false), + serializer: from.undefinedIfValue(false), label: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.commonFields.ignoreMissingFieldLabel', { 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 e4ad90f61af0..326492344288 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<Props> = ({ initialType }) => <UseField<string> 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<Props> = ({ initialType }) => <EuiFormRow label={typeField.label} labelAppend={typeField.labelAppend} - helpText={ - typeof typeField.helpText === 'function' ? typeField.helpText() : typeField.helpText - } + helpText={typeof description === 'function' ? description() : description} error={error} isInvalid={isInvalid} fullWidth diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/target_field.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/target_field.tsx index 9bf44425a7c5..69ce01777b61 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/target_field.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/common_fields/target_field.tsx @@ -21,8 +21,7 @@ const fieldsConfig: FieldsConfig = { helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.commonFields.targetFieldHelpText', { - defaultMessage: - 'The field to assign the joined value to. If empty, the field is updated in-place.', + defaultMessage: 'Output field. If empty, the input field is updated in place.', } ), }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/convert.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/convert.tsx index 2bf642dd9b51..7284bd6c6275 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/convert.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/convert.tsx @@ -30,7 +30,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Type', }), helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.convertForm.typeFieldHelpText', { - defaultMessage: 'The type to convert the existing value to.', + defaultMessage: 'Field data type for the output.', }), validations: [ { @@ -50,7 +50,7 @@ export const Convert: FunctionComponent = () => { <FieldNameField helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.convertForm.fieldNameHelpText', - { defaultMessage: 'The field whose value is to be converted.' } + { defaultMessage: 'Field to convert.' } )} /> @@ -115,14 +115,7 @@ export const Convert: FunctionComponent = () => { path="fields.type" /> - <TargetField - helpText={i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.convertForm.targetFieldHelpText', - { - defaultMessage: 'The field to assign the converted value to.', - } - )} - /> + <TargetField /> <IgnoreMissingField /> </> 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 835177dd861d..471efaa56dea 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: ( <FormattedMessage id="xpack.ingestPipelines.pipelineEditor.convertForm.separatorHelpText" - defaultMessage="Separator used in CSV, has to be single character string. Default value is {value}." + defaultMessage="Delimiter used in the CSV data. Defaults to {value}." values={{ value: <EuiCode inline>{','}</EuiCode> }} /> ), @@ -102,7 +102,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( <FormattedMessage id="xpack.ingestPipelines.pipelineEditor.convertForm.quoteHelpText" - defaultMessage="Quote used in CSV, has to be single character string. Default value is {value}." + defaultMessage="Escape character used in the CSV data. Defaults to {value}." values={{ value: <EuiCode inline>{'"'}</EuiCode> }} /> ), @@ -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 = () => { <> <FieldNameField helpText={i18n.translate('xpack.ingestPipelines.pipelineEditor.csvForm.fieldNameHelpText', { - defaultMessage: 'The field to extract data from.', + defaultMessage: 'Field containing CSV data.', })} /> diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/custom.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/custom.tsx index 82fdc81e0a84..c2aab62cf893 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/custom.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/custom.tsx @@ -17,6 +17,7 @@ import { const { emptyField, isJsonField } = fieldValidators; import { XJsonEditor } from '../field_components'; +import { EDITOR_PX_HEIGHT } from './shared'; const customConfig: FieldConfig = { type: FIELD_TYPES.TEXT, @@ -78,7 +79,7 @@ export const Custom: FunctionComponent<Props> = ({ defaultOptions }) => { componentProps={{ editorProps: { 'data-test-subj': 'processorOptionsEditor', - height: 300, + height: EDITOR_PX_HEIGHT.large, 'aria-label': i18n.translate( 'xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldAriaLabel', { 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 7e3f8e0d7cd7..8d6d88d2b066 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: ( <FormattedMessage id="xpack.ingestPipelines.pipelineEditor.dateForm.timezoneHelpText" - defaultMessage="The timezone to use when parsing the date. Default value is {timezone}." + defaultMessage="Timezone for the date. Defaults to {timezone}." values={{ timezone: <EuiCode inline>{'UTC'}</EuiCode> }} /> ), @@ -73,7 +73,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( <FormattedMessage id="xpack.ingestPipelines.pipelineEditor.dateForm.localeHelpText" - defaultMessage="The locale to use when parsing the date, relevant when parsing month names or week days. Default value is {timezone}." + defaultMessage="Locale for the date. Useful when parsing month or day names. Defaults to {timezone}." values={{ timezone: <EuiCode inline>{'ENGLISH'}</EuiCode> }} /> ), @@ -89,7 +89,7 @@ export const DateProcessor: FunctionComponent = () => { <FieldNameField helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateForm.fieldNameHelpText', - { defaultMessage: 'The field to get the date from.' } + { defaultMessage: 'Field to convert.' } )} /> @@ -99,7 +99,7 @@ export const DateProcessor: FunctionComponent = () => { helpText={ <FormattedMessage id="xpack.ingestPipelines.pipelineEditor.dateForm.targetFieldHelpText" - defaultMessage="The field that will hold the parsed date. Default field is {defaultField}." + defaultMessage="Output field. If empty, the input field is updated in place. Defaults to {defaultField}." values={{ defaultField: <EuiCode inline>{'@timestamp'}</EuiCode>, }} 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 8cbc064c1c90..73fa54429734 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: ( <FormattedMessage id="xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.indexNameFormatFieldHelpText" - defaultMessage="The format to be used when printing the parsed date into the index name. Default value is {value}." + defaultMessage="Date format used to print the parsed date into the index name. Defaults to {value}." values={{ value: <EuiCode inline>{'yyyy-MM-dd'}</EuiCode> }} /> ), @@ -99,7 +100,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( <FormattedMessage id="xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.dateFormatsHelpText" - defaultMessage="An array of the expected date formats for parsing dates / timestamps in the document being preprocessed. Can be a java time pattern or one of the following formats: ISO8601, UNIX, UNIX_MS, or TAI64N. Default value is {value}." + defaultMessage="Expected date formats. Provided formats are applied sequentially. Accepts a Java time pattern, ISO8601, UNIX, UNIX_MS, or TAI64N formats. Defaults to {value}." values={{ value: <EuiCode inline>{"yyyy-MM-dd'T'HH:mm:ss.SSSXX"}</EuiCode> }} /> ), @@ -116,7 +117,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( <FormattedMessage id="xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.timezoneHelpText" - defaultMessage="The timezone to use when parsing the date and when date math index supports resolves expressions into concrete index names. Default value is {timezone}." + defaultMessage="Timezone used when parsing the date and constructing the index name expression. Defaults to {timezone}." values={{ timezone: <EuiCode inline>{'UTC'}</EuiCode> }} /> ), @@ -133,7 +134,7 @@ const fieldsConfig: FieldsConfig = { helpText: ( <FormattedMessage id="xpack.ingestPipelines.pipelineEditor.dateIndexForm.localeHelpText" - defaultMessage="The locale to use when parsing the date from the document being preprocessed, relevant when parsing month names or week days. Default value is {locale}." + defaultMessage="Locale to use when parsing the date. Useful when parsing month or day names. Defaults to {locale}." values={{ locale: <EuiCode inline>{'ENGLISH'}</EuiCode> }} /> ), @@ -149,7 +150,7 @@ export const DateIndexName: FunctionComponent = () => { <FieldNameField helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dateIndexNameForm.fieldNameHelpText', - { defaultMessage: 'The field to get the date or timestamp from.' } + { defaultMessage: 'Field containing the date or timestamp.' } )} /> 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 5f9f55ced1a2..51bc54c5b372 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,62 +16,88 @@ import { fieldValidators, UseField, Field, + useKibana, } from '../../../../../../shared_imports'; import { FieldNameField } from './common_fields/field_name_field'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; +import { EDITOR_PX_HEIGHT } from './shared'; const { emptyField } = fieldValidators; -const fieldsConfig: Record<string, FieldConfig> = { - /* 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: ( - <FormattedMessage - id="xpack.ingestPipelines.pipelineEditor.dissectForm.appendSeparatorHelpText" - defaultMessage="The character(s) that separate the appended fields. Default value is {value} (an empty string)." - values={{ value: <EuiCode inline>{'""'}</EuiCode> }} - /> - ), - }, +const getFieldsConfig = (esDocUrl: string): Record<string, FieldConfig> => { + return { + /* Required field config */ + pattern: { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldLabel', { + defaultMessage: 'Pattern', + }), + helpText: ( + <FormattedMessage + id="xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldHelpText" + defaultMessage="Pattern used to dissect the specified field. The pattern is defined by the parts of the string to discard. Use a {keyModifier} to alter the dissection behavior." + values={{ + keyModifier: ( + <EuiLink + target="_blank" + external + href={esDocUrl + '/dissect-processor.html#dissect-key-modifiers'} + > + {i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.dissectForm.patternFieldHelpText.dissectProcessorLink', + { + defaultMessage: 'key modifier', + } + )} + </EuiLink> + ), + }} + /> + ), + 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: ( + <FormattedMessage + id="xpack.ingestPipelines.pipelineEditor.dissectForm.appendSeparatorHelpText" + defaultMessage="If you specify a key modifier, this character separates the fields when appending results. Defaults to {value}." + values={{ value: <EuiCode inline>{'""'}</EuiCode> }} + /> + ), + }, + }; }; export const Dissect: FunctionComponent = () => { + const { services } = useKibana(); + const fieldsConfig = getFieldsConfig(services.documentation.getEsDocsBasePath()); + return ( <> <FieldNameField helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dissectForm.fieldNameHelpText', - { defaultMessage: 'The field to dissect.' } + { defaultMessage: 'Field to dissect.' } )} /> @@ -80,7 +106,7 @@ export const Dissect: FunctionComponent = () => { component={TextEditor} componentProps={{ editorProps: { - height: 75, + height: EDITOR_PX_HEIGHT.extraSmall, options: { minimap: { enabled: false } }, }, }} 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 4e50c61ac930..4f2aa2915fde 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<string, FieldConfig> = { 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 = () => { <FieldNameField helpText={i18n.translate( 'xpack.ingestPipelines.pipelineEditor.dotExpanderForm.fieldNameHelpText', - { defaultMessage: 'The field to expand into an object field.' } + { defaultMessage: 'Field containing dot notation.' } )} additionalValidations={[ { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/enrich.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/enrich.tsx index 5986374b338c..ba1c55b731cc 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/enrich.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/enrich.tsx @@ -81,7 +81,7 @@ const fieldsConfig: FieldsConfig = { type: FIELD_TYPES.TOGGLE, defaultValue: true, deserializer: to.booleanOrUndef, - serializer: from.defaultBoolToUndef, + serializer: from.undefinedIfValue, label: i18n.translate('xpack.ingestPipelines.pipelineEditor.enrichForm.overrideFieldLabel', { defaultMessage: 'Override', }), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/foreach.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/foreach.tsx index ce606af08689..c32a85310d21 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/foreach.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/foreach.tsx @@ -12,7 +12,7 @@ import { FIELD_TYPES, fieldValidators, UseField } from '../../../../../../shared import { XJsonEditor } from '../field_components'; import { FieldNameField } from './common_fields/field_name_field'; -import { FieldsConfig, to } from './shared'; +import { FieldsConfig, to, EDITOR_PX_HEIGHT } from './shared'; const { emptyField, isJsonField } = fieldValidators; @@ -31,15 +31,18 @@ const fieldsConfig: FieldsConfig = { validations: [ { validator: emptyField( - i18n.translate('xpack.ingestPipelines.pipelineEditor.failForm.processorRequiredError', { - defaultMessage: 'A processor is required.', - }) + i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.foreachForm.processorRequiredError', + { + defaultMessage: 'A processor is required.', + } + ) ), }, { validator: isJsonField( i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.failForm.processorInvalidJsonError', + 'xpack.ingestPipelines.pipelineEditor.foreachForm.processorInvalidJsonError', { defaultMessage: 'Invalid JSON', } @@ -64,9 +67,9 @@ export const Foreach: FunctionComponent = () => { component={XJsonEditor} componentProps={{ editorProps: { - height: 200, + height: EDITOR_PX_HEIGHT.medium, 'aria-label': i18n.translate( - 'xpack.ingestPipelines.pipelineEditor.customForm.optionsFieldAriaLabel', + 'xpack.ingestPipelines.pipelineEditor.foreachForm.optionsFieldAriaLabel', { defaultMessage: 'Configuration JSON editor', } diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx index 9bb1d679938e..c0624c988061 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/geoip.tsx @@ -61,7 +61,7 @@ const fieldsConfig: FieldsConfig = { type: FIELD_TYPES.TOGGLE, defaultValue: true, deserializer: to.booleanOrUndef, - serializer: from.defaultBoolToUndef(true), + serializer: from.undefinedIfValue(true), label: i18n.translate('xpack.ingestPipelines.pipelineEditor.geoIPForm.firstOnlyFieldLabel', { defaultMessage: 'First only', }), diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/grok.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/grok.tsx index d021038fda94..c5c6adbe2a7a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/grok.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/grok.tsx @@ -19,7 +19,7 @@ import { XJsonEditor } from '../field_components'; import { FieldNameField } from './common_fields/field_name_field'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; -import { FieldsConfig, to, from } from './shared'; +import { FieldsConfig, to, from, EDITOR_PX_HEIGHT } from './shared'; const { emptyField, isJsonField } = fieldValidators; @@ -80,7 +80,7 @@ const fieldsConfig: FieldsConfig = { type: FIELD_TYPES.TOGGLE, defaultValue: false, deserializer: to.booleanOrUndef, - serializer: from.defaultBoolToUndef(false), + serializer: from.undefinedIfValue(false), label: i18n.translate('xpack.ingestPipelines.pipelineEditor.grokForm.traceMatchFieldLabel', { defaultMessage: 'Trace match', }), @@ -110,7 +110,7 @@ export const Grok: FunctionComponent = () => { config={fieldsConfig.pattern_definitions} componentProps={{ editorProps: { - height: 200, + height: EDITOR_PX_HEIGHT.medium, 'aria-label': i18n.translate( 'xpack.ingestPipelines.pipelineEditor.grokForm.patternDefinitionsAriaLabel', { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/gsub.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/gsub.tsx index a0bda245d667..a42df6873d57 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/gsub.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/gsub.tsx @@ -13,7 +13,7 @@ import { FIELD_TYPES, fieldValidators, UseField, Field } from '../../../../../.. import { TextEditor } from '../field_components'; -import { FieldsConfig } from './shared'; +import { EDITOR_PX_HEIGHT, FieldsConfig } from './shared'; import { FieldNameField } from './common_fields/field_name_field'; import { IgnoreMissingField } from './common_fields/ignore_missing_field'; import { TargetField } from './common_fields/target_field'; @@ -78,7 +78,7 @@ export const Gsub: FunctionComponent = () => { component={TextEditor} componentProps={{ editorProps: { - height: 75, + height: EDITOR_PX_HEIGHT.extraSmall, options: { minimap: { enabled: false } }, }, }} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/index.ts index 4974361bf041..e83560b4a44c 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/index.ts @@ -24,3 +24,15 @@ export { HtmlStrip } from './html_strip'; export { Inference } from './inference'; export { Join } from './join'; export { Json } from './json'; +export { Kv } from './kv'; +export { Lowercase } from './lowercase'; +export { Pipeline } from './pipeline'; +export { Remove } from './remove'; +export { Rename } from './rename'; +export { Script } from './script'; +export { SetProcessor } from './set'; +export { SetSecurityUser } from './set_security_user'; +export { Split } from './split'; +export { Sort } from './sort'; + +export { FormFieldsComponent } from './shared'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/inference.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/inference.tsx index 68281fc11f34..85f995fa77ce 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/inference.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/inference.tsx @@ -21,7 +21,7 @@ import { XJsonEditor } from '../field_components'; import { TargetField } from './common_fields/target_field'; -import { FieldsConfig, to, from } from './shared'; +import { FieldsConfig, to, from, EDITOR_PX_HEIGHT } from './shared'; const { emptyField, isJsonField } = fieldValidators; @@ -177,7 +177,7 @@ export const Inference: FunctionComponent = () => { component={XJsonEditor} componentProps={{ editorProps: { - height: 200, + height: EDITOR_PX_HEIGHT.medium, options: { minimap: { enabled: false } }, }, }} @@ -192,7 +192,7 @@ export const Inference: FunctionComponent = () => { component={XJsonEditor} componentProps={{ editorProps: { - height: 200, + height: EDITOR_PX_HEIGHT.medium, options: { minimap: { enabled: false } }, }, }} diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/join.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/join.tsx index c35a5b463f57..ab077d3337f6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/join.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/join.tsx @@ -35,7 +35,7 @@ const fieldsConfig: FieldsConfig = { { validator: emptyField( i18n.translate('xpack.ingestPipelines.pipelineEditor.joinForm.separatorRequiredError', { - defaultMessage: 'A separator value is required.', + defaultMessage: 'A value is required.', }) ), }, diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/json.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/json.tsx index 5c4c53b65b6d..b68b39832508 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/json.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/json.tsx @@ -29,7 +29,7 @@ const fieldsConfig: FieldsConfig = { defaultMessage: 'Add to root', }), deserializer: to.booleanOrUndef, - serializer: from.defaultBoolToUndef(false), + serializer: from.undefinedIfValue(false), helpText: i18n.translate( 'xpack.ingestPipelines.pipelineEditor.jsonForm.addToRootFieldHelpText', { diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx new file mode 100644 index 000000000000..f51bf19ad180 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/kv.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode } from '@elastic/eui'; + +import { + FIELD_TYPES, + fieldValidators, + UseField, + Field, + ComboBoxField, + ToggleField, +} from '../../../../../../shared_imports'; + +import { FieldsConfig, from, to } from './shared'; +import { FieldNameField } from './common_fields/field_name_field'; +import { TargetField } from './common_fields/target_field'; +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; + +const { emptyField } = fieldValidators; + +const fieldsConfig: FieldsConfig = { + /* Required fields config */ + field_split: { + type: FIELD_TYPES.TEXT, + deserializer: String, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.fieldSplitFieldLabel', { + defaultMessage: 'Field split', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.fieldSplitHelpText', { + defaultMessage: 'Regex pattern for splitting key-value pairs.', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.fieldSplitRequiredError', { + defaultMessage: 'A value is required.', + }) + ), + }, + ], + }, + value_split: { + type: FIELD_TYPES.TEXT, + deserializer: String, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.valueSplitFieldLabel', { + defaultMessage: 'Value split', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.valueSplitHelpText', { + defaultMessage: 'Regex pattern for splitting the key from the value within a key-value pair.', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.valueSplitRequiredError', { + defaultMessage: 'A value is required.', + }) + ), + }, + ], + }, + + /* Optional fields config */ + include_keys: { + type: FIELD_TYPES.COMBO_BOX, + deserializer: to.arrayOfStrings, + serializer: from.optionalArrayOfStrings, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.includeKeysFieldLabel', { + defaultMessage: 'Include keys', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.includeKeysHelpText', { + defaultMessage: + 'List of keys to filter and insert into document. Defaults to including all keys.', + }), + }, + + exclude_keys: { + type: FIELD_TYPES.COMBO_BOX, + deserializer: to.arrayOfStrings, + serializer: from.optionalArrayOfStrings, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.excludeKeysFieldLabel', { + defaultMessage: 'Exclude keys', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.excludeKeysHelpText', { + defaultMessage: 'List of keys to exclude from document.', + }), + }, + + prefix: { + type: FIELD_TYPES.TEXT, + deserializer: String, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.prefixFieldLabel', { + defaultMessage: 'Prefix', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.prefixHelpText', { + defaultMessage: 'Prefix to be added to extracted keys.', + }), + }, + + trim_key: { + type: FIELD_TYPES.TEXT, + deserializer: String, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.trimKeyFieldLabel', { + defaultMessage: 'Trim key', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.trimKeyHelpText', { + defaultMessage: 'Characters to trim from extracted keys.', + }), + }, + + trim_value: { + type: FIELD_TYPES.TEXT, + deserializer: String, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.trimValueFieldLabel', { + defaultMessage: 'Trim value', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.trimValueHelpText', { + defaultMessage: 'Characters to trim from extracted values.', + }), + }, + + strip_brackets: { + type: FIELD_TYPES.TOGGLE, + defaultValue: false, + deserializer: to.booleanOrUndef, + serializer: from.undefinedIfValue(false), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.stripBracketsFieldLabel', { + defaultMessage: 'Strip brackets', + }), + helpText: ( + <FormattedMessage + id="xpack.ingestPipelines.pipelineEditor.kvForm.stripBracketsHelpText" + defaultMessage="If true, strip brackets {paren}, {angle}, {square} as well as quotes {singleQuote} and {doubleQuote} from extracted values." + values={{ + paren: <EuiCode>{'()'}</EuiCode>, + angle: <EuiCode><></EuiCode>, + square: <EuiCode>{'[]'}</EuiCode>, + singleQuote: <EuiCode>{"'"}</EuiCode>, + doubleQuote: <EuiCode>{'"'}</EuiCode>, + }} + /> + ), + }, +}; + +export const Kv: FunctionComponent = () => { + return ( + <> + <FieldNameField + helpText={i18n.translate('xpack.ingestPipelines.pipelineEditor.kvForm.fieldNameHelpText', { + defaultMessage: 'Field to be parsed.', + })} + /> + + <UseField component={Field} config={fieldsConfig.field_split} path="fields.field_split" /> + + <UseField component={Field} config={fieldsConfig.value_split} path="fields.value_split" /> + + <TargetField + helpText={i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.kvForm.targetFieldHelpText', + { + defaultMessage: + 'Field to insert the extracted keys into. Defaults to the root of the document.', + } + )} + /> + + <UseField + component={ComboBoxField} + config={fieldsConfig.include_keys} + path="fields.include_keys" + /> + + <UseField + component={ComboBoxField} + config={fieldsConfig.exclude_keys} + path="fields.exclude_keys" + /> + + <IgnoreMissingField /> + + <UseField component={Field} config={fieldsConfig.prefix} path="fields.prefix" /> + + <UseField component={Field} config={fieldsConfig.trim_key} path="fields.trim_key" /> + + <UseField component={Field} config={fieldsConfig.trim_value} path="fields.trim_value" /> + + <UseField + component={ToggleField} + config={fieldsConfig.strip_brackets} + path="fields.strip_brackets" + /> + </> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx new file mode 100644 index 000000000000..9db313a05007 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/lowercase.tsx @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode } from '@elastic/eui'; + +import { FieldNameField } from './common_fields/field_name_field'; +import { TargetField } from './common_fields/target_field'; +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; + +export const Lowercase: FunctionComponent = () => { + return ( + <> + <FieldNameField + helpText={i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.lowerCaseForm.fieldNameHelpText', + { defaultMessage: 'Field to lowercase.' } + )} + /> + + <TargetField + helpText={ + <FormattedMessage + id="xpack.ingestPipelines.pipelineEditor.lowerCaseForm.targetFieldHelpText" + defaultMessage="Field to assign the converted value to. Defaults to {field}." + values={{ + field: <EuiCode>{'field'}</EuiCode>, + }} + /> + } + /> + + <IgnoreMissingField /> + </> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx new file mode 100644 index 000000000000..c785cf935833 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/pipeline.tsx @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { FIELD_TYPES, fieldValidators, UseField, Field } from '../../../../../../shared_imports'; + +import { FieldsConfig } from './shared'; + +const { emptyField } = fieldValidators; + +const fieldsConfig: FieldsConfig = { + /* Required fields config */ + name: { + type: FIELD_TYPES.TEXT, + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.pipelineForm.pipelineNameFieldLabel', + { + defaultMessage: 'Pipeline name', + } + ), + deserializer: String, + helpText: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.pipelineForm.pipelineNameFieldHelpText', + { + defaultMessage: 'Name of the pipeline to execute.', + } + ), + validations: [ + { + validator: emptyField( + i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.pipelineForm.pipelineNameRequiredError', + { + defaultMessage: 'A value is required.', + } + ) + ), + }, + ], + }, +}; + +export const Pipeline: FunctionComponent = () => { + return <UseField config={fieldsConfig.name} component={Field} path="fields.name" />; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx new file mode 100644 index 000000000000..3e90ce2b76f7 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/remove.tsx @@ -0,0 +1,54 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { + FIELD_TYPES, + UseField, + ComboBoxField, + fieldValidators, +} from '../../../../../../shared_imports'; + +import { FieldsConfig, to } from './shared'; + +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; + +const { emptyField } = fieldValidators; + +const fieldsConfig: FieldsConfig = { + field: { + type: FIELD_TYPES.COMBO_BOX, + deserializer: to.arrayOfStrings, + serializer: (v: string[]) => (v.length === 1 ? v[0] : v), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.removeForm.fieldNameField', { + defaultMessage: 'Fields', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.removeForm.fieldNameHelpText', { + defaultMessage: 'Fields to be removed.', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.removeForm.fieldNameRequiredError', { + defaultMessage: 'A value is required.', + }) + ), + }, + ], + }, +}; + +export const Remove: FunctionComponent = () => { + return ( + <> + <UseField config={fieldsConfig.field} component={ComboBoxField} path="fields.field" /> + + <IgnoreMissingField /> + </> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx new file mode 100644 index 000000000000..8b796d966458 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/rename.tsx @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { fieldValidators } from '../../../../../../shared_imports'; + +import { FieldNameField } from './common_fields/field_name_field'; +import { TargetField } from './common_fields/target_field'; +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; + +const { emptyField } = fieldValidators; + +export const Rename: FunctionComponent = () => { + return ( + <> + <FieldNameField + helpText={i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.renameForm.fieldNameHelpText', + { defaultMessage: 'Field to be renamed.' } + )} + /> + + <TargetField + label={i18n.translate('xpack.ingestPipelines.pipelineEditor.renameForm.targetFieldLabel', { + defaultMessage: 'Target field', + })} + helpText={i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.renameForm.targetFieldHelpText', + { defaultMessage: 'Name of the new field.' } + )} + validations={[ + { + validator: emptyField( + i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.renameForm.targetFieldRequiredError', + { defaultMessage: 'A value is required.' } + ) + ), + }, + ]} + /> + + <IgnoreMissingField /> + </> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx new file mode 100644 index 000000000000..ae0bbbb490ae --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/script.tsx @@ -0,0 +1,186 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode, EuiSwitch, EuiFormRow } from '@elastic/eui'; + +import { FIELD_TYPES, fieldValidators, UseField, Field } from '../../../../../../shared_imports'; + +import { XJsonEditor, TextEditor } from '../field_components'; + +import { FieldsConfig, to, from, FormFieldsComponent, EDITOR_PX_HEIGHT } from './shared'; + +const { isJsonField, emptyField } = fieldValidators; + +const fieldsConfig: FieldsConfig = { + /* Required fields config */ + + id: { + type: FIELD_TYPES.TEXT, + deserializer: String, + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.scriptForm.storedScriptIDFieldLabel', + { + defaultMessage: 'Stored script ID', + } + ), + helpText: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.scriptForm.storedScriptIDFieldHelpText', + { + defaultMessage: 'Stored script reference.', + } + ), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.scriptForm.idRequiredError', { + defaultMessage: 'A value is required.', + }) + ), + }, + ], + }, + + source: { + type: FIELD_TYPES.TEXT, + deserializer: String, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.scriptForm.sourceFieldLabel', { + defaultMessage: 'Source', + }), + helpText: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.scriptForm.sourceFieldHelpText', + { + defaultMessage: 'Script to be executed.', + } + ), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.scriptForm.sourceRequiredError', { + defaultMessage: 'A value is required.', + }) + ), + }, + ], + }, + + /* Optional fields config */ + lang: { + type: FIELD_TYPES.TEXT, + deserializer: String, + serializer: from.undefinedIfValue('painless'), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.scriptForm.langFieldLabel', { + defaultMessage: 'Language (optional)', + }), + helpText: ( + <FormattedMessage + id="xpack.ingestPipelines.pipelineEditor.scriptForm.langFieldHelpText" + defaultMessage="Script language. Defaults to {lang}." + values={{ + lang: <EuiCode>{'painless'}</EuiCode>, + }} + /> + ), + }, + + params: { + type: FIELD_TYPES.TEXT, + deserializer: to.jsonString, + serializer: from.optionalJson, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.scriptForm.paramsFieldLabel', { + defaultMessage: 'Parameters', + }), + helpText: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.scriptForm.paramsFieldHelpText', + { + defaultMessage: 'Script parameters.', + } + ), + validations: [ + { + validator: (value) => { + if (value.value) { + return isJsonField( + i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.scriptForm.processorInvalidJsonError', + { + defaultMessage: 'Invalid JSON', + } + ) + )(value); + } + }, + }, + ], + }, +}; + +export const Script: FormFieldsComponent = ({ initialFieldValues }) => { + const [showId, setShowId] = useState(() => !!initialFieldValues?.id); + return ( + <> + <EuiFormRow> + <EuiSwitch + label={i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.scriptForm.useScriptIdToggleLabel', + { defaultMessage: 'Use stored script' } + )} + checked={showId} + onChange={() => setShowId((v) => !v)} + /> + </EuiFormRow> + + {showId ? ( + <UseField key="fields.id" path="fields.id" component={Field} config={fieldsConfig.id} /> + ) : ( + <> + <UseField component={Field} config={fieldsConfig.lang} path="fields.lang" /> + + <UseField + key="fields.source" + path="fields.source" + component={TextEditor} + componentProps={{ + editorProps: { + height: EDITOR_PX_HEIGHT.medium, + 'aria-label': i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.scriptForm.sourceFieldAriaLabel', + { + defaultMessage: 'Source script JSON editor', + } + ), + options: { + minimap: { enabled: false }, + lineNumbers: 'off', + }, + }, + }} + config={fieldsConfig.source} + /> + </> + )} + + <UseField + component={XJsonEditor} + componentProps={{ + editorProps: { + height: EDITOR_PX_HEIGHT.medium, + 'aria-label': i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.scriptForm.paramsFieldAriaLabel', + { + defaultMessage: 'Parameters JSON editor', + } + ), + }, + }} + config={fieldsConfig.params} + path="fields.params" + /> + </> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx index 88cea620ae80..c282be35e507 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set.tsx @@ -6,9 +6,10 @@ import React, { FunctionComponent } from 'react'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode } from '@elastic/eui'; import { - FieldConfig, FIELD_TYPES, fieldValidators, ToggleField, @@ -16,46 +17,68 @@ import { Field, } from '../../../../../../shared_imports'; -const { emptyField } = fieldValidators; +import { FieldsConfig, to, from } from './shared'; -const fieldConfig: FieldConfig = { - type: FIELD_TYPES.TEXT, - label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.fieldFieldLabel', { - defaultMessage: 'Field', - }), - validations: [ - { - validator: emptyField( - i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.fieldRequiredError', { - defaultMessage: 'A field value is required.', - }) - ), - }, - ], -}; +import { FieldNameField } from './common_fields/field_name_field'; -const valueConfig: FieldConfig = { - type: FIELD_TYPES.TEXT, - label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueFieldLabel', { - defaultMessage: 'Value', - }), - validations: [ - { - validator: emptyField( - i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueRequiredError', { - defaultMessage: 'A value to set is required.', - }) - ), - }, - ], -}; +const { emptyField } = fieldValidators; -const overrideConfig: FieldConfig = { - defaultValue: false, - label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldLabel', { - defaultMessage: 'Override', - }), - type: FIELD_TYPES.TOGGLE, +const fieldsConfig: FieldsConfig = { + /* Required fields config */ + value: { + type: FIELD_TYPES.TEXT, + deserializer: String, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueFieldLabel', { + defaultMessage: 'Value', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueFieldHelpText', { + defaultMessage: 'Value to be set for the field', + }), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.valueRequiredError', { + defaultMessage: 'A value is required', + }) + ), + }, + ], + }, + /* Optional fields config */ + override: { + type: FIELD_TYPES.TOGGLE, + defaultValue: true, + deserializer: to.booleanOrUndef, + serializer: from.undefinedIfValue(true), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldLabel', { + defaultMessage: 'Override', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldHelpText', { + defaultMessage: 'If disabled, fields containing non-null values will not be updated.', + }), + }, + ignore_empty_value: { + type: FIELD_TYPES.TOGGLE, + defaultValue: false, + deserializer: to.booleanOrUndef, + serializer: from.undefinedIfValue(false), + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.setForm.ignoreEmptyValueFieldLabel', + { + defaultMessage: 'Ignore empty value', + } + ), + helpText: ( + <FormattedMessage + id="xpack.ingestPipelines.pipelineEditor.setForm.ignoreEmptyValueFieldHelpText" + defaultMessage="If enabled and {valueField} is a template snippet that evaluates to {nullValue} or an empty string, quietly exit without modifying the document." + values={{ + valueField: <EuiCode>{'value'}</EuiCode>, + nullValue: <EuiCode>{'null'}</EuiCode>, + }} + /> + ), + }, }; /** @@ -64,11 +87,21 @@ const overrideConfig: FieldConfig = { export const SetProcessor: FunctionComponent = () => { return ( <> - <UseField config={fieldConfig} component={Field} path="fields.field" /> + <FieldNameField + helpText={i18n.translate('xpack.ingestPipelines.pipelineEditor.setForm.fieldNameField', { + defaultMessage: 'Field to insert or update', + })} + /> + + <UseField config={fieldsConfig.value} component={Field} path="fields.value" /> - <UseField config={valueConfig} component={Field} path="fields.value" /> + <UseField config={fieldsConfig.override} component={ToggleField} path="fields.override" /> - <UseField config={overrideConfig} component={ToggleField} path="fields.override" /> + <UseField + config={fieldsConfig.ignore_empty_value} + component={ToggleField} + path="fields.ignore_empty_value" + /> </> ); }; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx new file mode 100644 index 000000000000..78128b3d54c7 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/set_security_user.tsx @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode } from '@elastic/eui'; + +import { FIELD_TYPES, UseField, ComboBoxField } from '../../../../../../shared_imports'; + +import { FieldsConfig, to, from } from './shared'; + +import { FieldNameField } from './common_fields/field_name_field'; + +const userProperties: string[] = [ + 'username', + 'roles', + 'email', + 'full_name', + 'metadata', + 'api_key', + 'realm', + 'authentication_type', +]; + +const comboBoxOptions = userProperties.map((prop) => ({ label: prop })); +const helpTextValues = userProperties.join(', '); + +const fieldsConfig: FieldsConfig = { + /* Optional fields config */ + properties: { + type: FIELD_TYPES.COMBO_BOX, + deserializer: to.arrayOfStrings, + serializer: from.optionalArrayOfStrings, + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.setSecurityUserForm.propertiesFieldLabel', + { + defaultMessage: 'Properties (optional)', + } + ), + helpText: ( + <FormattedMessage + id="xpack.ingestPipelines.pipelineEditor.setForm.propertiesFieldHelpText" + defaultMessage="User properties to add. Defaults to {value}." + values={{ + value: <EuiCode>[{helpTextValues}]</EuiCode>, + }} + /> + ), + }, +}; + +export const SetSecurityUser: FunctionComponent = () => { + return ( + <> + <FieldNameField + helpText={i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.setSecurityUserForm.fieldNameField', + { + defaultMessage: 'Field to store the user information', + } + )} + /> + + <UseField + config={fieldsConfig.properties} + component={ComboBoxField} + componentProps={{ + euiFieldProps: { + options: comboBoxOptions, + noSuggestions: false, + }, + }} + path="fields.properties" + /> + </> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/shared.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/shared.ts index 84b308dd9cd7..e45469e23e8a 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/shared.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/shared.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - +import { FunctionComponent } from 'react'; import * as rt from 'io-ts'; import { isRight } from 'fp-ts/lib/Either'; @@ -31,7 +31,8 @@ export function isArrayOfStrings(v: unknown): v is string[] { */ export const to = { booleanOrUndef: (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : undefined), - arrayOfStrings: (v: unknown): string[] => (isArrayOfStrings(v) ? v : []), + arrayOfStrings: (v: unknown): string[] => + isArrayOfStrings(v) ? v : typeof v === 'string' && v.length ? [v] : [], jsonString: (v: unknown) => (v ? JSON.stringify(v, null, 2) : '{}'), }; @@ -62,7 +63,17 @@ export const from = { } } }, - defaultBoolToUndef: (defaultBool: boolean) => (v: boolean) => (v === defaultBool ? undefined : v), + optionalArrayOfStrings: (v: string[]) => (v.length ? v : undefined), + undefinedIfValue: (value: any) => (v: boolean) => (v === value ? undefined : v), +}; + +export const EDITOR_PX_HEIGHT = { + extraSmall: 75, + small: 100, + medium: 200, + large: 300, }; export type FieldsConfig = Record<string, FieldConfig>; + +export type FormFieldsComponent = FunctionComponent<{ initialFieldValues?: Record<string, any> }>; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx new file mode 100644 index 000000000000..cdd0ff888acc --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/sort.tsx @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { FIELD_TYPES, UseField, SelectField } from '../../../../../../shared_imports'; + +import { FieldNameField } from './common_fields/field_name_field'; +import { TargetField } from './common_fields/target_field'; +import { FieldsConfig, from } from './shared'; + +const fieldsConfig: FieldsConfig = { + /* Optional fields config */ + order: { + type: FIELD_TYPES.SELECT, + defaultValue: 'asc', + deserializer: (v) => (v === 'asc' || v === 'desc' ? v : 'asc'), + serializer: from.undefinedIfValue('asc'), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.sortForm.orderFieldLabel', { + defaultMessage: 'Order', + }), + helpText: i18n.translate('xpack.ingestPipelines.pipelineEditor.sortForm.orderFieldHelpText', { + defaultMessage: 'Sort order to use', + }), + }, +}; + +export const Sort: FunctionComponent = () => { + return ( + <> + <FieldNameField + helpText={i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.sortForm.fieldNameHelpText', + { defaultMessage: 'Field to sort' } + )} + /> + + <UseField + config={fieldsConfig.order} + component={SelectField} + componentProps={{ + euiFieldProps: { + options: [ + { + value: 'asc', + text: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.sortForm.orderField.ascendingOption', + { defaultMessage: 'Ascending' } + ), + }, + { + value: 'desc', + text: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.sortForm.orderField.descendingOption', + { defaultMessage: 'Descending' } + ), + }, + ], + }, + }} + path="fields.order" + /> + + <TargetField /> + </> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx new file mode 100644 index 000000000000..b48ce74110b3 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/manage_processor_form/processors/split.tsx @@ -0,0 +1,91 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; + +import { + FIELD_TYPES, + fieldValidators, + UseField, + Field, + ToggleField, +} from '../../../../../../shared_imports'; + +import { FieldNameField } from './common_fields/field_name_field'; +import { TargetField } from './common_fields/target_field'; +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; +import { FieldsConfig, to, from } from './shared'; + +const { emptyField } = fieldValidators; + +const fieldsConfig: FieldsConfig = { + /* Required fields config */ + separator: { + type: FIELD_TYPES.TEXT, + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.splitForm.separatorFieldLabel', { + defaultMessage: 'Separator', + }), + deserializer: String, + helpText: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.splitForm.separatorFieldHelpText', + { + defaultMessage: 'Regex to match a separator', + } + ), + validations: [ + { + validator: emptyField( + i18n.translate('xpack.ingestPipelines.pipelineEditor.splitForm.separatorRequiredError', { + defaultMessage: 'A value is required.', + }) + ), + }, + ], + }, + /* Optional fields config */ + preserve_trailing: { + type: FIELD_TYPES.TOGGLE, + defaultValue: false, + deserializer: to.booleanOrUndef, + serializer: from.undefinedIfValue(false), + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.splitForm.preserveTrailingFieldLabel', + { + defaultMessage: 'Preserve trailing', + } + ), + helpText: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.splitForm.preserveTrailingFieldHelpText', + { defaultMessage: 'If enabled, preserve any trailing space.' } + ), + }, +}; + +export const Split: FunctionComponent = () => { + return ( + <> + <FieldNameField + helpText={i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.splitForm.fieldNameHelpText', + { defaultMessage: 'Field to split' } + )} + /> + + <UseField config={fieldsConfig.separator} component={Field} path="fields.separator" /> + + <TargetField /> + + <UseField + config={fieldsConfig.preserve_trailing} + component={ToggleField} + path="fields.preserve_trailing" + /> + + <IgnoreMissingField /> + </> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx index 854c6632ab94..799551b296ba 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_processors_editor/components/shared/map_processor_type_to_form.tsx @@ -5,7 +5,9 @@ */ import { i18n } from '@kbn/i18n'; -import { FunctionComponent } from 'react'; +import React, { ReactNode } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { EuiCode } from '@elastic/eui'; import { Append, @@ -28,18 +30,27 @@ import { Inference, Join, Json, + Kv, + Lowercase, + Pipeline, + Remove, + Rename, + Script, + SetProcessor, + SetSecurityUser, + Split, + Sort, + FormFieldsComponent, } from '../manage_processor_form/processors'; -// import { SetProcessor } from './processors/set'; -// import { Gsub } from './processors/gsub'; - interface FieldDescriptor { - FieldsComponent?: FunctionComponent; + FieldsComponent?: FormFieldsComponent; docLinkPath: string; /** * A sentence case label that can be displayed to users */ label: string; + description?: string | ReactNode; } type MapProcessorTypeToDescriptor = Record<string, FieldDescriptor>; @@ -51,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, @@ -58,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, @@ -65,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, @@ -72,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, @@ -79,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, @@ -86,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, @@ -93,6 +125,13 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { label: i18n.translate('xpack.ingestPipelines.processors.label.dateIndexName', { defaultMessage: 'Date index name', }), + description: () => ( + <FormattedMessage + id="xpack.ingestPipelines.processors.description.dateIndexName" + defaultMessage="Uses a date or timestamp to add documents to the correct time-based index. Index names must use a date math pattern, such as {value}." + values={{ value: <EuiCode inline>{'my-index-yyyy-MM-dd'}</EuiCode> }} + /> + ), }, dissect: { FieldsComponent: Dissect, @@ -100,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, @@ -107,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, @@ -114,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, @@ -186,63 +236,70 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { }), }, kv: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: Kv, docLinkPath: '/kv-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.kv', { defaultMessage: 'KV', }), }, lowercase: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: Lowercase, docLinkPath: '/lowercase-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.lowercase', { defaultMessage: 'Lowercase', }), }, pipeline: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: Pipeline, docLinkPath: '/pipeline-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.pipeline', { defaultMessage: 'Pipeline', }), }, remove: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: Remove, docLinkPath: '/remove-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.remove', { defaultMessage: 'Remove', }), }, rename: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: Rename, docLinkPath: '/rename-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.rename', { defaultMessage: 'Rename', }), }, script: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: Script, docLinkPath: '/script-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.script', { defaultMessage: 'Script', }), }, + set: { + FieldsComponent: SetProcessor, + docLinkPath: '/set-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.set', { + defaultMessage: 'Set', + }), + }, set_security_user: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: SetSecurityUser, docLinkPath: '/ingest-node-set-security-user-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.setSecurityUser', { defaultMessage: 'Set security user', }), }, split: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: Split, docLinkPath: '/split-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.split', { defaultMessage: 'Split', }), }, sort: { - FieldsComponent: undefined, // TODO: Implement + FieldsComponent: Sort, docLinkPath: '/sort-processor.html', label: i18n.translate('xpack.ingestPipelines.processors.label.sort', { defaultMessage: 'Sort', @@ -276,15 +333,6 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { defaultMessage: 'User agent', }), }, - - // --- The below processor descriptors have components implemented --- - set: { - FieldsComponent: undefined, - docLinkPath: '/set-processor.html', - label: i18n.translate('xpack.ingestPipelines.processors.label.set', { - defaultMessage: 'Set', - }), - }, }; export type ProcessorType = keyof typeof mapProcessorTypeToDescriptor; diff --git a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts index 936db37f0c62..abdbdf214040 100644 --- a/x-pack/plugins/ingest_pipelines/public/shared_imports.ts +++ b/x-pack/plugins/ingest_pipelines/public/shared_imports.ts @@ -62,6 +62,7 @@ export { RadioGroupField, NumericField, SelectField, + CheckBoxField, } from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index 194f12cf9291..0db456e0760e 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -205,10 +205,10 @@ describe('Datatable Visualization', () => { }, frame, }).groups - ).toHaveLength(1); + ).toHaveLength(2); }); - it('allows all kinds of operations', () => { + it('allows only bucket operations one category', () => { const datasource = createMockDatasource('test'); const frame = mockFrame(); frame.datasourceLayers = { first: datasource.publicAPIMock }; @@ -232,6 +232,40 @@ describe('Datatable Visualization', () => { expect(filterOperations({ ...baseOperation, dataType: 'boolean' })).toEqual(true); expect(filterOperations({ ...baseOperation, dataType: 'other' as DataType })).toEqual(true); expect(filterOperations({ ...baseOperation, dataType: 'date', isBucketed: false })).toEqual( + false + ); + expect(filterOperations({ ...baseOperation, dataType: 'number', isBucketed: false })).toEqual( + false + ); + }); + + it('allows only metric operations in one category', () => { + const datasource = createMockDatasource('test'); + const frame = mockFrame(); + frame.datasourceLayers = { first: datasource.publicAPIMock }; + + const filterOperations = datatableVisualization.getConfiguration({ + layerId: 'first', + state: { + layers: [{ layerId: 'first', columns: [] }], + }, + frame, + }).groups[1].filterOperations; + + const baseOperation: Operation = { + dataType: 'string', + isBucketed: true, + label: '', + }; + expect(filterOperations({ ...baseOperation })).toEqual(false); + expect(filterOperations({ ...baseOperation, dataType: 'number' })).toEqual(false); + expect(filterOperations({ ...baseOperation, dataType: 'date' })).toEqual(false); + expect(filterOperations({ ...baseOperation, dataType: 'boolean' })).toEqual(false); + expect(filterOperations({ ...baseOperation, dataType: 'other' as DataType })).toEqual(false); + expect(filterOperations({ ...baseOperation, dataType: 'date', isBucketed: false })).toEqual( + true + ); + expect(filterOperations({ ...baseOperation, dataType: 'number', isBucketed: false })).toEqual( true ); }); @@ -248,7 +282,7 @@ describe('Datatable Visualization', () => { layerId: 'a', state: { layers: [layer] }, frame, - }).groups[0].accessors + }).groups[1].accessors ).toEqual(['c', 'b']); }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index 5aff4e14b17f..836ffcb15cfa 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -143,15 +143,29 @@ export const datatableVisualization: Visualization<DatatableVisualizationState> groups: [ { groupId: 'columns', - groupLabel: i18n.translate('xpack.lens.datatable.columns', { - defaultMessage: 'Columns', + groupLabel: i18n.translate('xpack.lens.datatable.breakdown', { + defaultMessage: 'Break down by', }), layerId: state.layers[0].layerId, - accessors: sortedColumns, + accessors: sortedColumns.filter((c) => datasource.getOperationForColumnId(c)?.isBucketed), supportsMoreColumns: true, - filterOperations: () => true, + filterOperations: (op) => op.isBucketed, dataTestSubj: 'lnsDatatable_column', }, + { + groupId: 'metrics', + groupLabel: i18n.translate('xpack.lens.datatable.metrics', { + defaultMessage: 'Metrics', + }), + layerId: state.layers[0].layerId, + accessors: sortedColumns.filter( + (c) => !datasource.getOperationForColumnId(c)?.isBucketed + ), + supportsMoreColumns: true, + filterOperations: (op) => !op.isBucketed, + required: true, + dataTestSubj: 'lnsDatatable_metrics', + }, ], }; }, diff --git a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap index d18a2db614f5..3581151dd5f7 100644 --- a/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap +++ b/x-pack/plugins/lens/public/drag_drop/__snapshots__/drag_drop.test.tsx.snap @@ -9,6 +9,20 @@ exports[`DragDrop droppable is reflected in the className 1`] = ` </div> `; +exports[`DragDrop items that have droppable=false get special styling when another item is dragged 1`] = ` +<div + className="lnsDragDrop lnsDragDrop-isNotDroppable" + data-test-subj="lnsDragDrop" + onDragEnd={[Function]} + onDragLeave={[Function]} + onDragOver={[Function]} + onDragStart={[Function]} + onDrop={[Function]} +> + Hello! +</div> +`; + exports[`DragDrop renders if nothing is being dragged 1`] = ` <div class="lnsDragDrop" diff --git a/x-pack/plugins/lens/public/drag_drop/_drag_drop.scss b/x-pack/plugins/lens/public/drag_drop/_drag_drop.scss index 5a4fb4e95ad0..c971540e165c 100644 --- a/x-pack/plugins/lens/public/drag_drop/_drag_drop.scss +++ b/x-pack/plugins/lens/public/drag_drop/_drag_drop.scss @@ -1,3 +1,7 @@ +.lnsDragDrop-isNotDroppable { + opacity: .5; +} + // Fix specificity by chaining classes .lnsDragDrop.lnsDragDrop-isDropTarget { diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx index 765522067eaf..3240357c254e 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.test.tsx @@ -49,7 +49,7 @@ describe('DragDrop', () => { const value = {}; const component = mount( - <ChildDragDropProvider dragging={undefined} setDragging={setDragging}> + <ChildDragDropProvider dragging={value} setDragging={setDragging}> <DragDrop value={value} draggable={true} label="drag label"> Hello! </DragDrop> @@ -127,4 +127,63 @@ describe('DragDrop', () => { expect(component).toMatchSnapshot(); }); + + test('items that have droppable=false get special styling when another item is dragged', () => { + const component = mount( + <ChildDragDropProvider dragging={'ignored'} setDragging={() => {}}> + <DragDrop value="ignored" draggable={true} label="a"> + Ignored + </DragDrop> + <DragDrop onDrop={(x: unknown) => {}} droppable={false}> + Hello! + </DragDrop> + </ChildDragDropProvider> + ); + + expect(component.find('[data-test-subj="lnsDragDrop"]').at(1)).toMatchSnapshot(); + }); + + test('additional styles are reflected in the className until drop', () => { + let dragging: string | undefined; + const getAdditionalClasses = jest.fn().mockReturnValue('additional'); + const component = mount( + <ChildDragDropProvider + dragging={dragging} + setDragging={() => { + dragging = 'hello'; + }} + > + <DragDrop value="ignored" draggable={true} label="a"> + Ignored + </DragDrop> + <DragDrop + onDrop={(x: unknown) => {}} + droppable + getAdditionalClassesOnEnter={getAdditionalClasses} + > + Hello! + </DragDrop> + </ChildDragDropProvider> + ); + + const dataTransfer = { + setData: jest.fn(), + getData: jest.fn(), + }; + component + .find('[data-test-subj="lnsDragDrop"]') + .first() + .simulate('dragstart', { dataTransfer }); + jest.runAllTimers(); + + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); + expect(component.find('.additional')).toHaveLength(1); + + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragleave'); + expect(component.find('.additional')).toHaveLength(0); + + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('dragover'); + component.find('[data-test-subj="lnsDragDrop"]').at(1).simulate('drop'); + expect(component.find('.additional')).toHaveLength(0); + }); }); diff --git a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx index 5a0fc3b3839f..85bdd24bd4f8 100644 --- a/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx +++ b/x-pack/plugins/lens/public/drag_drop/drag_drop.tsx @@ -49,6 +49,11 @@ interface BaseProps { */ droppable?: boolean; + /** + * Additional class names to apply when another element is over the drop target + */ + getAdditionalClassesOnEnter?: () => string; + /** * The optional test subject associated with this DOM element. */ @@ -97,6 +102,12 @@ export const DragDrop = (props: Props) => { {...props} dragging={droppable ? dragging : undefined} isDragging={!!(draggable && value === dragging)} + isNotDroppable={ + // If the configuration has provided a droppable flag, but this particular item is not + // droppable, then it should be less prominent. Ignores items that are both + // draggable and drop targets + droppable === false && Boolean(dragging) && value !== dragging + } setDragging={setDragging} /> ); @@ -107,9 +118,13 @@ const DragDropInner = React.memo(function DragDropInner( dragging: unknown; setDragging: (dragging: unknown) => void; isDragging: boolean; + isNotDroppable: boolean; } ) { - const [state, setState] = useState({ isActive: false }); + const [state, setState] = useState({ + isActive: false, + dragEnterClassNames: '', + }); const { className, onDrop, @@ -120,13 +135,20 @@ const DragDropInner = React.memo(function DragDropInner( dragging, setDragging, isDragging, + isNotDroppable, } = props; - const classes = classNames('lnsDragDrop', className, { - 'lnsDragDrop-isDropTarget': droppable, - 'lnsDragDrop-isActiveDropTarget': droppable && state.isActive, - 'lnsDragDrop-isDragging': isDragging, - }); + const classes = classNames( + 'lnsDragDrop', + className, + { + 'lnsDragDrop-isDropTarget': droppable, + 'lnsDragDrop-isActiveDropTarget': droppable && state.isActive, + 'lnsDragDrop-isDragging': isDragging, + 'lnsDragDrop-isNotDroppable': isNotDroppable, + }, + state.dragEnterClassNames + ); const dragStart = (e: DroppableEvent) => { // Setting stopPropgagation causes Chrome failures, so @@ -159,19 +181,25 @@ const DragDropInner = React.memo(function DragDropInner( // An optimization to prevent a bunch of React churn. if (!state.isActive) { - setState({ ...state, isActive: true }); + setState({ + ...state, + isActive: true, + dragEnterClassNames: props.getAdditionalClassesOnEnter + ? props.getAdditionalClassesOnEnter() + : '', + }); } }; const dragLeave = () => { - setState({ ...state, isActive: false }); + setState({ ...state, isActive: false, dragEnterClassNames: '' }); }; const drop = (e: DroppableEvent) => { e.preventDefault(); e.stopPropagation(); - setState({ ...state, isActive: false }); + setState({ ...state, isActive: false, dragEnterClassNames: '' }); setDragging(undefined); if (onDrop && droppable) { diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss index 4e13fd95d196..62bc6d7ed7cc 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/_layer_panel.scss @@ -27,6 +27,14 @@ overflow: hidden; } +.lnsLayerPanel__dimension-isHidden { + opacity: 0; +} + +.lnsLayerPanel__dimension-isReplacing { + text-decoration: line-through; +} + .lnsLayerPanel__triggerLink { padding: $euiSizeS; width: 100%; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index b3ad03b71770..85dbee6de524 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -12,6 +12,7 @@ import { createMockDatasource, DatasourceMock, } from '../../mocks'; +import { ChildDragDropProvider } from '../../../drag_drop'; import { EuiFormRow, EuiPopover } from '@elastic/eui'; import { mount } from 'enzyme'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; @@ -272,6 +273,7 @@ describe('LayerPanel', () => { expect(component.find(EuiPopover).prop('isOpen')).toBe(true); }); + it('should close the popover when the active visualization changes', () => { /** * The ID generation system for new dimensions has been messy before, so @@ -324,4 +326,151 @@ describe('LayerPanel', () => { expect(component.find(EuiPopover).prop('isOpen')).toBe(false); }); }); + + // This test is more like an integration test, since the layer panel owns all + // the coordination between drag and drop + describe('drag and drop behavior', () => { + it('should determine if the datasource supports dropping of a field onto empty dimension', () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: [], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + mockDatasource.canHandleDrop.mockReturnValue(true); + + const draggingField = { field: { name: 'dragged' }, indexPatternId: 'a' }; + + const component = mountWithIntl( + <ChildDragDropProvider dragging={draggingField} setDragging={jest.fn()}> + <LayerPanel {...getDefaultProps()} /> + </ChildDragDropProvider> + ); + + expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith( + expect.objectContaining({ + dragDropContext: expect.objectContaining({ + dragging: draggingField, + }), + }) + ); + + component.find('DragDrop[data-test-subj="lnsGroup"]').first().simulate('drop'); + + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + dragDropContext: expect.objectContaining({ + dragging: draggingField, + }), + }) + ); + }); + + it('should allow drag to move between groups', () => { + (generateId as jest.Mock).mockReturnValue(`newid`); + + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: ['a'], + filterOperations: () => true, + supportsMoreColumns: false, + dataTestSubj: 'lnsGroupA', + }, + { + groupLabel: 'B', + groupId: 'b', + accessors: ['b'], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroupB', + }, + ], + }); + + mockDatasource.canHandleDrop.mockReturnValue(true); + + const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a' }; + + const component = mountWithIntl( + <ChildDragDropProvider dragging={draggingOperation} setDragging={jest.fn()}> + <LayerPanel {...getDefaultProps()} /> + </ChildDragDropProvider> + ); + + expect(mockDatasource.canHandleDrop).toHaveBeenCalledTimes(2); + expect(mockDatasource.canHandleDrop).toHaveBeenCalledWith( + expect.objectContaining({ + dragDropContext: expect.objectContaining({ + dragging: draggingOperation, + }), + }) + ); + + // Simulate drop on the pre-populated dimension + component.find('DragDrop[data-test-subj="lnsGroupB"]').at(0).simulate('drop'); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + columnId: 'b', + dragDropContext: expect.objectContaining({ + dragging: draggingOperation, + }), + }) + ); + + // Simulate drop on the empty dimension + component.find('DragDrop[data-test-subj="lnsGroupB"]').at(1).simulate('drop'); + expect(mockDatasource.onDrop).toHaveBeenCalledWith( + expect.objectContaining({ + columnId: 'newid', + dragDropContext: expect.objectContaining({ + dragging: draggingOperation, + }), + }) + ); + }); + + it('should prevent dropping in the same group', () => { + mockVisualization.getConfiguration.mockReturnValue({ + groups: [ + { + groupLabel: 'A', + groupId: 'a', + accessors: ['a', 'b'], + filterOperations: () => true, + supportsMoreColumns: true, + dataTestSubj: 'lnsGroup', + }, + ], + }); + + const draggingOperation = { layerId: 'first', columnId: 'a', groupId: 'a' }; + + const component = mountWithIntl( + <ChildDragDropProvider dragging={draggingOperation} setDragging={jest.fn()}> + <LayerPanel {...getDefaultProps()} /> + </ChildDragDropProvider> + ); + + expect(mockDatasource.canHandleDrop).not.toHaveBeenCalled(); + + component.find('DragDrop[data-test-subj="lnsGroup"]').at(0).simulate('drop'); + expect(mockDatasource.onDrop).not.toHaveBeenCalled(); + + component.find('DragDrop[data-test-subj="lnsGroup"]').at(1).simulate('drop'); + expect(mockDatasource.onDrop).not.toHaveBeenCalled(); + + component.find('DragDrop[data-test-subj="lnsGroup"]').at(2).simulate('drop'); + expect(mockDatasource.onDrop).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index b2804cfddba5..b45dd13bfa4f 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -17,8 +17,9 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import classNames from 'classnames'; import { NativeRenderer } from '../../../native_renderer'; -import { StateSetter } from '../../../types'; +import { StateSetter, isDraggedOperation } from '../../../types'; import { DragContext, DragDrop, ChildDragDropProvider } from '../../../drag_drop'; import { LayerSettings } from './layer_settings'; import { trackUiEvent } from '../../../lens_ui_telemetry'; @@ -154,6 +155,7 @@ export function LayerPanel( {groups.map((group, index) => { const newId = generateId(); const isMissing = !isEmptyLayer && group.required && group.accessors.length === 0; + return ( <EuiFormRow className="lnsLayerPanel__row" @@ -215,10 +217,32 @@ export function LayerPanel( return ( <DragDrop key={accessor} - className="lnsLayerPanel__dimension" + className={classNames('lnsLayerPanel__dimension', { + // eslint-disable-next-line @typescript-eslint/naming-convention + 'lnsLayerPanel__dimension-isHidden': + isDraggedOperation(dragDropContext.dragging) && + accessor === dragDropContext.dragging.columnId, + })} + getAdditionalClassesOnEnter={() => { + // If we are dragging another column, add an indication that the behavior will be a replacement' + if ( + isDraggedOperation(dragDropContext.dragging) && + group.groupId !== dragDropContext.dragging.groupId + ) { + return 'lnsLayerPanel__dimension-isReplacing'; + } + return ''; + }} data-test-subj={group.dataTestSubj} + draggable={true} + value={{ columnId: accessor, groupId: group.groupId, layerId }} + label={group.groupLabel} droppable={ - dragDropContext.dragging && + Boolean(dragDropContext.dragging) && + // Verify that the dragged item is not coming from the same group + // since this would be a reorder + (!isDraggedOperation(dragDropContext.dragging) || + dragDropContext.dragging.groupId !== group.groupId) && layerDatasource.canHandleDrop({ ...layerDatasourceDropProps, columnId: accessor, @@ -226,12 +250,22 @@ export function LayerPanel( }) } onDrop={(droppedItem) => { - layerDatasource.onDrop({ + const dropResult = layerDatasource.onDrop({ ...layerDatasourceDropProps, droppedItem, columnId: accessor, filterOperations: group.filterOperations, }); + if (typeof dropResult === 'object') { + // When a column is moved, we delete the reference to the old + props.updateVisualization( + activeVisualization.removeDimension({ + layerId, + columnId: dropResult.deleted, + prevState: props.visualizationState, + }) + ); + } }} > <DimensionPopover @@ -315,7 +349,11 @@ export function LayerPanel( className="lnsLayerPanel__dimension" data-test-subj={group.dataTestSubj} droppable={ - dragDropContext.dragging && + Boolean(dragDropContext.dragging) && + // Verify that the dragged item is not coming from the same group + // since this would be a reorder + (!isDraggedOperation(dragDropContext.dragging) || + dragDropContext.dragging.groupId !== group.groupId) && layerDatasource.canHandleDrop({ ...layerDatasourceDropProps, columnId: newId, @@ -323,13 +361,13 @@ export function LayerPanel( }) } onDrop={(droppedItem) => { - const dropSuccess = layerDatasource.onDrop({ + const dropResult = layerDatasource.onDrop({ ...layerDatasourceDropProps, droppedItem, columnId: newId, filterOperations: group.filterOperations, }); - if (dropSuccess) { + if (dropResult) { props.updateVisualization( activeVisualization.setDimension({ layerId, @@ -338,6 +376,17 @@ export function LayerPanel( prevState: props.visualizationState, }) ); + + if (typeof dropResult === 'object') { + // When a column is moved, we delete the reference to the old + props.updateVisualization( + activeVisualization.removeDimension({ + layerId, + columnId: dropResult.deleted, + prevState: props.visualizationState, + }) + ); + } } }} > diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 3ee109376d97..f184d5628ab1 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -1378,6 +1378,66 @@ describe('IndexPatternDimensionEditorPanel', () => { ).toBe(false); }); + it('is droppable if the dragged column is compatible', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }, + }, + state: dragDropState(), + columnId: 'col2', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }) + ).toBe(true); + }); + + it('is not droppable if the dragged column is the same as the current column', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }, + }, + state: dragDropState(), + columnId: 'col1', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }) + ).toBe(false); + }); + + it('is not droppable if the dragged column is incompatible', () => { + expect( + canHandleDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging: { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }, + }, + state: dragDropState(), + columnId: 'col2', + filterOperations: (op: OperationMetadata) => op.dataType === 'number', + layerId: 'myLayer', + }) + ).toBe(false); + }); + it('appends the dropped column when a field is dropped', () => { const dragging = { field: { type: 'number', name: 'bar', aggregatable: true }, @@ -1526,5 +1586,109 @@ describe('IndexPatternDimensionEditorPanel', () => { }, }); }); + + it('updates the column id when moving an operation to an empty dimension', () => { + const dragging = { + columnId: 'col1', + groupId: 'a', + layerId: 'myLayer', + }; + const testState = dragDropState(); + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col2', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col2'], + columns: { + col2: testState.layers.myLayer.columns.col1, + }, + }, + }, + }); + }); + + it('replaces an operation when moving to a populated dimension', () => { + const dragging = { + columnId: 'col2', + groupId: 'a', + layerId: 'myLayer', + }; + const testState = dragDropState(); + testState.layers.myLayer = { + indexPatternId: 'foo', + columnOrder: ['col1', 'col2', 'col3'], + columns: { + col1: testState.layers.myLayer.columns.col1, + + col2: { + label: 'Top values of src', + dataType: 'string', + isBucketed: true, + + // Private + operationType: 'terms', + params: { + orderBy: { type: 'column', columnId: 'col3' }, + orderDirection: 'desc', + size: 10, + }, + sourceField: 'src', + }, + col3: { + label: 'Count', + dataType: 'number', + isBucketed: false, + + // Private + operationType: 'count', + sourceField: 'Records', + }, + }, + }; + + onDrop({ + ...defaultProps, + dragDropContext: { + ...dragDropContext, + dragging, + }, + droppedItem: dragging, + state: testState, + columnId: 'col1', + filterOperations: (op: OperationMetadata) => true, + layerId: 'myLayer', + }); + + expect(setState).toBeCalledTimes(1); + expect(setState).toHaveBeenCalledWith({ + ...testState, + layers: { + myLayer: { + ...testState.layers.myLayer, + columnOrder: ['col1', 'col3'], + columns: { + col1: testState.layers.myLayer.columns.col2, + col3: testState.layers.myLayer.columns.col3, + }, + }, + }, + }); + }); }); }); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx index 1e8f73b19a3b..1fbbefd8f111 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.tsx @@ -15,6 +15,7 @@ import { DatasourceDimensionEditorProps, DatasourceDimensionDropProps, DatasourceDimensionDropHandlerProps, + isDraggedOperation, } from '../../types'; import { DataPublicPluginStart } from '../../../../../../src/plugins/data/public'; import { IndexPatternColumn, OperationType } from '../indexpattern'; @@ -99,16 +100,25 @@ export function canHandleDrop(props: DatasourceDimensionDropProps<IndexPatternPr return Boolean(operationFieldSupportMatrix.operationByField[field.name]); } - return ( - isDraggedField(dragging) && - layerIndexPatternId === dragging.indexPatternId && - Boolean(hasOperationForField(dragging.field)) - ); + if (isDraggedField(dragging)) { + return ( + layerIndexPatternId === dragging.indexPatternId && + Boolean(hasOperationForField(dragging.field)) + ); + } + + if ( + isDraggedOperation(dragging) && + dragging.layerId === props.layerId && + props.columnId !== dragging.columnId + ) { + const op = props.state.layers[props.layerId].columns[dragging.columnId]; + return props.filterOperations(op); + } + return false; } -export function onDrop( - props: DatasourceDimensionDropHandlerProps<IndexPatternPrivateState> -): boolean { +export function onDrop(props: DatasourceDimensionDropHandlerProps<IndexPatternPrivateState>) { const operationFieldSupportMatrix = getOperationFieldSupportMatrix(props); const droppedItem = props.droppedItem; @@ -116,6 +126,42 @@ export function onDrop( return Boolean(operationFieldSupportMatrix.operationByField[field.name]); } + if (isDraggedOperation(droppedItem) && droppedItem.layerId === props.layerId) { + const layer = props.state.layers[props.layerId]; + const op = { ...layer.columns[droppedItem.columnId] }; + if (!props.filterOperations(op)) { + return false; + } + + const newColumns = { ...layer.columns }; + delete newColumns[droppedItem.columnId]; + newColumns[props.columnId] = op; + + const newColumnOrder = [...layer.columnOrder]; + const oldIndex = newColumnOrder.findIndex((c) => c === droppedItem.columnId); + const newIndex = newColumnOrder.findIndex((c) => c === props.columnId); + + if (newIndex === -1) { + newColumnOrder[oldIndex] = props.columnId; + } else { + newColumnOrder.splice(oldIndex, 1); + } + + // Time to replace + props.setState({ + ...props.state, + layers: { + ...props.state.layers, + [props.layerId]: { + ...layer, + columnOrder: newColumnOrder, + columns: newColumns, + }, + }, + }); + return { deleted: droppedItem.columnId }; + } + if (!isDraggedField(droppedItem) || !hasOperationForField(droppedItem.field)) { // TODO: What do we do if we couldn't find a column? return false; diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts index 0cd92fd96c95..374dbe77b4ca 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/utils.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { DataType } from '../types'; import { DraggedField } from './indexpattern'; import { BaseIndexPatternColumn, FieldBasedIndexPatternColumn, } from './operations/definitions/column_types'; -import { DataType } from '../types'; /** * Normalizes the specified operation type. (e.g. document operations diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 729daed7223f..d8b77afdfe00 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -157,7 +157,7 @@ export interface Datasource<T = unknown, P = unknown> { renderDimensionEditor: (domElement: Element, props: DatasourceDimensionEditorProps<T>) => void; renderLayerPanel: (domElement: Element, props: DatasourceLayerPanelProps<T>) => void; canHandleDrop: (props: DatasourceDimensionDropProps<T>) => boolean; - onDrop: (props: DatasourceDimensionDropHandlerProps<T>) => boolean; + onDrop: (props: DatasourceDimensionDropHandlerProps<T>) => false | true | { deleted: string }; toExpression: (state: T, layerId: string) => Ast | string | null; @@ -230,6 +230,22 @@ export interface DatasourceLayerPanelProps<T> { setState: StateSetter<T>; } +export interface DraggedOperation { + layerId: string; + groupId: string; + columnId: string; +} + +export function isDraggedOperation( + operationCandidate: unknown +): operationCandidate is DraggedOperation { + return ( + typeof operationCandidate === 'object' && + operationCandidate !== null && + 'columnId' in operationCandidate + ); +} + export type DatasourceDimensionDropProps<T> = SharedDimensionProps & { layerId: string; columnId: string; diff --git a/x-pack/plugins/lists/public/exceptions/api.test.ts b/x-pack/plugins/lists/public/exceptions/api.test.ts index b02a82f98af9..0c31015fc9f5 100644 --- a/x-pack/plugins/lists/public/exceptions/api.test.ts +++ b/x-pack/plugins/lists/public/exceptions/api.test.ts @@ -375,7 +375,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'single,single', page: '1', per_page: '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', }, signal: abortCtrl.signal, @@ -408,7 +408,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'single', page: '1', per_page: '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', }, signal: abortCtrl.signal, @@ -441,7 +441,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'agnostic', page: '1', per_page: '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', }, signal: abortCtrl.signal, @@ -474,7 +474,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'agnostic', page: '1', per_page: '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', }, signal: abortCtrl.signal, @@ -508,7 +508,7 @@ describe('Exceptions Lists API', () => { namespace_type: 'agnostic', page: '1', per_page: '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', }, signal: abortCtrl.signal, diff --git a/x-pack/plugins/lists/public/exceptions/api.ts b/x-pack/plugins/lists/public/exceptions/api.ts index 3f5ec8032050..824a25296260 100644 --- a/x-pack/plugins/lists/public/exceptions/api.ts +++ b/x-pack/plugins/lists/public/exceptions/api.ts @@ -288,7 +288,7 @@ export const fetchExceptionListsItemsByListIds = async ({ namespace_type: namespaceTypes.join(','), page: pagination.page ? `${pagination.page}` : '1', per_page: pagination.perPage ? `${pagination.perPage}` : '20', - sort_field: 'created_at', + sort_field: 'exception-list.created_at', sort_order: 'desc', ...(filters.trim() !== '' ? { filter: filters } : {}), }; diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_editor/pipeline_editor.js b/x-pack/plugins/logstash/public/application/components/pipeline_editor/pipeline_editor.js index 694e50cfe3e3..6cb1f87648da 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 8ce04c83afdb..0b7c3888b6f0 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<string, any>; } interface DownstreamPipeline { - description: string; + description?: string; pipeline: string; settings?: Record<string, any>; } @@ -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<string, any>; diff --git a/x-pack/plugins/logstash/server/routes/pipeline/save.ts b/x-pack/plugins/logstash/server/routes/pipeline/save.ts index e484d0e221b6..755a82e670a2 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 2df2ecd22e07..bab2aa2f2a0a 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 000000000000..368e758a027c --- /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 62a74ed142cc..6c57b3d08180 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<JobSelectorFlyoutProps> = ({ <EuiFlexItem grow={false}> <EuiSwitch label={i18n.translate('xpack.ml.jobSelector.applyTimerangeSwitchLabel', { - defaultMessage: 'Apply timerange', + defaultMessage: 'Apply time range', })} checked={applyTimeRange} onChange={toggleTimerangeSwitch} diff --git a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js index 7b104ea372ae..1136487485f1 100644 --- a/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js +++ b/x-pack/plugins/ml/public/application/components/job_selector/job_selector_table/job_selector_table.js @@ -174,7 +174,7 @@ export function JobSelectorTable({ id: 'checkbox', isCheckbox: true, textOnly: false, - width: '24px', + width: '32px', }, { label: 'group ID', diff --git a/x-pack/plugins/ml/public/application/explorer/explorer.js b/x-pack/plugins/ml/public/application/explorer/explorer.js index d78df80fad94..cea1159ebc14 100644 --- a/x-pack/plugins/ml/public/application/explorer/explorer.js +++ b/x-pack/plugins/ml/public/application/explorer/explorer.js @@ -68,6 +68,7 @@ import { ExplorerChartsContainer } from './explorer_charts/explorer_charts_conta import { AnomaliesTable } from '../components/anomalies_table/anomalies_table'; import { getTimefilter, getToastNotifications } from '../util/dependency_cache'; +import { ANOMALY_DETECTION_DEFAULT_TIME_RANGE } from '../../../common/constants/settings'; const ExplorerPage = ({ children, @@ -145,6 +146,22 @@ export class Explorer extends React.Component { state = { filterIconTriggeredQuery: undefined, language: DEFAULT_QUERY_LANG }; htmlIdGen = htmlIdGenerator(); + componentDidMount() { + const { invalidTimeRangeError } = this.props; + if (invalidTimeRangeError) { + const toastNotifications = getToastNotifications(); + toastNotifications.addWarning( + i18n.translate('xpack.ml.explorer.invalidTimeRangeInUrlCallout', { + defaultMessage: + 'The time filter was changed to the full range due to an invalid default time filter. Check the advanced settings for {field}.', + values: { + field: ANOMALY_DETECTION_DEFAULT_TIME_RANGE, + }, + }) + ); + } + } + // Escape regular parens from fieldName as that portion of the query is not wrapped in double quotes // and will cause a syntax error when called with getKqlQueryValues applyFilter = (fieldName, fieldValue, action) => { @@ -298,7 +315,6 @@ export class Explorer extends React.Component { <div className={mainColumnClasses}> <EuiSpacer size="m" /> - {stoppedPartitions && ( <EuiCallOut size={'s'} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js index 6b8d1d80aeda..d0d0442dd4ae 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_actions/results.js @@ -8,16 +8,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; - -import { mlJobService } from '../../../../services/job_service'; import { i18n } from '@kbn/i18n'; -import { getBasePath } from '../../../../util/dependency_cache'; - -export function getLink(location, jobs) { - const basePath = getBasePath(); - const resultsPageUrl = mlJobService.createResultsUrlForJobs(jobs, location); - return `${basePath.get()}/app/ml${resultsPageUrl}`; -} +import { useCreateADLinks } from '../../../../components/custom_hooks/use_create_ad_links'; export function ResultLinks({ jobs }) { const openJobsInSingleMetricViewerText = i18n.translate( @@ -44,13 +36,13 @@ export function ResultLinks({ jobs }) { const singleMetricVisible = jobs.length < 2; const singleMetricEnabled = jobs.length === 1 && jobs[0].isSingleMetricViewerJob; const jobActionsDisabled = jobs.length === 1 && jobs[0].deleting === true; - + const { createLinkWithUserDefaults } = useCreateADLinks(); return ( <React.Fragment> {singleMetricVisible && ( <EuiToolTip position="bottom" content={openJobsInSingleMetricViewerText}> <EuiButtonIcon - href={getLink('timeseriesexplorer', jobs)} + href={createLinkWithUserDefaults('timeseriesexplorer', jobs)} iconType="visLine" aria-label={openJobsInSingleMetricViewerText} className="results-button" @@ -61,7 +53,7 @@ export function ResultLinks({ jobs }) { )} <EuiToolTip position="bottom" content={openJobsInAnomalyExplorerText}> <EuiButtonIcon - href={getLink('explorer', jobs)} + href={createLinkWithUserDefaults('explorer', jobs)} iconType="visTable" aria-label={openJobsInAnomalyExplorerText} className="results-button" diff --git a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx index 07a555c13dbf..a71141d0356d 100644 --- a/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx +++ b/x-pack/plugins/ml/public/application/overview/components/anomaly_detection_panel/actions.tsx @@ -7,9 +7,8 @@ import React, { FC } from 'react'; import { EuiToolTip, EuiButtonEmpty } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -// @ts-ignore no module file -import { getLink } from '../../../jobs/jobs_list/components/job_actions/results'; import { MlSummaryJobs } from '../../../../../common/types/anomaly_detection_jobs'; +import { useCreateADLinks } from '../../../components/custom_hooks/use_create_ad_links'; interface Props { jobsList: MlSummaryJobs; @@ -23,13 +22,14 @@ export const ExplorerLink: FC<Props> = ({ jobsList }) => { values: { jobsCount: jobsList.length, jobId: jobsList[0] && jobsList[0].id }, } ); + const { createLinkWithUserDefaults } = useCreateADLinks(); return ( <EuiToolTip position="bottom" content={openJobsInAnomalyExplorerText}> <EuiButtonEmpty color="text" size="xs" - href={getLink('explorer', jobsList)} + href={createLinkWithUserDefaults('explorer', jobsList)} iconType="visTable" aria-label={openJobsInAnomalyExplorerText} className="results-button" diff --git a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx index 2f2fc77283ef..f89e27925d74 100644 --- a/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx +++ b/x-pack/plugins/ml/public/application/routing/routes/explorer.tsx @@ -73,7 +73,7 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ jobsWithTim const [globalState, setGlobalState] = useUrlState('_g'); const [lastRefresh, setLastRefresh] = useState(0); const [stoppedPartitions, setStoppedPartitions] = useState<string[] | undefined>(); - + const [invalidTimeRangeError, setInValidTimeRangeError] = useState<boolean>(false); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); const { jobIds } = useJobSelection(jobsWithTimeRange); @@ -99,6 +99,9 @@ const ExplorerUrlStateManager: FC<ExplorerUrlStateManagerProps> = ({ 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<ExplorerUrlStateManagerProps> = ({ jobsWithTim showCharts, severity: tableSeverity.val, stoppedPartitions, + invalidTimeRangeError, }} /> </div> 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 1f122ed18a85..817c97541599 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<TimeSeriesExplorerUrlStateMan const previousRefresh = usePrevious(lastRefresh); const [selectedJobId, setSelectedJobId] = useState<string>(); const timefilter = useTimefilter({ timeRangeSelector: true, autoRefreshSelector: true }); + const [invalidTimeRangeError, setInValidTimeRangeError] = useState<boolean>(false); const refresh = useRefresh(); useEffect(() => { @@ -114,6 +115,9 @@ export const TimeSeriesExplorerUrlStateManager: FC<TimeSeriesExplorerUrlStateMan const [bounds, setBounds] = useState<TimeRangeBounds | undefined>(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<TimeSeriesExplorerUrlStateMan tableSeverity: tableSeverity.val, timefilter, zoom: zoomProp, + invalidTimeRangeError, }} /> ); 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 2134d157e1ba..30b2ec044285 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 704d76059f75..640f63617b7d 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 83a789074d35..0e99d64cf202 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 8f3215b6cd21..21adc0b4b9c6 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 38b1f5e3fc08..a9ee24fbb5ce 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 420fa8347cde..e0d018869cef 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 20e22c2db00e..8c0e352aa06c 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 a9f39d2db608..498b561a818f 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<typeof status>; export const job_status = t.keyof({ succeeded: null, failed: null, 'going to run': null }); export type JobStatus = t.TypeOf<typeof job_status>; +export const conflicts = t.keyof({ abort: null, proceed: null }); +export type Conflicts = t.TypeOf<typeof conflicts>; + // TODO: Create a regular expression type or custom date math part type here export const to = t.string; export type To = t.TypeOf<typeof to>; @@ -338,7 +341,7 @@ export const sortFieldOrUndefined = t.union([sort_field, t.undefined]); export type SortFieldOrUndefined = t.TypeOf<typeof sortFieldOrUndefined>; export const sort_order = t.keyof({ asc: null, desc: null }); -export type sortOrder = t.TypeOf<typeof sort_order>; +export type SortOrder = t.TypeOf<typeof sort_order>; export const sortOrderOrUndefined = t.union([sort_order, t.undefined]); export type SortOrderOrUndefined = t.TypeOf<typeof sortOrderOrUndefined>; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/index.ts new file mode 100644 index 000000000000..abfbc3918964 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export * from './add_prepackaged_rules_schema'; +export * from './create_rules_bulk_schema'; +export * from './create_rules_schema'; +export * from './export_rules_schema'; +export * from './find_rules_schema'; +export * from './import_rules_schema'; +export * from './patch_rules_bulk_schema'; +export * from './patch_rules_schema'; +export * from './query_rules_schema'; +export * from './query_signals_index_schema'; +export * from './set_signal_status_schema'; +export * from './update_rules_bulk_schema'; +export * from './update_rules_schema'; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts index 1464896e5029..b039558d827b 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/request/set_signal_status_schema.ts @@ -6,13 +6,14 @@ import * as t from 'io-ts'; -import { signal_ids, signal_status_query, status } from '../common/schemas'; +import { conflicts, signal_ids, signal_status_query, status } from '../common/schemas'; export const setSignalsStatusSchema = t.intersection([ t.type({ status, }), t.partial({ + conflicts, signal_ids, query: signal_status_query, }), diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts new file mode 100644 index 000000000000..6c22b8140e73 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/response/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export * from './error_schema'; +export * from './find_rules_schema'; +export * from './import_rules_schema'; +export * from './prepackaged_rules_schema'; +export * from './prepackaged_rules_status_schema'; +export * from './rules_bulk_schema'; +export * from './rules_schema'; +export * from './type_timeline_only_schema'; diff --git a/x-pack/plugins/security_solution/common/search_strategy/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/common/index.ts index 0576871a2bf8..b55226b08b80 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/hosts/authentications/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/authentications/index.ts index 0fb0609b60ba..efdc96b33562 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/authentications/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/authentications/index.ts @@ -19,14 +19,14 @@ import { } from '../../../common'; import { RequestOptionsPaginated } from '../../'; -export interface AuthenticationsStrategyResponse extends IEsSearchResponse { +export interface HostAuthenticationsStrategyResponse extends IEsSearchResponse { edges: AuthenticationsEdges[]; totalCount: number; pageInfo: PageInfoPaginated; inspect?: Maybe<Inspect>; } -export interface AuthenticationsRequestOptions extends RequestOptionsPaginated { +export interface HostAuthenticationsRequestOptions extends RequestOptionsPaginated { defaultIndex: string[]; } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts index 8ae41a101cee..902e9909cf72 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/common/index.ts @@ -6,7 +6,7 @@ import { CloudEcs } from '../../../../ecs/cloud'; import { HostEcs, OsEcs } from '../../../../ecs/host'; -import { Maybe, SearchHit, TotalValue } from '../../../common'; +import { Hit, Hits, Maybe, SearchHit, StringOrNumber, TotalValue } from '../../../common'; export enum HostPolicyResponseActionStatus { success = 'success', @@ -98,3 +98,15 @@ export interface HostAggEsData extends SearchHit { sort: string[]; aggregations: HostAggEsItem; } + +export interface HostHit extends Hit { + _source: { + '@timestamp'?: string; + host: HostEcs; + }; + cursor?: string; + firstSeen?: string; + sort?: StringOrNumber[]; +} + +export type HostHits = Hits<number, HostHit>; diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts index 9cb43c91adfd..f5d46078fcea 100644 --- a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/index.ts @@ -9,10 +9,12 @@ export * from './all'; export * from './common'; export * from './overview'; export * from './first_last_seen'; +export * from './uncommon_processes'; export enum HostsQueries { authentications = 'authentications', firstLastSeen = 'firstLastSeen', hosts = 'hosts', hostOverview = 'hostOverview', + uncommonProcesses = 'uncommonProcesses', } diff --git a/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/uncommon_processes/index.ts b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/uncommon_processes/index.ts new file mode 100644 index 000000000000..28c0ccb7f6f4 --- /dev/null +++ b/x-pack/plugins/security_solution/common/search_strategy/security_solution/hosts/uncommon_processes/index.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IEsSearchResponse } from '../../../../../../../../src/plugins/data/common'; + +import { HostEcs } from '../../../../ecs/host'; +import { UserEcs } from '../../../../ecs/user'; +import { + RequestOptionsPaginated, + SortField, + CursorType, + Inspect, + Maybe, + PageInfoPaginated, + Hit, + TotalHit, + StringOrNumber, + Hits, +} from '../../..'; + +export interface HostUncommonProcessesRequestOptions extends RequestOptionsPaginated { + sort: SortField; + defaultIndex: string[]; +} + +export interface HostUncommonProcessesStrategyResponse extends IEsSearchResponse { + edges: UncommonProcessesEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe<Inspect>; +} + +export interface UncommonProcessesEdges { + node: UncommonProcessItem; + cursor: CursorType; +} + +export interface UncommonProcessItem { + _id: string; + instances: number; + process: ProcessEcsFields; + hosts: HostEcs[]; + user?: Maybe<UserEcs>; +} + +export interface ProcessEcsFields { + hash?: Maybe<ProcessHashData>; + pid?: Maybe<number[]>; + name?: Maybe<string[]>; + ppid?: Maybe<number[]>; + args?: Maybe<string[]>; + entity_id?: Maybe<string[]>; + executable?: Maybe<string[]>; + title?: Maybe<string[]>; + thread?: Maybe<Thread>; + working_directory?: Maybe<string[]>; +} + +export interface ProcessHashData { + md5?: Maybe<string[]>; + sha1?: Maybe<string[]>; + sha256?: Maybe<string[]>; +} + +export interface Thread { + id?: Maybe<number[]>; + start?: Maybe<string[]>; +} + +export interface UncommonProcessHit extends Hit { + total: TotalHit; + host: Array<{ + id: string[] | undefined; + name: string[] | undefined; + }>; + _source: { + '@timestamp': string; + process: ProcessEcsFields; + }; + cursor: string; + sort: StringOrNumber[]; +} + +export type ProcessHits = Hits<TotalHit, UncommonProcessHit>; 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 85ffc6aa4c73..7721f2ae97d7 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 @@ -8,17 +8,17 @@ import { IEsSearchRequest } from '../../../../../../src/plugins/data/common'; import { ESQuery } from '../../typed_json'; import { HostOverviewStrategyResponse, + HostAuthenticationsRequestOptions, + HostAuthenticationsStrategyResponse, HostOverviewRequestOptions, HostFirstLastSeenStrategyResponse, HostFirstLastSeenRequestOptions, HostsQueries, HostsRequestOptions, HostsStrategyResponse, + HostUncommonProcessesStrategyResponse, + HostUncommonProcessesRequestOptions, } from './hosts'; -import { - AuthenticationsRequestOptions, - AuthenticationsStrategyResponse, -} from './hosts/authentications'; import { NetworkQueries, NetworkTlsStrategyResponse, @@ -27,6 +27,8 @@ import { NetworkHttpRequestOptions, NetworkTopCountriesStrategyResponse, NetworkTopCountriesRequestOptions, + NetworkTopNFlowStrategyResponse, + NetworkTopNFlowRequestOptions, } from './network'; import { DocValueFields, @@ -66,15 +68,19 @@ export type StrategyResponseType<T extends FactoryQueryTypes> = T extends HostsQ : T extends HostsQueries.hostOverview ? HostOverviewStrategyResponse : T extends HostsQueries.authentications - ? AuthenticationsStrategyResponse + ? HostAuthenticationsStrategyResponse : T extends HostsQueries.firstLastSeen ? HostFirstLastSeenStrategyResponse + : T extends HostsQueries.uncommonProcesses + ? HostUncommonProcessesStrategyResponse : T extends NetworkQueries.tls ? NetworkTlsStrategyResponse : T extends NetworkQueries.http ? NetworkHttpStrategyResponse : T extends NetworkQueries.topCountries ? NetworkTopCountriesStrategyResponse + : T extends NetworkQueries.topNFlow + ? NetworkTopNFlowStrategyResponse : never; export type StrategyRequestType<T extends FactoryQueryTypes> = T extends HostsQueries.hosts @@ -82,13 +88,17 @@ export type StrategyRequestType<T extends FactoryQueryTypes> = T extends HostsQu : T extends HostsQueries.hostOverview ? HostOverviewRequestOptions : T extends HostsQueries.authentications - ? AuthenticationsRequestOptions + ? HostAuthenticationsRequestOptions : T extends HostsQueries.firstLastSeen ? HostFirstLastSeenRequestOptions + : T extends HostsQueries.uncommonProcesses + ? HostUncommonProcessesRequestOptions : T extends NetworkQueries.tls ? NetworkTlsRequestOptions : T extends NetworkQueries.http ? 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 a6ae956a4218..66676569b3c9 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<number>; + bytes_out?: Maybe<number>; +} + +export interface GeoItem { + geo?: Maybe<GeoEcs>; + flowTarget?: Maybe<FlowTargetSourceDest>; +} 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 ac5e6fdacc94..2992ee32f8ac 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 3188a26dd69f..f499db82d647 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<GeoEcs>; - flowTarget?: Maybe<FlowTargetSourceDest>; -} - export interface TopCountriesItemSource { country?: Maybe<string>; destination_ips?: Maybe<number>; @@ -79,11 +70,6 @@ export interface TopCountriesItemDestination { source_ips?: Maybe<number>; } -export interface TopNetworkTablesEcsField { - bytes_in?: Maybe<number>; - bytes_out?: Maybe<number>; -} - 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 000000000000..d6be2d29c6ed --- /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<NetworkTopTablesFields> { + flowTarget: FlowTargetSourceDest; + ip?: Maybe<string>; +} + +export interface NetworkTopNFlowStrategyResponse extends IEsSearchResponse { + edges: NetworkTopNFlowEdges[]; + totalCount: number; + pageInfo: PageInfoPaginated; + inspect?: Maybe<Inspect>; +} + +export interface NetworkTopNFlowEdges { + node: NetworkTopNFlowItem; + cursor: CursorType; +} + +export interface NetworkTopNFlowItem { + _id?: Maybe<string>; + source?: Maybe<TopNFlowItemSource>; + destination?: Maybe<TopNFlowItemDestination>; + network?: Maybe<TopNetworkTablesEcsField>; +} + +export interface TopNFlowItemSource { + autonomous_system?: Maybe<AutonomousSystemItem>; + domain?: Maybe<string[]>; + ip?: Maybe<string>; + location?: Maybe<GeoItem>; + flows?: Maybe<number>; + destination_ips?: Maybe<number>; +} + +export interface AutonomousSystemItem { + name?: Maybe<string>; + number?: Maybe<number>; +} + +export interface TopNFlowItemDestination { + autonomous_system?: Maybe<AutonomousSystemItem>; + domain?: Maybe<string[]>; + ip?: Maybe<string>; + location?: Maybe<GeoItem>; + flows?: Maybe<number>; + source_ips?: Maybe<number>; +} + +export interface AutonomousSystemHit<T> { + 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<object>; + bytes_in: { + value: number; + }; + bytes_out: { + value: number; + }; + domain: { + buckets: GenericBuckets[]; + }; + location: LocationHit<object>; + flows: number; + destination_ips?: number; + source_ips?: number; +} + +export interface LocationHit<T> { + 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 ba1de0e40e27..d9d9fde8fc8c 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 83ace877cd51..397d0c014217 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 1cce72a48e0f..3fa300ce9d8d 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 c46eb1b6b59c..c1befabdd780 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<ExceptionListItemSchema | CreateExceptionListItemSchema> >([]); const [fetchOrCreateListError, setFetchOrCreateListError] = useState<ErrorInfo | null>(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<ExceptionListItemSchema | CreateExceptionListItemSchema>; - }) => { + }): 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<HTMLInputElement>) => { + (event: React.ChangeEvent<HTMLInputElement>): void => { setShouldCloseAlert(event.currentTarget.checked); }, [setShouldCloseAlert] ); const onBulkCloseAlertCheckboxChange = useCallback( - (event: React.ChangeEvent<HTMLInputElement>) => { + (event: React.ChangeEvent<HTMLInputElement>): 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<ExceptionListItemSchema | CreateExceptionListItemSchema> = []; 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 46923e07d225..2398f8d799c2 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 be289b0e85e6..dbd634e97a32 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 e0e629793952..da43d0c51099 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 bc59d8710005..ae811e740073 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<ToastsStart, 'addSuccess'> & { +export type UseAppToasts = Pick<ToastsStart, 'addSuccess' | 'addWarning'> & { 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 075f06084384..b4fb307a62b6 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<StartServices>(); +const useTypedKibana = () => useKibana<StartServices>(); 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 3b94ac895949..c4a9540f6291 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 3545bfd91e55..972a8aa4b087 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 63e1c8aca908..0416b3d2a459 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<AlertsTableComponentProps> = ({ ); 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<AlertsTableComponentProps> = ({ ); 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 216ed0cbe264..cbf0e08fef5c 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<AlertContextMenuProps> = ({ ); const eventId = ecsRowData._id; + const { addWarning } = useAppToasts(); + const onButtonClick = useCallback(() => { setPopover(!isPopoverOpen); }, [isPopoverOpen]); @@ -124,22 +128,30 @@ const AlertContextMenuComponent: React.FC<AlertContextMenuProps> = ({ ); 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 d8ba0ab2d40b..f8b3cd6af8b8 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 8b3d05ce5a57..8179e5865e4e 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( - <StepRuleDescriptionComponent columns="multi" data={mockAboutStep} schema={schema} /> + <StepRuleDescription columns="multi" data={mockAboutStep} schema={schema} /> ); expect(wrapper.find('[data-test-subj="listItemColumnStepRuleDescription"]')).toHaveLength(2); }); test('renders single column when "columns" is "single"', () => { const wrapper = shallow( - <StepRuleDescriptionComponent columns="single" data={mockAboutStep} schema={schema} /> + <StepRuleDescription columns="single" data={mockAboutStep} schema={schema} /> ); 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( - <StepRuleDescriptionComponent columns="singleSplit" data={mockAboutStep} schema={schema} /> + <StepRuleDescription columns="singleSplit" data={mockAboutStep} schema={schema} /> ); 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 cf27fa97b147..99e36669f78b 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<T> { columns?: 'multi' | 'single' | 'singleSplit'; data: unknown; indexPatterns?: IIndexPattern; - schema: FormSchema; + schema: FormSchema<T>; } -export const StepRuleDescriptionComponent: React.FC<StepRuleDescriptionProps> = ({ +export const StepRuleDescriptionComponent = <T,>({ data, columns = 'multi', indexPatterns, schema, -}) => { +}: StepRuleDescriptionProps<T>) => { const kibana = useKibana(); const [filterManager] = useState<FilterManager>(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<StepRuleDescriptionProps> = ...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<StepRuleDescriptionProps> = ); }; -export const StepRuleDescription = memo(StepRuleDescriptionComponent); +export const StepRuleDescription = memo( + StepRuleDescriptionComponent +) as typeof StepRuleDescriptionComponent; -export const buildListItems = ( +export const buildListItems = <T,>( data: unknown, - schema: FormSchema, + schema: FormSchema<T>, 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 3152fef12c65..ec46610286b4 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(<MlJobDescription job={mockOpenedJob} />); + const wrapper = shallow(<MlJobDescription jobId={'myJobId'} />); 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 6baa2abab33d..414f6f2c2d3b 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 = <span data-test-subj="machineLearningJobId">{jobId}</span>; + + return job != null ? ( <Wrapper> <div> - <JobLink data-test-subj="machineLearningJobId" href={jobUrl} target="_blank"> - {job.id} + <JobLink href={jobUrl} target="_blank"> + {jobIdSpan} </JobLink> <AuditIcon message={job.auditMessage} /> </div> <JobStatusBadge job={job} /> </Wrapper> + ) : ( + 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 ? <MlJobDescription job={job} /> : jobId, - }; -}; +export const buildMlJobDescription = (jobId: string, label: string): ListItems => ({ + title: label, + description: <MlJobDescription jobId={jobId} />, +}); 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 d97c2b4c8c0a..7c8f5230cc8b 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<void>; + 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 1ef3edf8c720..08cea23d89e1 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<SeverityValue, number> = { +export const defaultRiskScoreBySeverity: Record<Severity, number> = { 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 b9c3e4f84c18..56c61c2ad676 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 0c834b9fff33..cb25785eaa5b 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 = <K extends keyof RuleStepsFormHooks>( + 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( <StepAboutRule @@ -47,7 +60,7 @@ describe('StepAboutRuleComponent', () => { 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( <ThemeProvider theme={theme}> <StepAboutRule @@ -55,43 +68,26 @@ describe('StepAboutRuleComponent', () => { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={jest.fn()} /> </ThemeProvider> ); + 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( <ThemeProvider theme={theme}> <StepAboutRule @@ -99,47 +95,26 @@ describe('StepAboutRuleComponent', () => { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={jest.fn()} /> </ThemeProvider> ); + 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( <ThemeProvider theme={theme}> <StepAboutRule @@ -147,54 +122,55 @@ describe('StepAboutRuleComponent', () => { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={stepDataMock} /> </ThemeProvider> ); + 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<AboutStepRule, 'isNew'> = { - 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( <ThemeProvider theme={theme}> <StepAboutRule @@ -202,13 +178,16 @@ describe('StepAboutRuleComponent', () => { defaultValues={stepAboutDefaultValue} descriptionColumns="multi" isReadOnlyView={false} + setForm={setFormHook} isLoading={false} - setForm={jest.fn()} - setStepData={stepDataMock} /> </ThemeProvider> ); + 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<AboutStepRule, 'isNew'> = { + 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( + <ThemeProvider theme={theme}> + <StepAboutRule + addPadding={true} + defaultValues={stepAboutDefaultValue} + descriptionColumns="multi" + isReadOnlyView={false} + setForm={setFormHook} + isLoading={false} + /> + </ThemeProvider> + ); + + 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 295b13717f07..d2c84883fa99 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<StepAboutRuleProps> = ({ isReadOnlyView, isUpdateView = false, isLoading, + onSubmit, setForm, - setStepData, }) => { const initialState = defaultValues ?? stepAboutDefaultValue; - const [myStepData, setMyStepData] = useState<AboutStepRule>(initialState); + const [severityValue, setSeverityValue] = useState<string>(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<AboutStepRule>({ 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<AboutStepRule>]; - 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<AboutStepRule['riskScore']>; + 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 ? ( <StepContentWrapper data-test-subj="aboutStep" addPadding={addPadding}> - <StepRuleDescription columns={descriptionColumns} schema={schema} data={myStepData} /> + <StepRuleDescription columns={descriptionColumns} schema={schema} data={initialState} /> </StepContentWrapper> ) : ( <> @@ -305,26 +328,11 @@ const StepAboutRuleComponent: FC<StepAboutRuleProps> = ({ }} /> </EuiAccordion> - <FormDataProvider pathsToWatch="severity"> - {({ 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; - }} - </FormDataProvider> </Form> </StepContentWrapper> {!isUpdateView && ( - <NextStep dataTestSubj="about-continue" onClick={onSubmit} isDisabled={isLoading} /> + <NextStep dataTestSubj="about-continue" onClick={handleSubmit} isDisabled={isLoading} /> )} </> ); 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 2264a11341eb..6df94eca429c 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<AboutStepRule> = { 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 8a1f96188351..158f323b739e 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<StepDefineRuleProps> = ({ isReadOnlyView, isLoading, isUpdateView = false, + onSubmit, setForm, - setStepData, }) => { const mlCapabilities = useMlCapabilities(); const [openTimelineSearch, setOpenTimelineSearch] = useState(false); @@ -112,38 +109,54 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ const [indicesConfig] = useUiSetting$<string[]>(DEFAULT_INDEX_KEY); const initialState = defaultValues ?? { ...stepDefineDefaultValue, - index: indicesConfig ?? [], + index: indicesConfig, }; - const [localRuleType, setLocalRuleType] = useState(initialState.ruleType); - const [myStepData, setMyStepData] = useState<DefineStepRule>(initialState); - const [ - { browserFields, indexPatterns: indexPatternQueryBar, isLoading: indexPatternLoadingQueryBar }, - ] = useFetchIndexPatterns(myStepData.index, 'step_define_rule'); - - const { form } = useForm({ + const { form } = useForm<DefineStepRule>({ 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<DefineStepRule>]; + 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<StepDefineRuleProps> = ({ <StepContentWrapper data-test-subj="definitionRule" addPadding={addPadding}> <StepRuleDescription columns={descriptionColumns} - indexPatterns={indexPatternQueryBar as IIndexPattern} - schema={filterRuleFieldsForType(schema as FormSchema & RuleFields, myStepData.ruleType)} - data={filterRuleFieldsForType(myStepData, myStepData.ruleType)} + indexPatterns={indexPatternQueryBar} + schema={filterRuleFieldsForType(schema as typeof schema & RuleFields, ruleType)} + data={filterRuleFieldsForType(initialState, ruleType)} /> </StepContentWrapper> ) : ( @@ -192,7 +205,7 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ isMlAdmin: hasMlAdminPermissions(mlCapabilities), }} /> - <RuleTypeEuiFormRow $isVisible={!isMlRule(localRuleType)} fullWidth> + <RuleTypeEuiFormRow $isVisible={!isMlRule(ruleType)} fullWidth> <> <CommonUseField path="index" @@ -241,7 +254,7 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ /> </> </RuleTypeEuiFormRow> - <RuleTypeEuiFormRow $isVisible={isMlRule(localRuleType)} fullWidth> + <RuleTypeEuiFormRow $isVisible={isMlRule(ruleType)} fullWidth> <> <UseField path="machineLearningJobId" @@ -260,7 +273,7 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ </> </RuleTypeEuiFormRow> <RuleTypeEuiFormRow - $isVisible={localRuleType === 'threshold'} + $isVisible={ruleType === 'threshold'} data-test-subj="thresholdInput" fullWidth > @@ -269,11 +282,9 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ fields={{ thresholdField: { path: 'threshold.field', - defaultValue: initialState.threshold.field, }, thresholdValue: { path: 'threshold.value', - defaultValue: initialState.threshold.value, }, }} > @@ -290,31 +301,11 @@ const StepDefineRuleComponent: FC<StepDefineRuleProps> = ({ dataTestSubj: 'detectionEngineStepDefineRuleTimeline', }} /> - <FormDataProvider pathsToWatch={['index', 'ruleType']}> - {({ 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; - }} - </FormDataProvider> </Form> </StepContentWrapper> {!isUpdateView && ( - <NextStep dataTestSubj="define-continue" onClick={onSubmit} isDisabled={isLoading} /> + <NextStep dataTestSubj="define-continue" onClick={handleSubmit} isDisabled={isLoading} /> )} </> ); 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 333b28bf27bb..07eff94bbbef 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<DefineStepRule> = { 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 5b4f7677dbc3..e6f1c25bf9da 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<StepRuleActionsProps> = ({ isReadOnlyView, isLoading, isUpdateView = false, - setStepData, + onSubmit, setForm, actionMessageParams, }) => { - const initialState = defaultValues ?? stepActionsDefaultValue; - const [myStepData, setMyStepData] = useState<ActionsStepRule>(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<StepRuleActionsProps> = ({ }), [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<ActionsStepRule>({ + defaultValue: initialState, + options: { stripEmptyFields: false }, + schema, + }); + const { getFields, getFormData, submit } = form; + const [{ throttle: formThrottle }] = (useFormData({ + form, + watch: ['throttle'], + }) as unknown) as [Partial<ActionsStepRule>]; + 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<StepRuleActionsProps> = ({ 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 ? ( <StepContentWrapper addPadding={addPadding}> - <StepRuleDescription schema={schema} data={myStepData} columns="single" /> + <StepRuleDescription schema={schema} data={initialState} columns="single" /> </StepContentWrapper> ) : ( <> @@ -154,12 +155,11 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({ component={ThrottleSelectField} componentProps={throttleFieldComponentProps} /> - {myStepData.throttle !== stepActionsDefaultValue.throttle ? ( + {throttle !== stepActionsDefaultValue.throttle ? ( <> <EuiSpacer /> <UseField path="actions" - defaultValue={myStepData.actions} component={RuleActionsField} componentProps={{ messageVariables: actionMessageParams, @@ -167,18 +167,10 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({ /> </> ) : ( - <UseField - path="actions" - defaultValue={myStepData.actions} - component={GhostFormField} - /> + <UseField path="actions" component={GhostFormField} /> )} - <UseField - path="kibanaSiemAppUrl" - defaultValue={kibanaAbsoluteUrl} - component={GhostFormField} - /> - <UseField path="enabled" defaultValue={myStepData.enabled} component={GhostFormField} /> + <UseField path="kibanaSiemAppUrl" component={GhostFormField} /> + <UseField path="enabled" component={GhostFormField} /> </EuiForm> </Form> </StepContentWrapper> @@ -197,7 +189,7 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({ fill={false} isDisabled={isLoading} isLoading={isLoading} - onClick={onSubmit.bind(null, false)} + onClick={() => handleSubmit(false)} > {I18n.COMPLETE_WITHOUT_ACTIVATING} </EuiButton> @@ -207,7 +199,7 @@ const StepRuleActionsComponent: FC<StepRuleActionsProps> = ({ 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 a093f991afaf..38de3a2026ec 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<FormData> => ({ +}): FormSchema<ActionsStepRule> => ({ 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 52f04f8423be..d451932a6b63 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<StepScheduleRuleProps> = ({ isReadOnlyView, isLoading, isUpdateView = false, - setStepData, + onSubmit, setForm, }) => { const initialState = defaultValues ?? stepScheduleDefaultValue; - const [myStepData, setMyStepData] = useState<ScheduleStepRule>(initialState); - const { form } = useForm({ + const { form } = useForm<ScheduleStepRule>({ 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 ? ( <StepContentWrapper addPadding={addPadding}> - <StepRuleDescription columns={descriptionColumns} schema={schema} data={myStepData} /> + <StepRuleDescription columns={descriptionColumns} schema={schema} data={initialState} /> </StepContentWrapper> ) : ( <> @@ -96,7 +100,7 @@ const StepScheduleRuleComponent: FC<StepScheduleRuleProps> = ({ </StepContentWrapper> {!isUpdateView && ( - <NextStep dataTestSubj="schedule-continue" onClick={onSubmit} isDisabled={isLoading} /> + <NextStep dataTestSubj="schedule-continue" onClick={handleSubmit} isDisabled={isLoading} /> )} </> ); 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 f4c371a2364f..cf93a9b61710 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<ScheduleStepRule> = { 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 3cd819b55685..19007c4d2e43 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 3fe676fe2c7d..a8a2ae10a3bb 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<UpdateDocumentByQueryResponse> => KibanaServices.get().http.fetch(DETECTION_ENGINE_SIGNALS_STATUS_URL, { method: 'POST', - body: JSON.stringify({ status, ...query }), + body: JSON.stringify({ conflicts: 'proceed', status, ...query }), signal, }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts index f12a5d523bad..0ed091f2c18a 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/__mocks__/api.ts @@ -5,9 +5,9 @@ */ import { - AddRulesProps, PatchRuleProps, - NewRule, + CreateRulesProps, + UpdateRulesProps, PrePackagedRulesStatusResponse, BasicFetchProps, RuleStatusResponse, @@ -16,13 +16,18 @@ import { FetchRulesResponse, FetchRulesProps, } from '../types'; -import { ruleMock, savedRuleMock, rulesMock } from '../mock'; +import { savedRuleMock, rulesMock } from '../mock'; +import { getRulesSchemaMock } from '../../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; +import { RulesSchema } from '../../../../../../common/detection_engine/schemas/response'; -export const addRule = async ({ rule, signal }: AddRulesProps): Promise<NewRule> => - Promise.resolve(ruleMock); +export const updateRule = async ({ rule, signal }: UpdateRulesProps): Promise<RulesSchema> => + Promise.resolve(getRulesSchemaMock()); -export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise<NewRule> => - Promise.resolve(ruleMock); +export const createRule = async ({ rule, signal }: CreateRulesProps): Promise<RulesSchema> => + Promise.resolve(getRulesSchemaMock()); + +export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise<RulesSchema> => + Promise.resolve(getRulesSchemaMock()); export const getPrePackagedRulesStatus = async ({ signal, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts index f58c95ed71e2..cd1ded544cfe 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.test.ts @@ -6,7 +6,9 @@ import { KibanaServices } from '../../../../common/lib/kibana'; import { - addRule, + createRule, + updateRule, + patchRule, fetchRules, fetchRuleById, enableRules, @@ -19,9 +21,12 @@ import { fetchTags, getPrePackagedRulesStatus, } from './api'; -import { ruleMock, rulesMock } from './mock'; +import { getRulesSchemaMock } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; +import { getCreateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/create_rules_schema.mock'; +import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/update_rules_schema.mock'; +import { getPatchRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema.mock'; +import { rulesMock } from './mock'; import { buildEsQuery } from 'src/plugins/data/common'; - const abortCtrl = new AbortController(); const mockKibanaServices = KibanaServices.get as jest.Mock; jest.mock('../../../../common/lib/kibana'); @@ -30,25 +35,56 @@ const fetchMock = jest.fn(); mockKibanaServices.mockReturnValue({ http: { fetch: fetchMock } }); describe('Detections Rules API', () => { - describe('addRule', () => { + describe('createRule', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue(ruleMock); + fetchMock.mockResolvedValue(getRulesSchemaMock()); }); - test('check parameter url, body', async () => { - await addRule({ rule: ruleMock, signal: abortCtrl.signal }); + test('POSTs rule', async () => { + const payload = getCreateRulesSchemaMock(); + await createRule({ rule: payload, signal: abortCtrl.signal }); expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', { body: - '{"description":"some desc","enabled":true,"false_positives":[],"filters":[],"from":"now-360s","index":["apm-*-transaction*","auditbeat-*","endgame-*","filebeat-*","packetbeat-*","winlogbeat-*"],"interval":"5m","rule_id":"bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf","language":"kuery","risk_score":75,"name":"Test rule","query":"user.email: \'root@elastic.co\'","references":[],"severity":"high","tags":["APM"],"to":"now","type":"query","threat":[],"throttle":null}', + '{"description":"Detecting root and admin users","name":"Query with a rule id","query":"user.name: root or user.name: admin","severity":"high","type":"query","risk_score":55,"language":"kuery","rule_id":"rule-1"}', method: 'POST', signal: abortCtrl.signal, }); }); + }); - test('happy path', async () => { - const ruleResp = await addRule({ rule: ruleMock, signal: abortCtrl.signal }); - expect(ruleResp).toEqual(ruleMock); + describe('updateRule', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(getRulesSchemaMock()); + }); + + test('PUTs rule', async () => { + const payload = getUpdateRulesSchemaMock(); + await updateRule({ rule: payload, signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', { + body: + '{"description":"some description","name":"Query with a rule id","query":"user.name: root or user.name: admin","severity":"high","type":"query","risk_score":55,"language":"kuery","rule_id":"rule-1"}', + method: 'PUT', + signal: abortCtrl.signal, + }); + }); + }); + + describe('patchRule', () => { + beforeEach(() => { + fetchMock.mockClear(); + fetchMock.mockResolvedValue(getRulesSchemaMock()); + }); + + test('PATCHs rule', async () => { + const payload = getPatchRulesSchemaMock(); + await patchRule({ ruleProperties: payload, signal: abortCtrl.signal }); + expect(fetchMock).toHaveBeenCalledWith('/api/detection_engine/rules', { + body: JSON.stringify(payload), + method: 'PATCH', + signal: abortCtrl.signal, + }); }); }); @@ -280,7 +316,7 @@ describe('Detections Rules API', () => { describe('fetchRuleById', () => { beforeEach(() => { fetchMock.mockClear(); - fetchMock.mockResolvedValue(ruleMock); + fetchMock.mockResolvedValue(getRulesSchemaMock()); }); test('check parameter url, query', async () => { @@ -296,7 +332,7 @@ describe('Detections Rules API', () => { test('happy path', async () => { const ruleResp = await fetchRuleById({ id: 'mySuperRuleId', signal: abortCtrl.signal }); - expect(ruleResp).toEqual(ruleMock); + expect(ruleResp).toEqual(getRulesSchemaMock()); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts index 3538d8ec8c9b..e254516d1107 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/api.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { HttpStart } from '../../../../../../../../src/core/public'; import { DETECTION_ENGINE_RULES_URL, @@ -13,13 +12,13 @@ import { DETECTION_ENGINE_TAGS_URL, } from '../../../../../common/constants'; import { - AddRulesProps, + UpdateRulesProps, + CreateRulesProps, DeleteRulesProps, DuplicateRulesProps, EnableRulesProps, FetchRulesProps, FetchRulesResponse, - NewRule, Rule, FetchRuleProps, BasicFetchProps, @@ -33,32 +32,51 @@ import { } from './types'; import { KibanaServices } from '../../../../common/lib/kibana'; import * as i18n from '../../../pages/detection_engine/rules/translations'; +import { RulesSchema } from '../../../../../common/detection_engine/schemas/response'; /** - * Add provided Rule + * Create provided Rule * - * @param rule to add + * @param rule CreateRulesSchema to add * @param signal to cancel request * * @throws An error if response is not OK */ -export const addRule = async ({ rule, signal }: AddRulesProps): Promise<NewRule> => - KibanaServices.get().http.fetch<NewRule>(DETECTION_ENGINE_RULES_URL, { - method: rule.id != null ? 'PUT' : 'POST', +export const createRule = async ({ rule, signal }: CreateRulesProps): Promise<RulesSchema> => + KibanaServices.get().http.fetch<RulesSchema>(DETECTION_ENGINE_RULES_URL, { + method: 'POST', + body: JSON.stringify(rule), + signal, + }); + +/** + * Update provided Rule using PUT + * + * @param rule UpdateRulesSchema to be updated + * @param signal to cancel request + * + * @throws An error if response is not OK + */ +export const updateRule = async ({ rule, signal }: UpdateRulesProps): Promise<RulesSchema> => + KibanaServices.get().http.fetch<RulesSchema>(DETECTION_ENGINE_RULES_URL, { + method: 'PUT', body: JSON.stringify(rule), signal, }); /** - * Patch provided Rule + * Patch provided rule + * NOTE: The rule edit flow does NOT use patch as it relies on the + * functionality of PUT to delete field values when not provided, if + * just expecting changes, use this `patchRule` * * @param ruleProperties to patch * @param signal to cancel request * * @throws An error if response is not OK */ -export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise<NewRule> => - KibanaServices.get().http.fetch<NewRule>(DETECTION_ENGINE_RULES_URL, { +export const patchRule = async ({ ruleProperties, signal }: PatchRuleProps): Promise<RulesSchema> => + KibanaServices.get().http.fetch<RulesSchema>(DETECTION_ENGINE_RULES_URL, { method: 'PATCH', body: JSON.stringify(ruleProperties), signal, diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts index c7ecfb33cd90..a40ab2e48785 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/index.ts @@ -6,7 +6,8 @@ export * from './api'; export * from './fetch_index_patterns'; -export * from './persist_rule'; +export * from './use_update_rule'; +export * from './use_create_rule'; export * from './types'; export * from './use_rule'; export * from './use_rules'; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts index fa11cfabcdf8..c0397b0af6db 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/mock.ts @@ -4,36 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { NewRule, FetchRulesResponse, Rule } from './types'; - -export const ruleMock: NewRule = { - description: 'some desc', - enabled: true, - false_positives: [], - filters: [], - from: 'now-360s', - index: [ - 'apm-*-transaction*', - 'auditbeat-*', - 'endgame-*', - 'filebeat-*', - 'packetbeat-*', - 'winlogbeat-*', - ], - interval: '5m', - rule_id: 'bbd3106e-b4b5-4d7c-a1a2-47531d6a2baf', - language: 'kuery', - risk_score: 75, - name: 'Test rule', - query: "user.email: 'root@elastic.co'", - references: [], - severity: 'high', - tags: ['APM'], - to: 'now', - type: 'query', - threat: [], - throttle: null, -}; +import { FetchRulesResponse, Rule } from './types'; export const savedRuleMock: Rule = { author: [], diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts index 166bb90113ae..e94e57ad82bc 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/types.ts @@ -7,6 +7,7 @@ import * as t from 'io-ts'; import { + SortOrder, author, building_block_type, license, @@ -17,11 +18,12 @@ import { threshold, type, } from '../../../../../common/detection_engine/schemas/common/schemas'; +import { listArray } from '../../../../../common/detection_engine/schemas/types'; import { - listArray, - listArrayOrUndefined, -} from '../../../../../common/detection_engine/schemas/types'; -import { PatchRulesSchema } from '../../../../../common/detection_engine/schemas/request/patch_rules_schema'; + CreateRulesSchema, + PatchRulesSchema, + UpdateRulesSchema, +} from '../../../../../common/detection_engine/schemas/request'; /** * Params is an "record", since it is a type of AlertActionParams which is action templates. @@ -36,48 +38,13 @@ export const action = t.exact( }) ); -export const NewRuleSchema = t.intersection([ - t.type({ - description: t.string, - enabled: t.boolean, - interval: t.string, - name: t.string, - risk_score: t.number, - severity: t.string, - type, - }), - t.partial({ - actions: t.array(action), - anomaly_threshold: t.number, - created_by: t.string, - false_positives: t.array(t.string), - filters: t.array(t.unknown), - from: t.string, - id: t.string, - index: t.array(t.string), - language: t.string, - machine_learning_job_id: t.string, - max_signals: t.number, - query: t.string, - references: t.array(t.string), - rule_id: t.string, - saved_id: t.string, - tags: t.array(t.string), - threat: t.array(t.unknown), - threshold, - throttle: t.union([t.string, t.null]), - to: t.string, - updated_by: t.string, - note: t.string, - exceptions_list: listArrayOrUndefined, - }), -]); - -export const NewRulesSchema = t.array(NewRuleSchema); -export type NewRule = t.TypeOf<typeof NewRuleSchema>; +export interface CreateRulesProps { + rule: CreateRulesSchema; + signal: AbortSignal; +} -export interface AddRulesProps { - rule: NewRule; +export interface UpdateRulesProps { + rule: UpdateRulesSchema; signal: AbortSignal; } @@ -185,7 +152,7 @@ export interface FetchRulesProps { export interface FilterOptions { filter: string; sortField: string; - sortOrder: 'asc' | 'desc'; + sortOrder: SortOrder; showCustomRules?: boolean; showElasticRules?: boolean; tags?: string[]; diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.test.tsx similarity index 68% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.test.tsx rename to x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.test.tsx index 1bf21623992e..42d6a2a92a4c 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.test.tsx @@ -6,25 +6,25 @@ import { renderHook, act } from '@testing-library/react-hooks'; -import { usePersistRule, ReturnPersistRule } from './persist_rule'; -import { ruleMock } from './mock'; +import { useCreateRule, ReturnCreateRule } from './use_create_rule'; +import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/update_rules_schema.mock'; jest.mock('./api'); -describe('usePersistRule', () => { +describe('useCreateRule', () => { test('init', async () => { - const { result } = renderHook<unknown, ReturnPersistRule>(() => usePersistRule()); + const { result } = renderHook<unknown, ReturnCreateRule>(() => useCreateRule()); expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]); }); test('saving rule with isLoading === true', async () => { await act(async () => { - const { result, rerender, waitForNextUpdate } = renderHook<void, ReturnPersistRule>(() => - usePersistRule() + const { result, rerender, waitForNextUpdate } = renderHook<void, ReturnCreateRule>(() => + useCreateRule() ); await waitForNextUpdate(); - result.current[1](ruleMock); + result.current[1](getUpdateRulesSchemaMock()); rerender(); expect(result.current).toEqual([{ isLoading: true, isSaved: false }, result.current[1]]); }); @@ -32,11 +32,11 @@ describe('usePersistRule', () => { test('saved rule with isSaved === true', async () => { await act(async () => { - const { result, waitForNextUpdate } = renderHook<void, ReturnPersistRule>(() => - usePersistRule() + const { result, waitForNextUpdate } = renderHook<void, ReturnCreateRule>(() => + useCreateRule() ); await waitForNextUpdate(); - result.current[1](ruleMock); + result.current[1](getUpdateRulesSchemaMock()); await waitForNextUpdate(); expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); }); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx similarity index 76% rename from x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.tsx rename to x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx index fd139d59c0a2..2bbd27994fc7 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/persist_rule.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_create_rule.tsx @@ -7,20 +7,20 @@ import { useEffect, useState, Dispatch } from 'react'; import { errorToToaster, useStateToaster } from '../../../../common/components/toasters'; +import { CreateRulesSchema } from '../../../../../common/detection_engine/schemas/request'; -import { addRule as persistRule } from './api'; +import { createRule } from './api'; import * as i18n from './translations'; -import { NewRule } from './types'; -interface PersistRuleReturn { +interface CreateRuleReturn { isLoading: boolean; isSaved: boolean; } -export type ReturnPersistRule = [PersistRuleReturn, Dispatch<NewRule | null>]; +export type ReturnCreateRule = [CreateRuleReturn, Dispatch<CreateRulesSchema | null>]; -export const usePersistRule = (): ReturnPersistRule => { - const [rule, setRule] = useState<NewRule | null>(null); +export const useCreateRule = (): ReturnCreateRule => { + const [rule, setRule] = useState<CreateRulesSchema | null>(null); const [isSaved, setIsSaved] = useState(false); const [isLoading, setIsLoading] = useState(false); const [, dispatchToaster] = useStateToaster(); @@ -33,7 +33,7 @@ export const usePersistRule = (): ReturnPersistRule => { if (rule != null) { try { setIsLoading(true); - await persistRule({ rule, signal: abortCtrl.signal }); + await createRule({ rule, signal: abortCtrl.signal }); if (isSubscribed) { setIsSaved(true); } diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx index 6721d89f2799..2ba78cd90cf9 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_dissasociate_exception_list.test.tsx @@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react-hooks'; import { coreMock } from '../../../../../../../../src/core/public/mocks'; import * as api from './api'; -import { ruleMock } from './mock'; +import { getRulesSchemaMock } from '../../../../../common/detection_engine/schemas/response/rules_schema.mocks'; import { ReturnUseDissasociateExceptionList, UseDissasociateExceptionListProps, @@ -23,7 +23,7 @@ describe('useDissasociateExceptionList', () => { const onSuccess = jest.fn(); beforeEach(() => { - jest.spyOn(api, 'patchRule').mockResolvedValue(ruleMock); + jest.spyOn(api, 'patchRule').mockResolvedValue(getRulesSchemaMock()); }); afterEach(() => { diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx index 9a6ea4f60fdc..92d46a785b03 100644 --- a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_pre_packaged_rules.test.tsx @@ -10,7 +10,7 @@ import * as api from './api'; jest.mock('./api'); -describe('usePersistRule', () => { +describe('usePrePackagedRules', () => { beforeEach(() => { jest.clearAllMocks(); jest.restoreAllMocks(); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.test.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.test.tsx new file mode 100644 index 000000000000..9603a4151933 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.test.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { useUpdateRule, ReturnUpdateRule } from './use_update_rule'; +import { getUpdateRulesSchemaMock } from '../../../../../common/detection_engine/schemas/request/update_rules_schema.mock'; + +jest.mock('./api'); + +describe('useUpdateRule', () => { + test('init', async () => { + const { result } = renderHook<unknown, ReturnUpdateRule>(() => useUpdateRule()); + + expect(result.current).toEqual([{ isLoading: false, isSaved: false }, result.current[1]]); + }); + + test('saving rule with isLoading === true', async () => { + await act(async () => { + const { result, rerender, waitForNextUpdate } = renderHook<void, ReturnUpdateRule>(() => + useUpdateRule() + ); + await waitForNextUpdate(); + result.current[1](getUpdateRulesSchemaMock()); + rerender(); + expect(result.current).toEqual([{ isLoading: true, isSaved: false }, result.current[1]]); + }); + }); + + test('saved rule with isSaved === true', async () => { + await act(async () => { + const { result, waitForNextUpdate } = renderHook<void, ReturnUpdateRule>(() => + useUpdateRule() + ); + await waitForNextUpdate(); + result.current[1](getUpdateRulesSchemaMock()); + await waitForNextUpdate(); + expect(result.current).toEqual([{ isLoading: false, isSaved: true }, result.current[1]]); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx new file mode 100644 index 000000000000..a437974e93ba --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/containers/detection_engine/rules/use_update_rule.tsx @@ -0,0 +1,60 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useEffect, useState, Dispatch } from 'react'; + +import { errorToToaster, useStateToaster } from '../../../../common/components/toasters'; +import { UpdateRulesSchema } from '../../../../../common/detection_engine/schemas/request'; + +import { updateRule } from './api'; +import * as i18n from './translations'; + +interface UpdateRuleReturn { + isLoading: boolean; + isSaved: boolean; +} + +export type ReturnUpdateRule = [UpdateRuleReturn, Dispatch<UpdateRulesSchema | null>]; + +export const useUpdateRule = (): ReturnUpdateRule => { + const [rule, setRule] = useState<UpdateRulesSchema | null>(null); + const [isSaved, setIsSaved] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [, dispatchToaster] = useStateToaster(); + + useEffect(() => { + let isSubscribed = true; + const abortCtrl = new AbortController(); + setIsSaved(false); + async function saveRule() { + if (rule != null) { + try { + setIsLoading(true); + await updateRule({ rule, signal: abortCtrl.signal }); + if (isSubscribed) { + setIsSaved(true); + } + } catch (error) { + if (isSubscribed) { + errorToToaster({ title: i18n.RULE_ADD_FAILURE, error, dispatchToaster }); + } + } + if (isSubscribed) { + setIsLoading(false); + } + } + } + + saveRule(); + return () => { + isSubscribed = false; + abortCtrl.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rule]); + + return [{ isLoading, isSaved }, setRule]; +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/__mocks__/mock.ts index 8c6e91254314..5a626ce0ff00 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.test.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts index d6dc97fbae15..79488231b29e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.test.ts @@ -5,7 +5,8 @@ */ import { List } from '../../../../../../common/detection_engine/schemas/types'; -import { NewRule } from '../../../../containers/detection_engine/rules'; +import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request/create_rules_schema'; +import { Rule } from '../../../../containers/detection_engine/rules'; import { getListMock, getEndpointListMock, @@ -721,13 +722,13 @@ describe('helpers', () => { mockActions = mockActionsStepRule(); }); - test('returns NewRule with type of saved_query when saved_id exists', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); + test('returns rule with type of saved_query when saved_id exists', () => { + const result: Rule = formatRule<Rule>(mockDefine, mockAbout, mockSchedule, mockActions); expect(result.type).toEqual('saved_query'); }); - test('returns NewRule with type of query when saved_id does not exist', () => { + test('returns rule with type of query when saved_id does not exist', () => { const mockDefineStepRuleWithoutSavedId = { ...mockDefine, queryBar: { @@ -735,7 +736,7 @@ describe('helpers', () => { saved_id: '', }, }; - const result: NewRule = formatRule( + const result: CreateRulesSchema = formatRule<CreateRulesSchema>( mockDefineStepRuleWithoutSavedId, mockAbout, mockSchedule, @@ -745,10 +746,15 @@ describe('helpers', () => { expect(result.type).toEqual('query'); }); - test('returns NewRule without id if ruleId does not exist', () => { - const result: NewRule = formatRule(mockDefine, mockAbout, mockSchedule, mockActions); + test('returns rule without id if ruleId does not exist', () => { + const result: CreateRulesSchema = formatRule<CreateRulesSchema>( + mockDefine, + mockAbout, + mockSchedule, + mockActions + ); - expect(result.id).toBeUndefined(); + expect(result).not.toHaveProperty<CreateRulesSchema>('id'); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/helpers.ts index f4a40b771c9f..0137777f8f8f 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,8 +13,8 @@ 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 { NewRule, Rule } from '../../../../containers/detection_engine/rules'; +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'; import { @@ -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 = <T extends RuleStepsFormData[keyof RuleStepsFormData]>( + formData?: T +): formData is { [K in keyof T]: Exclude<T[K], undefined> } => + !!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 ? { @@ -237,16 +255,19 @@ export const formatActionsStepData = (actionsStepData: ActionsStepRule): Actions }; }; -export const formatRule = ( +// Used to format form data in rule edit and +// create flows so "T" here would likely +// either be CreateRulesSchema or Rule +export const formatRule = <T>( defineStepData: DefineStepRule, aboutStepData: AboutStepRule, scheduleData: ScheduleStepRule, actionsData: ActionsStepRule, rule?: Rule | null -): NewRule => - deepmerge.all([ +): T => + (deepmerge.all([ formatDefineStepData(defineStepData), formatAboutStepData(aboutStepData, rule?.exceptions_list), formatScheduleStepData(scheduleData), formatActionsStepData(actionsData), - ]) as NewRule; + ]) as unknown) as T; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx index d2eb3228cbbf..48247392dfe7 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/create/index.tsx @@ -9,7 +9,8 @@ import React, { useCallback, useRef, useState, useMemo } from 'react'; import { useHistory } from 'react-router-dom'; import styled, { StyledComponent } from 'styled-components'; -import { usePersistRule } from '../../../../containers/detection_engine/rules'; +import { useCreateRule } from '../../../../containers/detection_engine/rules'; +import { CreateRulesSchema } from '../../../../../../common/detection_engine/schemas/request'; import { useListsConfig } from '../../../../containers/detection_engine/lists/use_lists_config'; import { @@ -21,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> => undefined; const MyEuiPanel = styled(EuiPanel)<{ zindex?: number; @@ -99,95 +88,137 @@ const CreateRulePageComponent: React.FC = () => { } = useListsConfig(); const loading = userInfoLoading || listsConfigLoading; const [, dispatchToaster] = useStateToaster(); - const [openAccordionId, setOpenAccordionId] = useState<RuleStep>(RuleStep.defineRule); + const [activeStep, setActiveStep] = useState<RuleStep>(RuleStep.defineRule); + const getNextStep = (step: RuleStep): RuleStep | undefined => + ruleStepsOrder[ruleStepsOrder.indexOf(step) + 1]; const defineRuleRef = useRef<EuiAccordion | null>(null); const aboutRuleRef = useRef<EuiAccordion | null>(null); const scheduleRuleRef = useRef<EuiAccordion | null>(null); const ruleActionsRef = useRef<EuiAccordion | null>(null); - const stepsForm = useRef<Record<RuleStep, FormHook<FormData> | null>>({ - [RuleStep.defineRule]: null, - [RuleStep.aboutRule]: null, - [RuleStep.scheduleRule]: null, - [RuleStep.ruleActions]: null, + const formHooks = useRef<RuleStepsFormHooks>({ + [RuleStep.defineRule]: formHookNoop, + [RuleStep.aboutRule]: formHookNoop, + [RuleStep.scheduleRule]: formHookNoop, + [RuleStep.ruleActions]: formHookNoop, }); - const stepsData = useRef<Record<RuleStep, RuleStepData>>({ + const setFormHook = useCallback( + <K extends keyof RuleStepsFormHooks>(step: K, hook: RuleStepsFormHooks[K]) => { + formHooks.current[step] = hook; + }, + [] + ); + const stepsData = useRef<RuleStepsFormData>({ [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<Record<RuleStep, boolean>>({ + const setStepData = <K extends keyof RuleStepsFormData>( + 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] = usePersistRule(); - 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 [{ isLoading, isSaved }, setRule] = useCreateRule(); + 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<CreateRulesSchema>( + 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<FormData>) => { - 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 = ( @@ -197,7 +228,6 @@ const CreateRulePageComponent: React.FC = () => { type={getAccordionType(RuleStep.defineRule)} /> ); - const aboutRuleButton = ( <AccordionTitle name="2" @@ -205,7 +235,6 @@ const CreateRulePageComponent: React.FC = () => { type={getAccordionType(RuleStep.aboutRule)} /> ); - const scheduleRuleButton = ( <AccordionTitle name="3" @@ -213,7 +242,6 @@ const CreateRulePageComponent: React.FC = () => { type={getAccordionType(RuleStep.scheduleRule)} /> ); - const ruleActionsButton = ( <AccordionTitle name="4" @@ -222,63 +250,7 @@ const CreateRulePageComponent: React.FC = () => { /> ); - 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; @@ -319,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 && ( <EuiButtonEmpty + data-test-subj="edit-define-rule" iconType="pencil" size="xs" - onClick={manageIsEditable.bind(null, RuleStep.defineRule)} + onClick={() => editStep(RuleStep.defineRule)} > {i18n.EDIT_RULE} </EuiButtonEmpty> @@ -335,11 +308,11 @@ const CreateRulePageComponent: React.FC = () => { <EuiHorizontalRule margin="m" /> <StepDefineRule addPadding={true} - defaultValues={stepsData.current[RuleStep.defineRule].data as DefineStepRule} - isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.defineRule]} + defaultValues={stepsData.current[RuleStep.defineRule].data} + isReadOnlyView={activeStep !== RuleStep.defineRule} isLoading={isLoading || loading} - setForm={setStepsForm} - setStepData={setStepData} + setForm={setFormHook} + onSubmit={() => submitStep(RuleStep.defineRule)} descriptionColumns="singleSplit" /> </StepDefineRuleAccordion> @@ -352,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 && ( <EuiButtonEmpty + data-test-subj="edit-about-rule" iconType="pencil" size="xs" - onClick={manageIsEditable.bind(null, RuleStep.aboutRule)} + onClick={() => editStep(RuleStep.aboutRule)} > {i18n.EDIT_RULE} </EuiButtonEmpty> @@ -368,13 +342,13 @@ const CreateRulePageComponent: React.FC = () => { <EuiHorizontalRule margin="m" /> <StepAboutRule addPadding={true} - defaultValues={stepsData.current[RuleStep.aboutRule].data as AboutStepRule} - defineRuleData={stepsData.current[RuleStep.defineRule].data as DefineStepRule} + defaultValues={stepsData.current[RuleStep.aboutRule].data} + defineRuleData={stepsData.current[RuleStep.defineRule].data} descriptionColumns="singleSplit" - isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.aboutRule]} + isReadOnlyView={activeStep !== RuleStep.aboutRule} isLoading={isLoading || loading} - setForm={setStepsForm} - setStepData={setStepData} + setForm={setFormHook} + onSubmit={() => submitStep(RuleStep.aboutRule)} /> </EuiAccordion> </MyEuiPanel> @@ -386,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 && ( <EuiButtonEmpty iconType="pencil" size="xs" - onClick={manageIsEditable.bind(null, RuleStep.scheduleRule)} + onClick={() => editStep(RuleStep.scheduleRule)} > {i18n.EDIT_RULE} </EuiButtonEmpty> @@ -402,12 +376,12 @@ const CreateRulePageComponent: React.FC = () => { <EuiHorizontalRule margin="m" /> <StepScheduleRule addPadding={true} - defaultValues={stepsData.current[RuleStep.scheduleRule].data as ScheduleStepRule} + defaultValues={stepsData.current[RuleStep.scheduleRule].data} descriptionColumns="singleSplit" - isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.scheduleRule]} + isReadOnlyView={activeStep !== RuleStep.scheduleRule} isLoading={isLoading || loading} - setForm={setStepsForm} - setStepData={setStepData} + setForm={setFormHook} + onSubmit={() => submitStep(RuleStep.scheduleRule)} /> </EuiAccordion> </MyEuiPanel> @@ -419,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 && ( <EuiButtonEmpty iconType="pencil" size="xs" - onClick={manageIsEditable.bind(null, RuleStep.ruleActions)} + onClick={() => editStep(RuleStep.ruleActions)} > {i18n.EDIT_RULE} </EuiButtonEmpty> @@ -435,10 +409,11 @@ const CreateRulePageComponent: React.FC = () => { <EuiHorizontalRule margin="m" /> <StepRuleActions addPadding={true} - isReadOnlyView={isStepRuleInReadOnlyView[RuleStep.ruleActions]} + defaultValues={stepsData.current[RuleStep.ruleActions].data} + isReadOnlyView={activeStep !== RuleStep.ruleActions} isLoading={isLoading || loading} - setForm={setStepsForm} - setStepData={setStepData} + setForm={setFormHook} + onSubmit={() => submitStep(RuleStep.ruleActions)} actionMessageParams={actionMessageParams} /> </EuiAccordion> 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 530222ee1962..5f4fd5966910 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/edit/index.tsx @@ -17,7 +17,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import React, { FC, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useParams, useHistory } from 'react-router-dom'; -import { useRule, usePersistRule } from '../../../../containers/detection_engine/rules'; +import { 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'; import { @@ -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,32 +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 { ruleStepsOrder } from '../utils'; -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; -} - -interface ActionsStepRuleForm extends StepRuleForm { - data: ActionsStepRule | null; -} +const formHookNoop = async (): Promise<undefined> => undefined; const EditRulePageComponent: FC = () => { const history = useHistory(); @@ -85,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<AboutStepRuleForm>({ - data: null, - isValid: false, + const formHooks = useRef<RuleStepsFormHooks>({ + [RuleStep.defineRule]: formHookNoop, + [RuleStep.aboutRule]: formHookNoop, + [RuleStep.scheduleRule]: formHookNoop, + [RuleStep.ruleActions]: formHookNoop, }); - const [myDefineRuleForm, setMyDefineRuleForm] = useState<DefineStepRuleForm>({ - data: null, - isValid: false, + const stepsData = useRef<RuleStepsFormData>({ + [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 [myScheduleRuleForm, setMyScheduleRuleForm] = useState<ScheduleStepRuleForm>({ - data: null, - isValid: false, + 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>(RuleStep.defineRule); + const invalidSteps = ruleStepsOrder.filter((step) => { + const stepData = stepsData.current[step]; + return stepData.data != null && !stepIsValid(stepData); }); - const [myActionsRuleForm, setMyActionsRuleForm] = useState<ActionsStepRuleForm>({ - data: null, - isValid: false, - }); - const [selectedTab, setSelectedTab] = useState<EuiTabbedContentTab>(); - const stepsForm = useRef<Record<RuleStep, FormHook<FormData> | null>>({ - [RuleStep.defineRule]: null, - [RuleStep.aboutRule]: null, - [RuleStep.scheduleRule]: null, - [RuleStep.ruleActions]: null, - }); - const [{ isLoading, isSaved }, setRule] = usePersistRule(); - const [tabHasError, setTabHasError] = useState<RuleStep[]>([]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule]); - const setStepsForm = useCallback( - (step: RuleStep, form: FormHook<FormData>) => { - stepsForm.current[step] = form; - if (initForm && step === (selectedTab?.id as RuleStep) && form.isSubmitted === false) { - setInitForm(false); - form.submit(); + const [{ isLoading, isSaved }, setRule] = useUpdateRule(); + const actionMessageParams = useMemo(() => getActionMessageParams(rule?.type), [rule?.type]); + const setFormHook = useCallback( + <K extends keyof RuleStepsFormHooks>(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( + <K extends keyof RuleStepsData>(step: K, data: RuleStepsData[K], isValid: boolean) => { + stepsData.current[step] = { ...stepsData.current[step], data, isValid }; + }, + [] + ); + const tabs = useMemo( () => [ { @@ -137,14 +124,14 @@ const EditRulePageComponent: FC = () => { content: ( <> <EuiSpacer /> - <StepPanel loading={loading || initLoading} title={ruleI18n.DEFINITION}> - {myDefineRuleForm.data != null && ( + <StepPanel loading={loading} title={ruleI18n.DEFINITION}> + {defineStep.data != null && ( <StepDefineRule isReadOnlyView={false} isLoading={isLoading} isUpdateView - defaultValues={myDefineRuleForm.data} - setForm={setStepsForm} + defaultValues={defineStep.data} + setForm={setFormHook} /> )} <EuiSpacer /> @@ -159,15 +146,15 @@ const EditRulePageComponent: FC = () => { content: ( <> <EuiSpacer /> - <StepPanel loading={loading || initLoading} title={ruleI18n.ABOUT}> - {myAboutRuleForm.data != null && myDefineRuleForm.data != null && ( + <StepPanel loading={loading} title={ruleI18n.ABOUT}> + {aboutStep.data != null && defineStep.data != null && ( <StepAboutRule isReadOnlyView={false} isLoading={isLoading} isUpdateView - defaultValues={myAboutRuleForm.data} - defineRuleData={myDefineRuleForm.data} - setForm={setStepsForm} + defaultValues={aboutStep.data} + defineRuleData={defineStep.data} + setForm={setFormHook} /> )} <EuiSpacer /> @@ -182,14 +169,14 @@ const EditRulePageComponent: FC = () => { content: ( <> <EuiSpacer /> - <StepPanel loading={loading || initLoading} title={ruleI18n.SCHEDULE}> - {myScheduleRuleForm.data != null && ( + <StepPanel loading={loading} title={ruleI18n.SCHEDULE}> + {scheduleStep.data != null && ( <StepScheduleRule isReadOnlyView={false} isLoading={isLoading} isUpdateView - defaultValues={myScheduleRuleForm.data} - setForm={setStepsForm} + defaultValues={scheduleStep.data} + setForm={setFormHook} /> )} <EuiSpacer /> @@ -203,14 +190,14 @@ const EditRulePageComponent: FC = () => { content: ( <> <EuiSpacer /> - <StepPanel loading={loading || initLoading} title={ruleI18n.ACTIONS}> - {myActionsRuleForm.data != null && ( + <StepPanel loading={loading} title={ruleI18n.ACTIONS}> + {actionsStep.data != null && ( <StepRuleActions isReadOnlyView={false} isLoading={isLoading} isUpdateView - defaultValues={myActionsRuleForm.data} - setForm={setStepsForm} + defaultValues={actionsStep.data} + setForm={setFormHook} actionMessageParams={actionMessageParams} /> )} @@ -220,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<RuleStep[]>((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, + ...formatRule<UpdateRulesSchema>( + 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(() => { @@ -297,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( @@ -350,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 ?? '')); @@ -400,14 +338,14 @@ const EditRulePageComponent: FC = () => { isLoading={isLoading} title={i18n.PAGE_TITLE} /> - {tabHasError.length > 0 && ( + {invalidSteps.length > 0 && ( <EuiCallOut title={i18n.SORRY_ERRORS} color="danger" iconType="alert"> <FormattedMessage id="xpack.securitySolution.detectionEngine.rule.editRule.errorMsgDescription" defaultMessage="You have an invalid input in {countError, plural, one {this tab} other {these tabs}}: {tabHasError}" values={{ - countError: tabHasError.length, - tabHasError: tabHasError + countError: invalidSteps.length, + tabHasError: invalidSteps .map((t) => { if (t === RuleStep.aboutRule) { return ruleI18n.ABOUT; @@ -428,7 +366,7 @@ const EditRulePageComponent: FC = () => { <EuiTabbedContent initialSelectedTab={tabs[0]} - selectedTab={tabs.find((t) => t.id === selectedTab?.id)} + selectedTab={tabs.find((t) => t.id === activeStep)} onTabClick={onTabClick} tabs={tabs} /> @@ -453,7 +391,7 @@ const EditRulePageComponent: FC = () => { onClick={onSubmit} iconType="save" isLoading={isLoading} - isDisabled={initLoading} + isDisabled={loading} > {i18n.SAVE_CHANGES} </EuiButton> 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 10a20807d6f8..f11b0ac4ec3f 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 8178f5ae5ba1..aab73c5d5a1e 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 891af4b8ca80..e3d0ea123872 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'; @@ -18,14 +17,16 @@ import { RiskScoreMapping, RuleNameOverride, SeverityMapping, + SortOrder, TimestampOverride, Type, + Severity, } from '../../../../../common/detection_engine/schemas/common/schemas'; import { List } from '../../../../../common/detection_engine/schemas/types'; export interface EuiBasicTableSortTypes { field: string; - direction: 'asc' | 'desc'; + direction: SortOrder; } export interface EuiBasicTableOnChange { @@ -36,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<RuleStepsFormData[K] | undefined>; +}; + 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<FormData>) => void; + setForm?: <K extends keyof RuleStepsFormHooks>(step: K, hook: RuleStepsFormHooks[K]) => void; } -interface StepRuleData { - isNew: boolean; -} -export interface AboutStepRule extends StepRuleData { +export interface AboutStepRule { author: string[]; name: string; description: string; @@ -87,7 +105,7 @@ export interface AboutStepRuleDetails { } export interface AboutStepSeverity { - value: string; + value: Severity; mapping: SeverityMapping; isMappingChecked: boolean; } @@ -98,7 +116,7 @@ export interface AboutStepRiskScore { isMappingChecked: boolean; } -export interface DefineStepRule extends StepRuleData { +export interface DefineStepRule { anomalyThreshold: number; index: string[]; machineLearningJobId: string; @@ -108,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 f862a06807e6..890746838b0d 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/hosts/components/authentications_table/mock.ts b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/mock.ts index 759b34cd258d..9e60c35b746d 100644 --- a/x-pack/plugins/security_solution/public/hosts/components/authentications_table/mock.ts +++ b/x-pack/plugins/security_solution/public/hosts/components/authentications_table/mock.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import { SearchResponse } from 'elasticsearch'; -import { AuthenticationsStrategyResponse } from '../../../../common/search_strategy/security_solution/hosts/authentications'; +import { HostAuthenticationsStrategyResponse } from '../../../../common/search_strategy/security_solution/hosts/authentications'; -export const mockData: { Authentications: AuthenticationsStrategyResponse } = { +export const mockData: { Authentications: HostAuthenticationsStrategyResponse } = { Authentications: { rawResponse: { aggregations: { diff --git a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx index 79d83404f8c4..543646940919 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/authentications/index.tsx @@ -12,15 +12,14 @@ import deepEqual from 'fast-deep-equal'; import { AbortError } from '../../../../../../../src/plugins/data/common'; import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; +import { HostsQueries } from '../../../../common/search_strategy/security_solution'; import { - Direction, - DocValueFields, - HostPolicyResponseActionStatus, - HostsQueries, - PageInfoPaginated, - AuthenticationsRequestOptions, - AuthenticationsStrategyResponse, + HostAuthenticationsRequestOptions, + HostAuthenticationsStrategyResponse, AuthenticationsEdges, + PageInfoPaginated, + DocValueFields, + SortField, } from '../../../../common/search_strategy'; import { ESTermQuery } from '../../../../common/typed_json'; @@ -75,7 +74,7 @@ export const useAuthentications = ({ const defaultIndex = uiSettings.get<string[]>(DEFAULT_INDEX_KEY); const [loading, setLoading] = useState(false); const [authenticationsRequest, setAuthenticationsRequest] = useState< - AuthenticationsRequestOptions + HostAuthenticationsRequestOptions >({ defaultIndex, docValueFields: docValueFields ?? [], @@ -87,10 +86,7 @@ export const useAuthentications = ({ from: startDate, to: endDate, }, - sort: { - direction: Direction.desc, - field: HostPolicyResponseActionStatus.success, - }, + sort: {} as SortField, }); const wrappedLoadMore = useCallback( @@ -125,14 +121,14 @@ export const useAuthentications = ({ }); const authenticationsSearch = useCallback( - (request: AuthenticationsRequestOptions) => { + (request: HostAuthenticationsRequestOptions) => { let didCancel = false; const asyncSearch = async () => { abortCtrl.current = new AbortController(); setLoading(true); const searchSubscription$ = data.search - .search<AuthenticationsRequestOptions, AuthenticationsStrategyResponse>(request, { + .search<HostAuthenticationsRequestOptions, HostAuthenticationsStrategyResponse>(request, { strategy: 'securitySolutionSearchStrategy', abortSignal: abortCtrl.current.signal, }) diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx index f8e5b1bed73c..82f5a97e9e41 100644 --- a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/index.tsx @@ -4,36 +4,39 @@ * 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, ConnectedProps } from 'react-redux'; -import { compose } from 'redux'; +import deepEqual from 'fast-deep-equal'; +import { noop } from 'lodash/fp'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; + +import { AbortError } from '../../../../../../../src/plugins/data/common'; import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; -import { - GetUncommonProcessesQuery, - PageInfoPaginated, - UncommonProcessesEdges, -} from '../../../graphql/types'; -import { inputsModel, State, inputsSelectors } from '../../../common/store'; -import { withKibana, WithKibanaProps } from '../../../common/lib/kibana'; +import { PageInfoPaginated, UncommonProcessesEdges } from '../../../graphql/types'; +import { inputsModel, State } from '../../../common/store'; +import { useKibana } from '../../../common/lib/kibana'; 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 { createFilter } from '../../../common/containers/helpers'; + import { hostsModel, hostsSelectors } from '../../store'; -import { uncommonProcessesQuery } from './index.gql_query'; +import { + HostUncommonProcessesRequestOptions, + HostUncommonProcessesStrategyResponse, +} from '../../../../common/search_strategy/security_solution/hosts/uncommon_processes'; +import { HostsQueries } from '../../../../common/search_strategy/security_solution/hosts'; +import { DocValueFields, SortField } from '../../../../common/search_strategy'; + +import * as i18n from './translations'; +import { ESTermQuery } from '../../../../common/typed_json'; +import { getInspectResponse } from '../../../helpers'; +import { InspectResponse } from '../../../types'; const ID = 'uncommonProcessesQuery'; export interface UncommonProcessesArgs { id: string; - inspect: inputsModel.InspectQuery; + inspect: InspectResponse; isInspected: boolean; - loading: boolean; loadPage: (newActivePage: number) => void; pageInfo: PageInfoPaginated; refetch: inputsModel.Refetch; @@ -41,111 +44,164 @@ export interface UncommonProcessesArgs { uncommonProcesses: UncommonProcessesEdges[]; } -export interface OwnProps extends QueryTemplatePaginatedProps { - children: (args: UncommonProcessesArgs) => React.ReactNode; +interface UseUncommonProcesses { + docValueFields?: DocValueFields[]; + filterQuery?: ESTermQuery | string; + endDate: string; + skip?: boolean; + startDate: string; type: hostsModel.HostsType; } -type UncommonProcessesProps = OwnProps & PropsFromRedux & WithKibanaProps; - -class UncommonProcessesComponentQuery extends QueryTemplatePaginated< - UncommonProcessesProps, - GetUncommonProcessesQuery.Query, - GetUncommonProcessesQuery.Variables -> { - public render() { - const { - activePage, - children, - endDate, - filterQuery, - id = ID, - isInspected, - kibana, - limit, - skip, - sourceId, - startDate, - } = this.props; - const variables: GetUncommonProcessesQuery.Variables = { - defaultIndex: kibana.services.uiSettings.get<string[]>(DEFAULT_INDEX_KEY), - filterQuery: createFilter(filterQuery), - inspect: isInspected, - pagination: generateTablePaginationOptions(activePage, limit), - sourceId, - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, +export const useUncommonProcesses = ({ + docValueFields, + filterQuery, + endDate, + skip = false, + startDate, + type, +}: UseUncommonProcesses): [boolean, UncommonProcessesArgs] => { + const getUncommonProcessesSelector = hostsSelectors.uncommonProcessesSelector(); + const { activePage, limit } = useSelector((state: State) => + getUncommonProcessesSelector(state, type) + ); + const { data, notifications, uiSettings } = useKibana().services; + const refetch = useRef<inputsModel.Refetch>(noop); + const abortCtrl = useRef(new AbortController()); + const defaultIndex = uiSettings.get<string[]>(DEFAULT_INDEX_KEY); + const [loading, setLoading] = useState(false); + const [uncommonProcessesRequest, setUncommonProcessesRequest] = useState< + HostUncommonProcessesRequestOptions + >({ + defaultIndex, + docValueFields: docValueFields ?? [], + factoryQueryType: HostsQueries.uncommonProcesses, + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate!, + to: endDate!, + }, + sort: {} as SortField, + }); + + const wrappedLoadMore = useCallback( + (newActivePage: number) => { + setUncommonProcessesRequest((prevRequest) => { + return { + ...prevRequest, + pagination: generateTablePaginationOptions(newActivePage, limit), + }; + }); + }, + [limit] + ); + + const [uncommonProcessesResponse, setUncommonProcessesResponse] = useState<UncommonProcessesArgs>( + { + uncommonProcesses: [], + id: ID, + inspect: { + dsl: [], + response: [], }, - }; - return ( - <Query<GetUncommonProcessesQuery.Query, GetUncommonProcessesQuery.Variables> - query={uncommonProcessesQuery} - fetchPolicy={getDefaultFetchPolicy()} - notifyOnNetworkStatusChange - skip={skip} - variables={variables} - > - {({ data, loading, fetchMore, networkStatus, refetch }) => { - const uncommonProcesses = getOr([], 'source.UncommonProcesses.edges', data); - this.setFetchMore(fetchMore); - this.setFetchMoreOptions((newActivePage: number) => ({ - variables: { - pagination: generateTablePaginationOptions(newActivePage, limit), + isInspected: false, + loadPage: wrappedLoadMore, + pageInfo: { + activePage: 0, + fakeTotalCount: 0, + showMorePagesIndicator: false, + }, + refetch: refetch.current, + totalCount: -1, + } + ); + + const uncommonProcessesSearch = useCallback( + (request: HostUncommonProcessesRequestOptions) => { + let didCancel = false; + const asyncSearch = async () => { + abortCtrl.current = new AbortController(); + setLoading(true); + + const searchSubscription$ = data.search + .search<HostUncommonProcessesRequestOptions, HostUncommonProcessesStrategyResponse>( + request, + { + strategy: 'securitySolutionSearchStrategy', + abortSignal: abortCtrl.current.signal, + } + ) + .subscribe({ + next: (response) => { + if (!response.isPartial && !response.isRunning) { + if (!didCancel) { + setLoading(false); + setUncommonProcessesResponse((prevResponse) => ({ + ...prevResponse, + uncommonProcesses: 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); + } + notifications.toasts.addWarning(i18n.ERROR_UNCOMMON_PROCESSES); + searchSubscription$.unsubscribe(); + } }, - updateQuery: (prev, { fetchMoreResult }) => { - if (!fetchMoreResult) { - return prev; + error: (msg) => { + if (!(msg instanceof AbortError)) { + notifications.toasts.addDanger({ + title: i18n.FAIL_UNCOMMON_PROCESSES, + text: msg.message, + }); } - return { - ...fetchMoreResult, - source: { - ...fetchMoreResult.source, - UncommonProcesses: { - ...fetchMoreResult.source.UncommonProcesses, - edges: [...fetchMoreResult.source.UncommonProcesses.edges], - }, - }, - }; }, - })); - const isLoading = this.isItAValidLoading(loading, variables, networkStatus); - return children({ - id, - inspect: getOr(null, 'source.UncommonProcesses.inspect', data), - isInspected, - loading: isLoading, - loadPage: this.wrappedLoadMore, - pageInfo: getOr({}, 'source.UncommonProcesses.pageInfo', data), - refetch: this.memoizedRefetchQuery(variables, limit, refetch), - totalCount: getOr(-1, 'source.UncommonProcesses.totalCount', data), - uncommonProcesses, }); - }} - </Query> - ); - } -} + }; + abortCtrl.current.abort(); + asyncSearch(); + refetch.current = asyncSearch; + return () => { + didCancel = true; + abortCtrl.current.abort(); + }; + }, + [data.search, notifications.toasts] + ); -const makeMapStateToProps = () => { - const getUncommonProcessesSelector = hostsSelectors.uncommonProcessesSelector(); - const getQuery = inputsSelectors.globalQueryByIdSelector(); - const mapStateToProps = (state: State, { type, id = ID }: OwnProps) => { - const { isInspected } = getQuery(state, id); - return { - ...getUncommonProcessesSelector(state, type), - isInspected, - }; - }; - return mapStateToProps; -}; + useEffect(() => { + setUncommonProcessesRequest((prevRequest) => { + const myRequest = { + ...prevRequest, + defaultIndex, + docValueFields: docValueFields ?? [], + filterQuery: createFilter(filterQuery), + pagination: generateTablePaginationOptions(activePage, limit), + timerange: { + interval: '12h', + from: startDate, + to: endDate, + }, + sort: {} as SortField, + }; + if (!skip && !deepEqual(prevRequest, myRequest)) { + return myRequest; + } + return prevRequest; + }); + }, [activePage, defaultIndex, docValueFields, endDate, filterQuery, limit, skip, startDate]); -const connector = connect(makeMapStateToProps); + useEffect(() => { + uncommonProcessesSearch(uncommonProcessesRequest); + }, [uncommonProcessesRequest, uncommonProcessesSearch]); -type PropsFromRedux = ConnectedProps<typeof connector>; - -export const UncommonProcessesQuery = compose<React.ComponentClass<OwnProps>>( - connector, - withKibana -)(UncommonProcessesComponentQuery); + return [loading, uncommonProcessesResponse]; +}; diff --git a/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/translations.ts b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/translations.ts new file mode 100644 index 000000000000..d563d90dfb26 --- /dev/null +++ b/x-pack/plugins/security_solution/public/hosts/containers/uncommon_processes/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_UNCOMMON_PROCESSES = i18n.translate( + 'xpack.securitySolution.uncommonProcesses.errorSearchDescription', + { + defaultMessage: `An error has occurred on uncommon processes search`, + } +); + +export const FAIL_UNCOMMON_PROCESSES = i18n.translate( + 'xpack.securitySolution.uncommonProcesses.failSearchDescription', + { + defaultMessage: `Failed to run search on uncommon processes`, + } +); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx index f1691dbaa04b..713958f05a3d 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/uncommon_process_query_tab_body.tsx @@ -6,7 +6,7 @@ import { getOr } from 'lodash/fp'; import React from 'react'; -import { UncommonProcessesQuery } from '../../containers/uncommon_processes'; +import { useUncommonProcesses } from '../../containers/uncommon_processes'; import { HostsComponentsQueryProps } from './types'; import { UncommonProcessTable } from '../../components/uncommon_process_table'; import { manageQuery } from '../../../common/components/page/manage_query'; @@ -15,49 +15,35 @@ const UncommonProcessTableManage = manageQuery(UncommonProcessTable); export const UncommonProcessQueryTabBody = ({ deleteQuery, + docValueFields, endDate, filterQuery, skip, setQuery, startDate, type, -}: HostsComponentsQueryProps) => ( - <UncommonProcessesQuery - endDate={endDate} - filterQuery={filterQuery} - skip={skip} - sourceId="default" - startDate={startDate} - type={type} - > - {({ - uncommonProcesses, - totalCount, - loading, - pageInfo, - loadPage, - id, - inspect, - isInspected, - refetch, - }) => ( - <UncommonProcessTableManage - deleteQuery={deleteQuery} - data={uncommonProcesses} - fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)} - id={id} - inspect={inspect} - isInspect={isInspected} - loading={loading} - loadPage={loadPage} - refetch={refetch} - setQuery={setQuery} - showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} - totalCount={totalCount} - type={type} - /> - )} - </UncommonProcessesQuery> -); +}: HostsComponentsQueryProps) => { + const [ + loading, + { uncommonProcesses, totalCount, pageInfo, loadPage, id, inspect, isInspected, refetch }, + ] = useUncommonProcesses({ docValueFields, endDate, filterQuery, skip, startDate, type }); + return ( + <UncommonProcessTableManage + deleteQuery={deleteQuery} + data={uncommonProcesses} + fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)} + id={id} + inspect={inspect} + isInspect={isInspected} + loading={loading} + loadPage={loadPage} + refetch={refetch} + setQuery={setQuery} + showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} + totalCount={totalCount} + type={type} + /> + ); +}; UncommonProcessQueryTabBody.dispalyName = 'UncommonProcessQueryTabBody'; 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 6bed779d4963..747f5e4f502d 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 770574b0813c..cc0da816c57e 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<inputsModel.Refetch>(noop); + const abortCtrl = useRef(new AbortController()); + const defaultIndex = uiSettings.get<string[]>(DEFAULT_INDEX_KEY); + const [loading, setLoading] = useState(false); + + const [networkTopNFlowRequest, setTopNFlowRequest] = useState<NetworkTopNFlowRequestOptions>({ + 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<NetworkTopNFlowArgs>({ + 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<string[]>(DEFAULT_INDEX_KEY), - filterQuery: createFilter(filterQuery), - flowTarget, - inspect: isInspected, - ip, - pagination: generateTablePaginationOptions(activePage, limit), - sort, - sourceId, - timerange: { - interval: '12h', - from: startDate!, - to: endDate!, - }, - }; - return ( - <Query<GetNetworkTopNFlowQuery.Query, GetNetworkTopNFlowQuery.Variables> - 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<NetworkTopNFlowRequestOptions, NetworkTopNFlowStrategyResponse>(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), }); - }} - </Query> - ); - } -} + }; + 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<React.ComponentClass<OwnProps>>( - 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 000000000000..4ea704571cf2 --- /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 158b4057a7d5..821452201b78 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) => ( - <NetworkTopNFlowQuery - endDate={endDate} - filterQuery={filterQuery} - flowTarget={flowTarget} - ip={ip} - skip={skip} - sourceId="default" - startDate={startDate} - type={type} - > - {({ - id, - inspect, - isInspected, - loading, - loadPage, - networkTopNFlow, - pageInfo, - refetch, - totalCount, - }) => ( - <NetworkTopNFlowTableManage - data={networkTopNFlow} - fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)} - flowTargeted={flowTarget} - id={id} - inspect={inspect} - isInspect={isInspected} - loading={loading} - loadPage={loadPage} - refetch={refetch} - setQuery={setQuery} - showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} - totalCount={totalCount} - type={type} - /> - )} - </NetworkTopNFlowQuery> -); +}: NetworkWithIndexComponentsQueryTableProps) => { + const [ + loading, + { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, + ] = useNetworkTopNFlow({ + endDate, + filterQuery, + flowTarget, + ip, + skip, + startDate, + type, + }); + + return ( + <NetworkTopNFlowTableManage + data={networkTopNFlow} + fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)} + flowTargeted={flowTarget} + id={id} + inspect={inspect} + isInspect={isInspected} + loading={loading} + loadPage={loadPage} + refetch={refetch} + setQuery={setQuery} + showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} + totalCount={totalCount} + type={type} + /> + ); +}; 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 a9f4d504847a..c83bf6ff8090 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) => ( - <NetworkTopNFlowQuery - endDate={endDate} - flowTarget={flowTarget} - filterQuery={filterQuery} - skip={skip} - sourceId="default" - startDate={startDate} - type={networkModel.NetworkType.page} - > - {({ - id, - inspect, - isInspected, - loading, - loadPage, - networkTopNFlow, - pageInfo, - refetch, - totalCount, - }) => ( - <NetworkTopNFlowTableManage - data={networkTopNFlow} - fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)} - flowTargeted={flowTarget} - id={id} - inspect={inspect} - isInspect={isInspected} - loading={loading} - loadPage={loadPage} - refetch={refetch} - setQuery={setQuery} - showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} - totalCount={totalCount} - type={networkModel.NetworkType.page} - /> - )} - </NetworkTopNFlowQuery> -); +}: IPsQueryTabBodyProps) => { + const [ + loading, + { id, inspect, isInspected, loadPage, networkTopNFlow, pageInfo, refetch, totalCount }, + ] = useNetworkTopNFlow({ + endDate, + flowTarget, + filterQuery, + skip, + startDate, + type: networkModel.NetworkType.page, + }); + + return ( + <NetworkTopNFlowTableManage + data={networkTopNFlow} + fakeTotalCount={getOr(50, 'fakeTotalCount', pageInfo)} + flowTargeted={flowTarget} + id={id} + inspect={inspect} + isInspect={isInspected} + loading={loading} + loadPage={loadPage} + refetch={refetch} + setQuery={setQuery} + showMorePagesIndicator={getOr(false, 'showMorePagesIndicator', pageInfo)} + totalCount={totalCount} + type={networkModel.NetworkType.page} + /> + ); +}; IPsQueryTabBody.displayName = 'IPsQueryTabBody'; diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts index dee53a624baf..55d52d4ba325 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/factory.ts @@ -12,7 +12,6 @@ import { ResolverTree, ResolverEntityIndex, } from '../../../common/endpoint/types'; -import { DEFAULT_INDEX_KEY as defaultIndexKey } from '../../../common/constants'; /** * The data access layer for resolver. All communication with the Kibana server is done through this object. This object is provided to Resolver. In tests, a mock data access layer can be used instead. @@ -38,13 +37,6 @@ export function dataAccessLayerFactory( }); }, - /** - * Used to get the default index pattern from the SIEM application. - */ - indexPatterns(): string[] { - return context.services.uiSettings.get(defaultIndexKey); - }, - /** * Used to get the entity_id for an _id. */ diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts index 43282848dcf9..631eab18fc01 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/emptify_mock.ts @@ -12,7 +12,7 @@ import { import { mockTreeWithNoProcessEvents } from '../../mocks/resolver_tree'; import { DataAccessLayer } from '../../types'; -type EmptiableRequests = 'relatedEvents' | 'resolverTree' | 'entities' | 'indexPatterns'; +type EmptiableRequests = 'relatedEvents' | 'resolverTree' | 'entities'; interface Metadata<T> { /** @@ -66,15 +66,6 @@ export function emptifyMock<T>( : dataAccessLayer.resolverTree(...args); }, - /** - * Get an array of index patterns that contain events. - */ - indexPatterns(...args): string[] { - return dataShouldBeEmpty.includes('indexPatterns') - ? [] - : dataAccessLayer.indexPatterns(...args); - }, - /** * Get entities matching a document. */ diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts index b0407fa5d7c1..0883a3787fcc 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children.ts @@ -78,13 +78,6 @@ export function noAncestorsTwoChildren(): { dataAccessLayer: DataAccessLayer; me ); }, - /** - * Get an array of index patterns that contain events. - */ - indexPatterns(): string[] { - return ['index pattern']; - }, - /** * Get entities matching a document. */ diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts new file mode 100644 index 000000000000..ec0fa9348578 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ResolverRelatedEvents, + ResolverTree, + ResolverEntityIndex, +} from '../../../../common/endpoint/types'; +import { mockEndpointEvent } from '../../mocks/endpoint_event'; +import { mockTreeWithNoAncestorsAnd2Children } from '../../mocks/resolver_tree'; +import { DataAccessLayer } from '../../types'; + +interface Metadata { + /** + * The `_id` of the document being analyzed. + */ + databaseDocumentID: string; + /** + * A record of entityIDs to be used in tests assertions. + */ + entityIDs: { + /** + * The entityID of the node related to the document being analyzed. + */ + origin: 'origin'; + /** + * The entityID of the first child of the origin. + */ + firstChild: 'firstChild'; + /** + * The entityID of the second child of the origin. + */ + secondChild: 'secondChild'; + }; +} + +/** + * A mock DataAccessLayer that will return an origin in two children. The `entity` response will be empty unless + * `awesome_index` is passed in the indices array. + */ +export function noAncestorsTwoChildenInIndexCalledAwesomeIndex(): { + dataAccessLayer: DataAccessLayer; + metadata: Metadata; +} { + const metadata: Metadata = { + databaseDocumentID: '_id', + entityIDs: { origin: 'origin', firstChild: 'firstChild', secondChild: 'secondChild' }, + }; + return { + metadata, + dataAccessLayer: { + /** + * Fetch related events for an entity ID + */ + relatedEvents(entityID: string): Promise<ResolverRelatedEvents> { + return Promise.resolve({ + entityID, + events: [ + mockEndpointEvent({ + entityID, + name: 'event', + timestamp: 0, + }), + ], + nextEvent: null, + }); + }, + + /** + * Fetch a ResolverTree for a entityID + */ + resolverTree(): Promise<ResolverTree> { + return Promise.resolve( + mockTreeWithNoAncestorsAnd2Children({ + originID: metadata.entityIDs.origin, + firstChildID: metadata.entityIDs.firstChild, + secondChildID: metadata.entityIDs.secondChild, + }) + ); + }, + + /** + * Get entities matching a document. + */ + entities({ indices }): Promise<ResolverEntityIndex> { + // Only return values if the `indices` array contains exactly `'awesome_index'` + if (indices.length === 1 && indices[0] === 'awesome_index') { + return Promise.resolve([{ entity_id: metadata.entityIDs.origin }]); + } + return Promise.resolve([]); + }, + }, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts index 01e75e3eefdb..95ec0cd1a5f7 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/no_ancestors_two_children_with_related_events_on_origin.ts @@ -76,13 +76,6 @@ export function noAncestorsTwoChildrenWithRelatedEventsOnOrigin(): { return Promise.resolve(tree); }, - /** - * Get an array of index patterns that contain events. - */ - indexPatterns(): string[] { - return ['index pattern']; - }, - /** * Get entities matching a document. */ diff --git a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts index baddcdfd0cd8..6a4955b104b8 100644 --- a/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts +++ b/x-pack/plugins/security_solution/public/resolver/data_access_layer/mocks/pausify_mock.ts @@ -105,13 +105,6 @@ export function pausifyMock<T>({ return dataAccessLayer.resolverTree(...args); }, - /** - * Get an array of index patterns that contain events. - */ - indexPatterns(...args): string[] { - return dataAccessLayer.indexPatterns(...args); - }, - /** * Get entities matching a document. */ diff --git a/x-pack/plugins/security_solution/public/resolver/mocks/tree_fetcher_parameters.ts b/x-pack/plugins/security_solution/public/resolver/mocks/tree_fetcher_parameters.ts new file mode 100644 index 000000000000..98efb459a069 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/mocks/tree_fetcher_parameters.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TreeFetcherParameters } from '../types'; + +/** + * A factory for the most basic `TreeFetcherParameters`. Many tests need to provide this even when the values aren't relevant to the test. + */ +export function mockTreeFetcherParameters(): TreeFetcherParameters { + return { + databaseDocumentID: '', + indices: [], + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.test.ts b/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.test.ts new file mode 100644 index 000000000000..faa4edfccdc3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TreeFetcherParameters } from '../types'; + +import { equal } from './tree_fetcher_parameters'; +describe('TreeFetcherParameters#equal:', () => { + const cases: Array<[TreeFetcherParameters, TreeFetcherParameters, boolean]> = [ + // different databaseDocumentID + [{ databaseDocumentID: 'a', indices: [] }, { databaseDocumentID: 'b', indices: [] }, false], + // different indices length + [{ databaseDocumentID: 'a', indices: [''] }, { databaseDocumentID: 'a', indices: [] }, false], + // same indices length, different databaseDocumentID + [{ databaseDocumentID: 'a', indices: [''] }, { databaseDocumentID: 'b', indices: [''] }, false], + // 1 item in `indices` + [{ databaseDocumentID: 'b', indices: [''] }, { databaseDocumentID: 'b', indices: [''] }, true], + // 2 item in `indices` + [ + { databaseDocumentID: 'b', indices: ['1', '2'] }, + { databaseDocumentID: 'b', indices: ['1', '2'] }, + true, + ], + // 2 item in `indices`, but order inversed + [ + { databaseDocumentID: 'b', indices: ['2', '1'] }, + { databaseDocumentID: 'b', indices: ['1', '2'] }, + true, + ], + ]; + describe.each(cases)('%p when compared to %p', (first, second, expected) => { + it(`should ${expected ? '' : 'not'}be equal`, () => { + expect(equal(first, second)).toBe(expected); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.ts b/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.ts new file mode 100644 index 000000000000..d8280c749090 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/models/tree_fetcher_parameters.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { TreeFetcherParameters } from '../types'; + +/** + * Determine if two instances of `TreeFetcherParameters` are equivalent. Use this to determine if + * a change to a `TreeFetcherParameters` warrants invaliding a request or response. + */ +export function equal(param1: TreeFetcherParameters, param2?: TreeFetcherParameters): boolean { + if (!param2) { + return false; + } + if (param1 === param2) { + return true; + } + if (param1.databaseDocumentID !== param2.databaseDocumentID) { + return false; + } + return arraysContainTheSameElements(param1.indices, param2.indices); +} + +function arraysContainTheSameElements(first: unknown[], second: unknown[]): boolean { + if (first === second) { + return true; + } + if (first.length !== second.length) { + return false; + } + const firstSet = new Set(first); + for (let index = 0; index < second.length; index++) { + if (!firstSet.has(second[index])) { + return false; + } + } + return true; +} diff --git a/x-pack/plugins/security_solution/public/resolver/store/actions.ts b/x-pack/plugins/security_solution/public/resolver/store/actions.ts index e03f24d78e2a..7d71cbd97b9e 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/actions.ts @@ -115,7 +115,7 @@ interface AppReceivedNewExternalProperties { /** * the `_id` of an ES document. This defines the origin of the Resolver graph. */ - databaseDocumentID?: string; + databaseDocumentID: string; /** * An ID that uniquely identifies this Resolver instance from other concurrent Resolvers. */ @@ -125,6 +125,11 @@ interface AppReceivedNewExternalProperties { * The `search` part of the URL of this page. */ locationSearch: string; + + /** + * Indices that the backend will use to find the document. + */ + indices: string[]; }; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index 466c37d4ad5f..59d1494ae8c2 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -5,6 +5,7 @@ */ import { ResolverRelatedEvents, ResolverTree } from '../../../../common/endpoint/types'; +import { TreeFetcherParameters } from '../../types'; interface ServerReturnedResolverData { readonly type: 'serverReturnedResolverData'; @@ -14,9 +15,9 @@ interface ServerReturnedResolverData { */ result: ResolverTree; /** - * The database document ID that was used to fetch the resolver tree + * The database parameters that was used to fetch the resolver tree */ - databaseDocumentID: string; + parameters: TreeFetcherParameters; }; } @@ -25,7 +26,7 @@ interface AppRequestedResolverData { /** * entity ID used to make the request. */ - readonly payload: string; + readonly payload: TreeFetcherParameters; } interface ServerFailedToReturnResolverData { @@ -33,7 +34,7 @@ interface ServerFailedToReturnResolverData { /** * entity ID used to make the failed request */ - readonly payload: string; + readonly payload: TreeFetcherParameters; } interface AppAbortedResolverDataRequest { @@ -41,7 +42,7 @@ interface AppAbortedResolverDataRequest { /** * entity ID used to make the aborted request */ - readonly payload: string; + readonly payload: TreeFetcherParameters; } /** diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts index 21c4f92f8e50..e6e525334e81 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.test.ts @@ -12,6 +12,7 @@ import { DataState } from '../../types'; import { DataAction } from './action'; import { ResolverChildNode, ResolverTree } from '../../../../common/endpoint/types'; import * as eventModel from '../../../../common/endpoint/models/event'; +import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; /** * Test the data reducer and selector. @@ -27,7 +28,7 @@ describe('Resolver Data Middleware', () => { type: 'serverReturnedResolverData', payload: { result: tree, - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }; store.dispatch(action); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index c43182ddbf83..c8df95aaee6f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -7,34 +7,53 @@ import { Reducer } from 'redux'; import { DataState } from '../../types'; import { ResolverAction } from '../actions'; +import * as treeFetcherParameters from '../../models/tree_fetcher_parameters'; const initialState: DataState = { relatedEvents: new Map(), relatedEventsReady: new Map(), resolverComponentInstanceID: undefined, + tree: {}, }; export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialState, action) => { if (action.type === 'appReceivedNewExternalProperties') { const nextState: DataState = { ...state, - databaseDocumentID: action.payload.databaseDocumentID, + tree: { + ...state.tree, + currentParameters: { + databaseDocumentID: action.payload.databaseDocumentID, + indices: action.payload.indices, + }, + }, resolverComponentInstanceID: action.payload.resolverComponentInstanceID, }; return nextState; } else if (action.type === 'appRequestedResolverData') { // keep track of what we're requesting, this way we know when to request and when not to. - return { + const nextState: DataState = { ...state, - pendingRequestDatabaseDocumentID: action.payload, + tree: { + ...state.tree, + pendingRequestParameters: { + databaseDocumentID: action.payload.databaseDocumentID, + indices: action.payload.indices, + }, + }, }; + return nextState; } else if (action.type === 'appAbortedResolverDataRequest') { - if (action.payload === state.pendingRequestDatabaseDocumentID) { + if (treeFetcherParameters.equal(action.payload, state.tree.pendingRequestParameters)) { // the request we were awaiting was aborted - return { + const nextState: DataState = { ...state, - pendingRequestDatabaseDocumentID: undefined, + tree: { + ...state.tree, + pendingRequestParameters: undefined, + }, }; + return nextState; } else { return state; } @@ -43,29 +62,35 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialS const nextState: DataState = { ...state, - /** - * Store the last received data, as well as the databaseDocumentID it relates to. - */ - lastResponse: { - result: action.payload.result, - databaseDocumentID: action.payload.databaseDocumentID, - successful: true, - }, + tree: { + ...state.tree, + /** + * Store the last received data, as well as the databaseDocumentID it relates to. + */ + lastResponse: { + result: action.payload.result, + parameters: action.payload.parameters, + successful: true, + }, - // This assumes that if we just received something, there is no longer a pending request. - // This cannot model multiple in-flight requests - pendingRequestDatabaseDocumentID: undefined, + // This assumes that if we just received something, there is no longer a pending request. + // This cannot model multiple in-flight requests + pendingRequestParameters: undefined, + }, }; return nextState; } else if (action.type === 'serverFailedToReturnResolverData') { /** Only handle this if we are expecting a response */ - if (state.pendingRequestDatabaseDocumentID !== undefined) { + if (state.tree.pendingRequestParameters !== undefined) { const nextState: DataState = { ...state, - pendingRequestDatabaseDocumentID: undefined, - lastResponse: { - databaseDocumentID: state.pendingRequestDatabaseDocumentID, - successful: false, + tree: { + ...state.tree, + pendingRequestParameters: undefined, + lastResponse: { + parameters: state.tree.pendingRequestParameters, + successful: false, + }, }, }; return nextState; @@ -76,16 +101,18 @@ export const dataReducer: Reducer<DataState, ResolverAction> = (state = initialS action.type === 'userRequestedRelatedEventData' || action.type === 'appDetectedMissingEventData' ) { - return { + const nextState: DataState = { ...state, relatedEventsReady: new Map([...state.relatedEventsReady, [action.payload, false]]), }; + return nextState; } else if (action.type === 'serverReturnedRelatedEventData') { - return { + const nextState: DataState = { ...state, relatedEventsReady: new Map([...state.relatedEventsReady, [action.payload.entityID, true]]), relatedEvents: new Map([...state.relatedEvents, [action.payload.entityID, action.payload]]), }; + return nextState; } else { return state; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index dc478ede7279..539325faffdf 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -18,6 +18,7 @@ import { } from '../../mocks/resolver_tree'; import { uniquePidForProcess } from '../../models/process_event'; import { EndpointEvent } from '../../../../common/endpoint/types'; +import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; describe('data state', () => { let actions: ResolverAction[] = []; @@ -39,29 +40,32 @@ describe('data state', () => { */ const viewAsAString = (dataState: DataState) => { return [ - ['is loading', selectors.isLoading(dataState)], - ['has an error', selectors.hasError(dataState)], + ['is loading', selectors.isTreeLoading(dataState)], + ['has an error', selectors.hadErrorLoadingTree(dataState)], ['has more children', selectors.hasMoreChildren(dataState)], ['has more ancestors', selectors.hasMoreAncestors(dataState)], - ['document to fetch', selectors.databaseDocumentIDToFetch(dataState)], - ['requires a pending request to be aborted', selectors.databaseDocumentIDToAbort(dataState)], + ['parameters to fetch', selectors.treeParametersToFetch(dataState)], + [ + 'requires a pending request to be aborted', + selectors.treeRequestParametersToAbort(dataState), + ], ] .map(([message, value]) => `${message}: ${JSON.stringify(value)}`) .join('\n'); }; - it(`shouldn't initially be loading, or have an error, or have more children or ancestors, or have a document to fetch, or have a pending request that needs to be aborted.`, () => { + it(`shouldn't initially be loading, or have an error, or have more children or ancestors, or have a request to make, or have a pending request that needs to be aborted.`, () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: false has an error: false has more children: false has more ancestors: false - document to fetch: null + parameters to fetch: null requires a pending request to be aborted: null" `); }); - describe('when there is a databaseDocumentID but no pending request', () => { + describe('when there are parameters to fetch but no pending request', () => { const databaseDocumentID = 'databaseDocumentID'; const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { @@ -74,12 +78,13 @@ describe('data state', () => { // `locationSearch` doesn't matter for this test locationSearch: '', + indices: [], }, }, ]; }); - it('should need to fetch the databaseDocumentID', () => { - expect(selectors.databaseDocumentIDToFetch(state())).toBe(databaseDocumentID); + it('should need to request the tree', () => { + expect(selectors.treeParametersToFetch(state())?.databaseDocumentID).toBe(databaseDocumentID); }); it('should not be loading, have an error, have more children or ancestors, or have a pending request that needs to be aborted.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` @@ -87,39 +92,41 @@ describe('data state', () => { has an error: false has more children: false has more ancestors: false - document to fetch: \\"databaseDocumentID\\" + parameters to fetch: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[]} requires a pending request to be aborted: null" `); }); }); - describe('when there is a pending request but no databaseDocumentID', () => { + describe('when there is a pending request but no current tree fetching parameters', () => { const databaseDocumentID = 'databaseDocumentID'; beforeEach(() => { actions = [ { type: 'appRequestedResolverData', - payload: databaseDocumentID, + payload: { databaseDocumentID, indices: [] }, }, ]; }); it('should be loading', () => { - expect(selectors.isLoading(state())).toBe(true); + expect(selectors.isTreeLoading(state())).toBe(true); }); it('should have a request to abort', () => { - expect(selectors.databaseDocumentIDToAbort(state())).toBe(databaseDocumentID); + expect(selectors.treeRequestParametersToAbort(state())?.databaseDocumentID).toBe( + databaseDocumentID + ); }); - it('should not have an error, more children, more ancestors, or a document to fetch.', () => { + it('should not have an error, more children, more ancestors, or request to make.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: true has an error: false has more children: false has more ancestors: false - document to fetch: null - requires a pending request to be aborted: \\"databaseDocumentID\\"" + parameters to fetch: null + requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"databaseDocumentID\\",\\"indices\\":[]}" `); }); }); - describe('when there is a pending request for the current databaseDocumentID', () => { + describe('when there is a pending request that was made using the current parameters', () => { const databaseDocumentID = 'databaseDocumentID'; const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { @@ -132,27 +139,28 @@ describe('data state', () => { // `locationSearch` doesn't matter for this test locationSearch: '', + indices: [], }, }, { type: 'appRequestedResolverData', - payload: databaseDocumentID, + payload: { databaseDocumentID, indices: [] }, }, ]; }); it('should be loading', () => { - expect(selectors.isLoading(state())).toBe(true); + expect(selectors.isTreeLoading(state())).toBe(true); }); it('should not have a request to abort', () => { - expect(selectors.databaseDocumentIDToAbort(state())).toBe(null); + expect(selectors.treeRequestParametersToAbort(state())).toBe(null); }); - it('should not have an error, more children, more ancestors, a document to begin fetching, or a pending request that should be aborted.', () => { + it('should not have an error, more children, more ancestors, a request to make, or a pending request that should be aborted.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: true has an error: false has more children: false has more ancestors: false - document to fetch: null + parameters to fetch: null requires a pending request to be aborted: null" `); }); @@ -160,28 +168,28 @@ describe('data state', () => { beforeEach(() => { actions.push({ type: 'serverFailedToReturnResolverData', - payload: databaseDocumentID, + payload: { databaseDocumentID, indices: [] }, }); }); it('should not be loading', () => { - expect(selectors.isLoading(state())).toBe(false); + expect(selectors.isTreeLoading(state())).toBe(false); }); it('should have an error', () => { - expect(selectors.hasError(state())).toBe(true); + expect(selectors.hadErrorLoadingTree(state())).toBe(true); }); - it('should not be loading, have more children, have more ancestors, have a document to fetch, or have a pending request that needs to be aborted.', () => { + it('should not be loading, have more children, have more ancestors, have a request to make, or have a pending request that needs to be aborted.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: false has an error: true has more children: false has more ancestors: false - document to fetch: null + parameters to fetch: null requires a pending request to be aborted: null" `); }); }); }); - describe('when there is a pending request for a different databaseDocumentID than the current one', () => { + describe('when there is a pending request that was made with parameters that are different than the current tree fetching parameters', () => { const firstDatabaseDocumentID = 'first databaseDocumentID'; const secondDatabaseDocumentID = 'second databaseDocumentID'; const resolverComponentInstanceID1 = 'resolverComponentInstanceID1'; @@ -196,12 +204,13 @@ describe('data state', () => { resolverComponentInstanceID: resolverComponentInstanceID1, // `locationSearch` doesn't matter for this test locationSearch: '', + indices: [], }, }, // this happens when the middleware starts the request { type: 'appRequestedResolverData', - payload: firstDatabaseDocumentID, + payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [] }, }, // receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one { @@ -211,18 +220,23 @@ describe('data state', () => { resolverComponentInstanceID: resolverComponentInstanceID2, // `locationSearch` doesn't matter for this test locationSearch: '', + indices: [], }, }, ]; }); it('should be loading', () => { - expect(selectors.isLoading(state())).toBe(true); + expect(selectors.isTreeLoading(state())).toBe(true); }); - it('should need to fetch the second databaseDocumentID', () => { - expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); + it('should need to request the tree using the second set of parameters', () => { + expect(selectors.treeParametersToFetch(state())?.databaseDocumentID).toBe( + secondDatabaseDocumentID + ); }); it('should need to abort the request for the databaseDocumentID', () => { - expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); + expect(selectors.treeParametersToFetch(state())?.databaseDocumentID).toBe( + secondDatabaseDocumentID + ); }); it('should use the correct location for the second resolver', () => { expect(selectors.resolverComponentInstanceID(state())).toBe(resolverComponentInstanceID2); @@ -233,25 +247,27 @@ describe('data state', () => { has an error: false has more children: false has more ancestors: false - document to fetch: \\"second databaseDocumentID\\" - requires a pending request to be aborted: \\"first databaseDocumentID\\"" + parameters to fetch: {\\"databaseDocumentID\\":\\"second databaseDocumentID\\",\\"indices\\":[]} + requires a pending request to be aborted: {\\"databaseDocumentID\\":\\"first databaseDocumentID\\",\\"indices\\":[]}" `); }); describe('and when the old request was aborted', () => { beforeEach(() => { actions.push({ type: 'appAbortedResolverDataRequest', - payload: firstDatabaseDocumentID, + payload: { databaseDocumentID: firstDatabaseDocumentID, indices: [] }, }); }); it('should not require a pending request to be aborted', () => { - expect(selectors.databaseDocumentIDToAbort(state())).toBe(null); + expect(selectors.treeRequestParametersToAbort(state())).toBe(null); }); it('should have a document to fetch', () => { - expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); + expect(selectors.treeParametersToFetch(state())?.databaseDocumentID).toBe( + secondDatabaseDocumentID + ); }); it('should not be loading', () => { - expect(selectors.isLoading(state())).toBe(false); + expect(selectors.isTreeLoading(state())).toBe(false); }); it('should not have an error, more children, or more ancestors.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` @@ -259,7 +275,7 @@ describe('data state', () => { has an error: false has more children: false has more ancestors: false - document to fetch: \\"second databaseDocumentID\\" + parameters to fetch: {\\"databaseDocumentID\\":\\"second databaseDocumentID\\",\\"indices\\":[]} requires a pending request to be aborted: null" `); }); @@ -267,14 +283,14 @@ describe('data state', () => { beforeEach(() => { actions.push({ type: 'appRequestedResolverData', - payload: secondDatabaseDocumentID, + payload: { databaseDocumentID: secondDatabaseDocumentID, indices: [] }, }); }); it('should not have a document ID to fetch', () => { - expect(selectors.databaseDocumentIDToFetch(state())).toBe(null); + expect(selectors.treeParametersToFetch(state())).toBe(null); }); it('should be loading', () => { - expect(selectors.isLoading(state())).toBe(true); + expect(selectors.isTreeLoading(state())).toBe(true); }); it('should not have an error, more children, more ancestors, or a pending request that needs to be aborted.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` @@ -282,7 +298,7 @@ describe('data state', () => { has an error: false has more children: false has more ancestors: false - document to fetch: null + parameters to fetch: null requires a pending request to be aborted: null" `); }); @@ -303,7 +319,7 @@ describe('data state', () => { secondAncestorID, }), // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -331,7 +347,7 @@ describe('data state', () => { secondAncestorID, }), // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -355,7 +371,7 @@ describe('data state', () => { payload: { result: mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -386,7 +402,7 @@ describe('data state', () => { payload: { result: tree, // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -417,7 +433,7 @@ describe('data state', () => { payload: { result: tree, // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -433,7 +449,7 @@ describe('data state', () => { payload: { result: tree, // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index eaa80b46471f..e647828ddb60 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -15,6 +15,7 @@ import { AABB, VisibleEntites, SectionData, + TreeFetcherParameters, } from '../../types'; import { isGraphableProcess, @@ -34,6 +35,7 @@ import { LegacyEndpointEvent, } from '../../../../common/endpoint/types'; import * as resolverTreeModel from '../../models/resolver_tree'; +import * as treeFetcherParametersModel from '../../models/tree_fetcher_parameters'; import * as isometricTaxiLayoutModel from '../../models/indexed_process_tree/isometric_taxi_layout'; import * as eventModel from '../../../../common/endpoint/models/event'; import * as vector2 from '../../models/vector2'; @@ -42,26 +44,25 @@ import { formatDate } from '../../view/panels/panel_content_utilities'; /** * If there is currently a request. */ -export function isLoading(state: DataState): boolean { - return state.pendingRequestDatabaseDocumentID !== undefined; +export function isTreeLoading(state: DataState): boolean { + return state.tree.pendingRequestParameters !== undefined; } /** - * A string for uniquely identifying the instance of resolver within the app. + * If a request was made and it threw an error or returned a failure response code. */ -export function resolverComponentInstanceID(state: DataState): string { - return state.resolverComponentInstanceID ? state.resolverComponentInstanceID : ''; +export function hadErrorLoadingTree(state: DataState): boolean { + if (state.tree.lastResponse) { + return !state.tree.lastResponse.successful; + } + return false; } /** - * If a request was made and it threw an error or returned a failure response code. + * A string for uniquely identifying the instance of resolver within the app. */ -export function hasError(state: DataState): boolean { - if (state.lastResponse && state.lastResponse.successful === false) { - return true; - } else { - return false; - } +export function resolverComponentInstanceID(state: DataState): string { + return state.resolverComponentInstanceID ? state.resolverComponentInstanceID : ''; } /** @@ -69,11 +70,7 @@ export function hasError(state: DataState): boolean { * we're currently interested in. */ const resolverTreeResponse = (state: DataState): ResolverTree | undefined => { - if (state.lastResponse && state.lastResponse.successful) { - return state.lastResponse.result; - } else { - return undefined; - } + return state.tree.lastResponse?.successful ? state.tree.lastResponse.result : undefined; }; /** @@ -458,18 +455,24 @@ export const relatedEventInfoByEntityId: ( ); /** - * If we need to fetch, this is the ID to fetch. + * If the tree resource needs to be fetched then these are the parameters that should be used. */ -export function databaseDocumentIDToFetch(state: DataState): string | null { - // If there is an ID, it must match either the last received version, or the pending version. - // Otherwise, we need to fetch it - // NB: this technique will not allow for refreshing of data. +export function treeParametersToFetch(state: DataState): TreeFetcherParameters | null { + /** + * If there are current tree parameters that don't match the parameters used in the pending request (if there is a pending request) and that don't match the parameters used in the last completed request (if there was a last completed request) then we need to fetch the tree resource using the current parameters. + */ if ( - state.databaseDocumentID !== undefined && - state.databaseDocumentID !== state.pendingRequestDatabaseDocumentID && - state.databaseDocumentID !== state.lastResponse?.databaseDocumentID + state.tree.currentParameters !== undefined && + !treeFetcherParametersModel.equal( + state.tree.currentParameters, + state.tree.lastResponse?.parameters + ) && + !treeFetcherParametersModel.equal( + state.tree.currentParameters, + state.tree.pendingRequestParameters + ) ) { - return state.databaseDocumentID; + return state.tree.currentParameters; } else { return null; } @@ -692,15 +695,18 @@ export const nodesAndEdgelines: ( /** * If there is a pending request that's for a entity ID that doesn't matche the `entityID`, then we should cancel it. */ -export function databaseDocumentIDToAbort(state: DataState): string | null { +export function treeRequestParametersToAbort(state: DataState): TreeFetcherParameters | null { /** - * If there is a pending request, and its not for the current databaseDocumentID (even, if the current databaseDocumentID is undefined) then we should abort the request. + * If there is a pending request, and its not for the current parameters (even, if the current parameters are undefined) then we should abort the request. */ if ( - state.pendingRequestDatabaseDocumentID !== undefined && - state.pendingRequestDatabaseDocumentID !== state.databaseDocumentID + state.tree.pendingRequestParameters !== undefined && + !treeFetcherParametersModel.equal( + state.tree.pendingRequestParameters, + state.tree.currentParameters + ) ) { - return state.pendingRequestDatabaseDocumentID; + return state.tree.pendingRequestParameters; } else { return null; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts index e91c455c9445..28948debae89 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/visible_entities.test.ts @@ -12,6 +12,7 @@ import { LegacyEndpointEvent, ResolverEvent } from '../../../../common/endpoint/ import { visibleNodesAndEdgeLines } from '../selectors'; import { mockProcessEvent } from '../../models/process_event_test_helpers'; import { mock as mockResolverTree } from '../../models/resolver_tree'; +import { mockTreeFetcherParameters } from '../../mocks/tree_fetcher_parameters'; describe('resolver visible entities', () => { let processA: LegacyEndpointEvent; @@ -112,7 +113,7 @@ describe('resolver visible entities', () => { ]; const action: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: mockResolverTree({ events })!, databaseDocumentID: '' }, + payload: { result: mockResolverTree({ events })!, parameters: mockTreeFetcherParameters() }, }; const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [300, 200] }; store.dispatch(action); @@ -140,7 +141,7 @@ describe('resolver visible entities', () => { ]; const action: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: mockResolverTree({ events })!, databaseDocumentID: '' }, + payload: { result: mockResolverTree({ events })!, parameters: mockTreeFetcherParameters() }, }; const cameraAction: ResolverAction = { type: 'userSetRasterSize', payload: [2000, 2000] }; store.dispatch(action); diff --git a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts index 0ec340efbdac..ef4ca2380ebf 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/middleware/resolver_tree_fetcher.ts @@ -28,32 +28,31 @@ export function ResolverTreeFetcher( // if the entityID changes while return async () => { const state = api.getState(); - const databaseDocumentIDToFetch = selectors.databaseDocumentIDToFetch(state); + const databaseParameters = selectors.treeParametersToFetch(state); - if (selectors.databaseDocumentIDToAbort(state) && lastRequestAbortController) { + if (selectors.treeRequestParametersToAbort(state) && lastRequestAbortController) { lastRequestAbortController.abort(); // calling abort will cause an action to be fired - } else if (databaseDocumentIDToFetch !== null) { + } else if (databaseParameters !== null) { lastRequestAbortController = new AbortController(); let result: ResolverTree | undefined; // Inform the state that we've made the request. Without this, the middleware will try to make the request again // immediately. api.dispatch({ type: 'appRequestedResolverData', - payload: databaseDocumentIDToFetch, + payload: databaseParameters, }); try { - const indices: string[] = dataAccessLayer.indexPatterns(); const matchingEntities: ResolverEntityIndex = await dataAccessLayer.entities({ - _id: databaseDocumentIDToFetch, - indices, + _id: databaseParameters.databaseDocumentID, + indices: databaseParameters.indices ?? [], signal: lastRequestAbortController.signal, }); if (matchingEntities.length < 1) { // If no entity_id could be found for the _id, bail out with a failure. api.dispatch({ type: 'serverFailedToReturnResolverData', - payload: databaseDocumentIDToFetch, + payload: databaseParameters, }); return; } @@ -67,12 +66,12 @@ export function ResolverTreeFetcher( if (error instanceof DOMException && error.name === 'AbortError') { api.dispatch({ type: 'appAbortedResolverDataRequest', - payload: databaseDocumentIDToFetch, + payload: databaseParameters, }); } else { api.dispatch({ type: 'serverFailedToReturnResolverData', - payload: databaseDocumentIDToFetch, + payload: databaseParameters, }); } } @@ -81,7 +80,7 @@ export function ResolverTreeFetcher( type: 'serverReturnedResolverData', payload: { result, - databaseDocumentID: databaseDocumentIDToFetch, + parameters: databaseParameters, }, }); } diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts index f113e861d3ce..d15274f0363a 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.test.ts @@ -14,6 +14,7 @@ import { mockTreeWithNoAncestorsAnd2Children, } from '../mocks/resolver_tree'; import { SafeResolverEvent } from '../../../common/endpoint/types'; +import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters'; describe('resolver selectors', () => { const actions: ResolverAction[] = []; @@ -43,7 +44,7 @@ describe('resolver selectors', () => { secondAncestorID, }), // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); @@ -77,7 +78,7 @@ describe('resolver selectors', () => { payload: { result: mockTreeWithNoAncestorsAnd2Children({ originID, firstChildID, secondChildID }), // this value doesn't matter - databaseDocumentID: '', + parameters: mockTreeFetcherParameters(), }, }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index bdea08df3d7f..8ea0bc9199cb 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -84,14 +84,14 @@ export const layout: (state: ResolverState) => IsometricTaxiLayout = composeSele /** * If we need to fetch, this is the entity ID to fetch. */ -export const databaseDocumentIDToFetch = composeSelectors( +export const treeParametersToFetch = composeSelectors( dataStateSelector, - dataSelectors.databaseDocumentIDToFetch + dataSelectors.treeParametersToFetch ); -export const databaseDocumentIDToAbort = composeSelectors( +export const treeRequestParametersToAbort = composeSelectors( dataStateSelector, - dataSelectors.databaseDocumentIDToAbort + dataSelectors.treeRequestParametersToAbort ); export const resolverComponentInstanceID = composeSelectors( @@ -207,12 +207,15 @@ function uiStateSelector(state: ResolverState) { /** * Whether or not the resolver is pending fetching data */ -export const isLoading = composeSelectors(dataStateSelector, dataSelectors.isLoading); +export const isTreeLoading = composeSelectors(dataStateSelector, dataSelectors.isTreeLoading); /** * Whether or not the resolver encountered an error while fetching data */ -export const hasError = composeSelectors(dataStateSelector, dataSelectors.hasError); +export const hadErrorLoadingTree = composeSelectors( + dataStateSelector, + dataSelectors.hadErrorLoadingTree +); /** * True if the children cursor is not null diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index a6520c8f0e06..9d10d1c2b64a 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -49,6 +49,7 @@ export class Simulator { dataAccessLayer, resolverComponentInstanceID, databaseDocumentID, + indices, history, }: { /** @@ -59,10 +60,14 @@ export class Simulator { * A string that uniquely identifies this Resolver instance among others mounted in the DOM. */ resolverComponentInstanceID: string; + /** + * Indices that the backend would use to find the document ID. + */ + indices: string[]; /** * a databaseDocumentID to pass to Resolver. Resolver will use this in requests to the mock data layer. */ - databaseDocumentID?: string; + databaseDocumentID: string; history?: HistoryPackageHistoryInterface<never>; }) { // create the spy middleware (for debugging tests) @@ -99,6 +104,7 @@ export class Simulator { store={this.store} coreStart={coreStart} databaseDocumentID={databaseDocumentID} + indices={indices} /> ); } @@ -124,6 +130,20 @@ export class Simulator { this.wrapper.setProps({ resolverComponentInstanceID: value }); } + /** + * Change the indices (updates the React component props.) + */ + public set indices(value: string[]) { + this.wrapper.setProps({ indices: value }); + } + + /** + * Get the indices (updates the React component props.) + */ + public get indices(): string[] { + return this.wrapper.prop('indices'); + } + /** * Call this to console.log actions (and state). Use this to debug your tests. * State and actions aren't exposed otherwise because the tests using this simulator should diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx index 5d5a414761db..89218e9fca8c 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/mock_resolver.tsx @@ -99,6 +99,7 @@ export const MockResolver = React.memo((props: MockResolverProps) => { ref={resolverRef} databaseDocumentID={props.databaseDocumentID} resolverComponentInstanceID={props.resolverComponentInstanceID} + indices={props.indices} /> </Provider> </SideEffectContext.Provider> diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index e8304bf838e2..1e7e2a8eba8a 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -194,53 +194,68 @@ export interface VisibleEntites { connectingEdgeLineSegments: EdgeLineSegment[]; } +export interface TreeFetcherParameters { + /** + * The `_id` for an ES document. Used to select a process that we'll show the graph for. + */ + databaseDocumentID: string; + + /** + * The indices that the backend will use to search for the document ID. + */ + indices: string[]; +} + /** * State for `data` reducer which handles receiving Resolver data from the back-end. */ export interface DataState { readonly relatedEvents: Map<string, ResolverRelatedEvents>; readonly relatedEventsReady: Map<string, boolean>; - /** - * The `_id` for an ES document. Used to select a process that we'll show the graph for. - */ - readonly databaseDocumentID?: string; - /** - * The id used for the pending request, if there is one. - */ - readonly pendingRequestDatabaseDocumentID?: string; + + readonly tree: { + /** + * The parameters passed from the resolver properties + */ + readonly currentParameters?: TreeFetcherParameters; + + /** + * The id used for the pending request, if there is one. + */ + readonly pendingRequestParameters?: TreeFetcherParameters; + /** + * The parameters and response from the last successful request. + */ + readonly lastResponse?: { + /** + * The id used in the request. + */ + readonly parameters: TreeFetcherParameters; + } & ( + | { + /** + * If a response with a success code was received, this is `true`. + */ + readonly successful: true; + /** + * The ResolverTree parsed from the response. + */ + readonly result: ResolverTree; + } + | { + /** + * If the request threw an exception or the response had a failure code, this will be false. + */ + readonly successful: false; + } + ); + }; /** * An ID that is used to differentiate this Resolver instance from others concurrently running on the same page. * Used to prevent collisions in things like query parameters. */ readonly resolverComponentInstanceID?: string; - - /** - * The parameters and response from the last successful request. - */ - readonly lastResponse?: { - /** - * The id used in the request. - */ - readonly databaseDocumentID: string; - } & ( - | { - /** - * If a response with a success code was received, this is `true`. - */ - readonly successful: true; - /** - * The ResolverTree parsed from the response. - */ - readonly result: ResolverTree; - } - | { - /** - * If the request threw an exception or the response had a failure code, this will be false. - */ - readonly successful: false; - } - ); } /** @@ -494,11 +509,6 @@ export interface DataAccessLayer { */ resolverTree: (entityID: string, signal: AbortSignal) => Promise<ResolverTree>; - /** - * Get an array of index patterns that contain events. - */ - indexPatterns: () => string[]; - /** * Get entities matching a document. */ @@ -524,13 +534,18 @@ export interface ResolverProps { * The `_id` value of an event in ES. * Used as the origin of the Resolver graph. */ - databaseDocumentID?: string; + databaseDocumentID: string; /** * An ID that is used to differentiate this Resolver instance from others concurrently running on the same page. * Used to prevent collisions in things like query parameters. */ resolverComponentInstanceID: string; + + /** + * Indices that the backend should use to find the originating document. + */ + indices: string[]; } /** diff --git a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx index 1e5ac093cac7..223ce728f426 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/clickthrough.test.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { noAncestorsTwoChildenInIndexCalledAwesomeIndex } from '../data_access_layer/mocks/no_ancestors_two_children_in_index_called_awesome_index'; import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children'; import { Simulator } from '../test_utilities/simulator'; // Extend jest with a custom matcher @@ -19,6 +20,62 @@ let entityIDs: { origin: string; firstChild: string; secondChild: string }; // the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances const resolverComponentInstanceID = 'resolverComponentInstanceID'; +describe("Resolver, when rendered with the `indices` prop set to `[]` and the `databaseDocumentID` prop set to `_id`, and when the document is found in an index called 'awesome_index'", () => { + beforeEach(async () => { + // create a mock data access layer + const { + metadata: dataAccessLayerMetadata, + dataAccessLayer, + } = noAncestorsTwoChildenInIndexCalledAwesomeIndex(); + + // save a reference to the entity IDs exposed by the mock data layer + entityIDs = dataAccessLayerMetadata.entityIDs; + + // save a reference to the `_id` supported by the mock data layer + databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + indices: [], + }); + }); + + it('should render no processes', async () => { + await expect( + simulator.map(() => ({ + processes: simulator.processNodeElements().length, + })) + ).toYieldEqualTo({ + processes: 0, + }); + }); + + describe("when rerendered with the `indices` prop set to `['awesome_index'`]", () => { + beforeEach(async () => { + simulator.indices = ['awesome_index']; + }); + // Combining assertions here for performance. Unfortunately, Enzyme + jsdom + React is slow. + it(`should have 3 nodes, with the entityID's 'origin', 'firstChild', and 'secondChild'. 'origin' should be selected when the simulator has the right indices`, async () => { + await expect( + simulator.map(() => ({ + selectedOriginCount: simulator.selectedProcessNode(entityIDs.origin).length, + unselectedFirstChildCount: simulator.unselectedProcessNode(entityIDs.firstChild).length, + unselectedSecondChildCount: simulator.unselectedProcessNode(entityIDs.secondChild).length, + nodePrimaryButtonCount: simulator.testSubject('resolver:node:primary-button').length, + })) + ).toYieldEqualTo({ + selectedOriginCount: 1, + unselectedFirstChildCount: 1, + unselectedSecondChildCount: 1, + nodePrimaryButtonCount: 3, + }); + }); + }); +}); + describe('Resolver, when analyzing a tree that has no ancestors and 2 children', () => { beforeEach(async () => { // create a mock data access layer @@ -31,7 +88,12 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children', databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; // create a resolver simulator, using the data access layer and an arbitrary component instance ID - simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + indices: [], + }); }); describe('when it has loaded', () => { @@ -159,7 +221,12 @@ describe('Resolver, when analyzing a tree that has two related events for the or databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; // create a resolver simulator, using the data access layer and an arbitrary component instance ID - simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + indices: [], + }); }); describe('when it has loaded', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx index 6497cc297198..95fe68d95d70 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/graph_controls.test.tsx @@ -34,6 +34,7 @@ describe('graph controls: when relsover is loaded with an origin node', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); originEntityID = entityIDs.origin; diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx index 1add907ae933..7021e476e643 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx @@ -49,6 +49,7 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an dataAccessLayer, resolverComponentInstanceID, history: memoryHistory, + indices: [], }); return simulatorInstance; } diff --git a/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts b/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts index a86237e0e2b4..e42de5009a0f 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts @@ -29,7 +29,12 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children', databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; // create a resolver simulator, using the data access layer and an arbitrary component instance ID - simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + simulator = new Simulator({ + databaseDocumentID, + dataAccessLayer, + resolverComponentInstanceID, + indices: [], + }); }); describe("when the second child node's first button has been clicked", () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx index c357ee18acfe..d8d8de640d78 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_loading_state.test.tsx @@ -26,6 +26,7 @@ describe('Resolver: data loading and resolution states', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); }); @@ -56,6 +57,7 @@ describe('Resolver: data loading and resolution states', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); }); @@ -85,6 +87,7 @@ describe('Resolver: data loading and resolution states', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); }); @@ -114,6 +117,7 @@ describe('Resolver: data loading and resolution states', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); }); @@ -145,6 +149,7 @@ describe('Resolver: data loading and resolution states', () => { dataAccessLayer, databaseDocumentID, resolverComponentInstanceID, + indices: [], }); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index aa845e7283eb..f4d471b384b3 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -31,7 +31,7 @@ export const ResolverWithoutProviders = React.memo( * Use `forwardRef` so that the `Simulator` used in testing can access the top level DOM element. */ React.forwardRef(function ( - { className, databaseDocumentID, resolverComponentInstanceID }: ResolverProps, + { className, databaseDocumentID, resolverComponentInstanceID, indices }: ResolverProps, refToForward ) { useResolverQueryParamCleaner(); @@ -39,7 +39,7 @@ export const ResolverWithoutProviders = React.memo( * This is responsible for dispatching actions that include any external data. * `databaseDocumentID` */ - useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); + useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID, indices }); const { timestamp } = useContext(SideEffectContext); @@ -69,8 +69,8 @@ export const ResolverWithoutProviders = React.memo( }, [cameraRef, refToForward] ); - const isLoading = useSelector(selectors.isLoading); - const hasError = useSelector(selectors.hasError); + const isLoading = useSelector(selectors.isTreeLoading); + const hasError = useSelector(selectors.hadErrorLoadingTree); const activeDescendantId = useSelector(selectors.ariaActiveDescendant); const { colorMap } = useResolverTheme(); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx index 630ee2f7ff7f..495cd238d22f 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/use_camera.test.tsx @@ -20,6 +20,7 @@ import { mock as mockResolverTree } from '../models/resolver_tree'; import { ResolverAction } from '../store/actions'; import { createStore } from 'redux'; import { resolverReducer } from '../store/reducer'; +import { mockTreeFetcherParameters } from '../mocks/tree_fetcher_parameters'; describe('useCamera on an unpainted element', () => { let element: HTMLElement; @@ -181,7 +182,7 @@ describe('useCamera on an unpainted element', () => { if (tree !== null) { const serverResponseAction: ResolverAction = { type: 'serverReturnedResolverData', - payload: { result: tree, databaseDocumentID: '' }, + payload: { result: tree, parameters: mockTreeFetcherParameters() }, }; act(() => { store.dispatch(serverResponseAction); diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts index eaba4438bb1f..7f3cdcbec76a 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts @@ -15,19 +15,21 @@ import { useResolverDispatch } from './use_resolver_dispatch'; export function useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID, + indices, }: { /** * The `_id` of an event in ES. Used to determine the origin of the Resolver graph. */ - databaseDocumentID?: string; + databaseDocumentID: string; resolverComponentInstanceID: string; + indices: string[]; }) { const dispatch = useResolverDispatch(); const locationSearch = useLocation().search; useLayoutEffect(() => { dispatch({ type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID, resolverComponentInstanceID, locationSearch }, + payload: { databaseDocumentID, resolverComponentInstanceID, locationSearch, indices }, }); - }, [dispatch, databaseDocumentID, resolverComponentInstanceID, locationSearch]); + }, [dispatch, databaseDocumentID, resolverComponentInstanceID, locationSearch, indices]); } diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 097166a9c866..08e9fb854e5a 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/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index ededf7015296..dc9557da70f9 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -19,7 +19,7 @@ import styled from 'styled-components'; import { FULL_SCREEN } from '../timeline/body/column_headers/translations'; import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations'; -import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; +import { DEFAULT_INDEX_KEY, FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; import { useFullScreen } from '../../../common/containers/use_full_screen'; import { State } from '../../../common/store'; import { TimelineId, TimelineType } from '../../../../common/types/timeline'; @@ -33,6 +33,8 @@ import { Resolver } from '../../../resolver/view'; import { useAllCasesModal } from '../../../cases/components/use_all_cases_modal'; import * as i18n from './translations'; +import { useUiSetting$ } from '../../../common/lib/kibana'; +import { useSignalIndex } from '../../../detections/containers/detection_engine/alerts/use_signal_index'; const OverlayContainer = styled.div` height: 100%; @@ -137,6 +139,16 @@ const GraphOverlayComponent = ({ globalFullScreen, ]); + const { signalIndexName } = useSignalIndex(); + const [siemDefaultIndices] = useUiSetting$<string[]>(DEFAULT_INDEX_KEY); + const indices: string[] | null = useMemo(() => { + if (signalIndexName === null) { + return null; + } else { + return [...siemDefaultIndices, signalIndexName]; + } + }, [signalIndexName, siemDefaultIndices]); + return ( <OverlayContainer> <EuiHorizontalRule margin="none" /> @@ -178,10 +190,13 @@ const GraphOverlayComponent = ({ </EuiFlexGroup> <EuiHorizontalRule margin="none" /> - <StyledResolver - databaseDocumentID={graphEventId} - resolverComponentInstanceID={currentTimeline.id} - /> + {graphEventId !== undefined && indices !== null && ( + <StyledResolver + databaseDocumentID={graphEventId} + resolverComponentInstanceID={currentTimeline.id} + indices={indices} + /> + )} <AllCasesModal /> </OverlayContainer> ); 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 2792b264ba7e..d01e8634a489 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<StatefulRowRenderersBrowser const hideFieldBrowser = useCallback(() => 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 7baa7c42fb45..f1414724e243 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/row_renderers_browser/row_renderers_browser.tsx @@ -88,7 +88,7 @@ const RowRenderersBrowserComponent = React.forwardRef( (item: RowRendererOption) => () => { const newSelection = xor([item], notExcludedRowRenderers); // @ts-expect-error - ref?.current?.setSelection(newSelection); // eslint-disable-line no-unused-expressions + ref?.current?.setSelection(newSelection); }, [notExcludedRowRenderers, ref] ); diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts index bcac559d61f7..510bb6c54555 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/entity.ts @@ -18,11 +18,6 @@ export function handleEntities(): RequestHandler<unknown, TypeOf<typeof validate query: { _id, indices }, } = request; - const siemClient = context.securitySolution!.getAppClient(); - const queryIndices = indices; - // if the alert was promoted by a rule it will exist in the signals index so search there too - queryIndices.push(siemClient.getSignalsIndex()); - /** * A safe type for the response based on the semantics of the query. * We specify _source, asking for `process.entity_id` and we only @@ -36,8 +31,8 @@ export function handleEntities(): RequestHandler<unknown, TypeOf<typeof validate | [ { _source: { - process: { - entity_id: string; + process?: { + entity_id?: string; }; }; } @@ -49,7 +44,7 @@ export function handleEntities(): RequestHandler<unknown, TypeOf<typeof validate 'search', { ignoreUnavailable: true, - index: queryIndices, + index: indices, body: { // only return process.entity_id _source: 'process.entity_id', @@ -64,19 +59,6 @@ export function handleEntities(): RequestHandler<unknown, TypeOf<typeof validate values: _id, }, }, - { - exists: { - // only return documents that have process.entity_id - field: 'process.entity_id', - }, - }, - { - bool: { - must_not: { - term: { 'process.entity_id': '' }, - }, - }, - }, ], }, }, @@ -85,15 +67,13 @@ export function handleEntities(): RequestHandler<unknown, TypeOf<typeof validate ); const responseBody: ResolverEntityIndex = []; - for (const { - _source: { - // eslint-disable-next-line @typescript-eslint/naming-convention - process: { entity_id }, - }, - } of queryResponse.hits.hits) { - responseBody.push({ - entity_id, - }); + for (const hit of queryResponse.hits.hits) { + // check that the field is defined and that is not an empty string + if (hit._source.process?.entity_id) { + responseBody.push({ + entity_id: hit._source.process.entity_id, + }); + } } return response.ok({ body: responseBody }); }; 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 ac5132d93a06..be6e57aee6d0 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/hosts/all/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts index 5c29d2747f68..355082402847 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/all/helpers.ts @@ -5,8 +5,8 @@ */ import { set } from '@elastic/safer-lodash-set/fp'; import { get, has, head } from 'lodash/fp'; +import { hostFieldsMap } from '../../../../../../common/ecs/ecs_fields'; import { HostsEdges } from '../../../../../../common/search_strategy/security_solution/hosts'; -import { hostFieldsMap } from '../../../../../lib/ecs_fields'; import { HostAggEsItem, HostBuckets, HostValue } from '../../../../../lib/hosts/types'; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/dsl/query.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/dsl/query.dsl.ts index 35e4d2cc8e1f..df300c85e300 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/dsl/query.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/dsl/query.dsl.ts @@ -6,14 +6,14 @@ import { isEmpty } from 'lodash/fp'; -import { AuthenticationsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/hosts/authentications'; +import { HostAuthenticationsRequestOptions } from '../../../../../../../common/search_strategy/security_solution/hosts/authentications'; import { sourceFieldsMap, hostFieldsMap } from '../../../../../../../common/ecs/ecs_fields'; import { createQueryFilterClauses } from '../../../../../../utils/build_query'; import { reduceFields } from '../../../../../../utils/build_query/reduce_fields'; -import { extendMap } from '../../../../../../lib/ecs_fields/extend_map'; import { authenticationFields } from '../helpers'; +import { extendMap } from '../../../../../../../common/ecs/ecs_fields/extend_map'; export const auditdFieldsMap: Readonly<Record<string, string>> = { latest: '@timestamp', @@ -31,7 +31,7 @@ export const buildQuery = ({ pagination: { querySize }, defaultIndex, docValueFields, -}: AuthenticationsRequestOptions) => { +}: HostAuthenticationsRequestOptions) => { const esFields = reduceFields(authenticationFields, { ...hostFieldsMap, ...sourceFieldsMap }); const filter = [ diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/index.tsx b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/index.tsx index 200818c40dec..ded9a7917d92 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/index.tsx +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/authentications/index.tsx @@ -12,8 +12,8 @@ import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants import { HostsQueries, AuthenticationsEdges, - AuthenticationsRequestOptions, - AuthenticationsStrategyResponse, + HostAuthenticationsRequestOptions, + HostAuthenticationsStrategyResponse, AuthenticationHit, } from '../../../../../../common/search_strategy/security_solution/hosts'; @@ -23,7 +23,7 @@ import { auditdFieldsMap, buildQuery as buildAuthenticationQuery } from './dsl/q import { formatAuthenticationData, getHits } from './helpers'; export const authentications: SecuritySolutionFactory<HostsQueries.authentications> = { - buildDsl: (options: AuthenticationsRequestOptions) => { + buildDsl: (options: HostAuthenticationsRequestOptions) => { if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); } @@ -31,9 +31,9 @@ export const authentications: SecuritySolutionFactory<HostsQueries.authenticatio return buildAuthenticationQuery(options); }, parse: async ( - options: AuthenticationsRequestOptions, + options: HostAuthenticationsRequestOptions, response: IEsSearchResponse<unknown> - ): Promise<AuthenticationsStrategyResponse> => { + ): Promise<HostAuthenticationsStrategyResponse> => { const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; const totalCount = getOr(0, 'aggregations.user_count.value', response.rawResponse); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/helpers.ts index 48e210d82291..56f7aec2327a 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/helpers.ts @@ -5,11 +5,11 @@ */ import { set } from '@elastic/safer-lodash-set/fp'; import { get, has, head } from 'lodash/fp'; +import { hostFieldsMap } from '../../../../../common/ecs/ecs_fields'; import { HostsEdges, HostItem, } from '../../../../../common/search_strategy/security_solution/hosts'; -import { hostFieldsMap } from '../../../../lib/ecs_fields'; import { HostAggEsItem, HostBuckets, HostValue } from '../../../../lib/hosts/types'; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts index 6585abde6028..38d81c229ac5 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/index.ts @@ -13,11 +13,13 @@ import { SecuritySolutionFactory } from '../types'; import { allHosts } from './all'; import { overviewHost } from './overview'; import { firstLastSeenHost } from './last_first_seen'; +import { uncommonProcesses } from './uncommon_processes'; import { authentications } from './authentications'; export const hostsFactory: Record<HostsQueries, SecuritySolutionFactory<FactoryQueryTypes>> = { [HostsQueries.hosts]: allHosts, [HostsQueries.hostOverview]: overviewHost, [HostsQueries.firstLastSeen]: firstLastSeenHost, + [HostsQueries.uncommonProcesses]: uncommonProcesses, [HostsQueries.authentications]: authentications, }; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/helpers.ts index c7b0d8acc878..ed705e7f6ad5 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/helpers.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/helpers.ts @@ -5,8 +5,8 @@ */ import { set } from '@elastic/safer-lodash-set/fp'; import { get, has, head } from 'lodash/fp'; +import { hostFieldsMap } from '../../../../../../common/ecs/ecs_fields'; import { HostItem } from '../../../../../../common/search_strategy/security_solution/hosts'; -import { hostFieldsMap } from '../../../../../lib/ecs_fields'; import { HostAggEsItem, HostBuckets, HostValue } from '../../../../../lib/hosts/types'; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.host_overview.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.host_overview.dsl.ts index 913bc90df04b..85cc87414c38 100644 --- a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.host_overview.dsl.ts +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/overview/query.host_overview.dsl.ts @@ -5,8 +5,8 @@ */ import { ISearchRequestParams } from '../../../../../../../../../src/plugins/data/common'; +import { cloudFieldsMap, hostFieldsMap } from '../../../../../../common/ecs/ecs_fields'; import { HostOverviewRequestOptions } from '../../../../../../common/search_strategy/security_solution'; -import { cloudFieldsMap, hostFieldsMap } from '../../../../../lib/ecs_fields'; import { buildFieldsTermAggregation } from '../../../../../lib/hosts/helpers'; import { reduceFields } from '../../../../../utils/build_query/reduce_fields'; import { HOST_FIELDS } from './helpers'; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/dsl/query.dsl.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/dsl/query.dsl.ts new file mode 100644 index 000000000000..2e2d889dda11 --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/dsl/query.dsl.ts @@ -0,0 +1,226 @@ +/* + * 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 { createQueryFilterClauses } from '../../../../../../utils/build_query'; +import { reduceFields } from '../../../../../../utils/build_query/reduce_fields'; +import { + hostFieldsMap, + processFieldsMap, + userFieldsMap, +} from '../../../../../../../common/ecs/ecs_fields'; +import { RequestOptionsPaginated } from '../../../../../../../common/search_strategy/security_solution'; +import { uncommonProcessesFields } from '../helpers'; + +export const buildQuery = ({ + defaultIndex, + filterQuery, + pagination: { querySize }, + timerange: { from, to }, +}: RequestOptionsPaginated) => { + const processUserFields = reduceFields(uncommonProcessesFields, { + ...processFieldsMap, + ...userFieldsMap, + }); + const hostFields = reduceFields(uncommonProcessesFields, hostFieldsMap); + const filter = [ + ...createQueryFilterClauses(filterQuery), + { + range: { + '@timestamp': { + gte: from, + lte: to, + format: 'strict_date_optional_time', + }, + }, + }, + ]; + + const agg = { + process_count: { + cardinality: { + field: 'process.name', + }, + }, + }; + + const dslQuery = { + allowNoIndices: true, + index: defaultIndex, + ignoreUnavailable: true, + body: { + aggregations: { + ...agg, + group_by_process: { + terms: { + size: querySize, + field: 'process.name', + order: [ + { + host_count: 'asc', + }, + { + _count: 'asc', + }, + { + _key: 'asc', + }, + ], + }, + aggregations: { + process: { + top_hits: { + size: 1, + sort: [{ '@timestamp': { order: 'desc' } }], + _source: processUserFields, + }, + }, + host_count: { + cardinality: { + field: 'host.name', + }, + }, + hosts: { + terms: { + field: 'host.name', + }, + aggregations: { + host: { + top_hits: { + size: 1, + _source: hostFields, + }, + }, + }, + }, + }, + }, + }, + query: { + bool: { + should: [ + { + bool: { + filter: [ + { + term: { + 'agent.type': 'auditbeat', + }, + }, + { + term: { + 'event.module': 'auditd', + }, + }, + { + term: { + 'event.action': 'executed', + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + term: { + 'agent.type': 'auditbeat', + }, + }, + { + term: { + 'event.module': 'system', + }, + }, + { + term: { + 'event.dataset': 'process', + }, + }, + { + term: { + 'event.action': 'process_started', + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + term: { + 'agent.type': 'winlogbeat', + }, + }, + { + term: { + 'event.code': '4688', + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + term: { + 'winlog.event_id': 1, + }, + }, + { + term: { + 'winlog.channel': 'Microsoft-Windows-Sysmon/Operational', + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + term: { + 'event.type': 'process_start', + }, + }, + { + term: { + 'event.category': 'process', + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + term: { + 'event.category': 'process', + }, + }, + { + term: { + 'event.type': 'start', + }, + }, + ], + }, + }, + ], + minimum_should_match: 1, + filter, + }, + }, + }, + size: 0, + track_total_hits: false, + }; + + return dslQuery; +}; diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts new file mode 100644 index 000000000000..5c3d76175b7e --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/helpers.ts @@ -0,0 +1,94 @@ +/* + * 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 { get, getOr } from 'lodash/fp'; +import { set } from '@elastic/safer-lodash-set/fp'; + +import { mergeFieldsWithHit } from '../../../../../utils/build_query'; +import { + ProcessHits, + UncommonProcessesEdges, + UncommonProcessHit, +} from '../../../../../../common/search_strategy/security_solution/hosts/uncommon_processes'; +import { toArray } from '../../../../helpers/to_array'; +import { HostHits } from '../../../../../../common/search_strategy'; + +export const uncommonProcessesFields = [ + '_id', + 'instances', + 'process.args', + 'process.name', + 'user.id', + 'user.name', + 'hosts.name', +]; + +export const getHits = (buckets: readonly UncommonProcessBucket[]): readonly UncommonProcessHit[] => + buckets.map((bucket: Readonly<UncommonProcessBucket>) => ({ + _id: bucket.process.hits.hits[0]._id, + _index: bucket.process.hits.hits[0]._index, + _type: bucket.process.hits.hits[0]._type, + _score: bucket.process.hits.hits[0]._score, + _source: bucket.process.hits.hits[0]._source, + sort: bucket.process.hits.hits[0].sort, + cursor: bucket.process.hits.hits[0].cursor, + total: bucket.process.hits.total, + host: getHosts(bucket.hosts.buckets), + })); + +export interface UncommonProcessBucket { + key: string; + hosts: { + buckets: Array<{ key: string; host: HostHits }>; + }; + process: ProcessHits; +} + +export const getHosts = (buckets: ReadonlyArray<{ key: string; host: HostHits }>) => + buckets.map((bucket) => { + const source = get('host.hits.hits[0]._source', bucket); + return { + id: [bucket.key], + name: get('host.name', source), + }; + }); + +export const formatUncommonProcessesData = ( + fields: readonly string[], + hit: UncommonProcessHit, + fieldMap: Readonly<Record<string, string>> +): UncommonProcessesEdges => + fields.reduce<UncommonProcessesEdges>( + (flattenedFields, fieldName) => { + flattenedFields.node._id = hit._id; + flattenedFields.node.instances = getOr(0, 'total.value', hit); + flattenedFields.node.hosts = hit.host; + + if (hit.cursor) { + flattenedFields.cursor.value = hit.cursor; + } + + const mergedResult = mergeFieldsWithHit(fieldName, flattenedFields, fieldMap, hit); + let fieldPath = `node.${fieldName}`; + let fieldValue = get(fieldPath, mergedResult); + if (fieldPath === 'node.hosts.name') { + fieldPath = `node.hosts.0.name`; + fieldValue = get(fieldPath, mergedResult); + } + return set(fieldPath, toArray(fieldValue), mergedResult); + }, + { + node: { + _id: '', + instances: 0, + process: {}, + hosts: [], + }, + cursor: { + value: '', + tiebreaker: null, + }, + } + ); diff --git a/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/index.ts new file mode 100644 index 000000000000..fcc76eebe4cf --- /dev/null +++ b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/hosts/uncommon_processes/index.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getOr } from 'lodash/fp'; + +import { IEsSearchResponse } from '../../../../../../../../../src/plugins/data/common'; + +import { DEFAULT_MAX_TABLE_QUERY_SIZE } from '../../../../../../common/constants'; +import { HostsQueries } from '../../../../../../common/search_strategy/security_solution'; +import { processFieldsMap, userFieldsMap } from '../../../../../../common/ecs/ecs_fields'; +import { + HostUncommonProcessesRequestOptions, + HostUncommonProcessesStrategyResponse, +} from '../../../../../../common/search_strategy/security_solution/hosts/uncommon_processes'; + +import { inspectStringifyObject } from '../../../../../utils/build_query'; + +import { SecuritySolutionFactory } from '../../types'; +import { buildQuery } from './dsl/query.dsl'; +import { formatUncommonProcessesData, getHits, uncommonProcessesFields } from './helpers'; + +export const uncommonProcesses: SecuritySolutionFactory<HostsQueries.uncommonProcesses> = { + buildDsl: (options: HostUncommonProcessesRequestOptions) => { + if (options.pagination && options.pagination.querySize >= DEFAULT_MAX_TABLE_QUERY_SIZE) { + throw new Error(`No query size above ${DEFAULT_MAX_TABLE_QUERY_SIZE}`); + } + return buildQuery(options); + }, + parse: async ( + options: HostUncommonProcessesRequestOptions, + response: IEsSearchResponse<unknown> + ): Promise<HostUncommonProcessesStrategyResponse> => { + const { activePage, cursorStart, fakePossibleCount, querySize } = options.pagination; + const totalCount = getOr(0, 'aggregations.process_count.value', response.rawResponse); + const buckets = getOr([], 'aggregations.group_by_process.buckets', response.rawResponse); + const hits = getHits(buckets); + + const uncommonProcessesEdges = hits.map((hit) => + formatUncommonProcessesData(uncommonProcessesFields, hit, { + ...processFieldsMap, + ...userFieldsMap, + }) + ); + + const fakeTotalCount = fakePossibleCount <= totalCount ? fakePossibleCount : totalCount; + const edges = uncommonProcessesEdges.splice(cursorStart, querySize - cursorStart); + const inspect = { + dsl: [inspectStringifyObject(buildQuery(options))], + response: [inspectStringifyObject(response)], + }; + + 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/index.ts b/x-pack/plugins/security_solution/server/search_strategy/security_solution/factory/network/index.ts index 9e73312bdb8e..c5c98e5facbd 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, SecuritySolutionFactory<FactoryQueryTypes>> = { [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 000000000000..720661e12bd9 --- /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<unknown>, + 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<NetworkTopTablesFields> +): 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 000000000000..198368d98180 --- /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<NetworkQueries.topNFlow> = { + 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<unknown> + ): Promise<NetworkTopNFlowStrategyResponse> => { + 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 000000000000..374dfa4d485f --- /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<NetworkTopTablesFields>, + flowTarget: FlowTargetSourceDest, + querySize: number +) => ({ + [flowTarget]: { + terms: { + field: `${flowTarget}.ip`, + size: querySize, + order: { + ...getQueryOrder(sort), + }, + }, + aggs: { + bytes_in: { + sum: { + field: `${getOppositeField(flowTarget)}.bytes`, + }, + }, + bytes_out: { + sum: { + field: `${flowTarget}.bytes`, + }, + }, + domain: { + terms: { + field: `${flowTarget}.domain`, + order: { + timestamp: 'desc', + }, + }, + aggs: { + timestamp: { + max: { + field: '@timestamp', + }, + }, + }, + }, + location: { + filter: { + exists: { + field: `${flowTarget}.geo`, + }, + }, + aggs: { + top_geo: { + top_hits: { + _source: `${flowTarget}.geo.*`, + size: 1, + }, + }, + }, + }, + autonomous_system: { + filter: { + exists: { + field: `${flowTarget}.as`, + }, + }, + aggs: { + top_as: { + top_hits: { + _source: `${flowTarget}.as.*`, + size: 1, + }, + }, + }, + }, + flows: { + cardinality: { + field: 'network.community_id', + }, + }, + [`${getOppositeField(flowTarget)}_ips`]: { + cardinality: { + field: `${getOppositeField(flowTarget)}.ip`, + }, + }, + }, + }, +}); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index a745c2fc98b4..eacb1febd20f 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -9468,8 +9468,6 @@ "xpack.ingestPipelines.pipelineEditor.removeProcessorModal.cancelButtonLabel": "キャンセル", "xpack.ingestPipelines.pipelineEditor.removeProcessorModal.confirmationButtonLabel": "プロセッサーの削除", "xpack.ingestPipelines.pipelineEditor.removeProcessorModal.titleText": "{type}プロセッサーの削除", - "xpack.ingestPipelines.pipelineEditor.setForm.fieldFieldLabel": "フィールド", - "xpack.ingestPipelines.pipelineEditor.setForm.fieldRequiredError": "フィールド値が必要です。", "xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldLabel": "無効化", "xpack.ingestPipelines.pipelineEditor.setForm.valueFieldLabel": "値", "xpack.ingestPipelines.pipelineEditor.setForm.valueRequiredError": "設定する値が必要です。", @@ -9557,7 +9555,6 @@ "xpack.lens.configure.editConfig": "構成の編集", "xpack.lens.configure.emptyConfig": "ここにフィールドをドロップ", "xpack.lens.dataPanelWrapper.switchDatasource": "データソースに切り替える", - "xpack.lens.datatable.columns": "フィールド", "xpack.lens.datatable.conjunctionSign": " と ", "xpack.lens.datatable.expressionHelpLabel": "データベースレンダー", "xpack.lens.datatable.label": "データテーブル", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 7b630e1c1348..bd30703dd5bd 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -9474,8 +9474,6 @@ "xpack.ingestPipelines.pipelineEditor.removeProcessorModal.cancelButtonLabel": "取消", "xpack.ingestPipelines.pipelineEditor.removeProcessorModal.confirmationButtonLabel": "删除处理器", "xpack.ingestPipelines.pipelineEditor.removeProcessorModal.titleText": "删除 {type} 处理器", - "xpack.ingestPipelines.pipelineEditor.setForm.fieldFieldLabel": "字段", - "xpack.ingestPipelines.pipelineEditor.setForm.fieldRequiredError": "字段值必填。", "xpack.ingestPipelines.pipelineEditor.setForm.overrideFieldLabel": "覆盖", "xpack.ingestPipelines.pipelineEditor.setForm.valueFieldLabel": "值", "xpack.ingestPipelines.pipelineEditor.setForm.valueRequiredError": "需要设置值。", @@ -9563,7 +9561,6 @@ "xpack.lens.configure.editConfig": "编辑配置", "xpack.lens.configure.emptyConfig": "将字段拖放到此处", "xpack.lens.dataPanelWrapper.switchDatasource": "切换到数据源", - "xpack.lens.datatable.columns": "字段", "xpack.lens.datatable.conjunctionSign": " & ", "xpack.lens.datatable.expressionHelpLabel": "数据表呈现器", "xpack.lens.datatable.label": "数据表", diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx index a49251811239..16d0250c5721 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/action_wizard.tsx @@ -21,7 +21,12 @@ import { EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { txtChangeButton, txtTriggerPickerHelpText, txtTriggerPickerLabel } from './i18n'; +import { + txtChangeButton, + txtTriggerPickerHelpText, + txtTriggerPickerLabel, + txtTriggerPickerHelpTooltip, +} from './i18n'; import './action_wizard.scss'; import { ActionFactory, BaseActionFactoryContext } from '../../dynamic_actions'; import { Trigger, TriggerId } from '../../../../../../src/plugins/ui_actions/public'; @@ -157,14 +162,17 @@ const TriggerPicker: React.FC<TriggerPickerProps> = ({ const selectedTrigger = selectedTriggers ? selectedTriggers[0] : undefined; return ( <EuiFormFieldset + data-test-subj={`triggerPicker`} legend={{ children: ( <EuiText size="s"> <h5> <span>{txtTriggerPickerLabel}</span>{' '} - <EuiLink href={triggerPickerDocsLink} target={'blank'} external> - {txtTriggerPickerHelpText} - </EuiLink> + <EuiToolTip content={txtTriggerPickerHelpTooltip}> + <EuiLink href={triggerPickerDocsLink} target={'blank'} external> + {txtTriggerPickerHelpText} + </EuiLink> + </EuiToolTip> </h5> </EuiText> ), @@ -271,7 +279,7 @@ const SelectedActionFactory: React.FC<SelectedActionFactoryProps> = ({ /> </> )} - <EuiSpacer size="l" /> + <EuiSpacer size="m" /> <div> <actionFactory.ReactCollectConfig config={config} diff --git a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts index 678457f9794f..f494ecfb51f3 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/components/action_wizard/i18n.ts @@ -16,13 +16,20 @@ export const txtChangeButton = i18n.translate( export const txtTriggerPickerLabel = i18n.translate( 'xpack.uiActionsEnhanced.components.actionWizard.triggerPickerLabel', { - defaultMessage: 'Pick a trigger:', + defaultMessage: 'Show option on:', } ); export const txtTriggerPickerHelpText = i18n.translate( - 'xpack.uiActionsEnhanced.components.actionWizard.helpText', + 'xpack.uiActionsEnhanced.components.actionWizard.triggerPickerHelpText', { defaultMessage: "What's this?", } ); + +export const txtTriggerPickerHelpTooltip = i18n.translate( + 'xpack.uiActionsEnhanced.components.actionWizard.triggerPickerHelpTooltip', + { + defaultMessage: 'Determines when the drilldown appears in context menu', + } +); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx index b708bbc57375..8154ec45b8ae 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/connected_flyout_manage_drilldowns/connected_flyout_manage_drilldowns.tsx @@ -30,7 +30,7 @@ import { SerializedAction, SerializedEvent, } from '../../../dynamic_actions'; -import { ExtraActionFactoryContext } from '../types'; +import { ActionFactoryPlaceContext } from '../types'; interface ConnectedFlyoutManageDrilldownsProps< ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext @@ -47,7 +47,7 @@ interface ConnectedFlyoutManageDrilldownsProps< /** * Extra action factory context passed into action factories CollectConfig, getIconType, getDisplayName and etc... */ - extraContext?: ExtraActionFactoryContext<ActionFactoryContext>; + placeContext?: ActionFactoryPlaceContext<ActionFactoryContext>; } /** @@ -81,8 +81,8 @@ export function createFlyoutManageDrilldowns({ const isCreateOnly = props.viewMode === 'create'; const factoryContext: BaseActionFactoryContext = useMemo( - () => ({ ...props.extraContext, triggers: props.supportedTriggers }), - [props.extraContext, props.supportedTriggers] + () => ({ ...props.placeContext, triggers: props.supportedTriggers }), + [props.placeContext, props.supportedTriggers] ); const actionFactories = useCompatibleActionFactoriesForCurrentContext( allActionFactories, @@ -137,7 +137,7 @@ export function createFlyoutManageDrilldowns({ function mapToDrilldownToDrilldownListItem(drilldown: SerializedEvent): DrilldownListItem { const actionFactory = allActionFactoriesById[drilldown.action.factoryId]; const drilldownFactoryContext: BaseActionFactoryContext = { - ...props.extraContext, + ...props.placeContext, triggers: drilldown.triggers as TriggerId[], }; return { @@ -204,7 +204,7 @@ export function createFlyoutManageDrilldowns({ setRoute(Routes.Manage); setCurrentEditId(null); }} - extraActionFactoryContext={props.extraContext} + actionFactoryPlaceContext={props.placeContext} initialDrilldownWizardConfig={resolveInitialDrilldownWizardConfig()} supportedTriggers={props.supportedTriggers} getTrigger={getTrigger} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx index a908d53bf6ae..c8e3f454bd53 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/flyout_drilldown_wizard/flyout_drilldown_wizard.tsx @@ -18,7 +18,7 @@ import { import { DrilldownHelloBar } from '../drilldown_hello_bar'; import { ActionFactory, BaseActionFactoryContext } from '../../../dynamic_actions'; import { Trigger, TriggerId } from '../../../../../../../src/plugins/ui_actions/public'; -import { ExtraActionFactoryContext } from '../types'; +import { ActionFactoryPlaceContext } from '../types'; export interface DrilldownWizardConfig<ActionConfig extends object = object> { name: string; @@ -44,7 +44,7 @@ export interface FlyoutDrilldownWizardProps< showWelcomeMessage?: boolean; onWelcomeHideClick?: () => void; - extraActionFactoryContext?: ExtraActionFactoryContext<ActionFactoryContext>; + actionFactoryPlaceContext?: ActionFactoryPlaceContext<ActionFactoryContext>; docsLink?: string; @@ -143,7 +143,7 @@ export function FlyoutDrilldownWizard<CurrentActionConfig extends object = objec showWelcomeMessage = true, onWelcomeHideClick, drilldownActionFactories, - extraActionFactoryContext, + actionFactoryPlaceContext, docsLink, getTrigger, supportedTriggers, @@ -152,16 +152,16 @@ export function FlyoutDrilldownWizard<CurrentActionConfig extends object = objec wizardConfig, { setActionFactory, setActionConfig, setName, setSelectedTriggers }, ] = useWizardConfigState( - { ...extraActionFactoryContext, triggers: supportedTriggers }, + { ...actionFactoryPlaceContext, triggers: supportedTriggers }, initialDrilldownWizardConfig ); const actionFactoryContext: BaseActionFactoryContext = useMemo( () => ({ - ...extraActionFactoryContext, + ...actionFactoryPlaceContext, triggers: wizardConfig.selectedTriggers ?? [], }), - [extraActionFactoryContext, wizardConfig.selectedTriggers] + [actionFactoryPlaceContext, wizardConfig.selectedTriggers] ); const isActionValid = ( diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.ts index 870b55c24fb5..811680bf380f 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/components/types.ts @@ -10,6 +10,6 @@ import { BaseActionFactoryContext } from '../../dynamic_actions'; * Interface used as piece of ActionFactoryContext that is passed in from drilldown wizard component to action factories * Omitted values are added inside the wizard and then full {@link BaseActionFactoryContext} passed into action factory methods */ -export type ExtraActionFactoryContext< +export type ActionFactoryPlaceContext< ActionFactoryContext extends BaseActionFactoryContext = BaseActionFactoryContext > = Omit<ActionFactoryContext, 'triggers'>; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/README.md b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/README.md new file mode 100644 index 000000000000..acad968fa46c --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/README.md @@ -0,0 +1 @@ +This directory contains reusable building blocks for creating custom URL drilldowns diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts new file mode 100644 index 000000000000..70399617136b --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { UrlDrilldownCollectConfig } from './url_drilldown_collect_config/url_drilldown_collect_config'; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts new file mode 100644 index 000000000000..78f7218dce22 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/i18n.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const txtUrlTemplatePlaceholder = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplatePlaceholderText', + { + defaultMessage: 'Example: {exampleUrl}', + values: { + exampleUrl: 'https://www.my-url.com/?{{event.key}}={{event.value}}', + }, + } +); + +export const txtUrlPreviewHelpText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewHelpText', + { + defaultMessage: 'Please note that \\{\\{event.*\\}\\} variables replaced by dummy values.', + } +); + +export const txtAddVariableButtonTitle = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.addVariableButtonTitle', + { + defaultMessage: 'Add variable', + } +); + +export const txtUrlTemplateLabel = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateLabel', + { + defaultMessage: 'Enter URL template:', + } +); + +export const txtUrlTemplateSyntaxHelpLinkText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateSyntaxHelpLinkText', + { + defaultMessage: 'Syntax help', + } +); + +export const txtUrlTemplateVariablesHelpLinkText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesHelpLinkText', + { + defaultMessage: 'Help', + } +); + +export const txtUrlTemplateVariablesFilterPlaceholderText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlTemplateVariablesFilterPlaceholderText', + { + defaultMessage: 'Filter variables', + } +); + +export const txtUrlTemplatePreviewLabel = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLabel', + { + defaultMessage: 'URL preview:', + } +); + +export const txtUrlTemplatePreviewLinkText = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.urlPreviewLinkText', + { + defaultMessage: 'Preview', + } +); + +export const txtUrlTemplateOpenInNewTab = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownCollectConfig.openInNewTabLabel', + { + defaultMessage: 'Open in new tab', + } +); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.scss b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.scss new file mode 100644 index 000000000000..475c3f2a915e --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/index.scss @@ -0,0 +1,5 @@ +.uaeUrlDrilldownCollectConfig__urlTemplateFormRow { + .euiFormRow__label { + align-self: flex-end; + } +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx new file mode 100644 index 000000000000..e6c9797623e9 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/test_samples/demo.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { UrlDrilldownConfig, UrlDrilldownScope } from '../../../types'; +import { UrlDrilldownCollectConfig } from '../url_drilldown_collect_config'; + +export const Demo = () => { + const [config, onConfig] = React.useState<UrlDrilldownConfig>({ + openInNewTab: false, + url: { template: '' }, + }); + + const fakeScope: UrlDrilldownScope = { + kibanaUrl: 'http://localhost:5601/', + context: { + filters: [ + { + query: { match: { extension: { query: 'jpg', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + { + query: { match: { '@tags': { query: 'info', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + { + query: { match: { _type: { query: 'nginx', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + ], + }, + event: { + key: 'fakeKey', + value: 'fakeValue', + }, + }; + + return ( + <> + <UrlDrilldownCollectConfig config={config} onConfig={onConfig} scope={fakeScope} /> + {JSON.stringify(config)} + </> + ); +}; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx new file mode 100644 index 000000000000..244ea9bd2a97 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.story.tsx @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { Demo } from './test_samples/demo'; + +storiesOf('UrlDrilldownCollectConfig', module).add('default', () => <Demo />); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx new file mode 100644 index 000000000000..f55818379ef3 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 { Demo } from './test_samples/demo'; +import { cleanup, fireEvent, render } from '@testing-library/react/pure'; +import React from 'react'; + +afterEach(cleanup); + +test('configure valid URL template', () => { + const screen = render(<Demo />); + + const urlTemplate = 'https://elastic.co/?{{event.key}}={{event.value}}'; + fireEvent.change(screen.getByLabelText(/Enter URL template/i), { + target: { value: urlTemplate }, + }); + + const preview = screen.getByLabelText(/URL preview/i) as HTMLTextAreaElement; + expect(preview.value).toMatchInlineSnapshot(`"https://elastic.co/?fakeKey=fakeValue"`); + expect(preview.disabled).toEqual(true); + const previewLink = screen.getByText('Preview') as HTMLAnchorElement; + expect(previewLink.href).toMatchInlineSnapshot(`"https://elastic.co/?fakeKey=fakeValue"`); + expect(previewLink.target).toMatchInlineSnapshot(`"_blank"`); +}); + +test('configure invalid URL template', () => { + const screen = render(<Demo />); + + const urlTemplate = 'https://elastic.co/?{{event.wrongKey}}={{event.wrongValue}}'; + fireEvent.change(screen.getByLabelText(/Enter URL template/i), { + target: { value: urlTemplate }, + }); + + const previewTextArea = screen.getByLabelText(/URL preview/i) as HTMLTextAreaElement; + expect(previewTextArea.disabled).toEqual(true); + expect(previewTextArea.value).toEqual(urlTemplate); + expect(screen.getByText(/invalid format/i)).toBeInTheDocument(); // check that error is shown + + const previewLink = screen.getByText('Preview') as HTMLAnchorElement; + expect(previewLink.href).toEqual(urlTemplate); + expect(previewLink.target).toMatchInlineSnapshot(`"_blank"`); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx new file mode 100644 index 000000000000..dabf09e4b6e9 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/components/url_drilldown_collect_config/url_drilldown_collect_config.tsx @@ -0,0 +1,226 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useRef, useState } from 'react'; +import { + EuiCheckbox, + EuiFormRow, + EuiIcon, + EuiLink, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiSelectable, + EuiText, + EuiTextArea, + EuiSelectableOption, +} from '@elastic/eui'; +import { UrlDrilldownConfig, UrlDrilldownScope } from '../../types'; +import { compile } from '../../url_template'; +import { validateUrlTemplate } from '../../url_validation'; +import { buildScopeSuggestions } from '../../url_drilldown_scope'; +import './index.scss'; +import { + txtAddVariableButtonTitle, + txtUrlPreviewHelpText, + txtUrlTemplateSyntaxHelpLinkText, + txtUrlTemplateVariablesHelpLinkText, + txtUrlTemplateVariablesFilterPlaceholderText, + txtUrlTemplateLabel, + txtUrlTemplateOpenInNewTab, + txtUrlTemplatePlaceholder, + txtUrlTemplatePreviewLabel, + txtUrlTemplatePreviewLinkText, +} from './i18n'; + +export interface UrlDrilldownCollectConfig { + config: UrlDrilldownConfig; + onConfig: (newConfig: UrlDrilldownConfig) => void; + scope: UrlDrilldownScope; + syntaxHelpDocsLink?: string; +} + +export const UrlDrilldownCollectConfig: React.FC<UrlDrilldownCollectConfig> = ({ + config, + onConfig, + scope, + syntaxHelpDocsLink, +}) => { + const textAreaRef = useRef<HTMLTextAreaElement>(null); + const urlTemplate = config.url.template ?? ''; + const compiledUrl = React.useMemo(() => { + try { + return compile(urlTemplate, scope); + } catch { + return urlTemplate; + } + }, [urlTemplate, scope]); + const scopeVariables = React.useMemo(() => buildScopeSuggestions(scope), [scope]); + + function updateUrlTemplate(newUrlTemplate: string) { + if (config.url.template !== newUrlTemplate) { + onConfig({ + ...config, + url: { + ...config.url, + template: newUrlTemplate, + }, + }); + } + } + const { error, isValid } = React.useMemo( + () => validateUrlTemplate({ template: urlTemplate }, scope), + [urlTemplate, scope] + ); + const isEmpty = !urlTemplate; + const isInvalid = !isValid && !isEmpty; + return ( + <> + <EuiFormRow + fullWidth + isInvalid={isInvalid} + error={error} + className={'uaeUrlDrilldownCollectConfig__urlTemplateFormRow'} + label={txtUrlTemplateLabel} + helpText={ + syntaxHelpDocsLink && ( + <EuiLink external target={'_blank'} href={syntaxHelpDocsLink}> + {txtUrlTemplateSyntaxHelpLinkText} + </EuiLink> + ) + } + labelAppend={ + <AddVariableButton + variables={scopeVariables} + variablesHelpLink={syntaxHelpDocsLink} + onSelect={(variable: string) => { + if (textAreaRef.current) { + updateUrlTemplate( + urlTemplate.substr(0, textAreaRef.current!.selectionStart) + + `{{${variable}}}` + + urlTemplate.substr(textAreaRef.current!.selectionEnd) + ); + } else { + updateUrlTemplate(urlTemplate + `{{${variable}}}`); + } + }} + /> + } + > + <EuiTextArea + fullWidth + isInvalid={isInvalid} + name="url" + data-test-subj="urlInput" + value={urlTemplate} + placeholder={txtUrlTemplatePlaceholder} + onChange={(event) => updateUrlTemplate(event.target.value)} + rows={3} + inputRef={textAreaRef} + /> + </EuiFormRow> + <EuiFormRow + fullWidth + label={txtUrlTemplatePreviewLabel} + labelAppend={ + <EuiText size="xs"> + <EuiLink href={compiledUrl} target="_blank" external> + {txtUrlTemplatePreviewLinkText} + </EuiLink> + </EuiText> + } + helpText={txtUrlPreviewHelpText} + > + <EuiTextArea + fullWidth + name="urlPreview" + data-test-subj="urlPreview" + value={compiledUrl} + disabled={true} + rows={3} + /> + </EuiFormRow> + <EuiFormRow hasChildLabel={false}> + <EuiCheckbox + id="openInNewTab" + name="openInNewTab" + label={txtUrlTemplateOpenInNewTab} + checked={config.openInNewTab} + onChange={() => onConfig({ ...config, openInNewTab: !config.openInNewTab })} + /> + </EuiFormRow> + </> + ); +}; + +function AddVariableButton({ + variables, + onSelect, + variablesHelpLink, +}: { + variables: string[]; + onSelect: (variable: string) => void; + variablesHelpLink?: string; +}) { + const [isVariablesPopoverOpen, setIsVariablesPopoverOpen] = useState<boolean>(false); + const closePopover = () => setIsVariablesPopoverOpen(false); + + const options: EuiSelectableOption[] = variables.map((variable: string) => ({ + key: variable, + label: variable, + })); + + return ( + <EuiPopover + ownFocus={true} + button={ + <EuiText size="xs"> + <EuiLink onClick={() => setIsVariablesPopoverOpen(true)}> + {txtAddVariableButtonTitle} <EuiIcon type="indexOpen" /> + </EuiLink> + </EuiText> + } + isOpen={isVariablesPopoverOpen} + closePopover={closePopover} + panelPaddingSize="none" + anchorPosition="downLeft" + withTitle + > + <EuiSelectable + singleSelection={true} + searchable + searchProps={{ + placeholder: txtUrlTemplateVariablesFilterPlaceholderText, + compressed: true, + }} + options={options} + onChange={(newOptions) => { + const selected = newOptions.find((o) => o.checked === 'on'); + if (!selected) return; + onSelect(selected.key!); + closePopover(); + }} + listProps={{ + showIcons: false, + }} + > + {(list, search) => ( + <div style={{ width: 320 }}> + <EuiPopoverTitle>{search}</EuiPopoverTitle> + {list} + {variablesHelpLink && ( + <EuiPopoverFooter className={'eui-textRight'}> + <EuiLink external href={variablesHelpLink} target="_blank"> + {txtUrlTemplateVariablesHelpLinkText} + </EuiLink> + </EuiPopoverFooter> + )} + </div> + )} + </EuiSelectable> + </EuiPopover> + ); +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts new file mode 100644 index 000000000000..7b7a850050c4 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { UrlDrilldownConfig, UrlDrilldownGlobalScope, UrlDrilldownScope } from './types'; +export { UrlDrilldownCollectConfig } from './components'; +export { + validateUrlTemplate as urlDrilldownValidateUrlTemplate, + validateUrl as urlDrilldownValidateUrl, +} from './url_validation'; +export { compile as urlDrilldownCompileUrl } from './url_template'; +export { globalScopeProvider as urlDrilldownGlobalScopeProvider } from './url_drilldown_global_scope'; +export { + buildScope as urlDrilldownBuildScope, + buildScopeSuggestions as urlDrilldownBuildScopeSuggestions, +} from './url_drilldown_scope'; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.ts new file mode 100644 index 000000000000..31c7481c9d63 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/types.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. + */ + +export interface UrlDrilldownConfig { + url: { format?: 'handlebars_v1'; template: string }; + openInNewTab: boolean; +} + +/** + * URL drilldown has 3 sources for variables: global, context and event variables + */ +export interface UrlDrilldownScope< + ContextScope extends object = object, + EventScope extends object = object +> extends UrlDrilldownGlobalScope { + /** + * Dynamic variables that are differ depending on where drilldown is created and used, + * For example: variables extracted from embeddable panel + */ + context?: ContextScope; + + /** + * Variables extracted from trigger context + */ + event?: EventScope; +} + +/** + * Global static variables like, for example, `kibanaUrl` + * Such variables won’t change depending on a place where url drilldown is used. + */ +export interface UrlDrilldownGlobalScope { + kibanaUrl: string; +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_global_scope.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_global_scope.ts new file mode 100644 index 000000000000..afc7fa590a2f --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_global_scope.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { CoreSetup } from 'kibana/public'; +import { UrlDrilldownGlobalScope } from './types'; + +interface UrlDrilldownGlobalScopeDeps { + core: CoreSetup; +} + +export function globalScopeProvider({ + core, +}: UrlDrilldownGlobalScopeDeps): () => UrlDrilldownGlobalScope { + return () => ({ + kibanaUrl: window.location.origin + core.http.basePath.get(), + }); +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts new file mode 100644 index 000000000000..f95fc5e70ae0 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { buildScope, buildScopeSuggestions } from './url_drilldown_scope'; + +test('buildScopeSuggestions', () => { + expect( + buildScopeSuggestions( + buildScope({ + globalScope: { + kibanaUrl: 'http://localhost:5061/', + }, + eventScope: { + key: '__testKey__', + value: '__testValue__', + }, + contextScope: { + filters: [ + { + query: { match: { extension: { query: 'jpg', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + { + query: { match: { '@tags': { query: 'info', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + { + query: { match: { _type: { query: 'nginx', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + }, + ], + query: { + query: '', + language: 'kquery', + }, + }, + }) + ) + ).toMatchInlineSnapshot(` + Array [ + "event.key", + "event.value", + "context.filters", + "context.query.language", + "context.query.query", + "kibanaUrl", + ] + `); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts new file mode 100644 index 000000000000..d499812a9d5a --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_drilldown_scope.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import partition from 'lodash/partition'; +import { UrlDrilldownGlobalScope, UrlDrilldownScope } from './types'; +import { getFlattenedObject } from '../../../../../../src/core/public'; + +export function buildScope< + ContextScope extends object = object, + EventScope extends object = object +>({ + globalScope, + contextScope, + eventScope, +}: { + globalScope: UrlDrilldownGlobalScope; + contextScope?: ContextScope; + eventScope?: EventScope; +}): UrlDrilldownScope<ContextScope, EventScope> { + return { + ...globalScope, + context: contextScope, + event: eventScope, + }; +} + +/** + * Builds list of variables for suggestion from scope + * keys sorted alphabetically, except {{event.$}} variables are pulled to the top + * @param scope + */ +export function buildScopeSuggestions(scope: UrlDrilldownGlobalScope): string[] { + const allKeys = Object.keys(getFlattenedObject(scope)).sort(); + const [eventKeys, otherKeys] = partition(allKeys, (key) => key.startsWith('event')); + return [...eventKeys, ...otherKeys]; +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts new file mode 100644 index 000000000000..64b8cc49292b --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.test.ts @@ -0,0 +1,141 @@ +/* + * 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 { compile } from './url_template'; +import moment from 'moment-timezone'; + +test('should compile url without variables', () => { + const url = 'https://elastic.co'; + expect(compile(url, {})).toBe(url); +}); + +test('should fail on unknown syntax', () => { + const url = 'https://elastic.co/{{}'; + expect(() => compile(url, {})).toThrowError(); +}); + +test('should fail on not existing variable', () => { + const url = 'https://elastic.co/{{fake}}'; + expect(() => compile(url, {})).toThrowError(); +}); + +test('should fail on not existing nested variable', () => { + const url = 'https://elastic.co/{{fake.fake}}'; + expect(() => compile(url, { fake: {} })).toThrowError(); +}); + +test('should replace existing variable', () => { + const url = 'https://elastic.co/{{foo}}'; + expect(compile(url, { foo: 'bar' })).toMatchInlineSnapshot(`"https://elastic.co/bar"`); +}); + +test('should fail on unknown helper', () => { + const url = 'https://elastic.co/{{fake foo}}'; + expect(() => compile(url, { foo: 'bar' })).toThrowError(); +}); + +describe('json helper', () => { + test('should replace with json', () => { + const url = 'https://elastic.co/{{json foo bar}}'; + expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + `"https://elastic.co/%5B%7B%22foo%22:%22bar%22%7D,%7B%22bar%22:%22foo%22%7D%5D"` + ); + }); + test('should replace with json and skip encoding', () => { + const url = 'https://elastic.co/{{{json foo bar}}}'; + expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + `"https://elastic.co/%5B%7B%22foo%22:%22bar%22%7D,%7B%22bar%22:%22foo%22%7D%5D"` + ); + }); + test('should throw on unknown key', () => { + const url = 'https://elastic.co/{{{json fake}}}'; + expect(() => compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toThrowError(); + }); +}); + +describe('rison helper', () => { + test('should replace with rison', () => { + const url = 'https://elastic.co/{{rison foo bar}}'; + expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + `"https://elastic.co/!((foo:bar),(bar:foo))"` + ); + }); + test('should replace with rison and skip encoding', () => { + const url = 'https://elastic.co/{{{rison foo bar}}}'; + expect(compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toMatchInlineSnapshot( + `"https://elastic.co/!((foo:bar),(bar:foo))"` + ); + }); + test('should throw on unknown key', () => { + const url = 'https://elastic.co/{{{rison fake}}}'; + expect(() => compile(url, { foo: { foo: 'bar' }, bar: { bar: 'foo' } })).toThrowError(); + }); +}); + +describe('date helper', () => { + let spy: jest.SpyInstance; + const date = new Date('2020-08-18T14:45:00.000Z'); + beforeAll(() => { + spy = jest.spyOn(global.Date, 'now').mockImplementation(() => date.valueOf()); + moment.tz.setDefault('UTC'); + }); + afterAll(() => { + spy.mockRestore(); + moment.tz.setDefault('Browser'); + }); + + test('uses datemath', () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: 'now' })).toMatchInlineSnapshot( + `"https://elastic.co/2020-08-18T14:45:00.000Z"` + ); + }); + + test('can use format', () => { + const url = 'https://elastic.co/{{date time "dddd, MMMM Do YYYY, h:mm:ss a"}}'; + expect(compile(url, { time: 'now' })).toMatchInlineSnapshot( + `"https://elastic.co/Tuesday,%20August%2018th%202020,%202:45:00%20pm"` + ); + }); + + test('throws if missing variable', () => { + const url = 'https://elastic.co/{{date time}}'; + expect(() => compile(url, {})).toThrowError(); + }); + + test("doesn't throw if non valid date", () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: 'fake' })).toMatchInlineSnapshot(`"https://elastic.co/fake"`); + }); + + test("doesn't throw on boolean or number", () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: false })).toMatchInlineSnapshot(`"https://elastic.co/false"`); + expect(compile(url, { time: 24 })).toMatchInlineSnapshot( + `"https://elastic.co/1970-01-01T00:00:00.024Z"` + ); + }); + + test('works with ISO string', () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: date.toISOString() })).toMatchInlineSnapshot( + `"https://elastic.co/2020-08-18T14:45:00.000Z"` + ); + }); + + test('works with ts', () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: date.valueOf() })).toMatchInlineSnapshot( + `"https://elastic.co/2020-08-18T14:45:00.000Z"` + ); + }); + test('works with ts string', () => { + const url = 'https://elastic.co/{{date time}}'; + expect(compile(url, { time: String(date.valueOf()) })).toMatchInlineSnapshot( + `"https://elastic.co/2020-08-18T14:45:00.000Z"` + ); + }); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts new file mode 100644 index 000000000000..2c3537636b9d --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_template.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { create as createHandlebars, HelperDelegate, HelperOptions } from 'handlebars'; +import { encode, RisonValue } from 'rison-node'; +import dateMath from '@elastic/datemath'; +import moment, { Moment } from 'moment'; + +const handlebars = createHandlebars(); + +function createSerializationHelper( + fnName: string, + serializeFn: (value: unknown) => string +): HelperDelegate { + return (...args) => { + const { hash } = args.slice(-1)[0] as HelperOptions; + const hasHash = Object.keys(hash).length > 0; + const hasValues = args.length > 1; + if (hasHash && hasValues) { + throw new Error(`[${fnName}]: both value list and hash are not supported`); + } + if (hasHash) { + if (Object.values(hash).some((v) => typeof v === 'undefined')) + throw new Error(`[${fnName}]: unknown variable`); + return serializeFn(hash); + } else { + const values = args.slice(0, -1) as unknown[]; + if (values.some((value) => typeof value === 'undefined')) + throw new Error(`[${fnName}]: unknown variable`); + if (values.length === 0) throw new Error(`[${fnName}]: unknown variable`); + if (values.length === 1) return serializeFn(values[0]); + return serializeFn(values); + } + }; +} + +handlebars.registerHelper('json', createSerializationHelper('json', JSON.stringify)); +handlebars.registerHelper( + 'rison', + createSerializationHelper('rison', (v) => encode(v as RisonValue)) +); + +handlebars.registerHelper('date', (...args) => { + const values = args.slice(0, -1) as [string | Date, string | undefined]; + // eslint-disable-next-line prefer-const + let [date, format] = values; + if (typeof date === 'undefined') throw new Error(`[date]: unknown variable`); + let momentDate: Moment | undefined; + if (typeof date === 'string') { + momentDate = dateMath.parse(date); + if (!momentDate || !momentDate.isValid()) { + const ts = Number(date); + if (!Number.isNaN(ts)) { + momentDate = moment(ts); + } + } + } else { + momentDate = moment(date); + } + + if (!momentDate || !momentDate.isValid()) { + // do not throw error here, because it could be that in preview `__testValue__` is not parsable, + // but in runtime it is + return date; + } + return format ? momentDate.format(format) : momentDate.toISOString(); +}); + +export function compile(url: string, context: object): string { + const template = handlebars.compile(url, { strict: true, noEscape: true }); + return encodeURI(template(context)); +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts new file mode 100644 index 000000000000..cb6f4a28402d --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { validateUrl, validateUrlTemplate } from './url_validation'; + +describe('validateUrl', () => { + describe('unsafe urls', () => { + const unsafeUrls = [ + // eslint-disable-next-line no-script-url + 'javascript:evil()', + // eslint-disable-next-line no-script-url + 'JavaScript:abc', + 'evilNewProtocol:abc', + ' \n Java\n Script:abc', + 'javascript:', + 'javascript:', + 'j avascript:', + 'javascript:', + 'javascript:', + 'jav	ascript:alert();', + // 'jav\u0000ascript:alert();', CI fails on this one + 'data:;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + 'data:,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + 'data:iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + 'data:text/javascript;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + 'data:application/x-msdownload;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/', + ]; + + for (const url of unsafeUrls) { + test(`unsafe ${url}`, () => { + expect(validateUrl(url).isValid).toBe(false); + }); + } + }); + + describe('invalid urls', () => { + const invalidUrls = ['elastic.co', 'www.elastic.co', 'test', '', ' ', 'https://']; + for (const url of invalidUrls) { + test(`invalid ${url}`, () => { + expect(validateUrl(url).isValid).toBe(false); + }); + } + }); + + describe('valid urls', () => { + const validUrls = [ + 'https://elastic.co', + 'https://www.elastic.co', + 'http://elastic', + 'mailto:someone', + ]; + for (const url of validUrls) { + test(`valid ${url}`, () => { + expect(validateUrl(url).isValid).toBe(true); + }); + } + }); +}); + +describe('validateUrlTemplate', () => { + test('domain in variable is allowed', () => { + expect( + validateUrlTemplate( + { template: '{{kibanaUrl}}/test' }, + { kibanaUrl: 'http://localhost:5601/app' } + ).isValid + ).toBe(true); + }); + + test('unsafe domain in variable is not allowed', () => { + expect( + // eslint-disable-next-line no-script-url + validateUrlTemplate({ template: '{{kibanaUrl}}/test' }, { kibanaUrl: 'javascript:evil()' }) + .isValid + ).toBe(false); + }); + + test('if missing variable then invalid', () => { + expect( + validateUrlTemplate({ template: '{{url}}/test' }, { kibanaUrl: 'http://localhost:5601/app' }) + .isValid + ).toBe(false); + }); +}); diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts new file mode 100644 index 000000000000..b32f5d84c677 --- /dev/null +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/url_drilldown/url_validation.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; +import { UrlDrilldownConfig, UrlDrilldownScope } from './types'; +import { compile } from './url_template'; + +const generalFormatError = i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatGeneralErrorMessage', + { + defaultMessage: 'Invalid format. Example: {exampleUrl}', + values: { + exampleUrl: 'https://www.my-url.com/?{{event.key}}={{event.value}}', + }, + } +); + +const formatError = (message: string) => + i18n.translate( + 'xpack.uiActionsEnhanced.drilldowns.urlDrilldownValidation.urlFormatErrorMessage', + { + defaultMessage: 'Invalid format: {message}', + values: { + message, + }, + } + ); + +const SAFE_URL_PATTERN = /^(?:(?:https?|mailto):|[^&:/?#]*(?:[/?#]|$))/gi; +export function validateUrl(url: string): { isValid: boolean; error?: string } { + if (!url) + return { + isValid: false, + error: generalFormatError, + }; + + try { + new URL(url); + if (!url.match(SAFE_URL_PATTERN)) throw new Error(); + return { isValid: true }; + } catch (e) { + return { + isValid: false, + error: generalFormatError, + }; + } +} + +export function validateUrlTemplate( + urlTemplate: UrlDrilldownConfig['url'], + scope: UrlDrilldownScope +): { isValid: boolean; error?: string } { + if (!urlTemplate.template) + return { + isValid: false, + error: generalFormatError, + }; + + try { + const compiledUrl = compile(urlTemplate.template, scope); + return validateUrl(compiledUrl); + } catch (e) { + return { + isValid: false, + error: formatError(e.message), + }; + } +} diff --git a/x-pack/plugins/ui_actions_enhanced/public/index.ts b/x-pack/plugins/ui_actions_enhanced/public/index.ts index a255bc28f5c6..4a899b24852a 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/index.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/index.ts @@ -32,3 +32,4 @@ export { } from './dynamic_actions'; export { DrilldownDefinition as UiActionsEnhancedDrilldownDefinition } from './drilldowns'; +export * from './drilldowns/url_drilldown'; diff --git a/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js b/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js index 1e3ab0d96b81..bf43167a3ae5 100644 --- a/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js +++ b/x-pack/plugins/ui_actions_enhanced/scripts/storybook.js @@ -9,8 +9,5 @@ import { join } from 'path'; // eslint-disable-next-line require('@kbn/storybook').runStorybookCli({ name: 'ui_actions_enhanced', - storyGlobs: [ - join(__dirname, '..', 'public', 'components', '**', '*.story.tsx'), - join(__dirname, '..', 'public', 'drilldowns', 'components', '**', '*.story.tsx'), - ], + storyGlobs: [join(__dirname, '..', 'public', '**', '*.story.tsx')], }); 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 2a8cde85ee3c..e3d68ef69035 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 f44c5e6252d5..1c2f23e81eaf 100644 --- a/x-pack/test/api_integration/apis/logstash/pipeline/save.ts +++ b/x-pack/test/api_integration/apis/logstash/pipeline/save.ts @@ -26,7 +26,6 @@ export default function ({ getService }: FtrProviderContext) { .put('/api/logstash/pipeline/fast_generator') .set('kbn-xsrf', 'xxx') .send({ - id: 'fast_generator', description: 'foobar baz', pipeline: 'input { generator {} }\n\n output { stdout {} }', }) diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_drilldowns.ts b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts similarity index 99% rename from x-pack/test/functional/apps/dashboard/drilldowns/dashboard_drilldowns.ts rename to x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts index 29ead0db1c63..c300412c393b 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_drilldowns.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_dashboard_drilldown.ts @@ -22,7 +22,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const filterBar = getService('filterBar'); - describe('Dashboard Drilldowns', function () { + describe('Dashboard to dashboard drilldown', function () { before(async () => { log.debug('Dashboard Drilldowns:initTests'); await PageObjects.common.navigateToApp('dashboard'); diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_url_drilldown.ts b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_url_drilldown.ts new file mode 100644 index 000000000000..12de29c4fde1 --- /dev/null +++ b/x-pack/test/functional/apps/dashboard/drilldowns/dashboard_to_url_drilldown.ts @@ -0,0 +1,96 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const DRILLDOWN_TO_DISCOVER_URL = 'Go to discover'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const dashboardPanelActions = getService('dashboardPanelActions'); + const dashboardDrilldownPanelActions = getService('dashboardDrilldownPanelActions'); + const dashboardDrilldownsManage = getService('dashboardDrilldownsManage'); + const PageObjects = getPageObjects(['dashboard', 'common', 'header', 'timePicker', 'discover']); + const log = getService('log'); + const browser = getService('browser'); + const testSubjects = getService('testSubjects'); + + describe('Dashboard to URL drilldown', function () { + before(async () => { + log.debug('Dashboard to URL:initTests'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.preserveCrossAppState(); + }); + + it('should create dashboard to URL drilldown and use it to navigate to discover', async () => { + await PageObjects.dashboard.gotoDashboardEditMode( + dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME + ); + + // create drilldown + await dashboardPanelActions.openContextMenu(); + await dashboardDrilldownPanelActions.expectExistsCreateDrilldownAction(); + await dashboardDrilldownPanelActions.clickCreateDrilldown(); + await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutOpen(); + + const urlTemplate = `{{kibanaUrl}}/app/discover#/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:'{{event.from}}',to:'{{event.to}}'))&_a=(columns:!(_source),filters:{{rison context.panel.filters}},index:'{{context.panel.indexPatternId}}',interval:auto,query:(language:{{context.panel.query.language}},query:'{{context.panel.query.query}}'),sort:!())`; + + await dashboardDrilldownsManage.fillInDashboardToURLDrilldownWizard({ + drilldownName: DRILLDOWN_TO_DISCOVER_URL, + destinationURLTemplate: urlTemplate, + trigger: 'SELECT_RANGE_TRIGGER', + }); + await dashboardDrilldownsManage.saveChanges(); + await dashboardDrilldownsManage.expectsCreateDrilldownFlyoutClose(); + + // check that drilldown notification badge is shown + expect(await PageObjects.dashboard.getPanelDrilldownCount()).to.be(2); + + // save dashboard, navigate to view mode + await PageObjects.dashboard.saveDashboard( + dashboardDrilldownsManage.DASHBOARD_WITH_AREA_CHART_NAME, + { + saveAsNew: false, + waitDialogIsClosed: true, + } + ); + + const originalTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + + await brushAreaChart(); + await dashboardDrilldownPanelActions.expectMultipleActionsMenuOpened(); + await dashboardDrilldownPanelActions.clickActionByText(DRILLDOWN_TO_DISCOVER_URL); + + await PageObjects.discover.waitForDiscoverAppOnScreen(); + + // check that new time range duration was applied + const newTimeRangeDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + expect(newTimeRangeDurationHours).to.be.lessThan(originalTimeRangeDurationHours); + }); + }); + + // utils which shouldn't be a part of test flow, but also too specific to be moved to pageobject or service + async function brushAreaChart() { + const areaChart = await testSubjects.find('visualizationLoader'); + expect(await areaChart.getAttribute('data-title')).to.be('Visualization漢字 AreaChart'); + await browser.dragAndDrop( + { + location: areaChart, + offset: { + x: -100, + y: 0, + }, + }, + { + location: areaChart, + offset: { + x: 100, + y: 0, + }, + } + ); + } +} diff --git a/x-pack/test/functional/apps/dashboard/drilldowns/index.ts b/x-pack/test/functional/apps/dashboard/drilldowns/index.ts index ff604b18e1d5..57454f50266d 100644 --- a/x-pack/test/functional/apps/dashboard/drilldowns/index.ts +++ b/x-pack/test/functional/apps/dashboard/drilldowns/index.ts @@ -22,7 +22,8 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { await esArchiver.unload('dashboard/drilldowns'); }); - loadTestFile(require.resolve('./dashboard_drilldowns')); + loadTestFile(require.resolve('./dashboard_to_dashboard_drilldown')); + loadTestFile(require.resolve('./dashboard_to_url_drilldown')); loadTestFile(require.resolve('./explore_data_panel_action')); // Disabled for now as it requires xpack.discoverEnhanced.actions.exploreDataInChart.enabled diff --git a/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz b/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz new file mode 100644 index 000000000000..e1b9c01101f6 Binary files /dev/null and b/x-pack/test/functional/es_archives/endpoint/resolver/signals/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/endpoint/resolver/signals/mappings.json b/x-pack/test/functional/es_archives/endpoint/resolver/signals/mappings.json new file mode 100644 index 000000000000..ad77961a4144 --- /dev/null +++ b/x-pack/test/functional/es_archives/endpoint/resolver/signals/mappings.json @@ -0,0 +1,3239 @@ +{ + "type": "index", + "value": { + "aliases": { + ".siem-signals-default": { + "is_write_index": true + } + }, + "index": ".siem-signals-default-000001", + "mappings": { + "dynamic": "false", + "properties": { + "@timestamp": { + "type": "date" + }, + "agent": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "client": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "cloud": { + "properties": { + "account": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "availability_zone": { + "ignore_above": 1024, + "type": "keyword" + }, + "instance": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "machine": { + "properties": { + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "region": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "container": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "image": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "tag": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "runtime": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "destination": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dll": { + "properties": { + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "dns": { + "properties": { + "answers": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "data": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "ttl": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "header_flags": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "op_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "question": { + "properties": { + "class": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "subdomain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "resolved_ip": { + "type": "ip" + }, + "response_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ecs": { + "properties": { + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "error": { + "properties": { + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "message": { + "norms": false, + "type": "text" + }, + "stack_trace": { + "doc_values": false, + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "event": { + "properties": { + "action": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "code": { + "ignore_above": 1024, + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "ignore_above": 1024, + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingested": { + "type": "date" + }, + "kind": { + "ignore_above": 1024, + "type": "keyword" + }, + "module": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "outcome": { + "ignore_above": 1024, + "type": "keyword" + }, + "provider": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "url": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "file": { + "properties": { + "accessed": { + "type": "date" + }, + "attributes": { + "ignore_above": 1024, + "type": "keyword" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "created": { + "type": "date" + }, + "ctime": { + "type": "date" + }, + "device": { + "ignore_above": 1024, + "type": "keyword" + }, + "directory": { + "ignore_above": 1024, + "type": "keyword" + }, + "drive_letter": { + "ignore_above": 1, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "gid": { + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "inode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mime_type": { + "ignore_above": 1024, + "type": "keyword" + }, + "mode": { + "ignore_above": 1024, + "type": "keyword" + }, + "mtime": { + "type": "date" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "owner": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "size": { + "type": "long" + }, + "target_path": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uid": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "host": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "http": { + "properties": { + "request": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "method": { + "ignore_above": 1024, + "type": "keyword" + }, + "referrer": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "response": { + "properties": { + "body": { + "properties": { + "bytes": { + "type": "long" + }, + "content": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "bytes": { + "type": "long" + }, + "status_code": { + "type": "long" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "labels": { + "type": "object" + }, + "log": { + "properties": { + "level": { + "ignore_above": 1024, + "type": "keyword" + }, + "logger": { + "ignore_above": 1024, + "type": "keyword" + }, + "origin": { + "properties": { + "file": { + "properties": { + "line": { + "type": "integer" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "function": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "original": { + "doc_values": false, + "ignore_above": 1024, + "index": false, + "type": "keyword" + }, + "syslog": { + "properties": { + "facility": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "priority": { + "type": "long" + }, + "severity": { + "properties": { + "code": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + } + } + }, + "message": { + "norms": false, + "type": "text" + }, + "network": { + "properties": { + "application": { + "ignore_above": 1024, + "type": "keyword" + }, + "bytes": { + "type": "long" + }, + "community_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "direction": { + "ignore_above": 1024, + "type": "keyword" + }, + "forwarded_ip": { + "type": "ip" + }, + "iana_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "inner": { + "properties": { + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "packets": { + "type": "long" + }, + "protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "transport": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "observer": { + "properties": { + "egress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hostname": { + "ignore_above": 1024, + "type": "keyword" + }, + "ingress": { + "properties": { + "interface": { + "properties": { + "alias": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "zone": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + }, + "serial_number": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "vendor": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "organization": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "package": { + "properties": { + "architecture": { + "ignore_above": 1024, + "type": "keyword" + }, + "build_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "checksum": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "install_scope": { + "ignore_above": 1024, + "type": "keyword" + }, + "installed": { + "type": "date" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "size": { + "type": "long" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "process": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "parent": { + "properties": { + "args": { + "ignore_above": 1024, + "type": "keyword" + }, + "args_count": { + "type": "long" + }, + "code_signature": { + "properties": { + "exists": { + "type": "boolean" + }, + "status": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "trusted": { + "type": "boolean" + }, + "valid": { + "type": "boolean" + } + } + }, + "command_line": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "executable": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "exit_code": { + "type": "long" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha512": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pe": { + "properties": { + "company": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "file_version": { + "ignore_above": 1024, + "type": "keyword" + }, + "original_file_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "product": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "pgid": { + "type": "long" + }, + "pid": { + "type": "long" + }, + "ppid": { + "type": "long" + }, + "start": { + "type": "date" + }, + "thread": { + "properties": { + "id": { + "type": "long" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "title": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "uptime": { + "type": "long" + }, + "working_directory": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "registry": { + "properties": { + "data": { + "properties": { + "bytes": { + "ignore_above": 1024, + "type": "keyword" + }, + "strings": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hive": { + "ignore_above": 1024, + "type": "keyword" + }, + "key": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "value": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "related": { + "properties": { + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "ip": { + "type": "ip" + }, + "user": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "ignore_above": 1024, + "type": "keyword" + }, + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "license": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "ruleset": { + "ignore_above": 1024, + "type": "keyword" + }, + "uuid": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "server": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "service": { + "properties": { + "ephemeral_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "node": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "state": { + "ignore_above": 1024, + "type": "keyword" + }, + "type": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "signal": { + "properties": { + "ancestors": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "original_event": { + "properties": { + "action": { + "type": "keyword" + }, + "category": { + "type": "keyword" + }, + "code": { + "type": "keyword" + }, + "created": { + "type": "date" + }, + "dataset": { + "type": "keyword" + }, + "duration": { + "type": "long" + }, + "end": { + "type": "date" + }, + "hash": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "kind": { + "type": "keyword" + }, + "module": { + "type": "keyword" + }, + "original": { + "doc_values": false, + "index": false, + "type": "keyword" + }, + "outcome": { + "type": "keyword" + }, + "provider": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_norm": { + "type": "float" + }, + "sequence": { + "type": "long" + }, + "severity": { + "type": "long" + }, + "start": { + "type": "date" + }, + "timezone": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "original_time": { + "type": "date" + }, + "parent": { + "properties": { + "depth": { + "type": "long" + }, + "id": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "rule": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "rule": { + "properties": { + "author": { + "type": "keyword" + }, + "building_block_type": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "enabled": { + "type": "keyword" + }, + "false_positives": { + "type": "keyword" + }, + "filters": { + "type": "object" + }, + "from": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "immutable": { + "type": "keyword" + }, + "index": { + "type": "keyword" + }, + "interval": { + "type": "keyword" + }, + "language": { + "type": "keyword" + }, + "license": { + "type": "keyword" + }, + "max_signals": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "output_index": { + "type": "keyword" + }, + "query": { + "type": "keyword" + }, + "references": { + "type": "keyword" + }, + "risk_score": { + "type": "float" + }, + "risk_score_mapping": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + }, + "rule_id": { + "type": "keyword" + }, + "rule_name_override": { + "type": "keyword" + }, + "saved_id": { + "type": "keyword" + }, + "severity": { + "type": "keyword" + }, + "severity_mapping": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "severity": { + "type": "keyword" + }, + "value": { + "type": "keyword" + } + } + }, + "size": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "reference": { + "type": "keyword" + } + } + } + } + }, + "threshold": { + "properties": { + "field": { + "type": "keyword" + }, + "value": { + "type": "float" + } + } + }, + "timeline_id": { + "type": "keyword" + }, + "timeline_title": { + "type": "keyword" + }, + "timestamp_override": { + "type": "keyword" + }, + "to": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "status": { + "type": "keyword" + }, + "threshold_count": { + "type": "float" + } + } + }, + "source": { + "properties": { + "address": { + "ignore_above": 1024, + "type": "keyword" + }, + "as": { + "properties": { + "number": { + "type": "long" + }, + "organization": { + "properties": { + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "bytes": { + "type": "long" + }, + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "geo": { + "properties": { + "city_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "continent_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "country_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "location": { + "type": "geo_point" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_iso_code": { + "ignore_above": 1024, + "type": "keyword" + }, + "region_name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "ip": { + "type": "ip" + }, + "mac": { + "ignore_above": 1024, + "type": "keyword" + }, + "nat": { + "properties": { + "ip": { + "type": "ip" + }, + "port": { + "type": "long" + } + } + }, + "packets": { + "type": "long" + }, + "port": { + "type": "long" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "tags": { + "ignore_above": 1024, + "type": "keyword" + }, + "threat": { + "properties": { + "framework": { + "ignore_above": 1024, + "type": "keyword" + }, + "tactic": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "technique": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "tls": { + "properties": { + "cipher": { + "ignore_above": 1024, + "type": "keyword" + }, + "client": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "server_name": { + "ignore_above": 1024, + "type": "keyword" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + }, + "supported_ciphers": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "curve": { + "ignore_above": 1024, + "type": "keyword" + }, + "established": { + "type": "boolean" + }, + "next_protocol": { + "ignore_above": 1024, + "type": "keyword" + }, + "resumed": { + "type": "boolean" + }, + "server": { + "properties": { + "certificate": { + "ignore_above": 1024, + "type": "keyword" + }, + "certificate_chain": { + "ignore_above": 1024, + "type": "keyword" + }, + "hash": { + "properties": { + "md5": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha1": { + "ignore_above": 1024, + "type": "keyword" + }, + "sha256": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "issuer": { + "ignore_above": 1024, + "type": "keyword" + }, + "ja3s": { + "ignore_above": 1024, + "type": "keyword" + }, + "not_after": { + "type": "date" + }, + "not_before": { + "type": "date" + }, + "subject": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + }, + "version_protocol": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "trace": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "transaction": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "url": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "extension": { + "ignore_above": 1024, + "type": "keyword" + }, + "fragment": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "password": { + "ignore_above": 1024, + "type": "keyword" + }, + "path": { + "ignore_above": 1024, + "type": "keyword" + }, + "port": { + "type": "long" + }, + "query": { + "ignore_above": 1024, + "type": "keyword" + }, + "registered_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "scheme": { + "ignore_above": 1024, + "type": "keyword" + }, + "top_level_domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "username": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "email": { + "ignore_above": 1024, + "type": "keyword" + }, + "full_name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "group": { + "properties": { + "domain": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "hash": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "user_agent": { + "properties": { + "device": { + "properties": { + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + }, + "original": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "os": { + "properties": { + "family": { + "ignore_above": 1024, + "type": "keyword" + }, + "full": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "kernel": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "platform": { + "ignore_above": 1024, + "type": "keyword" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vlan": { + "properties": { + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "name": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "vulnerability": { + "properties": { + "category": { + "ignore_above": 1024, + "type": "keyword" + }, + "classification": { + "ignore_above": 1024, + "type": "keyword" + }, + "description": { + "fields": { + "text": { + "norms": false, + "type": "text" + } + }, + "ignore_above": 1024, + "type": "keyword" + }, + "enumeration": { + "ignore_above": 1024, + "type": "keyword" + }, + "id": { + "ignore_above": 1024, + "type": "keyword" + }, + "reference": { + "ignore_above": 1024, + "type": "keyword" + }, + "report_id": { + "ignore_above": 1024, + "type": "keyword" + }, + "scanner": { + "properties": { + "vendor": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "score": { + "properties": { + "base": { + "type": "float" + }, + "environmental": { + "type": "float" + }, + "temporal": { + "type": "float" + }, + "version": { + "ignore_above": 1024, + "type": "keyword" + } + } + }, + "severity": { + "ignore_above": 1024, + "type": "keyword" + } + } + } + } + }, + "settings": { + "index": { + "lifecycle": { + "name": ".siem-signals-default", + "rollover_alias": ".siem-signals-default" + }, + "mapping": { + "total_fields": { + "limit": "10000" + } + }, + "number_of_replicas": "1", + "number_of_shards": "1", + "routing": { + "allocation": { + "include": { + "_tier": "data_hot" + } + } + } + } + } + } +} diff --git a/x-pack/test/functional/services/dashboard/drilldowns_manage.ts b/x-pack/test/functional/services/dashboard/drilldowns_manage.ts index a01fde3a5233..7b66591fcf76 100644 --- a/x-pack/test/functional/services/dashboard/drilldowns_manage.ts +++ b/x-pack/test/functional/services/dashboard/drilldowns_manage.ts @@ -12,6 +12,8 @@ const DASHBOARD_TO_DASHBOARD_ACTION_LIST_ITEM = 'actionFactoryItem-DASHBOARD_TO_DASHBOARD_DRILLDOWN'; const DASHBOARD_TO_DASHBOARD_ACTION_WIZARD = 'selectedActionFactory-DASHBOARD_TO_DASHBOARD_DRILLDOWN'; +const DASHBOARD_TO_URL_ACTION_LIST_ITEM = 'actionFactoryItem-URL_DRILLDOWN'; +const DASHBOARD_TO_URL_ACTION_WIZARD = 'selectedActionFactory-URL_DRILLDOWN'; const DESTINATION_DASHBOARD_SELECT = 'dashboardDrilldownSelectDashboard'; const DRILLDOWN_WIZARD_SUBMIT = 'drilldownWizardSubmit'; @@ -68,10 +70,32 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon await this.selectDestinationDashboard(destinationDashboardTitle); } + async fillInDashboardToURLDrilldownWizard({ + drilldownName, + destinationURLTemplate, + trigger, + }: { + drilldownName: string; + destinationURLTemplate: string; + trigger: 'VALUE_CLICK_TRIGGER' | 'SELECT_RANGE_TRIGGER'; + }) { + await this.fillInDrilldownName(drilldownName); + await this.selectDashboardToURLActionIfNeeded(); + await this.selectTriggerIfNeeded(trigger); + await this.fillInURLTemplate(destinationURLTemplate); + } + async fillInDrilldownName(name: string) { await testSubjects.setValue('drilldownNameInput', name); } + async selectDashboardToURLActionIfNeeded() { + if (await testSubjects.exists(DASHBOARD_TO_URL_ACTION_LIST_ITEM)) { + await testSubjects.click(DASHBOARD_TO_URL_ACTION_LIST_ITEM); + } + await testSubjects.existOrFail(DASHBOARD_TO_URL_ACTION_WIZARD); + } + async selectDashboardToDashboardActionIfNeeded() { if (await testSubjects.exists(DASHBOARD_TO_DASHBOARD_ACTION_LIST_ITEM)) { await testSubjects.click(DASHBOARD_TO_DASHBOARD_ACTION_LIST_ITEM); @@ -83,6 +107,18 @@ export function DashboardDrilldownsManageProvider({ getService }: FtrProviderCon await comboBox.set(DESTINATION_DASHBOARD_SELECT, title); } + async selectTriggerIfNeeded(trigger: 'VALUE_CLICK_TRIGGER' | 'SELECT_RANGE_TRIGGER') { + if (await testSubjects.exists(`triggerPicker`)) { + const container = await testSubjects.find(`triggerPicker-${trigger}`); + const radio = await container.findByCssSelector('input[type=radio]'); + await radio.click(); + } + } + + async fillInURLTemplate(destinationURLTemplate: string) { + await testSubjects.setValue('urlInput', destinationURLTemplate); + } + async saveChanges() { await testSubjects.click(DRILLDOWN_WIZARD_SUBMIT); } diff --git a/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx b/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx index 4afd71fd67a6..f3d1eb60bf1c 100644 --- a/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx +++ b/x-pack/test/plugin_functional/plugins/resolver_test/public/applications/resolver_test/index.tsx @@ -82,6 +82,7 @@ const AppRoot = React.memo( <ResolverWithoutProviders databaseDocumentID="" resolverComponentInstanceID="test" + indices={[]} /> </Wrapper> </Provider> diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts new file mode 100644 index 000000000000..7fbba4e04798 --- /dev/null +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/entity.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import expect from '@kbn/expect'; +import { eventsIndexPattern } from '../../../../plugins/security_solution/common/endpoint/constants'; +import { ResolverEntityIndex } from '../../../../plugins/security_solution/common/endpoint/types'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('Resolver tests for the entity route', () => { + before(async () => { + await esArchiver.load('endpoint/resolver/signals'); + }); + + after(async () => { + await esArchiver.unload('endpoint/resolver/signals'); + }); + + it('returns an event even if it does not have a mapping for entity_id', async () => { + // this id is from the es archive + const _id = 'fa7eb1546f44fd47d8868be8d74e0082e19f22df493c67a7725457978eb648ab'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).eql([ + { + // this value is from the es archive + entity_id: + 'MTIwNWY1NWQtODRkYS00MzkxLWIyNWQtYTNkNGJmNDBmY2E1LTc1NTItMTMyNDM1NDY1MTQuNjI0MjgxMDA=', + }, + ]); + }); + + it('does not return an event when it does not have the entity_id field in the document', async () => { + // this id is from the es archive + const _id = 'no-entity-id-field'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).to.be.empty(); + }); + + it('does not return an event when it does not have the process field in the document', async () => { + // this id is from the es archive + const _id = 'no-process-field'; + const { body }: { body: ResolverEntityIndex } = await supertest.get( + `/api/endpoint/resolver/entity?_id=${_id}&indices=${eventsIndexPattern}&indices=.siem-signals-default` + ); + expect(body).to.be.empty(); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts index fc603af3619a..ecfc1ef5bb7f 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/resolver/index.ts @@ -10,6 +10,7 @@ export default function (providerContext: FtrProviderContext) { describe('Resolver tests', () => { loadTestFile(require.resolve('./entity_id')); + loadTestFile(require.resolve('./entity')); loadTestFile(require.resolve('./children')); loadTestFile(require.resolve('./tree')); loadTestFile(require.resolve('./alerts')); diff --git a/yarn.lock b/yarn.lock index aef1d5c9ebbe..95066c9fa8cd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4268,10 +4268,10 @@ dependencies: "@types/node" "*" -"@types/node-forge@^0.9.0": - version "0.9.0" - resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-0.9.0.tgz#e9f678ec09283f9f35cb8de6c01f86be9278ac08" - integrity sha512-J00+BIHJOfagO1Qs67Jp5CZO3VkFxY8YKMt44oBhXr+3ZYNnl8wv/vtcJyPjuH0QZ+q7+5nnc6o/YH91ZJy2pQ== +"@types/node-forge@^0.9.5": + version "0.9.5" + resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-0.9.5.tgz#648231d79da197216290429020698d4e767365a0" + integrity sha512-rrN3xfA/oZIzwOnO3d2wRQz7UdeVkmMMPjWUCfpPTPuKFVb3D6G10LuiVHYYmvrivBBLMx4m0P/FICoDbNZUMA== dependencies: "@types/node" "*" @@ -5590,7 +5590,7 @@ ajv@^4.7.0: co "^4.6.0" json-stable-stringify "^1.0.1" -ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.5.5, ajv@^6.9.1: +ajv@^6.1.0, ajv@^6.10.0, ajv@^6.10.2, ajv@^6.12.2, ajv@^6.12.4, ajv@^6.5.5, ajv@^6.9.1: version "6.12.4" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.4.tgz#0614facc4522127fa713445c6bfd3ebd376e2234" integrity sha512-eienB2c9qVQs2KWexhkrdMLVDoIQCz5KSeLxwg9Lzk4DOfBtIK9PQwwufcsn1jjGuf9WZmqPMbGxOzfcuphJCQ== @@ -20631,15 +20631,10 @@ node-forge@0.9.0: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" integrity sha512-7ASaDa3pD+lJ3WvXFsxekJQelBKRpne+GOVbLbtHYdd7pFspyeuJHnWfLplGf3SwKGbfs/aYl5V/JCIaHVUKKQ== -node-forge@^0.7.6: - version "0.7.6" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.7.6.tgz#fdf3b418aee1f94f0ef642cd63486c77ca9724ac" - integrity sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw== - -node-forge@^0.9.1: - version "0.9.1" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5" - integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ== +node-forge@^0.10.0, node-forge@^0.7.6: + version "0.10.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" + integrity sha512-PPmu8eEeG9saEUvI97fm4OYxXVB6bFvyNTyiUOBichBpFG8A1Ljw3bY62+5oOjDEMHRnd0Y7HQ+x7uzxOzC6JA== node-gyp@^3.8.0: version "3.8.0"